memware 0.2.2__tar.gz → 0.2.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. {memware-0.2.2 → memware-0.2.5}/.claude-plugin/marketplace.json +1 -1
  2. {memware-0.2.2 → memware-0.2.5}/.gitignore +3 -0
  3. {memware-0.2.2 → memware-0.2.5}/CHANGELOG.md +30 -1
  4. {memware-0.2.2 → memware-0.2.5}/PKG-INFO +8 -2
  5. {memware-0.2.2 → memware-0.2.5}/README.md +7 -1
  6. {memware-0.2.2 → memware-0.2.5}/RELEASING.md +12 -2
  7. {memware-0.2.2 → memware-0.2.5}/docs/backup.md +4 -1
  8. {memware-0.2.2 → memware-0.2.5}/integrations/claude-code/.claude-plugin/plugin.json +1 -1
  9. {memware-0.2.2 → memware-0.2.5}/src/memware/__init__.py +1 -1
  10. {memware-0.2.2 → memware-0.2.5}/src/memware/backup.py +2 -1
  11. {memware-0.2.2 → memware-0.2.5}/src/memware/cli.py +111 -22
  12. {memware-0.2.2 → memware-0.2.5}/src/memware/index.py +39 -3
  13. {memware-0.2.2 → memware-0.2.5}/tests/test_cli.py +48 -0
  14. {memware-0.2.2 → memware-0.2.5}/tests/test_hermes_upstream_plugin.py +4 -2
  15. {memware-0.2.2 → memware-0.2.5}/tests/test_index.py +35 -2
  16. memware-0.2.5/tests/test_plugin_manifest.py +24 -0
  17. {memware-0.2.2 → memware-0.2.5}/.github/CODEOWNERS +0 -0
  18. {memware-0.2.2 → memware-0.2.5}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  19. {memware-0.2.2 → memware-0.2.5}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  20. {memware-0.2.2 → memware-0.2.5}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  21. {memware-0.2.2 → memware-0.2.5}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  22. {memware-0.2.2 → memware-0.2.5}/.github/dependabot.yml +0 -0
  23. {memware-0.2.2 → memware-0.2.5}/.github/workflows/ci.yml +0 -0
  24. {memware-0.2.2 → memware-0.2.5}/.github/workflows/release.yml +0 -0
  25. {memware-0.2.2 → memware-0.2.5}/.pre-commit-config.yaml +0 -0
  26. {memware-0.2.2 → memware-0.2.5}/CODE_OF_CONDUCT.md +0 -0
  27. {memware-0.2.2 → memware-0.2.5}/CONTRIBUTING.md +0 -0
  28. {memware-0.2.2 → memware-0.2.5}/LICENSE +0 -0
  29. {memware-0.2.2 → memware-0.2.5}/SECURITY.md +0 -0
  30. {memware-0.2.2 → memware-0.2.5}/docs/design.md +0 -0
  31. {memware-0.2.2 → memware-0.2.5}/docs/eval.md +0 -0
  32. {memware-0.2.2 → memware-0.2.5}/docs/integrations.md +0 -0
  33. {memware-0.2.2 → memware-0.2.5}/docs/keeping-memory-clean.md +0 -0
  34. {memware-0.2.2 → memware-0.2.5}/docs/upstream-hermes-pr.md +0 -0
  35. {memware-0.2.2 → memware-0.2.5}/eval/questions.example.jsonl +0 -0
  36. {memware-0.2.2 → memware-0.2.5}/integrations/claude-code/README.md +0 -0
  37. {memware-0.2.2 → memware-0.2.5}/integrations/claude-code/hooks/hooks.json +0 -0
  38. {memware-0.2.2 → memware-0.2.5}/integrations/hermes/README.md +0 -0
  39. {memware-0.2.2 → memware-0.2.5}/integrations/hermes/memware/__init__.py +0 -0
  40. {memware-0.2.2 → memware-0.2.5}/integrations/hermes/memware/plugin.yaml +0 -0
  41. {memware-0.2.2 → memware-0.2.5}/integrations/hermes/upstream/README.md +0 -0
  42. {memware-0.2.2 → memware-0.2.5}/integrations/hermes/upstream/plugins/memory/memware/README.md +0 -0
  43. {memware-0.2.2 → memware-0.2.5}/integrations/hermes/upstream/plugins/memory/memware/__init__.py +0 -0
  44. {memware-0.2.2 → memware-0.2.5}/integrations/hermes/upstream/plugins/memory/memware/config_schema.py +0 -0
  45. {memware-0.2.2 → memware-0.2.5}/integrations/hermes/upstream/plugins/memory/memware/plugin.yaml +0 -0
  46. {memware-0.2.2 → memware-0.2.5}/integrations/hermes/upstream/tests/plugins/memory/test_memware_provider.py +0 -0
  47. {memware-0.2.2 → memware-0.2.5}/integrations/hermes/upstream/website/docs/user-guide/features/memory-providers.memware.md +0 -0
  48. {memware-0.2.2 → memware-0.2.5}/pyproject.toml +0 -0
  49. {memware-0.2.2 → memware-0.2.5}/scripts/leak-check.sh +0 -0
  50. {memware-0.2.2 → memware-0.2.5}/src/memware/config.py +0 -0
  51. {memware-0.2.2 → memware-0.2.5}/src/memware/eval.py +0 -0
  52. {memware-0.2.2 → memware-0.2.5}/src/memware/ingest/__init__.py +0 -0
  53. {memware-0.2.2 → memware-0.2.5}/src/memware/ingest/claude_code.py +0 -0
  54. {memware-0.2.2 → memware-0.2.5}/src/memware/ingest/generic.py +0 -0
  55. {memware-0.2.2 → memware-0.2.5}/src/memware/ledger.py +0 -0
  56. {memware-0.2.2 → memware-0.2.5}/src/memware/mcp_server.py +0 -0
  57. {memware-0.2.2 → memware-0.2.5}/src/memware/passage.py +0 -0
  58. {memware-0.2.2 → memware-0.2.5}/src/memware/py.typed +0 -0
  59. {memware-0.2.2 → memware-0.2.5}/src/memware/review.py +0 -0
  60. {memware-0.2.2 → memware-0.2.5}/src/memware/store.py +0 -0
  61. {memware-0.2.2 → memware-0.2.5}/tests/__init__.py +0 -0
  62. {memware-0.2.2 → memware-0.2.5}/tests/conftest.py +0 -0
  63. {memware-0.2.2 → memware-0.2.5}/tests/test_backup.py +0 -0
  64. {memware-0.2.2 → memware-0.2.5}/tests/test_eval.py +0 -0
  65. {memware-0.2.2 → memware-0.2.5}/tests/test_hermes_plugin.py +0 -0
  66. {memware-0.2.2 → memware-0.2.5}/tests/test_ingest.py +0 -0
  67. {memware-0.2.2 → memware-0.2.5}/tests/test_ledger.py +0 -0
  68. {memware-0.2.2 → memware-0.2.5}/tests/test_passage.py +0 -0
  69. {memware-0.2.2 → memware-0.2.5}/tests/test_review.py +0 -0
