memento-flashcards

Spaced-repetition flashcards: create, review, quiz, export.

  • Education
  • Flashcards
  • Spaced Repetition
  • Learning
  • Quiz
  • YouTube

Declared platforms: macos · linux

Install
npx skills add 'https://github.com/NousResearch/hermes-agent/tree/main/optional-skills/productivity/memento-flashcards'
Download bundle ↓
main · 24fd22bScanned 2026-09-15

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗

scripts/memento_cards.py

scripts/memento_cards.pyBrowse 3 files
View on GitHub
← Back to SKILL.md
#!/usr/bin/env python3"""Memento card storage, spaced-repetition engine, and CSV I/O. Stdlib-only. All output is JSON for agent parsing.Data file: $HERMES_HOME/skills/productivity/memento-flashcards/data/cards.json""" import argparseimport csvimport jsonimport osimport sysimport tempfileimport uuidfrom datetime import datetime, timedelta, timezonefrom pathlib import Path _HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))DATA_DIR = _HERMES_HOME / "skills" / "productivity" / "memento-flashcards" / "data"CARDS_FILE = DATA_DIR / "cards.json" RETIRED_SENTINEL = "9999-12-31T23:59:59+00:00"  def _now() -> datetime:    return datetime.now(timezone.utc)  def _iso(dt: datetime) -> str:    return dt.isoformat()  def _parse_iso(s: str) -> datetime:    return datetime.fromisoformat(s)  def _empty_store() -> dict:    return {"cards": [], "version": 1}  def _load() -> dict:    if not CARDS_FILE.exists():        return _empty_store()    try:        with open(CARDS_FILE, "r", encoding="utf-8") as f:            data = json.load(f)        if not isinstance(data, dict) or "cards" not in data:            return _empty_store()        return data    except (json.JSONDecodeError, OSError):        return _empty_store()  def _save(data: dict) -> None:    DATA_DIR.mkdir(parents=True, exist_ok=True)    fd, tmp = tempfile.mkstemp(dir=DATA_DIR, suffix=".tmp")    try:        with os.fdopen(fd, "w", encoding="utf-8") as f:            json.dump(data, f, indent=2, ensure_ascii=False)            f.write("\n")        os.replace(tmp, CARDS_FILE)    except BaseException:        try:            os.unlink(tmp)        except OSError:            pass        raise  def _out(obj: object) -> None:    json.dump(obj, sys.stdout, indent=2, ensure_ascii=False)    sys.stdout.write("\n")  # ── Subcommands ────────────────────────────────────────────────────────────── def cmd_add(args: argparse.Namespace) -> None:    data = _load()    now = _now()    card = {        "id": str(uuid.uuid4()),        "question": args.question,        "answer": args.answer,        "collection": args.collection or "General",        "status": "learning",        "ease_streak": 0,        "next_review_at": _iso(now),        "created_at": _iso(now),        "video_id": None,        "last_user_answer": None,    }    data["cards"].append(card)    _save(data)    _out({"ok": True, "card": card})  def cmd_add_quiz(args: argparse.Namespace) -> None:    data = _load()    now = _now()     try:        questions = json.loads(args.questions)    except json.JSONDecodeError as exc:        _out({"ok": False, "error": f"Invalid JSON for --questions: {exc}"})        sys.exit(1)     # Dedup: skip if cards with this video_id already exist    existing_ids = {c["video_id"] for c in data["cards"] if c.get("video_id")}    if args.video_id in existing_ids:        existing = [c for c in data["cards"] if c.get("video_id") == args.video_id]        _out({"ok": True, "skipped": True, "reason": "duplicate_video_id", "existing_count": len(existing), "cards": existing})        return     created = []    for qa in questions:        card = {            "id": str(uuid.uuid4()),            "question": qa["question"],            "answer": qa["answer"],            "collection": args.collection or "Quiz",            "status": "learning",            "ease_streak": 0,            "next_review_at": _iso(now),            "created_at": _iso(now),            "video_id": args.video_id,            "last_user_answer": None,        }        data["cards"].append(card)        created.append(card)     _save(data)    _out({"ok": True, "created_count": len(created), "cards": created})  def cmd_due(args: argparse.Namespace) -> None:    data = _load()    now = _now()    due = []    for card in data["cards"]:        if card["status"] == "retired":            continue        review_at = _parse_iso(card["next_review_at"])        if review_at <= now:            if args.collection and card["collection"] != args.collection:                continue            due.append(card)    _out({"ok": True, "count": len(due), "cards": due})  def cmd_rate(args: argparse.Namespace) -> None:    data = _load()    now = _now()    card = None    for c in data["cards"]:        if c["id"] == args.id:            card = c            break    if not card:        _out({"ok": False, "error": f"Card not found: {args.id}"})        sys.exit(1)     rating = args.rating    user_answer = getattr(args, "user_answer", None)    if user_answer is not None:        card["last_user_answer"] = user_answer     if rating == "retire":        card["status"] = "retired"        card["next_review_at"] = RETIRED_SENTINEL        card["ease_streak"] = 0    elif rating == "hard":        card["next_review_at"] = _iso(now + timedelta(days=1))        card["ease_streak"] = 0    elif rating == "good":        card["next_review_at"] = _iso(now + timedelta(days=3))        card["ease_streak"] = 0    elif rating == "easy":        card["next_review_at"] = _iso(now + timedelta(days=7))        card["ease_streak"] = card.get("ease_streak", 0) + 1        if card["ease_streak"] >= 3:            card["status"] = "retired"     _save(data)    _out({"ok": True, "card": card})  def cmd_list(args: argparse.Namespace) -> None:    data = _load()    cards = data["cards"]    if args.collection:        cards = [c for c in cards if c["collection"] == args.collection]    if args.status:        cards = [c for c in cards if c["status"] == args.status]    _out({"ok": True, "count": len(cards), "cards": cards})  def cmd_stats(args: argparse.Namespace) -> None:    data = _load()    now = _now()    total = len(data["cards"])    learning = sum(1 for c in data["cards"] if c["status"] == "learning")    retired = sum(1 for c in data["cards"] if c["status"] == "retired")    due_now = 0    for c in data["cards"]:        if c["status"] != "retired" and _parse_iso(c["next_review_at"]) <= now:            due_now += 1     collections: dict[str, int] = {}    for c in data["cards"]:        name = c["collection"]        collections[name] = collections.get(name, 0) + 1     _out({        "ok": True,        "total": total,        "learning": learning,        "retired": retired,        "due_now": due_now,        "collections": collections,    })  def cmd_export(args: argparse.Namespace) -> None:    data = _load()    output_path = Path(args.output).expanduser()    with open(output_path, "w", newline="", encoding="utf-8") as f:        writer = csv.writer(f, lineterminator="\n")        for card in data["cards"]:            writer.writerow([card["question"], card["answer"], card["collection"]])    _out({"ok": True, "exported": len(data["cards"]), "path": str(output_path)})  def cmd_import(args: argparse.Namespace) -> None:    data = _load()    now = _now()    file_path = Path(args.file).expanduser()     if not file_path.exists():        _out({"ok": False, "error": f"File not found: {file_path}"})        sys.exit(1)     created = 0    with open(file_path, "r", encoding="utf-8") as f:        reader = csv.reader(f)        for row in reader:            if len(row) < 2:                continue            question = row[0].strip()            answer = row[1].strip()            collection = row[2].strip() if len(row) >= 3 and row[2].strip() else (args.collection or "Imported")            if not question or not answer:                continue            card = {                "id": str(uuid.uuid4()),                "question": question,                "answer": answer,                "collection": collection,                "status": "learning",                "ease_streak": 0,                "next_review_at": _iso(now),                "created_at": _iso(now),                "video_id": None,                "last_user_answer": None,            }            data["cards"].append(card)            created += 1     _save(data)    _out({"ok": True, "imported": created})  def cmd_delete(args: argparse.Namespace) -> None:    data = _load()    original = len(data["cards"])    data["cards"] = [c for c in data["cards"] if c["id"] != args.id]    removed = original - len(data["cards"])    if removed == 0:        _out({"ok": False, "error": f"Card not found: {args.id}"})        sys.exit(1)    _save(data)    _out({"ok": True, "deleted": args.id})  def cmd_delete_collection(args: argparse.Namespace) -> None:    data = _load()    original = len(data["cards"])    data["cards"] = [c for c in data["cards"] if c["collection"] != args.collection]    removed = original - len(data["cards"])    _save(data)    _out({"ok": True, "deleted_count": removed, "collection": args.collection})  # ── CLI ────────────────────────────────────────────────────────────────────── def main() -> None:    parser = argparse.ArgumentParser(description="Memento flashcard manager")    sub = parser.add_subparsers(dest="command", required=True)     p_add = sub.add_parser("add", help="Create one card")    p_add.add_argument("--question", required=True)    p_add.add_argument("--answer", required=True)    p_add.add_argument("--collection", default="General")     p_quiz = sub.add_parser("add-quiz", help="Batch-add quiz cards")    p_quiz.add_argument("--video-id", required=True)    p_quiz.add_argument("--questions", required=True, help="JSON array of {question, answer}")    p_quiz.add_argument("--collection", default="Quiz")     p_due = sub.add_parser("due", help="List due cards")    p_due.add_argument("--collection", default=None)     p_rate = sub.add_parser("rate", help="Rate a card")    p_rate.add_argument("--id", required=True)    p_rate.add_argument("--rating", required=True, choices=["easy", "good", "hard", "retire"])    p_rate.add_argument("--user-answer", default=None)     p_list = sub.add_parser("list", help="List cards")    p_list.add_argument("--collection", default=None)    p_list.add_argument("--status", default=None, choices=["learning", "retired"])     sub.add_parser("stats", help="Show statistics")     p_export = sub.add_parser("export", help="Export cards to CSV")    p_export.add_argument("--output", required=True)     p_import = sub.add_parser("import", help="Import cards from CSV")    p_import.add_argument("--file", required=True)    p_import.add_argument("--collection", default="Imported")     p_del = sub.add_parser("delete", help="Delete one card")    p_del.add_argument("--id", required=True)     p_delcol = sub.add_parser("delete-collection", help="Delete all cards in a collection")    p_delcol.add_argument("--collection", required=True)     args = parser.parse_args()    cmd_map = {        "add": cmd_add,        "add-quiz": cmd_add_quiz,        "due": cmd_due,        "rate": cmd_rate,        "list": cmd_list,        "stats": cmd_stats,        "export": cmd_export,        "import": cmd_import,        "delete": cmd_delete,        "delete-collection": cmd_delete_collection,    }    cmd_map[args.command](args)  if __name__ == "__main__":    main()