scripts/memento_cards.py
scripts/memento_cards.pyBrowse 3 files
2,860 tokens
11,449 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""Memento card storage, spaced-repetition engine, and CSV I/O.3 4Stdlib-only. All output is JSON for agent parsing.5Data file: $HERMES_HOME/skills/productivity/memento-flashcards/data/cards.json6"""7 8import argparse9import csv10import json11import os12import sys13import tempfile14import uuid15from datetime import datetime, timedelta, timezone16from pathlib import Path17 18_HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))19DATA_DIR = _HERMES_HOME / "skills" / "productivity" / "memento-flashcards" / "data"20CARDS_FILE = DATA_DIR / "cards.json"21 22RETIRED_SENTINEL = "9999-12-31T23:59:59+00:00"23 24 25def _now() -> datetime:26 return datetime.now(timezone.utc)27 28 29def _iso(dt: datetime) -> str:30 return dt.isoformat()31 32 33def _parse_iso(s: str) -> datetime:34 return datetime.fromisoformat(s)35 36 37def _empty_store() -> dict:38 return {"cards": [], "version": 1}39 40 41def _load() -> dict:42 if not CARDS_FILE.exists():43 return _empty_store()44 try:45 with open(CARDS_FILE, "r", encoding="utf-8") as f:46 data = json.load(f)47 if not isinstance(data, dict) or "cards" not in data:48 return _empty_store()49 return data50 except (json.JSONDecodeError, OSError):51 return _empty_store()52 53 54def _save(data: dict) -> None:55 DATA_DIR.mkdir(parents=True, exist_ok=True)56 fd, tmp = tempfile.mkstemp(dir=DATA_DIR, suffix=".tmp")57 try:58 with os.fdopen(fd, "w", encoding="utf-8") as f:59 json.dump(data, f, indent=2, ensure_ascii=False)60 f.write("\n")61 os.replace(tmp, CARDS_FILE)62 except BaseException:63 try:64 os.unlink(tmp)65 except OSError:66 pass67 raise68 69 70def _out(obj: object) -> None:71 json.dump(obj, sys.stdout, indent=2, ensure_ascii=False)72 sys.stdout.write("\n")73 74 75# ── Subcommands ──────────────────────────────────────────────────────────────76 77def cmd_add(args: argparse.Namespace) -> None:78 data = _load()79 now = _now()80 card = {81 "id": str(uuid.uuid4()),82 "question": args.question,83 "answer": args.answer,84 "collection": args.collection or "General",85 "status": "learning",86 "ease_streak": 0,87 "next_review_at": _iso(now),88 "created_at": _iso(now),89 "video_id": None,90 "last_user_answer": None,91 }92 data["cards"].append(card)93 _save(data)94 _out({"ok": True, "card": card})95 96 97def cmd_add_quiz(args: argparse.Namespace) -> None:98 data = _load()99 now = _now()100 101 try:102 questions = json.loads(args.questions)103 except json.JSONDecodeError as exc:104 _out({"ok": False, "error": f"Invalid JSON for --questions: {exc}"})105 sys.exit(1)106 107 # Dedup: skip if cards with this video_id already exist108 existing_ids = {c["video_id"] for c in data["cards"] if c.get("video_id")}109 if args.video_id in existing_ids:110 existing = [c for c in data["cards"] if c.get("video_id") == args.video_id]111 _out({"ok": True, "skipped": True, "reason": "duplicate_video_id", "existing_count": len(existing), "cards": existing})112 return113 114 created = []115 for qa in questions:116 card = {117 "id": str(uuid.uuid4()),118 "question": qa["question"],119 "answer": qa["answer"],120 "collection": args.collection or "Quiz",121 "status": "learning",122 "ease_streak": 0,123 "next_review_at": _iso(now),124 "created_at": _iso(now),125 "video_id": args.video_id,126 "last_user_answer": None,127 }128 data["cards"].append(card)129 created.append(card)130 131 _save(data)132 _out({"ok": True, "created_count": len(created), "cards": created})133 134 135def cmd_due(args: argparse.Namespace) -> None:136 data = _load()137 now = _now()138 due = []139 for card in data["cards"]:140 if card["status"] == "retired":141 continue142 review_at = _parse_iso(card["next_review_at"])143 if review_at <= now:144 if args.collection and card["collection"] != args.collection:145 continue146 due.append(card)147 _out({"ok": True, "count": len(due), "cards": due})148 149 150def cmd_rate(args: argparse.Namespace) -> None:151 data = _load()152 now = _now()153 card = None154 for c in data["cards"]:155 if c["id"] == args.id:156 card = c157 break158 if not card:159 _out({"ok": False, "error": f"Card not found: {args.id}"})160 sys.exit(1)161 162 rating = args.rating163 user_answer = getattr(args, "user_answer", None)164 if user_answer is not None:165 card["last_user_answer"] = user_answer166 167 if rating == "retire":168 card["status"] = "retired"169 card["next_review_at"] = RETIRED_SENTINEL170 card["ease_streak"] = 0171 elif rating == "hard":172 card["next_review_at"] = _iso(now + timedelta(days=1))173 card["ease_streak"] = 0174 elif rating == "good":175 card["next_review_at"] = _iso(now + timedelta(days=3))176 card["ease_streak"] = 0177 elif rating == "easy":178 card["next_review_at"] = _iso(now + timedelta(days=7))179 card["ease_streak"] = card.get("ease_streak", 0) + 1180 if card["ease_streak"] >= 3:181 card["status"] = "retired"182 183 _save(data)184 _out({"ok": True, "card": card})185 186 187def cmd_list(args: argparse.Namespace) -> None:188 data = _load()189 cards = data["cards"]190 if args.collection:191 cards = [c for c in cards if c["collection"] == args.collection]192 if args.status:193 cards = [c for c in cards if c["status"] == args.status]194 _out({"ok": True, "count": len(cards), "cards": cards})195 196 197def cmd_stats(args: argparse.Namespace) -> None:198 data = _load()199 now = _now()200 total = len(data["cards"])201 learning = sum(1 for c in data["cards"] if c["status"] == "learning")202 retired = sum(1 for c in data["cards"] if c["status"] == "retired")203 due_now = 0204 for c in data["cards"]:205 if c["status"] != "retired" and _parse_iso(c["next_review_at"]) <= now:206 due_now += 1207 208 collections: dict[str, int] = {}209 for c in data["cards"]:210 name = c["collection"]211 collections[name] = collections.get(name, 0) + 1212 213 _out({214 "ok": True,215 "total": total,216 "learning": learning,217 "retired": retired,218 "due_now": due_now,219 "collections": collections,220 })221 222 223def cmd_export(args: argparse.Namespace) -> None:224 data = _load()225 output_path = Path(args.output).expanduser()226 with open(output_path, "w", newline="", encoding="utf-8") as f:227 writer = csv.writer(f, lineterminator="\n")228 for card in data["cards"]:229 writer.writerow([card["question"], card["answer"], card["collection"]])230 _out({"ok": True, "exported": len(data["cards"]), "path": str(output_path)})231 232 233def cmd_import(args: argparse.Namespace) -> None:234 data = _load()235 now = _now()236 file_path = Path(args.file).expanduser()237 238 if not file_path.exists():239 _out({"ok": False, "error": f"File not found: {file_path}"})240 sys.exit(1)241 242 created = 0243 with open(file_path, "r", encoding="utf-8") as f:244 reader = csv.reader(f)245 for row in reader:246 if len(row) < 2:247 continue248 question = row[0].strip()249 answer = row[1].strip()250 collection = row[2].strip() if len(row) >= 3 and row[2].strip() else (args.collection or "Imported")251 if not question or not answer:252 continue253 card = {254 "id": str(uuid.uuid4()),255 "question": question,256 "answer": answer,257 "collection": collection,258 "status": "learning",259 "ease_streak": 0,260 "next_review_at": _iso(now),261 "created_at": _iso(now),262 "video_id": None,263 "last_user_answer": None,264 }265 data["cards"].append(card)266 created += 1267 268 _save(data)269 _out({"ok": True, "imported": created})270 271 272def cmd_delete(args: argparse.Namespace) -> None:273 data = _load()274 original = len(data["cards"])275 data["cards"] = [c for c in data["cards"] if c["id"] != args.id]276 removed = original - len(data["cards"])277 if removed == 0:278 _out({"ok": False, "error": f"Card not found: {args.id}"})279 sys.exit(1)280 _save(data)281 _out({"ok": True, "deleted": args.id})282 283 284def cmd_delete_collection(args: argparse.Namespace) -> None:285 data = _load()286 original = len(data["cards"])287 data["cards"] = [c for c in data["cards"] if c["collection"] != args.collection]288 removed = original - len(data["cards"])289 _save(data)290 _out({"ok": True, "deleted_count": removed, "collection": args.collection})291 292 293# ── CLI ──────────────────────────────────────────────────────────────────────294 295def main() -> None:296 parser = argparse.ArgumentParser(description="Memento flashcard manager")297 sub = parser.add_subparsers(dest="command", required=True)298 299 p_add = sub.add_parser("add", help="Create one card")300 p_add.add_argument("--question", required=True)301 p_add.add_argument("--answer", required=True)302 p_add.add_argument("--collection", default="General")303 304 p_quiz = sub.add_parser("add-quiz", help="Batch-add quiz cards")305 p_quiz.add_argument("--video-id", required=True)306 p_quiz.add_argument("--questions", required=True, help="JSON array of {question, answer}")307 p_quiz.add_argument("--collection", default="Quiz")308 309 p_due = sub.add_parser("due", help="List due cards")310 p_due.add_argument("--collection", default=None)311 312 p_rate = sub.add_parser("rate", help="Rate a card")313 p_rate.add_argument("--id", required=True)314 p_rate.add_argument("--rating", required=True, choices=["easy", "good", "hard", "retire"])315 p_rate.add_argument("--user-answer", default=None)316 317 p_list = sub.add_parser("list", help="List cards")318 p_list.add_argument("--collection", default=None)319 p_list.add_argument("--status", default=None, choices=["learning", "retired"])320 321 sub.add_parser("stats", help="Show statistics")322 323 p_export = sub.add_parser("export", help="Export cards to CSV")324 p_export.add_argument("--output", required=True)325 326 p_import = sub.add_parser("import", help="Import cards from CSV")327 p_import.add_argument("--file", required=True)328 p_import.add_argument("--collection", default="Imported")329 330 p_del = sub.add_parser("delete", help="Delete one card")331 p_del.add_argument("--id", required=True)332 333 p_delcol = sub.add_parser("delete-collection", help="Delete all cards in a collection")334 p_delcol.add_argument("--collection", required=True)335 336 args = parser.parse_args()337 cmd_map = {338 "add": cmd_add,339 "add-quiz": cmd_add_quiz,340 "due": cmd_due,341 "rate": cmd_rate,342 "list": cmd_list,343 "stats": cmd_stats,344 "export": cmd_export,345 "import": cmd_import,346 "delete": cmd_delete,347 "delete-collection": cmd_delete_collection,348 }349 cmd_map[args.command](args)350 351 352if __name__ == "__main__":353 main()354