@@ -10,7 +10,7 @@
10
10
  "name": "memware",
11
11
  "source": "./integrations/claude-code",
12
12
  "description": "Session transcripts indexed for recall; a belief ledger that only remembers the latest truth. Hooks: SessionEnd/PreCompact sync, prompt-time belief context.",
13
- "version": "0.1.1",
13
+ "version": "0.2.5",
14
14
  "author": {
15
15
  "name": "ericwalisko"
16
16
  }
@@ -13,3 +13,6 @@ htmlcov/
13
13
  *.db-wal
14
14
  *.db-shm
15
15
  .memware/
16
+
17
+ # uv lockfile (library, not committed)
18
+ uv.lock
@@ -6,7 +6,36 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- ## [0.2.2] - 2026-09-03
9
+ ## [0.2.5] - 2026-09-03
10
+
11
+ ### Added
12
+ - `memware setup` is now a guided one-time walkthrough for both new installs and upgrades from
13
+ a pre-backup (pre-0.2) version: it offers to index the sessions already on disk, helps pick a
14
+ storage-agnostic backup destination, takes a first backup, and prints the operating guidance
15
+ (automatic session-end backups, `MEMWARE_NO_CAPTURE`, the wipe trap). `--yes` runs it
16
+ non-interactively. A one-line hint points anyone who has never configured backups at it, and
17
+ stops once setup has run or a destination is set.
18
+
19
+ ### Fixed
20
+ - The Claude Code plugin manifest version was stuck at 0.1.1 across every release, so
21
+ `claude plugin update` compared 0.1.1 to 0.1.1 and never reinstalled — no plugin or hook
22
+ change (e.g. the 0.2.x automatic session-end backup hook) could reach an installed
23
+ machine. Both manifests now track the package version, enforced by
24
+ `tests/test_plugin_manifest.py` so it cannot silently drift again.
25
+
26
+ ## [0.2.4] - 2026-09-03
27
+
28
+ ### Fixed
29
+ - Recall now collapses turns whose quoted text is **byte-identical** to a higher-ranked hit, so a
30
+ scheduled-automation prompt captured on many days takes one result slot instead of several. The
31
+ static skip-prefix list (a schema-v2 sweep) only catches harness-injected boilerplate; a user's
32
+ own repeated cron/system prompts are real turns it can never match. Collapse is a recall-time
33
+ view — every turn stays in the store, a session still reads back whole, and distinct findings
34
+ inside those sessions still surface. Deterministic, model-free; opt out with
35
+ `collapse_duplicates=False`.
36
+
37
+
38
+ ## [0.2.3] - 2026-09-03
10
39
 
11
40
  ### Fixed
12
41
  - Backup retention now **promotes**: a snapshot ages forward through the 1/3/7/14-day tiers
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memware
3
- Version: 0.2.2
3
+ Version: 0.2.5
4
4
  Summary: Memory for AI agents that only remembers the latest truth: a bi-temporal belief ledger plus a transcript index, in one SQLite file.
