diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 79a9d04..6fdae5f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "memware", "source": "./integrations/claude-code", "description": "Session transcripts indexed for recall; a belief ledger that only remembers the latest truth. Hooks: SessionEnd/PreCompact sync, prompt-time belief context.", - "version": "0.2.4", + "version": "0.2.5", "author": { "name": "ericwalisko" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2661188..1c6e0aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.2.5] - 2026-09-03 + +### Added +- `memware setup` is now a guided one-time walkthrough for both new installs and upgrades from + a pre-backup (pre-0.2) version: it offers to index the sessions already on disk, helps pick a + storage-agnostic backup destination, takes a first backup, and prints the operating guidance + (automatic session-end backups, `MEMWARE_NO_CAPTURE`, the wipe trap). `--yes` runs it + non-interactively. A one-line hint points anyone who has never configured backups at it, and + stops once setup has run or a destination is set. + ### Fixed - The Claude Code plugin manifest version was stuck at 0.1.1 across every release, so `claude plugin update` compared 0.1.1 to 0.1.1 and never reinstalled — no plugin or hook diff --git a/README.md b/README.md index f4f16a4..0f06ca6 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,10 @@ sessions; index the transcripts already on disk so recall works over past work f memware backfill # indexes ~/.claude/projects (idempotent; ~5 s for a month) ``` +Prefer a guided first run? `memware setup` walks through the backfill and backups together and +prints the operating guidance — safe on a fresh install and after upgrading from a pre-0.2 +(no-backups) version; `memware setup --yes` accepts the defaults non-interactively. + The *belief ledger* starts empty and is not backfilled — beliefs are derived, not stored in transcripts. It fills as you work (via the `remember` tool, or a derive job you schedule). Transcript recall is what backfill gives you immediately, and it is where most of the value is. @@ -147,7 +151,7 @@ larger than the store. Once a destination is set, backups happen **automatically end (~once a day)** — no cron, and immune to a laptop sleeping through a scheduled time. ```bash -memware setup # pick a folder: Dropbox / iCloud / Drive / external disk +memware setup # guided: index sessions, pick a folder, take a first backup memware backup # tiered snapshot (1/3/7/14-day) + transcript mirror memware restore --latest # after a wipe, restore — do not re-backfill memware nuke # delete everything, typed-confirmation guarded diff --git a/docs/backup.md b/docs/backup.md index 781bfa9..d077309 100644 --- a/docs/backup.md +++ b/docs/backup.md @@ -21,7 +21,10 @@ wipe, **restore from a backup — do not re-backfill.** memware helps: A destination is just a folder. Point it at whatever you already sync or keep: ```bash -memware setup # interactive: asks for the folder, offers transcript mirroring +memware setup # guided walkthrough: indexes existing sessions (new installs), + # picks a folder, offers transcript mirroring, takes a first backup. + # Run it on a fresh install or after upgrading from a pre-0.2 version; + # `memware setup --yes` accepts defaults non-interactively. # or set it directly: memware config backup.dest "~/Dropbox/memware" # or ~/Library/Mobile Documents/…/memware, # ~/Google Drive/memware, /Volumes/backup/memware diff --git a/integrations/claude-code/.claude-plugin/plugin.json b/integrations/claude-code/.claude-plugin/plugin.json index 3a07fe5..f64aaee 100644 --- a/integrations/claude-code/.claude-plugin/plugin.json +++ b/integrations/claude-code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memware", "description": "Session transcripts indexed for recall; a belief ledger that only remembers the latest truth.", - "version": "0.2.4", + "version": "0.2.5", "author": { "name": "ericwalisko" } diff --git a/src/memware/__init__.py b/src/memware/__init__.py index adcf821..4cdd26f 100644 --- a/src/memware/__init__.py +++ b/src/memware/__init__.py @@ -22,4 +22,4 @@ "history", "reject", ] -__version__ = "0.2.4" +__version__ = "0.2.5" diff --git a/src/memware/cli.py b/src/memware/cli.py index ba4c840..fc12f32 100644 --- a/src/memware/cli.py +++ b/src/memware/cli.py @@ -40,6 +40,7 @@ def _out(obj: object, as_json: bool) -> None: def cmd_init(a: argparse.Namespace) -> int: + _maybe_setup_hint(a) with Store(a.db) as s: _out({"db": str(s.path), **s.stats()}, a.json) return 0 @@ -117,6 +118,7 @@ def cmd_backfill(a: argparse.Namespace) -> int: The plugin only captures new sessions; this reads what is already on disk. Idempotent — safe to re-run — and it honours the ignore-markers list. """ + _maybe_setup_hint(a) root = Path(a.root).expanduser() if not root.exists(): print(f"nothing to backfill: {root} does not exist", file=sys.stderr) @@ -267,6 +269,7 @@ def cmd_prune(a: argparse.Namespace) -> int: def cmd_stats(a: argparse.Namespace) -> int: + _maybe_setup_hint(a) with Store(a.db) as s: _out({"db": str(s.path), **s.stats()}, a.json) return 0 @@ -344,37 +347,122 @@ def cmd_restore(a: argparse.Namespace) -> int: return 0 +def _prompt(msg: str, default: str = "") -> str: + """input() that returns ``default`` on a closed stdin, so setup is safe non-interactively.""" + try: + return input(msg).strip() + except EOFError: + return default + + +def _yes(msg: str, *, default_yes: bool = True) -> bool: + ans = _prompt(f"{msg} {'[Y/n]' if default_yes else '[y/N]'}: ").lower() + return default_yes if not ans else ans[0] == "y" + + +def _maybe_setup_hint(a: argparse.Namespace) -> None: + """A one-line nudge to `memware setup` for anyone who has never configured backups — new + installs and upgrades from a pre-backup (pre-0.2) version alike. Silent from hooks and in + --json mode; stops as soon as setup has run or a destination is configured.""" + from memware.config import get_dotted, load_config + + if getattr(a, "from_hook", False) or getattr(a, "json", False): + return + cfg = load_config() + if get_dotted(cfg, "setup.completed_version") or get_dotted(cfg, "backup.dest"): + return + print( + "Tip: run `memware setup` to configure backups (one time; this hint then stops).", + file=sys.stderr, + ) + + def cmd_setup(a: argparse.Namespace) -> int: - """Interactive first-run guidance for the backup destination (storage-agnostic).""" + """Guided one-time configuration: index the sessions already on disk (new installs), + choose a backup destination, run a first backup, and print the operating guidance. Safe to + re-run, and safe non-interactive — a closed stdin (or ``--yes``) keeps every current value. + Covers a fresh install and an upgrade from a pre-backup (pre-0.2) version alike.""" + from memware import backup as bk from memware.config import get_dotted, load_config, save_config, set_dotted cfg = load_config() + yes = getattr(a, "yes", False) + src_default = get_dotted(cfg, "backup.transcript_src") or "~/.claude/projects" + + with Store(a.db) as s: + stats = s.stats() + fresh = stats["turns"] == 0 + print("memware setup\n") + if fresh: + print("This store is empty. The plugin captures new sessions from now on; you can also") + print("index the transcripts already on disk so recall works over past work today.") + else: + print(f"This store holds {stats['turns']:,} turns from {stats['sessions']:,} sessions.") + print("Let's make sure backups are configured so an aged session can't be lost.") + + # 1. Backfill existing transcripts (mainly a fresh install / new machine). + root = Path(src_default).expanduser() + if ( + fresh + and root.exists() + and (yes or _yes(f"\nIndex existing sessions in {src_default} now?")) + ): + with Store(a.db) as s: + report = sync_tree(s, root, harness="claude-code") + stats = s.stats() + print( + f" indexed {sum(report.values()):,} turns from {len(report)} files " + f"({stats['sessions']:,} sessions)." + ) + + # 2. Backup destination. + print("\nBackups: pick a folder your OS already syncs, or a drive you keep — memware just") + print("writes there (Dropbox, iCloud Drive, Google Drive, an external disk, a network mount).") + print("Snapshots are a rolling 1/3/7/14-day set you can revert to; raw transcripts are") + print("mirrored separately so a session outlives your OS's ~30-day transcript cleanup.") cur = get_dotted(cfg, "backup.dest") - print("memware backup setup") - print(" Pick a folder your OS already syncs or a drive you keep — memware just writes") - print( - " snapshots there. Examples: ~/Dropbox/memware, ~/Library/Mobile Documents/com~apple~CloudDocs/memware," - ) - print(" ~/Google Drive/memware, /Volumes/backup/memware.") if cur: print(f" Current: {cur}") - try: - dest = input("Backup folder (blank to keep current): ").strip() - except EOFError: - dest = "" + dest = "" if yes else _prompt("Backup folder (blank to keep current / skip): ") if dest: set_dotted(cfg, "backup.dest", dest) - try: - t = ( - input("Also mirror transcripts there so they outlive the 30-day cleanup? [Y/n]: ") - .strip() - .lower() + dest = get_dotted(cfg, "backup.dest") + if dest: + set_dotted( + cfg, + "backup.include_transcripts", + True if yes else _yes("Also mirror raw transcripts there (recommended)?"), ) - except EOFError: - t = "" - set_dotted(cfg, "backup.include_transcripts", t != "n") - path = save_config(cfg) - print(f"\nSaved {path}. Run `memware backup` now, and schedule it daily (see docs/backup.md).") + + # 3. Persist, and mark setup done so the discovery hint stops. + set_dotted(cfg, "setup.completed_version", __version__) + print(f"\nSaved {save_config(cfg)}.") + + # 4. Offer a first backup right now. + if dest and (yes or _yes("Run a first backup now?")): + dpath = Path(dest).expanduser() + out = bk.snapshot(a.db, dpath) + bk.apply_retention(dpath, get_dotted(cfg, "backup.keep_days") or [1, 3, 7, 14]) + n = ( + bk.mirror_transcripts(get_dotted(cfg, "backup.transcript_src") or src_default, dpath) + if get_dotted(cfg, "backup.include_transcripts") + else 0 + ) + print(f" snapshot {Path(out).name}" + (f", {n} transcripts mirrored" if n else "")) + + # 5. Operating guidance. + print("\nHow backups keep running:") + if dest: + print(" • The Claude Code plugin backs up at session end, at most once every ~20h — no") + print(" cron, and never missed by a laptop sleeping through a scheduled time.") + print(" • Always-on machine without the plugin? Schedule `memware backup` (launchd on") + print(" macOS, systemd on Linux; avoid plain cron on a laptop). See docs/backup.md.") + else: + print(" • No destination set — recall still works, but there's no wipe-trap safety net.") + print(" Re-run `memware setup` any time to add one.") + print(" • Sensitive session? Set MEMWARE_NO_CAPTURE=1 and it is never indexed.") + print(" • After a wipe, `memware restore --latest` — never wipe-and-re-backfill (backfill") + print(" only re-indexes transcripts still on disk). See docs/backup.md.") return 0 @@ -596,7 +684,8 @@ def add(name: str, help: str) -> argparse.ArgumentParser: s.add_argument("--dest", metavar="DIR", help="backup destination to pick the latest from") s.set_defaults(fn=cmd_restore) - s = add("setup", "interactive one-time backup setup (storage-agnostic)") + s = add("setup", "guided one-time setup: index existing sessions, configure backups") + s.add_argument("--yes", action="store_true", help="accept defaults; non-interactive") s.set_defaults(fn=cmd_setup) s = add("config", "show or set configuration (e.g. backup.dest, backup.keep_days)") diff --git a/tests/test_cli.py b/tests/test_cli.py index 111e487..193eb8f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -59,3 +59,51 @@ def test_backfill_indexes_existing_transcripts(tmp_path, capsys): # idempotent assert main(["--db", db, "backfill", str(tmp_path / "projects"), "--json"]) == 0 assert json.loads(capsys.readouterr().out)["turns_added"] == 0 + + +def test_setup_yes_backfills_and_makes_first_backup(tmp_path, capsys, monkeypatch): + """A fresh-install walkthrough end to end: --yes indexes the sessions already on disk and + takes a first backup, and it marks setup done so the discovery hint stops.""" + from memware import __version__ + from memware import backup as bk + from memware.config import get_dotted, load_config + + monkeypatch.setenv("MEMWARE_HOME", str(tmp_path / "home")) + projects = tmp_path / "projects" + (projects / "p").mkdir(parents=True) + write_claude_jsonl( + projects / "p" / "s.jsonl", + "s", + [("assistant", "2026-08-20T00:00:00Z", "the nightly job compacts the write-ahead log")], + ) + dest = tmp_path / "dropbox" / "memware" + db = str(tmp_path / "m.db") + for k, v in (("backup.transcript_src", str(projects)), ("backup.dest", str(dest))): + main(["--db", db, "config", k, str(v)]) + capsys.readouterr() + + assert main(["--db", db, "setup", "--yes"]) == 0 + out = capsys.readouterr().out + assert "indexed 1 turns" in out + + assert main(["--db", db, "stats", "--json"]) == 0 + assert json.loads(capsys.readouterr().out)["turns"] == 1 # backfill happened + assert get_dotted(load_config(), "setup.completed_version") == __version__ + assert bk.list_snapshots(dest) # a first snapshot was taken + assert (dest / "transcripts" / "p" / "s.jsonl").exists() # transcripts mirrored + + +def test_setup_hint_shows_until_backups_configured(tmp_path, capsys, monkeypatch): + monkeypatch.setenv("MEMWARE_HOME", str(tmp_path / "home")) + db = str(tmp_path / "m.db") + + main(["--db", db, "stats"]) # never set up -> the tip appears on stderr + assert "memware setup" in capsys.readouterr().err + + main(["--db", db, "stats", "--json"]) # machine-readable callers never see it + assert "memware setup" not in capsys.readouterr().err + + main(["--db", db, "config", "backup.dest", str(tmp_path / "bk")]) + capsys.readouterr() + main(["--db", db, "stats"]) # once a destination exists the tip is gone + assert "memware setup" not in capsys.readouterr().err