Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/backup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion integrations/claude-code/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
Expand Down
2 changes: 1 addition & 1 deletion src/memware/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@
"history",
"reject",
]
__version__ = "0.2.4"
__version__ = "0.2.5"
133 changes: 111 additions & 22 deletions src/memware/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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)")
Expand Down
48 changes: 48 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading