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.
- {memware-0.2.2 → memware-0.2.5}/.claude-plugin/marketplace.json +1 -1
- {memware-0.2.2 → memware-0.2.5}/.gitignore +3 -0
- {memware-0.2.2 → memware-0.2.5}/CHANGELOG.md +30 -1
- {memware-0.2.2 → memware-0.2.5}/PKG-INFO +8 -2
- {memware-0.2.2 → memware-0.2.5}/README.md +7 -1
- {memware-0.2.2 → memware-0.2.5}/RELEASING.md +12 -2
- {memware-0.2.2 → memware-0.2.5}/docs/backup.md +4 -1
- {memware-0.2.2 → memware-0.2.5}/integrations/claude-code/.claude-plugin/plugin.json +1 -1
- {memware-0.2.2 → memware-0.2.5}/src/memware/__init__.py +1 -1
- {memware-0.2.2 → memware-0.2.5}/src/memware/backup.py +2 -1
- {memware-0.2.2 → memware-0.2.5}/src/memware/cli.py +111 -22
- {memware-0.2.2 → memware-0.2.5}/src/memware/index.py +39 -3
- {memware-0.2.2 → memware-0.2.5}/tests/test_cli.py +48 -0
- {memware-0.2.2 → memware-0.2.5}/tests/test_hermes_upstream_plugin.py +4 -2
- {memware-0.2.2 → memware-0.2.5}/tests/test_index.py +35 -2
- memware-0.2.5/tests/test_plugin_manifest.py +24 -0
- {memware-0.2.2 → memware-0.2.5}/.github/CODEOWNERS +0 -0
- {memware-0.2.2 → memware-0.2.5}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {memware-0.2.2 → memware-0.2.5}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {memware-0.2.2 → memware-0.2.5}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {memware-0.2.2 → memware-0.2.5}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {memware-0.2.2 → memware-0.2.5}/.github/dependabot.yml +0 -0
- {memware-0.2.2 → memware-0.2.5}/.github/workflows/ci.yml +0 -0
- {memware-0.2.2 → memware-0.2.5}/.github/workflows/release.yml +0 -0
- {memware-0.2.2 → memware-0.2.5}/.pre-commit-config.yaml +0 -0
- {memware-0.2.2 → memware-0.2.5}/CODE_OF_CONDUCT.md +0 -0
- {memware-0.2.2 → memware-0.2.5}/CONTRIBUTING.md +0 -0
- {memware-0.2.2 → memware-0.2.5}/LICENSE +0 -0
- {memware-0.2.2 → memware-0.2.5}/SECURITY.md +0 -0
- {memware-0.2.2 → memware-0.2.5}/docs/design.md +0 -0
- {memware-0.2.2 → memware-0.2.5}/docs/eval.md +0 -0
- {memware-0.2.2 → memware-0.2.5}/docs/integrations.md +0 -0
- {memware-0.2.2 → memware-0.2.5}/docs/keeping-memory-clean.md +0 -0
- {memware-0.2.2 → memware-0.2.5}/docs/upstream-hermes-pr.md +0 -0
- {memware-0.2.2 → memware-0.2.5}/eval/questions.example.jsonl +0 -0
- {memware-0.2.2 → memware-0.2.5}/integrations/claude-code/README.md +0 -0
- {memware-0.2.2 → memware-0.2.5}/integrations/claude-code/hooks/hooks.json +0 -0
- {memware-0.2.2 → memware-0.2.5}/integrations/hermes/README.md +0 -0
- {memware-0.2.2 → memware-0.2.5}/integrations/hermes/memware/__init__.py +0 -0
- {memware-0.2.2 → memware-0.2.5}/integrations/hermes/memware/plugin.yaml +0 -0
- {memware-0.2.2 → memware-0.2.5}/integrations/hermes/upstream/README.md +0 -0
- {memware-0.2.2 → memware-0.2.5}/integrations/hermes/upstream/plugins/memory/memware/README.md +0 -0
- {memware-0.2.2 → memware-0.2.5}/integrations/hermes/upstream/plugins/memory/memware/__init__.py +0 -0
- {memware-0.2.2 → memware-0.2.5}/integrations/hermes/upstream/plugins/memory/memware/config_schema.py +0 -0
- {memware-0.2.2 → memware-0.2.5}/integrations/hermes/upstream/plugins/memory/memware/plugin.yaml +0 -0
- {memware-0.2.2 → memware-0.2.5}/integrations/hermes/upstream/tests/plugins/memory/test_memware_provider.py +0 -0
- {memware-0.2.2 → memware-0.2.5}/integrations/hermes/upstream/website/docs/user-guide/features/memory-providers.memware.md +0 -0
- {memware-0.2.2 → memware-0.2.5}/pyproject.toml +0 -0
- {memware-0.2.2 → memware-0.2.5}/scripts/leak-check.sh +0 -0
- {memware-0.2.2 → memware-0.2.5}/src/memware/config.py +0 -0
- {memware-0.2.2 → memware-0.2.5}/src/memware/eval.py +0 -0
- {memware-0.2.2 → memware-0.2.5}/src/memware/ingest/__init__.py +0 -0
- {memware-0.2.2 → memware-0.2.5}/src/memware/ingest/claude_code.py +0 -0
- {memware-0.2.2 → memware-0.2.5}/src/memware/ingest/generic.py +0 -0
- {memware-0.2.2 → memware-0.2.5}/src/memware/ledger.py +0 -0
- {memware-0.2.2 → memware-0.2.5}/src/memware/mcp_server.py +0 -0
- {memware-0.2.2 → memware-0.2.5}/src/memware/passage.py +0 -0
- {memware-0.2.2 → memware-0.2.5}/src/memware/py.typed +0 -0
- {memware-0.2.2 → memware-0.2.5}/src/memware/review.py +0 -0
- {memware-0.2.2 → memware-0.2.5}/src/memware/store.py +0 -0
- {memware-0.2.2 → memware-0.2.5}/tests/__init__.py +0 -0
- {memware-0.2.2 → memware-0.2.5}/tests/conftest.py +0 -0
- {memware-0.2.2 → memware-0.2.5}/tests/test_backup.py +0 -0
- {memware-0.2.2 → memware-0.2.5}/tests/test_eval.py +0 -0
- {memware-0.2.2 → memware-0.2.5}/tests/test_hermes_plugin.py +0 -0
- {memware-0.2.2 → memware-0.2.5}/tests/test_ingest.py +0 -0
- {memware-0.2.2 → memware-0.2.5}/tests/test_ledger.py +0 -0
- {memware-0.2.2 → memware-0.2.5}/tests/test_passage.py +0 -0
- {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.
|
|
13
|
+
"version": "0.2.5",
|
|
14
14
|
"author": {
|
|
15
15
|
"name": "ericwalisko"
|
|
16
16
|
}
|
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
|
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.**
|
|
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]
|
|
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 #
|
|
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
|
|
@@ -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
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
.
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
set_dotted(cfg, "
|
|
376
|
-
|
|
377
|
-
|
|
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", "
|
|
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
|
-
|
|
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=
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
{memware-0.2.2 → memware-0.2.5}/integrations/hermes/upstream/plugins/memory/memware/README.md
RENAMED
|
File without changes
|
{memware-0.2.2 → memware-0.2.5}/integrations/hermes/upstream/plugins/memory/memware/__init__.py
RENAMED
|
File without changes
|
{memware-0.2.2 → memware-0.2.5}/integrations/hermes/upstream/plugins/memory/memware/config_schema.py
RENAMED
|
File without changes
|
{memware-0.2.2 → memware-0.2.5}/integrations/hermes/upstream/plugins/memory/memware/plugin.yaml
RENAMED
|
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
|