5
5
  Project-URL: Homepage, https://github.com/ericwalisko/memware
6
6
  Project-URL: Issues, https://github.com/ericwalisko/memware/issues
@@ -122,6 +122,10 @@ sessions; index the transcripts already on disk so recall works over past work f
122
122
  memware backfill # indexes ~/.claude/projects (idempotent; ~5 s for a month)
123
123
  ```
124
124
 
125
+ Prefer a guided first run? `memware setup` walks through the backfill and backups together and
126
+ prints the operating guidance — safe on a fresh install and after upgrading from a pre-0.2
127
+ (no-backups) version; `memware setup --yes` accepts the defaults non-interactively.
128
+
125
129
  The *belief ledger* starts empty and is not backfilled — beliefs are derived, not stored in
126
130
  transcripts. It fills as you work (via the `remember` tool, or a derive job you schedule).
127
131
  Transcript recall is what backfill gives you immediately, and it is where most of the value is.
@@ -164,6 +168,8 @@ expects), the same way it would issue a few grep or web-search queries:
164
168
  recall(queries=["which port does the api listen on", "api port", "8443", "gateway listen port"])
165
169
  ```
166
170
 
171
+ Byte-identical hits collapse to a single slot, so a prompt captured on many days — a scheduled job's own preamble, say — never crowds out distinct evidence; the turns stay in the store and a session still reads back whole.
172
+
167
173
  Prompt-time injection (the hooks) stays deterministic and only injects beliefs whose
168
174
  *subject* the prompt names.
169
175
 
@@ -176,7 +182,7 @@ larger than the store. Once a destination is set, backups happen **automatically
176
182
  end (~once a day)** — no cron, and immune to a laptop sleeping through a scheduled time.
177
183
 
178
184
  ```bash
179
- memware setup # pick a folder: Dropbox / iCloud / Drive / external disk
185
+ memware setup # guided: index sessions, pick a folder, take a first backup
180
186
  memware backup # tiered snapshot (1/3/7/14-day) + transcript mirror
181
187
  memware restore --latest # after a wipe, restore — do not re-backfill
182
188
  memware nuke # delete everything, typed-confirmation guarded
@@ -91,6 +91,10 @@ sessions; index the transcripts already on disk so recall works over past work f
91
91
  memware backfill # indexes ~/.claude/projects (idempotent; ~5 s for a month)
92
92
  ```
93
93
 
94
+ Prefer a guided first run? `memware setup` walks through the backfill and backups together and
95
+ prints the operating guidance — safe on a fresh install and after upgrading from a pre-0.2
96
+ (no-backups) version; `memware setup --yes` accepts the defaults non-interactively.
97
+
94
98
  The *belief ledger* starts empty and is not backfilled — beliefs are derived, not stored in
95
99
  transcripts. It fills as you work (via the `remember` tool, or a derive job you schedule).
96
100
  Transcript recall is what backfill gives you immediately, and it is where most of the value is.
@@ -133,6 +137,8 @@ expects), the same way it would issue a few grep or web-search queries:
133
137
  recall(queries=["which port does the api listen on", "api port", "8443", "gateway listen port"])
134
138
  ```
135
139
 
140
+ Byte-identical hits collapse to a single slot, so a prompt captured on many days — a scheduled job's own preamble, say — never crowds out distinct evidence; the turns stay in the store and a session still reads back whole.
141
+
136
142
  Prompt-time injection (the hooks) stays deterministic and only injects beliefs whose
137
143
  *subject* the prompt names.
138
144
 
@@ -145,7 +151,7 @@ larger than the store. Once a destination is set, backups happen **automatically
145
151
  end (~once a day)** — no cron, and immune to a laptop sleeping through a scheduled time.
146
152
 
147
153
  ```bash
148
- memware setup # pick a folder: Dropbox / iCloud / Drive / external disk
154
+ memware setup # guided: index sessions, pick a folder, take a first backup
149
155
  memware backup # tiered snapshot (1/3/7/14-day) + transcript mirror
150
156
  memware restore --latest # after a wipe, restore — do not re-backfill
151
157
  memware nuke # delete everything, typed-confirmation guarded
@@ -71,13 +71,23 @@ Troubleshooting below.
71
71
 
72
72
  ## Cutting a release
73
73
 
74
- 1. **Bump the version.** It lives in exactly one place:
74
+ 1. **Bump the version.** The package version lives in:
75
75
 
76
76
  ```
77
77
  src/memware/__init__.py → __version__ = "0.1.1"
78
78
  ```
79
79
 
80
- `pyproject.toml` reads it via `[tool.hatch.version]`, so nothing else needs editing.
80
+ `pyproject.toml` reads it via `[tool.hatch.version]`. The Claude Code plugin also carries
81
+ its own copy of the version in two manifests, and `tests/test_plugin_manifest.py` fails CI
82
+ unless both equal the package version — so bump all three together:
83
+
84
+ ```
85
+ integrations/claude-code/.claude-plugin/plugin.json → "version"
86
+ .claude-plugin/marketplace.json → plugins[memware].version
87
+ ```
88
+
89
+ `claude plugin update` only reinstalls when the manifest version rises, so a plugin or hook
90
+ change that skips this bump never reaches an installed machine.
81
91
 
82
92
  2. **Cut the CHANGELOG.** Move the `## [Unreleased]` entries under a new
83
93
  `## [X.Y.Z] - YYYY-MM-DD` heading and leave `## [Unreleased]` empty above it.
@@ -21,7 +21,10 @@ wipe, **restore from a backup — do not re-backfill.** memware helps:
21
21
  A destination is just a folder. Point it at whatever you already sync or keep:
22
22
 
23
23
  ```bash
24
- memware setup # interactive: asks for the folder, offers transcript mirroring
24
+ memware setup # guided walkthrough: indexes existing sessions (new installs),
25
+ # picks a folder, offers transcript mirroring, takes a first backup.
26
+ # Run it on a fresh install or after upgrading from a pre-0.2 version;
27
+ # `memware setup --yes` accepts defaults non-interactively.
25
28
  # or set it directly:
26
29
  memware config backup.dest "~/Dropbox/memware" # or ~/Library/Mobile Documents/…/memware,
27
30
  # ~/Google Drive/memware, /Volumes/backup/memware
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "memware",
3
3
  "description": "Session transcripts indexed for recall; a belief ledger that only remembers the latest truth.",
4
- "version": "0.1.1",
4
+ "version": "0.2.5",
5
5
  "author": {
6
6
  "name": "ericwalisko"
7
7
  }
@@ -22,4 +22,4 @@ __all__ = [
22
22
  "history",
23
23
  "reject",
24
24
  ]
25
- __version__ = "0.2.2"
25
+ __version__ = "0.2.5"
@@ -13,6 +13,7 @@ import re
13
13
  import shutil
14
14
  import sqlite3
15
15
  from datetime import UTC, datetime
16
+ from itertools import pairwise
16
17
  from pathlib import Path
17
18
 
18
19
  SNAPSHOT_GLOB = "memware-*.db"
@@ -84,7 +85,7 @@ def apply_retention(dest_dir: str | os.PathLike[str], keep_days: list[int]) -> l
84
85
  days = sorted(set(int(d) for d in keep_days))
85
86
  keep: set[Path] = {snaps[0]} # always the freshest
86
87
  edges = [0.0, *[float(d) for d in days]]
87
- for lo, hi in zip(edges, edges[1:], strict=False):
88
+ for lo, hi in pairwise(edges):
88
89
  band = [p for p in snaps if lo < _age_days(p, now) <= hi]
89
90
  if band:
90
91
  keep.add(band[-1]) # oldest in the band (snaps are newest-first) -> promotes forward
@@ -40,6 +40,7 @@ def _out(obj: object, as_json: bool) -> None:
40
40
 
41
41
 
42
42
  def cmd_init(a: argparse.Namespace) -> int:
43
+ _maybe_setup_hint(a)
43
44
  with Store(a.db) as s:
44
45
  _out({"db": str(s.path), **s.stats()}, a.json)
45
46
  return 0
@@ -117,6 +118,7 @@ def cmd_backfill(a: argparse.Namespace) -> int:
117
118
  The plugin only captures new sessions; this reads what is already on disk.
118
119
  Idempotent — safe to re-run — and it honours the ignore-markers list.
119
120
  """
121
+ _maybe_setup_hint(a)
120
122
  root = Path(a.root).expanduser()
121
123
  if not root.exists():
122
124
  print(f"nothing to backfill: {root} does not exist", file=sys.stderr)
@@ -267,6 +269,7 @@ def cmd_prune(a: argparse.Namespace) -> int:
267
269
 
268
270
 
269
271
  def cmd_stats(a: argparse.Namespace) -> int:
272
+ _maybe_setup_hint(a)
270
273
  with Store(a.db) as s:
271
274
  _out({"db": str(s.path), **s.stats()}, a.json)
272
275
  return 0
@@ -344,37 +347,122 @@ def cmd_restore(a: argparse.Namespace) -> int:
344
347
  return 0
345
348
 
346
349
 
350
+ def _prompt(msg: str, default: str = "") -> str:
351
+ """input() that returns ``default`` on a closed stdin, so setup is safe non-interactively."""
352
+ try:
353
+ return input(msg).strip()
354
+ except EOFError:
355
+ return default
356
+
357
+
358
+ def _yes(msg: str, *, default_yes: bool = True) -> bool:
359
+ ans = _prompt(f"{msg} {'[Y/n]' if default_yes else '[y/N]'}: ").lower()
360
+ return default_yes if not ans else ans[0] == "y"
361
+
362
+
363
+ def _maybe_setup_hint(a: argparse.Namespace) -> None:
364
+ """A one-line nudge to `memware setup` for anyone who has never configured backups — new
365
+ installs and upgrades from a pre-backup (pre-0.2) version alike. Silent from hooks and in
366
+ --json mode; stops as soon as setup has run or a destination is configured."""
367
+ from memware.config import get_dotted, load_config
368
+
369
+ if getattr(a, "from_hook", False) or getattr(a, "json", False):
370
+ return
371
+ cfg = load_config()
372
+ if get_dotted(cfg, "setup.completed_version") or get_dotted(cfg, "backup.dest"):
373
+ return
374
+ print(
375
+ "Tip: run `memware setup` to configure backups (one time; this hint then stops).",
376
+ file=sys.stderr,
377
+ )
378
+
379
+
347
380
  def cmd_setup(a: argparse.Namespace) -> int:
348
- """Interactive first-run guidance for the backup destination (storage-agnostic)."""
381
+ """Guided one-time configuration: index the sessions already on disk (new installs),
382
+ choose a backup destination, run a first backup, and print the operating guidance. Safe to
383
+ re-run, and safe non-interactive — a closed stdin (or ``--yes``) keeps every current value.
384
+ Covers a fresh install and an upgrade from a pre-backup (pre-0.2) version alike."""
385
+ from memware import backup as bk
349
386
  from memware.config import get_dotted, load_config, save_config, set_dotted
350
387
 
351
388
  cfg = load_config()
389
+ yes = getattr(a, "yes", False)
390
+ src_default = get_dotted(cfg, "backup.transcript_src") or "~/.claude/projects"
391
+
392
+ with Store(a.db) as s:
393
+ stats = s.stats()
394
+ fresh = stats["turns"] == 0
395
+ print("memware setup\n")
396
+ if fresh:
397
+ print("This store is empty. The plugin captures new sessions from now on; you can also")
398
+ print("index the transcripts already on disk so recall works over past work today.")
399
+ else:
400
+ print(f"This store holds {stats['turns']:,} turns from {stats['sessions']:,} sessions.")
401
+ print("Let's make sure backups are configured so an aged session can't be lost.")
402
+
403
+ # 1. Backfill existing transcripts (mainly a fresh install / new machine).
404
+ root = Path(src_default).expanduser()
405
+ if (
406
+ fresh
407
+ and root.exists()
408
+ and (yes or _yes(f"\nIndex existing sessions in {src_default} now?"))
409
+ ):
410
+ with Store(a.db) as s:
411
+ report = sync_tree(s, root, harness="claude-code")
412
+ stats = s.stats()
413
+ print(
414
+ f" indexed {sum(report.values()):,} turns from {len(report)} files "
415
+ f"({stats['sessions']:,} sessions)."
416
+ )
417
+
418
+ # 2. Backup destination.
419
+ print("\nBackups: pick a folder your OS already syncs, or a drive you keep — memware just")
420
+ print("writes there (Dropbox, iCloud Drive, Google Drive, an external disk, a network mount).")
421
+ print("Snapshots are a rolling 1/3/7/14-day set you can revert to; raw transcripts are")
422
+ print("mirrored separately so a session outlives your OS's ~30-day transcript cleanup.")
352
423
  cur = get_dotted(cfg, "backup.dest")
353
- print("memware backup setup")
354
- print(" Pick a folder your OS already syncs or a drive you keep — memware just writes")
355
- print(
356
- " snapshots there. Examples: ~/Dropbox/memware, ~/Library/Mobile Documents/com~apple~CloudDocs/memware,"
357
- )
358
- print(" ~/Google Drive/memware, /Volumes/backup/memware.")
359
424
  if cur:
360
425
  print(f" Current: {cur}")
361
- try:
362
- dest = input("Backup folder (blank to keep current): ").strip()
363
- except EOFError:
364
- dest = ""
426
+ dest = "" if yes else _prompt("Backup folder (blank to keep current / skip): ")
365
427
  if dest:
366
428
  set_dotted(cfg, "backup.dest", dest)
367
- try:
368
- t = (
369
- input("Also mirror transcripts there so they outlive the 30-day cleanup? [Y/n]: ")
370
- .strip()
371
- .lower()
429
+ dest = get_dotted(cfg, "backup.dest")
430
+ if dest:
431
+ set_dotted(
432
+ cfg,
433
+ "backup.include_transcripts",
434
+ True if yes else _yes("Also mirror raw transcripts there (recommended)?"),
372
435
  )
373
- except EOFError:
374
- t = ""
375
- set_dotted(cfg, "backup.include_transcripts", t != "n")
376
- path = save_config(cfg)
377
- print(f"\nSaved {path}. Run `memware backup` now, and schedule it daily (see docs/backup.md).")
436
+
437
+ # 3. Persist, and mark setup done so the discovery hint stops.
438
+ set_dotted(cfg, "setup.completed_version", __version__)
439
+ print(f"\nSaved {save_config(cfg)}.")
440
+
441
+ # 4. Offer a first backup right now.
442
+ if dest and (yes or _yes("Run a first backup now?")):
443
+ dpath = Path(dest).expanduser()
444
+ out = bk.snapshot(a.db, dpath)
445
+ bk.apply_retention(dpath, get_dotted(cfg, "backup.keep_days") or [1, 3, 7, 14])
446
+ n = (
447
+ bk.mirror_transcripts(get_dotted(cfg, "backup.transcript_src") or src_default, dpath)
448
+ if get_dotted(cfg, "backup.include_transcripts")
449
+ else 0
450
+ )
451
+ print(f" snapshot {Path(out).name}" + (f", {n} transcripts mirrored" if n else ""))
452
+
453
+ # 5. Operating guidance.
454
+ print("\nHow backups keep running:")
455
+ if dest:
456
+ print(" • The Claude Code plugin backs up at session end, at most once every ~20h — no")
457
+ print(" cron, and never missed by a laptop sleeping through a scheduled time.")
458
+ print(" • Always-on machine without the plugin? Schedule `memware backup` (launchd on")
459
+ print(" macOS, systemd on Linux; avoid plain cron on a laptop). See docs/backup.md.")
460
+ else:
461
+ print(" • No destination set — recall still works, but there's no wipe-trap safety net.")
462
+ print(" Re-run `memware setup` any time to add one.")
463
+ print(" • Sensitive session? Set MEMWARE_NO_CAPTURE=1 and it is never indexed.")
464
+ print(" • After a wipe, `memware restore --latest` — never wipe-and-re-backfill (backfill")
465
+ print(" only re-indexes transcripts still on disk). See docs/backup.md.")
378
466
  return 0
379
467
 
380
468
 
@@ -596,7 +684,8 @@ def build_parser() -> argparse.ArgumentParser:
596
684
  s.add_argument("--dest", metavar="DIR", help="backup destination to pick the latest from")
597
685
  s.set_defaults(fn=cmd_restore)
598
686
 
599
- s = add("setup", "interactive one-time backup setup (storage-agnostic)")
687
+ s = add("setup", "guided one-time setup: index existing sessions, configure backups")
688
+ s.add_argument("--yes", action="store_true", help="accept defaults; non-interactive")
600
689
  s.set_defaults(fn=cmd_setup)
601
690
 
602
691
  s = add("config", "show or set configuration (e.g. backup.dest, backup.keep_days)")
@@ -14,6 +14,11 @@ BM25 runs over *passages* (see :mod:`memware.passage`), not whole turns, while
14
14
  activation stays the turn's — recency and use belong to the conversation. Hits
15
15
  collapse to one per turn and quote only the matching passages, so recall costs
16
16
  roughly a third fewer tokens; reading a session back still returns whole turns.
17
+
18
+ Results also collapse across turns whose quoted text is byte-identical — the same
19
+ scheduled-automation prompt captured on many days would otherwise take several slots
20
+ with copies of one string. The highest-ranked copy is kept; the turns themselves stay
21
+ in the store, so a session still reads back whole and distinct findings still surface.
17
22
  """
18
23
 
19
24
  from __future__ import annotations
@@ -174,6 +179,7 @@ def search_turns(
174
179
  record_use: bool = True,
175
180
  snippet_tokens: int = 96,
176
181
  passages_per_turn: int = 3,
182
+ collapse_duplicates: bool = True,
177
183
  ) -> list[Hit]:
178
184
  """Top-k turns by BM25 x activation, each ranked on its best passage.
179
185
 
@@ -208,16 +214,25 @@ def search_turns(
208
214
  score = r["rel"] * activation(r["ts"], r["use_count"], decay=decay, use_weight=use_weight)
209
215
  scored.setdefault(r["turn_id"], []).append((score, r))
210
216
  hits: list[Hit] = []
211
- for turn_id in sorted(scored, key=lambda t: -max(s for s, _ in scored[t]))[:k]:
217
+ seen_text: set[str] = set()
218
+ for turn_id in sorted(scored, key=lambda t: -max(s for s, _ in scored[t])):
219
+ if len(hits) >= k:
220
+ break
212
221
  by_score = sorted(scored[turn_id], key=lambda sr: -sr[0])
213
222
  keep = sorted(by_score[: max(1, passages_per_turn)], key=lambda sr: sr[1]["ord"])
223
+ text = _join_passages([r for _, r in keep])
224
+ if collapse_duplicates:
225
+ sig = text.strip()
226
+ if sig in seen_text:
227
+ continue # byte-identical to a higher-ranked hit (e.g. a repeated cron prompt)
228
+ seen_text.add(sig)
214
229
  best = by_score[0][1]
215
230
  hits.append(
216
231
  Hit(
217
232
  id=turn_id,
218
233
  kind="turn",
219
234
  score=by_score[0][0],
220
- text=_join_passages([r for _, r in keep]),
235
+ text=text,
221
236
  session=best["session"],
222
237
  ts=best["ts"],
223
238
  role=best["role"],
@@ -313,6 +328,25 @@ def _rrf(ranked_lists: list[list[Hit]], k: int, c: int = 60) -> list[Hit]:
313
328
  return [best[kk] for kk in order]
314
329
 
315
330
 
331
+ def _collapse_identical(hits: list[Hit], k: int) -> list[Hit]:
332
+ """Drop turn hits whose quoted text is byte-identical to a higher-ranked one, then take the
333
+ first ``k``. Non-turn hits pass through. This is the cross-phrasing counterpart to the
334
+ per-query collapse in :func:`search_turns`: reciprocal-rank fusion keys on ``(kind, id)``,
335
+ so two different turns holding the same repeated text would otherwise both survive."""
336
+ seen: set[str] = set()
337
+ out: list[Hit] = []
338
+ for h in hits:
339
+ if h.kind == "turn":
340
+ sig = h.text.strip()
341
+ if sig in seen:
342
+ continue
343
+ seen.add(sig)
344
+ out.append(h)
345
+ if len(out) >= k:
346
+ break
347
+ return out
348
+
349
+
316
350
  def search_turns_multi(
317
351
  store: Store,
318
352
  queries: list[str],
@@ -334,8 +368,10 @@ def search_turns_multi(
334
368
  return []
335
369
  if len(seen) == 1:
336
370
  return search_turns(store, seen[0], k=k, record_use=record_use, **kw) # type: ignore[arg-type]
371
+ collapse = bool(kw.get("collapse_duplicates", True))
337
372
  lists = [search_turns(store, q, k=max(k, 20), record_use=False, **kw) for q in seen] # type: ignore[arg-type]
338
- fused = _rrf(lists, k)
373
+ fused = _rrf(lists, max(k * 3, 30) if collapse else k)
374
+ fused = _collapse_identical(fused, k) if collapse else fused[:k]
339
375
  if record_use and fused:
340
376
  ts = now_iso()
341
377
  store.conn.executemany(
@@ -59,3 +59,51 @@ def test_backfill_indexes_existing_transcripts(tmp_path, capsys):
59
59
  # idempotent
60
60
  assert main(["--db", db, "backfill", str(tmp_path / "projects"), "--json"]) == 0
61
61
  assert json.loads(capsys.readouterr().out)["turns_added"] == 0
62
+
63
+
64
+ def test_setup_yes_backfills_and_makes_first_backup(tmp_path, capsys, monkeypatch):
65
+ """A fresh-install walkthrough end to end: --yes indexes the sessions already on disk and
66
+ takes a first backup, and it marks setup done so the discovery hint stops."""
67
+ from memware import __version__
68
+ from memware import backup as bk
69
+ from memware.config import get_dotted, load_config
70
+
71
+ monkeypatch.setenv("MEMWARE_HOME", str(tmp_path / "home"))
72
+ projects = tmp_path / "projects"
73
+ (projects / "p").mkdir(parents=True)
74
+ write_claude_jsonl(
75
+ projects / "p" / "s.jsonl",
76
+ "s",
77
+ [("assistant", "2026-08-20T00:00:00Z", "the nightly job compacts the write-ahead log")],
78
+ )
79
+ dest = tmp_path / "dropbox" / "memware"
80
+ db = str(tmp_path / "m.db")
81
+ for k, v in (("backup.transcript_src", str(projects)), ("backup.dest", str(dest))):
82
+ main(["--db", db, "config", k, str(v)])
83
+ capsys.readouterr()
84
+
85
+ assert main(["--db", db, "setup", "--yes"]) == 0
86
+ out = capsys.readouterr().out
87
+ assert "indexed 1 turns" in out
88
+
89
+ assert main(["--db", db, "stats", "--json"]) == 0
90
+ assert json.loads(capsys.readouterr().out)["turns"] == 1 # backfill happened
91
+ assert get_dotted(load_config(), "setup.completed_version") == __version__
92
+ assert bk.list_snapshots(dest) # a first snapshot was taken
93
+ assert (dest / "transcripts" / "p" / "s.jsonl").exists() # transcripts mirrored
94
+
95
+
96
+ def test_setup_hint_shows_until_backups_configured(tmp_path, capsys, monkeypatch):
97
+ monkeypatch.setenv("MEMWARE_HOME", str(tmp_path / "home"))
98
+ db = str(tmp_path / "m.db")
99
+
100
+ main(["--db", db, "stats"]) # never set up -> the tip appears on stderr
101
+ assert "memware setup" in capsys.readouterr().err
102
+
103
+ main(["--db", db, "stats", "--json"]) # machine-readable callers never see it
104
+ assert "memware setup" not in capsys.readouterr().err
105
+
106
+ main(["--db", db, "config", "backup.dest", str(tmp_path / "bk")])
107
+ capsys.readouterr()
108
+ main(["--db", db, "stats"]) # once a destination exists the tip is gone
109
+ assert "memware setup" not in capsys.readouterr().err
@@ -131,10 +131,12 @@ def test_a_turn_is_captured_and_recallable(provider, tmp_path):
131
131
 
132
132
  def test_a_session_switch_keeps_each_session_separate(provider, tmp_path):
133
133
  """Turns must land in the session that was live when they happened."""
134
- provider.sync_turn("how is the scheduler wired", "the scheduler polls a work queue")
134
+ provider.sync_turn(
135
+ "how is the scheduler wired", "the scheduler polls a work queue every second"
136
+ )
135
137
  provider.shutdown()
136
138
  provider.on_session_switch("sess-2")
137
- provider.sync_turn("how is the scheduler wired", "the scheduler polls a work queue")
139
+ provider.sync_turn("how is the scheduler wired", "the scheduler drains a work queue in batches")
138
140
  provider.shutdown()
139
141
 
140
142
  hits = json.loads(
@@ -21,12 +21,12 @@ def test_turn_recall_ranks_recent_higher_and_records_use(store, tmp_path):
21
21
  write_claude_jsonl(
22
22
  old,
23
23
  "old",
24
- [("assistant", "2024-01-01T00:00:00Z", "deploy uses the blue-green rollout script")],
24
+ [("assistant", "2024-01-01T00:00:00Z", "the old deploy ran a blue-green rollout script")],
25
25
  )
26
26
  write_claude_jsonl(
27
27
  new,
28
28
  "new",
29
- [("assistant", "2026-08-30T00:00:00Z", "deploy uses the blue-green rollout script")],
29
+ [("assistant", "2026-08-30T00:00:00Z", "the new deploy runs a blue-green rollout script")],
30
30
  )
31
31
  sync_file(store, old, harness="claude-code")
32
32
  sync_file(store, new, harness="claude-code")
@@ -191,3 +191,36 @@ def test_multi_query_fusion_finds_what_single_phrasings_rank_low(store, tmp_path
191
191
  )
192
192
  assert "8443" in fused[0].text
193
193
  assert search_turns_multi(store, ["", " "], k=3) == []
194
+
195
+
196
+ def _write_turn(tmp_path, session, text, ts="2026-09-01T00:00:00Z"):
197
+ from tests.conftest import write_claude_jsonl
198
+
199
+ p = tmp_path / f"{session}.jsonl"
200
+ write_claude_jsonl(p, session, [("assistant", ts, text)])
201
+ return p
202
+
203
+
204
+ def test_recall_collapses_byte_identical_turns_but_keeps_distinct_findings(store, tmp_path):
205
+ """A scheduled prompt captured on several days is one string repeated across turns; it
206
+ should take a single result slot, not crowd out real evidence. Distinct findings that merely
207
+ share vocabulary must still surface, and every turn stays in the store."""
208
+ boiler = "you are the nightly drift scan for the pcc runbooks, review each entry"
209
+ for day in ("d1", "d2", "d3"): # byte-identical cron prompt, three days
210
+ sync_file(store, _write_turn(tmp_path, day, boiler), harness="claude-code")
211
+ sync_file( # a real finding from one of those runs — shares vocabulary, different text
212
+ store,
213
+ _write_turn(
214
+ tmp_path, "finding", "the nightly drift scan found a stale runbooks entry to fix"
215
+ ),
216
+ harness="claude-code",
217
+ )
218
+
219
+ collapsed = search_turns(store, "nightly drift scan runbooks", k=5)
220
+ assert len(collapsed) == 2 # the repeated prompt -> one slot, plus the distinct finding
221
+ assert any("stale runbooks entry" in h.text for h in collapsed) # finding not crowded out
222
+
223
+ raw = search_turns(store, "nightly drift scan runbooks", k=5, collapse_duplicates=False)
224
+ assert sum(1 for h in raw if h.text.strip() == boiler) == 3 # opt out -> copies return
225
+
226
+ assert store.stats()["turns"] == 4 # collapse is a recall-time view; nothing was deleted
@@ -0,0 +1,24 @@
1
+ """The Claude Code plugin carries its own version string, and `claude plugin update` only
2
+ reinstalls when it rises. Nothing in the release path enforced it, so it silently sat at 0.1.1
3
+ across many releases and no hook change ever reached an installed machine. These tests keep both
4
+ manifests equal to the package version, so cutting a release always moves the plugin too."""
5
+
6
+ import json
7
+ from pathlib import Path
8
+
9
+ import memware
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+ PLUGIN = ROOT / "integrations" / "claude-code" / ".claude-plugin" / "plugin.json"
13
+ MARKETPLACE = ROOT / ".claude-plugin" / "marketplace.json"
14
+
15
+
16
+ def test_plugin_manifest_version_matches_package():
17
+ plugin = json.loads(PLUGIN.read_text())
18
+ assert plugin["version"] == memware.__version__, (plugin["version"], memware.__version__)
19
+
20
+
21
+ def test_marketplace_plugin_version_matches_package():
22
+ market = json.loads(MARKETPLACE.read_text())
23
+ entry = next(p for p in market["plugins"] if p["name"] == "memware")
24
+ assert entry["version"] == memware.__version__, (entry["version"], memware.__version__)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes