memkernel 0.4.1__tar.gz → 0.6.0__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 (44) hide show
  1. {memkernel-0.4.1 → memkernel-0.6.0}/PKG-INFO +40 -17
  2. {memkernel-0.4.1 → memkernel-0.6.0}/README.md +37 -16
  3. {memkernel-0.4.1 → memkernel-0.6.0}/pyproject.toml +7 -2
  4. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/__init__.py +1 -1
  5. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/config.py +3 -0
  6. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/core/services.py +68 -7
  7. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/lifecycle.py +92 -22
  8. memkernel-0.6.0/src/memkernel/mcp/tools/wiki.py +127 -0
  9. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/storage/chroma.py +53 -13
  10. memkernel-0.6.0/src/memkernel/wiki/studio.py +555 -0
  11. memkernel-0.6.0/src/memkernel/wiki/sync.py +504 -0
  12. memkernel-0.4.1/src/memkernel/mcp/tools/wiki.py +0 -59
  13. memkernel-0.4.1/src/memkernel/wiki/sync.py +0 -228
  14. {memkernel-0.4.1 → memkernel-0.6.0}/LICENSE +0 -0
  15. {memkernel-0.4.1 → memkernel-0.6.0}/NOTICE +0 -0
  16. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/adapters/__init__.py +0 -0
  17. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/adapters/rlmstudio.py +0 -0
  18. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/api/__init__.py +0 -0
  19. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/api/app.py +0 -0
  20. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/api/routes/__init__.py +0 -0
  21. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/api/routes/admin.py +0 -0
  22. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/core/__init__.py +0 -0
  23. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/core/models.py +0 -0
  24. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/core/ports.py +0 -0
  25. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/factory.py +0 -0
  26. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/mcp/__init__.py +0 -0
  27. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/mcp/server.py +0 -0
  28. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/mcp/tools/__init__.py +0 -0
  29. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/mcp/tools/memory.py +0 -0
  30. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/memory/__init__.py +0 -0
  31. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/memory/dedup.py +0 -0
  32. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/memory/lifecycle.py +0 -0
  33. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/memory/types.py +0 -0
  34. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/py.typed +0 -0
  35. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/retrieval/__init__.py +0 -0
  36. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/retrieval/chunking.py +0 -0
  37. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/retrieval/embeddings.py +0 -0
  38. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/retrieval/reasoner.py +0 -0
  39. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/retrieval/search.py +0 -0
  40. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/retrieval/sparse.py +0 -0
  41. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/storage/__init__.py +0 -0
  42. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/storage/documents.py +0 -0
  43. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/wiki/__init__.py +0 -0
  44. {memkernel-0.4.1 → memkernel-0.6.0}/src/memkernel/wiki/pages.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memkernel
3
- Version: 0.4.1
3
+ Version: 0.6.0
4
4
  Summary: Code-aware memory server for AI coding copilots
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -13,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.11
13
13
  Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Topic :: Software Development :: Libraries
16
+ Provides-Extra: wiki
16
17
  Requires-Dist: chromadb (>=1.0,<2)
17
18
  Requires-Dist: fastapi (>=0.115,<1)
18
19
  Requires-Dist: mcp (>=1.25,<2)
@@ -22,6 +23,7 @@ Requires-Dist: rank-bm25 (>=0.2)
22
23
  Requires-Dist: sentence-transformers (>=4.0,<6)
23
24
  Requires-Dist: tree-sitter (>=0.22,<1)
24
25
  Requires-Dist: tree-sitter-python (>=0.23)
26
+ Requires-Dist: wiki-studio (>=0.7.0,<1) ; extra == "wiki"
25
27
  Project-URL: Documentation, https://memkernel.dev
26
28
  Project-URL: Homepage, https://memkernel.dev
27
29
  Project-URL: Repository, https://github.com/gosha70/memkernel
@@ -63,7 +65,7 @@ MemKernel gives Claude Code (and any MCP-compatible copilot) persistent memory a
63
65
  - **An index of the whole repository.** `code` memories are the snapshots the agent chose to retain, not an automatic scan of every file.
64
66
  - **A cross-project knowledge base.** One store per project id. Sharing is deliberate: a second server with a shared id, or the wiki bridge.
65
67
 
66
- **Evidence.** `eval/run_eval.py` measures retrieval (recall@k, precision@k, MRR). An agent-level benchmark that runs the same agent with and without MemKernel, including a negative control where files already carry all state, is tracked in [#17](https://github.com/gosha70/memkernel/issues/17). Results will be published per minor release, including the cases with no gain.
68
+ **Evidence.** `eval/run_eval.py` measures retrieval (recall@k, precision@k, MRR). The 0.5 lifecycle evaluation, a **maintainer-run supplemental evaluation** (memkernel's own runner, preregistered and announced in [#17](https://github.com/gosha70/memkernel/issues/17) before the run), ran the same agent in seven arms on 20 tasks: [report](eval/lifecycle-suite/report-0.5.0.md). **No gain is claimed.** Against files alone (`claude_md`), memkernel's memory store was ahead on 6 tasks, behind on none and tied on 14 (Holm-adjusted p 0.0625: "no detectable difference"; with so many ties this task set could not have reached significance); against earlier transcripts (`fs_grep`) it was equivalent. Two of the four task groups hit a ceiling, every arm passing every trial. The report's disclosures list its deviations and known defects. Results are published with the cases that show no gain; the 0.5 report is the latest evaluation, and 0.6.0 had no new measured run.
67
69
 
68
70
  ---
69
71
 
@@ -84,6 +86,9 @@ MemKernel is a Python package on PyPI. Its `memkernel` command is the MCP server
84
86
  pipx install memkernel
85
87
  # if pipx's default interpreter is outside 3.11–3.13:
86
88
  pipx install --python python3.11 memkernel
89
+ # optional: wiki-studio lint and health findings on wiki_export (ADR-010)
90
+ pipx install "memkernel[wiki]"
91
+ pipx inject memkernel "wiki-studio>=0.7.0,<1" # add it to an existing install
87
92
  ```
88
93
 
89
94
  **As a library inside a project**, install it into that project's virtual environment:
@@ -142,9 +147,11 @@ Copy the hook scripts from [`templates/hooks/`](templates/hooks/) to `.claude/ho
142
147
 
143
148
  | Hook | Claude Code event | What it does |
144
149
  |------|-------------------|--------------|
145
- | `session-start.py` | `SessionStart` | Consolidates, recovers the latest checkpoint and long-term context, lists promotion candidates |
146
- | `pre-compact.py` | `PreCompact` | Retains a checkpoint episode, runs a consolidation pass |
147
- | `post-compact.py` | `PostCompact` | Re-injects the checkpoint, long-term context and promotion candidates |
150
+ | `session-start.py` | `SessionStart` | By `source`: on `startup` and `clear`, consolidates, recovers the latest checkpoint and long-term context, lists promotion candidates; on `compact`, the recovery after a compaction (the latest checkpoint the agent saved, with its age, and long-term context; no consolidation); on `resume` and `fork`, nothing |
151
+ | `pre-compact.py` | `PreCompact` | Runs a consolidation pass (Claude Code passes no summary here, so no checkpoint is retained) |
152
+ | `post-compact.py` | `PostCompact` | Retains Claude Code's compaction summary (`compact_summary`) as a checkpoint for the next session; prints nothing for the model |
153
+
154
+ Register `SessionStart` without a matcher, so it runs for every `source`. Claude Code adds only `SessionStart`'s output to the model's context, not `PreCompact`'s or `PostCompact`'s, so the recovery after a compaction comes from `SessionStart` with `source: "compact"`. After upgrading memkernel, copy the three scripts again.
148
155
 
149
156
  See the Prerequisites section inside each script for the Python environment requirement. Each script is a thin wrapper over [`MemoryLifecycle`](#embedding-memkernel-in-your-own-harness), so an in-process harness gets the same behaviour without the scripts.
150
157
 
@@ -176,7 +183,7 @@ Kiro (and the Kiro CLI, formerly Amazon Q Developer CLI) connects to MemKernel a
176
183
  | `consolidate(session_id?, limit?, synthesize?, revalidate?)` | Purge expired short-term memories and list the ones most worth promoting (ranked by importance × how often they were recalled). `synthesize=true` also asks an external reasoner to draft long-term statements (`suggested`); `revalidate=true` first checks every code memory against its source file (`revalidated`). The report always carries a `health` summary. |
177
184
  | `ask(ref_id, question, mode?)` | Answer a question about one memory without loading it — for a `document` the full body is consulted. `mode`: `direct` \| `rag` \| `rlm` \| `auto`, passed to the reasoner unchanged; the built-in reasoner answers in `rag` mode with the best-matching chunks. Returns `answer`, `mode`, `citations`, `usage`. |
178
185
  | `wiki_export(wiki_dir?)` | Compile long-term memories without a wiki page into draft markdown pages (`index.md`, `log.md` updated). |
179
- | `wiki_import(wiki_dir?)` | Index curated wiki pages as long-term memories (idempotent; changed pages supersede). |
186
+ | `wiki_import(wiki_dir?)` | Index wiki pages as long-term memories: approved revisions only with wiki-studio ≥ 0.7.0, every page without it (idempotent; changed pages supersede; `corrects` supersedes the memories a page corrects). |
180
187
 
181
188
  ### Memory types and tiers
182
189
 
@@ -196,13 +203,13 @@ Retention: `ttl = base_ttl × (1 + access_boost × access_count)`, measured from
196
203
 
197
204
  ### Session protocol
198
205
 
199
- 1. **Session start** — `recall(tier="long_term", query=<task>)`, plus the latest checkpoint (`checkpoint_only=true`). The `session-start.py` hook does this and prints promotion candidates.
206
+ 1. **Session start** — `recall(tier="long_term", query=<task>)`, plus the latest checkpoint (`checkpoint_only=true`). The `session-start.py` hook does this and prints promotion candidates; after a compaction it prints a shorter recovery block instead.
200
207
  2. **While working** — `retain(type="task")` when the plan changes; `retain(type="observation")` for findings; durable knowledge as `decision` / `convention` / `fact` / `procedure`.
201
- 3. **Before compaction** — `retain(type="episode", checkpoint=true)` (the `pre-compact.py` hook) and `consolidate()`.
208
+ 3. **Before compaction** — `retain(type="episode", checkpoint=true)` with your own summary of where the work stands; the `pre-compact.py` hook runs `consolidate()`, and the `post-compact.py` hook keeps Claude Code's compaction summary as a checkpoint.
202
209
  4. **After compaction / end of phase** — `promote(ref_id, type, content)` for the candidates worth keeping.
203
210
  5. **Large artefacts** — `retain(type="document", content=<body>, source=<name>)` then `ask(ref_id, question)`; never `get` a document to read it.
204
211
  7. **Code snapshots** — `retain(type="code", content=<file>, source=<path>)`; retain the file again after editing it (the new snapshot supersedes the old one). The session-start hook revalidates snapshots against their files and lists the ones that need re-retaining.
205
- 6. **Curate** — `wiki_export` → review the drafts → `wiki_import`.
212
+ 6. **Curate** — `wiki_export` → review and approve the drafts in wiki-studio → `wiki_import`.
206
213
 
207
214
  ### Quick examples
208
215
 
@@ -246,7 +253,7 @@ setting is logged as a warning at startup.
246
253
  |----------|---------|-------------|
247
254
  | `MEMKERNEL_PROJECT_ID` | `default` | Project namespace — memories are isolated per project |
248
255
  | `MEMKERNEL_DEFAULT_SESSION_ID` | `default` | Session tag applied when `session_id` is omitted from `retain` |
249
- | `MEMKERNEL_CHROMA_PERSIST_PATH` | `.memkernel/chroma` | ChromaDB data directory. Empty string = in-memory (ephemeral) |
256
+ | `MEMKERNEL_CHROMA_PERSIST_PATH` | `.memkernel/chroma` | ChromaDB data directory. Empty string = in-memory (ephemeral). On POSIX systems (Linux, macOS), opening it takes a short lock on `.memkernel-open.lock` inside it, so the MCP server and a hook opening a new store at once do not collide; where `fcntl` is unavailable (Windows) the store opens without the lock, and two processes creating a new store at the same moment can still fail |
250
257
  | `MEMKERNEL_CHROMA_COLLECTION_PREFIX` | `memkernel` | Prefix for ChromaDB collection names (`{prefix}_{project_id}`) |
251
258
  | `MEMKERNEL_EMBEDDING_MODEL` | `all-MiniLM-L6-v2` | Sentence-transformers model name |
252
259
  | `MEMKERNEL_EMBEDDING_DIMENSION` | `384` | Output dimension of the embedding model |
@@ -272,6 +279,7 @@ setting is logged as a warning at startup.
272
279
  | `MEMKERNEL_RECALL_WEIGHT_RECENCY` | `0.3` | Weight of recency in the final recall score |
273
280
  | `MEMKERNEL_RECALL_WEIGHT_IMPORTANCE` | `0.3` | Weight of importance in the final recall score |
274
281
  | `MEMKERNEL_WIKI_DIR` | `` | Directory of the LLM wiki used by `wiki_export` / `wiki_import` (empty = pass `wiki_dir` explicitly) |
282
+ | `MEMKERNEL_WIKI_PROPOSALS_DIR` | `""` | Where wiki-studio patch-sets are written before promote (`memkernel[wiki]`); empty means `<project root>/.memkernel/wiki-proposals`. Setting it requests the gate: without wiki-studio installed, a warning is logged. |
275
283
  | `MEMKERNEL_TRANSPORT` | `stdio` | `stdio` or `streamable-http` |
276
284
  | `MEMKERNEL_HOST` | `127.0.0.1` | Host for `streamable-http` transport |
277
285
  | `MEMKERNEL_PORT` | `8765` | Port for `streamable-http` transport |
@@ -317,7 +325,7 @@ report = svc.consolidate() # {"purged": n, "candidates": [...]}
317
325
 
318
326
  ### The session protocol as a component
319
327
 
320
- `MemoryLifecycle` packages what the hook scripts do, so a harness calls four methods instead of re-implementing the protocol. Every method returns markdown ready for injection and does nothing but talk to the service:
328
+ `MemoryLifecycle` packages what the hook scripts do, so a harness calls five methods instead of re-implementing the protocol. Every method returns markdown ready for injection and does nothing but talk to the service:
321
329
 
322
330
  ```python
323
331
  from memkernel.factory import build_service
@@ -326,9 +334,13 @@ from memkernel.lifecycle import MemoryLifecycle
326
334
  memory = MemoryLifecycle(build_service())
327
335
  system_context = memory.on_session_start() # checkpoint, long-term context, promotion candidates, wiki pointer
328
336
  ...
329
- memory.on_pre_compact(summary) # checkpoint episode + consolidation, before the context is compacted
330
- recovered = memory.on_post_compact() # what to re-inject after compaction
337
+ memory.on_pre_compact(summary) # checkpoint episode (only if summary is not blank) + consolidation
338
+ recovered = memory.on_post_compact() # what to re-inject after your own compaction
331
339
  memory.on_session_end(summary) # final checkpoint + consolidation
340
+
341
+ # Driving it from Claude Code's hook events instead (what templates/hooks/ do):
342
+ memory.on_session_start(source) # source "startup"/"clear": full block; "compact": recovery block; "resume"/"fork": ""
343
+ memory.capture_compact_summary(compact_summary, session_id=..., trigger=...) # keep Claude Code's own summary as a checkpoint
332
344
  ```
333
345
 
334
346
  ### Documents and `ask`
@@ -349,9 +361,14 @@ memory.on_session_end(summary) # final checkpoint + consolidat
349
361
  Long-term memory has a human-readable twin: a markdown wiki in the [LLM Wiki](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f) pattern (`index.md`, `log.md`, typed pages with cited sources — the same schema as `code-copilot-team/knowledge/wiki`).
350
362
 
351
363
  - `wiki_export` compiles every long-term memory that has no page yet into a **draft** page under `decisions/`, `concepts/` or `workflows/`, with the required sections and a `memkernel://<project>/<ref_id>` citation. Existing pages are never overwritten.
352
- - `wiki_import` indexes curated pages as long-term memories so `recall` finds the reviewed version; a changed page supersedes its earlier memory.
364
+ - `wiki_import` indexes pages as long-term memories so `recall` finds the reviewed version; a changed page supersedes its earlier memory. With wiki-studio 0.7.0 or later installed it is **governed** (`"governed": true`): it imports only pages wiki-studio's review state reports approved, and only when the page's bytes are still the approved revision, so drafts, self-declared approvals and pages edited after approval are not imported (`not_approved` says why); an edited page keeps its last approved memory until it is approved again. memkernel never approves a page; a curator does, with `wiki-studio approve`. Without wiki-studio the import reads the page files, drafts included, and reports `"governed": false`. An installed wiki-studio that is broken or older than 0.7.0 imports nothing and reports `import_error`.
365
+ - **Corrections.** An approved page can name the memories it corrects in its frontmatter: `corrects: <ref_id>, <ref_id>` (one line; tokens of letters, digits, `-` and `_`). On import each named memory that did not come from the wiki is superseded by the page's memory: it stops being a current answer, and its record is kept. References that cannot be applied are listed in `unresolved` with a reason (unknown, another project's, already superseded, or a wiki memory: correct that page instead); a value that breaks the grammar is listed in `invalid_corrects`, and the page is still imported. An approved page that is already current is listed in `unchanged`, and its corrections are reconciled again on every import, so a reference that could not be applied earlier takes effect once it can; corrections already applied are not repeated.
366
+
367
+ Set `MEMKERNEL_WIKI_DIR` (for example `knowledge/wiki`) or pass `wiki_dir` to the tools. The standalone maintainer that owns this page schema is [wiki-studio](https://github.com/gosha70/wiki-studio). With the `memkernel[wiki]` extra installed, every `wiki_export` response carries wiki-studio's lint and health findings for the whole wiki after the export, curated pages included, under `lint` (`violations`, `audit_errors`, `pages_checked`, `health` items with `kind`, `severity`, `pages`, `description`); without it `lint` is `null` and `lint_hint` says how to enable it; if the lint pass itself fails, `lint` is `null` and `lint_error` says why. The files written are the same in every case.
353
368
 
354
- Set `MEMKERNEL_WIKI_DIR` (for example `knowledge/wiki`) or pass `wiki_dir` to the tools. The standalone maintainer that owns this page schema is [wiki-studio](https://github.com/gosha70/wiki-studio) (`pip install wiki-studio`; `wiki-studio lint --health` checks what `wiki_export` writes). Rationale: [`docs/adr/ADR-008-llm-wiki-as-semantic-layer.md`](docs/adr/ADR-008-llm-wiki-as-semantic-layer.md).
369
+ With wiki-studio 0.7.0 or later installed, `wiki_export` writes through wiki-studio's promote gate: wiki-studio's `init` creates `index.md` and `log.md` if they are missing (wiki-studio's own scaffold, `status: stable`), then every new page goes with its index and log lines into one patch-set that is proposed and promoted. Other writers can work in the same wiki: wiki-studio's commit writes only the paths the patch-set names, under its lock, and refuses a page someone else created in the meantime. A rejected export writes no page and the response carries `gate_error` (a new wiki keeps the `index.md` and `log.md` that `init` created); so does an installed wiki-studio that is broken or older than 0.7.0, rather than falling back to the direct writer. A promoted export's response adds `proposal`, `submission_digest` and `archived_dir` from wiki-studio's receipt. If `archived_dir` is `null`, the pages landed but wiki-studio did not finish: it could not archive the proposal, or it failed after committing the pages (memkernel checks wiki-studio's receipt: while it still says `committing`, every planned write, index and log included, must be at its post-write sha256; a `committed` receipt needs memkernel's pages at theirs; an unusable receipt falls back to comparing the pages' bytes). `archive_hint` gives the `promote` command that tries to finish it, with absolute paths, so it runs from any directory. It applies nothing twice, but if the wiki or the proposal changed since, for example another writer appended to `index.md`, it may refuse with `uncertain_outcome` or `payload_changed` and write nothing; then compare the wiki with the proposal's `receipt.json` by hand. memkernel never runs it itself. If wiki-studio failed during its commit and its receipt shows neither a commit nor a rollback (say the page was written, the `index.md` write failed and removing the page failed too), the response carries `gate_error` with `uncertain_outcome` and `proposal`: `written` is empty, but the pages it names may be on disk; compare the wiki with the `receipt.json` it names before exporting again. `receipt_error` says the receipt could not be read or used. A failed export leaves its patch-set under the proposals directory for inspection; delete it once the cause is fixed. Patch-sets go to `MEMKERNEL_WIKI_PROPOSALS_DIR`, by default `.memkernel/wiki-proposals/` under the project root. Commit `<wiki>/.audit/`, wiki-studio's audit trail of promoted proposals; ignore `.memkernel/wiki-proposals/` and wiki-studio's lock file next to the wiki directory, `.<wiki-dir>.wiki-studio.lock` (for `knowledge/wiki`, `knowledge/.wiki.wiki-studio.lock`).
370
+
371
+ Rationale: [`docs/adr/ADR-008-llm-wiki-as-semantic-layer.md`](docs/adr/ADR-008-llm-wiki-as-semantic-layer.md) and [`docs/adr/ADR-010-wiki-studio-as-wiki-maintainer.md`](docs/adr/ADR-010-wiki-studio-as-wiki-maintainer.md).
355
372
 
356
373
  ### Revalidation and store health
357
374
 
@@ -394,9 +411,15 @@ CI runs on every push and PR: lint, type check, tests (≥ 80% coverage), securi
394
411
 
395
412
  Releases are published to PyPI by [`.github/workflows/release.yml`](.github/workflows/release.yml) using trusted publishing (no token in the repo). To cut a release:
396
413
 
397
- 1. Set `version` in `pyproject.toml` and move the `[Unreleased]` entries in [`CHANGELOG.md`](CHANGELOG.md) under the new version.
414
+ 1. Set `version` in `pyproject.toml` and `__version__` in `src/memkernel/__init__.py` to `X.Y.Z`, and move the `[Unreleased]` entries in [`CHANGELOG.md`](CHANGELOG.md) under `[X.Y.Z] - <tag date>`.
398
415
  2. Merge to `main` with CI green.
399
- 3. Tag and push: `git tag -a v0.4.0 -m "memkernel 0.4.0" && git push origin v0.4.0`.
416
+ 3. Tag and push: `git tag -a vX.Y.Z -m "memkernel X.Y.Z" && git push origin vX.Y.Z`.
417
+ 4. Create the GitHub Release from the tag, with the version's CHANGELOG section as its notes:
418
+
419
+ ```bash
420
+ awk -v v=X.Y.Z 'index($0, "## [" v "]") == 1 {f=1; next} f && /^## \[/ {exit} f' CHANGELOG.md > release-notes.md
421
+ gh release create vX.Y.Z --title X.Y.Z --notes-file release-notes.md --verify-tag
422
+ ```
400
423
 
401
424
  The workflow refuses a tag that does not match the `pyproject.toml` version, builds the sdist and wheel, smoke-installs the wheel, and uploads through the `pypi` environment.
402
425
 
@@ -34,7 +34,7 @@ MemKernel gives Claude Code (and any MCP-compatible copilot) persistent memory a
34
34
  - **An index of the whole repository.** `code` memories are the snapshots the agent chose to retain, not an automatic scan of every file.
35
35
  - **A cross-project knowledge base.** One store per project id. Sharing is deliberate: a second server with a shared id, or the wiki bridge.
36
36
 
37
- **Evidence.** `eval/run_eval.py` measures retrieval (recall@k, precision@k, MRR). An agent-level benchmark that runs the same agent with and without MemKernel, including a negative control where files already carry all state, is tracked in [#17](https://github.com/gosha70/memkernel/issues/17). Results will be published per minor release, including the cases with no gain.
37
+ **Evidence.** `eval/run_eval.py` measures retrieval (recall@k, precision@k, MRR). The 0.5 lifecycle evaluation, a **maintainer-run supplemental evaluation** (memkernel's own runner, preregistered and announced in [#17](https://github.com/gosha70/memkernel/issues/17) before the run), ran the same agent in seven arms on 20 tasks: [report](eval/lifecycle-suite/report-0.5.0.md). **No gain is claimed.** Against files alone (`claude_md`), memkernel's memory store was ahead on 6 tasks, behind on none and tied on 14 (Holm-adjusted p 0.0625: "no detectable difference"; with so many ties this task set could not have reached significance); against earlier transcripts (`fs_grep`) it was equivalent. Two of the four task groups hit a ceiling, every arm passing every trial. The report's disclosures list its deviations and known defects. Results are published with the cases that show no gain; the 0.5 report is the latest evaluation, and 0.6.0 had no new measured run.
38
38
 
39
39
  ---
40
40
 
@@ -55,6 +55,9 @@ MemKernel is a Python package on PyPI. Its `memkernel` command is the MCP server
55
55
  pipx install memkernel
56
56
  # if pipx's default interpreter is outside 3.11–3.13:
57
57
  pipx install --python python3.11 memkernel
58
+ # optional: wiki-studio lint and health findings on wiki_export (ADR-010)
59
+ pipx install "memkernel[wiki]"
60
+ pipx inject memkernel "wiki-studio>=0.7.0,<1" # add it to an existing install
58
61
  ```
59
62
 
60
63
  **As a library inside a project**, install it into that project's virtual environment:
@@ -113,9 +116,11 @@ Copy the hook scripts from [`templates/hooks/`](templates/hooks/) to `.claude/ho
113
116
 
114
117
  | Hook | Claude Code event | What it does |
115
118
  |------|-------------------|--------------|
116
- | `session-start.py` | `SessionStart` | Consolidates, recovers the latest checkpoint and long-term context, lists promotion candidates |
117
- | `pre-compact.py` | `PreCompact` | Retains a checkpoint episode, runs a consolidation pass |
118
- | `post-compact.py` | `PostCompact` | Re-injects the checkpoint, long-term context and promotion candidates |
119
+ | `session-start.py` | `SessionStart` | By `source`: on `startup` and `clear`, consolidates, recovers the latest checkpoint and long-term context, lists promotion candidates; on `compact`, the recovery after a compaction (the latest checkpoint the agent saved, with its age, and long-term context; no consolidation); on `resume` and `fork`, nothing |
120
+ | `pre-compact.py` | `PreCompact` | Runs a consolidation pass (Claude Code passes no summary here, so no checkpoint is retained) |
121
+ | `post-compact.py` | `PostCompact` | Retains Claude Code's compaction summary (`compact_summary`) as a checkpoint for the next session; prints nothing for the model |
122
+
123
+ Register `SessionStart` without a matcher, so it runs for every `source`. Claude Code adds only `SessionStart`'s output to the model's context, not `PreCompact`'s or `PostCompact`'s, so the recovery after a compaction comes from `SessionStart` with `source: "compact"`. After upgrading memkernel, copy the three scripts again.
119
124
 
120
125
  See the Prerequisites section inside each script for the Python environment requirement. Each script is a thin wrapper over [`MemoryLifecycle`](#embedding-memkernel-in-your-own-harness), so an in-process harness gets the same behaviour without the scripts.
121
126
 
@@ -147,7 +152,7 @@ Kiro (and the Kiro CLI, formerly Amazon Q Developer CLI) connects to MemKernel a
147
152
  | `consolidate(session_id?, limit?, synthesize?, revalidate?)` | Purge expired short-term memories and list the ones most worth promoting (ranked by importance × how often they were recalled). `synthesize=true` also asks an external reasoner to draft long-term statements (`suggested`); `revalidate=true` first checks every code memory against its source file (`revalidated`). The report always carries a `health` summary. |
148
153
  | `ask(ref_id, question, mode?)` | Answer a question about one memory without loading it — for a `document` the full body is consulted. `mode`: `direct` \| `rag` \| `rlm` \| `auto`, passed to the reasoner unchanged; the built-in reasoner answers in `rag` mode with the best-matching chunks. Returns `answer`, `mode`, `citations`, `usage`. |
149
154
  | `wiki_export(wiki_dir?)` | Compile long-term memories without a wiki page into draft markdown pages (`index.md`, `log.md` updated). |
150
- | `wiki_import(wiki_dir?)` | Index curated wiki pages as long-term memories (idempotent; changed pages supersede). |
155
+ | `wiki_import(wiki_dir?)` | Index wiki pages as long-term memories: approved revisions only with wiki-studio ≥ 0.7.0, every page without it (idempotent; changed pages supersede; `corrects` supersedes the memories a page corrects). |
151
156
 
152
157
  ### Memory types and tiers
153
158
 
@@ -167,13 +172,13 @@ Retention: `ttl = base_ttl × (1 + access_boost × access_count)`, measured from
167
172
 
168
173
  ### Session protocol
169
174
 
170
- 1. **Session start** — `recall(tier="long_term", query=<task>)`, plus the latest checkpoint (`checkpoint_only=true`). The `session-start.py` hook does this and prints promotion candidates.
175
+ 1. **Session start** — `recall(tier="long_term", query=<task>)`, plus the latest checkpoint (`checkpoint_only=true`). The `session-start.py` hook does this and prints promotion candidates; after a compaction it prints a shorter recovery block instead.
171
176
  2. **While working** — `retain(type="task")` when the plan changes; `retain(type="observation")` for findings; durable knowledge as `decision` / `convention` / `fact` / `procedure`.
172
- 3. **Before compaction** — `retain(type="episode", checkpoint=true)` (the `pre-compact.py` hook) and `consolidate()`.
177
+ 3. **Before compaction** — `retain(type="episode", checkpoint=true)` with your own summary of where the work stands; the `pre-compact.py` hook runs `consolidate()`, and the `post-compact.py` hook keeps Claude Code's compaction summary as a checkpoint.
173
178
  4. **After compaction / end of phase** — `promote(ref_id, type, content)` for the candidates worth keeping.
174
179
  5. **Large artefacts** — `retain(type="document", content=<body>, source=<name>)` then `ask(ref_id, question)`; never `get` a document to read it.
175
180
  7. **Code snapshots** — `retain(type="code", content=<file>, source=<path>)`; retain the file again after editing it (the new snapshot supersedes the old one). The session-start hook revalidates snapshots against their files and lists the ones that need re-retaining.
176
- 6. **Curate** — `wiki_export` → review the drafts → `wiki_import`.
181
+ 6. **Curate** — `wiki_export` → review and approve the drafts in wiki-studio → `wiki_import`.
177
182
 
178
183
  ### Quick examples
179
184
 
@@ -217,7 +222,7 @@ setting is logged as a warning at startup.
217
222
  |----------|---------|-------------|
218
223
  | `MEMKERNEL_PROJECT_ID` | `default` | Project namespace — memories are isolated per project |
219
224
  | `MEMKERNEL_DEFAULT_SESSION_ID` | `default` | Session tag applied when `session_id` is omitted from `retain` |
220
- | `MEMKERNEL_CHROMA_PERSIST_PATH` | `.memkernel/chroma` | ChromaDB data directory. Empty string = in-memory (ephemeral) |
225
+ | `MEMKERNEL_CHROMA_PERSIST_PATH` | `.memkernel/chroma` | ChromaDB data directory. Empty string = in-memory (ephemeral). On POSIX systems (Linux, macOS), opening it takes a short lock on `.memkernel-open.lock` inside it, so the MCP server and a hook opening a new store at once do not collide; where `fcntl` is unavailable (Windows) the store opens without the lock, and two processes creating a new store at the same moment can still fail |
221
226
  | `MEMKERNEL_CHROMA_COLLECTION_PREFIX` | `memkernel` | Prefix for ChromaDB collection names (`{prefix}_{project_id}`) |
222
227
  | `MEMKERNEL_EMBEDDING_MODEL` | `all-MiniLM-L6-v2` | Sentence-transformers model name |
223
228
  | `MEMKERNEL_EMBEDDING_DIMENSION` | `384` | Output dimension of the embedding model |
@@ -243,6 +248,7 @@ setting is logged as a warning at startup.
243
248
  | `MEMKERNEL_RECALL_WEIGHT_RECENCY` | `0.3` | Weight of recency in the final recall score |
244
249
  | `MEMKERNEL_RECALL_WEIGHT_IMPORTANCE` | `0.3` | Weight of importance in the final recall score |
245
250
  | `MEMKERNEL_WIKI_DIR` | `` | Directory of the LLM wiki used by `wiki_export` / `wiki_import` (empty = pass `wiki_dir` explicitly) |
251
+ | `MEMKERNEL_WIKI_PROPOSALS_DIR` | `""` | Where wiki-studio patch-sets are written before promote (`memkernel[wiki]`); empty means `<project root>/.memkernel/wiki-proposals`. Setting it requests the gate: without wiki-studio installed, a warning is logged. |
246
252
  | `MEMKERNEL_TRANSPORT` | `stdio` | `stdio` or `streamable-http` |
247
253
  | `MEMKERNEL_HOST` | `127.0.0.1` | Host for `streamable-http` transport |
248
254
  | `MEMKERNEL_PORT` | `8765` | Port for `streamable-http` transport |
@@ -288,7 +294,7 @@ report = svc.consolidate() # {"purged": n, "candidates": [...]}
288
294
 
289
295
  ### The session protocol as a component
290
296
 
291
- `MemoryLifecycle` packages what the hook scripts do, so a harness calls four methods instead of re-implementing the protocol. Every method returns markdown ready for injection and does nothing but talk to the service:
297
+ `MemoryLifecycle` packages what the hook scripts do, so a harness calls five methods instead of re-implementing the protocol. Every method returns markdown ready for injection and does nothing but talk to the service:
292
298
 
293
299
  ```python
294
300
  from memkernel.factory import build_service
@@ -297,9 +303,13 @@ from memkernel.lifecycle import MemoryLifecycle
297
303
  memory = MemoryLifecycle(build_service())
298
304
  system_context = memory.on_session_start() # checkpoint, long-term context, promotion candidates, wiki pointer
299
305
  ...
300
- memory.on_pre_compact(summary) # checkpoint episode + consolidation, before the context is compacted
301
- recovered = memory.on_post_compact() # what to re-inject after compaction
306
+ memory.on_pre_compact(summary) # checkpoint episode (only if summary is not blank) + consolidation
307
+ recovered = memory.on_post_compact() # what to re-inject after your own compaction
302
308
  memory.on_session_end(summary) # final checkpoint + consolidation
309
+
310
+ # Driving it from Claude Code's hook events instead (what templates/hooks/ do):
311
+ memory.on_session_start(source) # source "startup"/"clear": full block; "compact": recovery block; "resume"/"fork": ""
312
+ memory.capture_compact_summary(compact_summary, session_id=..., trigger=...) # keep Claude Code's own summary as a checkpoint
303
313
  ```
304
314
 
305
315
  ### Documents and `ask`
@@ -320,9 +330,14 @@ memory.on_session_end(summary) # final checkpoint + consolidat
320
330
  Long-term memory has a human-readable twin: a markdown wiki in the [LLM Wiki](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f) pattern (`index.md`, `log.md`, typed pages with cited sources — the same schema as `code-copilot-team/knowledge/wiki`).
321
331
 
322
332
  - `wiki_export` compiles every long-term memory that has no page yet into a **draft** page under `decisions/`, `concepts/` or `workflows/`, with the required sections and a `memkernel://<project>/<ref_id>` citation. Existing pages are never overwritten.
323
- - `wiki_import` indexes curated pages as long-term memories so `recall` finds the reviewed version; a changed page supersedes its earlier memory.
333
+ - `wiki_import` indexes pages as long-term memories so `recall` finds the reviewed version; a changed page supersedes its earlier memory. With wiki-studio 0.7.0 or later installed it is **governed** (`"governed": true`): it imports only pages wiki-studio's review state reports approved, and only when the page's bytes are still the approved revision, so drafts, self-declared approvals and pages edited after approval are not imported (`not_approved` says why); an edited page keeps its last approved memory until it is approved again. memkernel never approves a page; a curator does, with `wiki-studio approve`. Without wiki-studio the import reads the page files, drafts included, and reports `"governed": false`. An installed wiki-studio that is broken or older than 0.7.0 imports nothing and reports `import_error`.
334
+ - **Corrections.** An approved page can name the memories it corrects in its frontmatter: `corrects: <ref_id>, <ref_id>` (one line; tokens of letters, digits, `-` and `_`). On import each named memory that did not come from the wiki is superseded by the page's memory: it stops being a current answer, and its record is kept. References that cannot be applied are listed in `unresolved` with a reason (unknown, another project's, already superseded, or a wiki memory: correct that page instead); a value that breaks the grammar is listed in `invalid_corrects`, and the page is still imported. An approved page that is already current is listed in `unchanged`, and its corrections are reconciled again on every import, so a reference that could not be applied earlier takes effect once it can; corrections already applied are not repeated.
335
+
336
+ Set `MEMKERNEL_WIKI_DIR` (for example `knowledge/wiki`) or pass `wiki_dir` to the tools. The standalone maintainer that owns this page schema is [wiki-studio](https://github.com/gosha70/wiki-studio). With the `memkernel[wiki]` extra installed, every `wiki_export` response carries wiki-studio's lint and health findings for the whole wiki after the export, curated pages included, under `lint` (`violations`, `audit_errors`, `pages_checked`, `health` items with `kind`, `severity`, `pages`, `description`); without it `lint` is `null` and `lint_hint` says how to enable it; if the lint pass itself fails, `lint` is `null` and `lint_error` says why. The files written are the same in every case.
324
337
 
325
- Set `MEMKERNEL_WIKI_DIR` (for example `knowledge/wiki`) or pass `wiki_dir` to the tools. The standalone maintainer that owns this page schema is [wiki-studio](https://github.com/gosha70/wiki-studio) (`pip install wiki-studio`; `wiki-studio lint --health` checks what `wiki_export` writes). Rationale: [`docs/adr/ADR-008-llm-wiki-as-semantic-layer.md`](docs/adr/ADR-008-llm-wiki-as-semantic-layer.md).
338
+ With wiki-studio 0.7.0 or later installed, `wiki_export` writes through wiki-studio's promote gate: wiki-studio's `init` creates `index.md` and `log.md` if they are missing (wiki-studio's own scaffold, `status: stable`), then every new page goes with its index and log lines into one patch-set that is proposed and promoted. Other writers can work in the same wiki: wiki-studio's commit writes only the paths the patch-set names, under its lock, and refuses a page someone else created in the meantime. A rejected export writes no page and the response carries `gate_error` (a new wiki keeps the `index.md` and `log.md` that `init` created); so does an installed wiki-studio that is broken or older than 0.7.0, rather than falling back to the direct writer. A promoted export's response adds `proposal`, `submission_digest` and `archived_dir` from wiki-studio's receipt. If `archived_dir` is `null`, the pages landed but wiki-studio did not finish: it could not archive the proposal, or it failed after committing the pages (memkernel checks wiki-studio's receipt: while it still says `committing`, every planned write, index and log included, must be at its post-write sha256; a `committed` receipt needs memkernel's pages at theirs; an unusable receipt falls back to comparing the pages' bytes). `archive_hint` gives the `promote` command that tries to finish it, with absolute paths, so it runs from any directory. It applies nothing twice, but if the wiki or the proposal changed since, for example another writer appended to `index.md`, it may refuse with `uncertain_outcome` or `payload_changed` and write nothing; then compare the wiki with the proposal's `receipt.json` by hand. memkernel never runs it itself. If wiki-studio failed during its commit and its receipt shows neither a commit nor a rollback (say the page was written, the `index.md` write failed and removing the page failed too), the response carries `gate_error` with `uncertain_outcome` and `proposal`: `written` is empty, but the pages it names may be on disk; compare the wiki with the `receipt.json` it names before exporting again. `receipt_error` says the receipt could not be read or used. A failed export leaves its patch-set under the proposals directory for inspection; delete it once the cause is fixed. Patch-sets go to `MEMKERNEL_WIKI_PROPOSALS_DIR`, by default `.memkernel/wiki-proposals/` under the project root. Commit `<wiki>/.audit/`, wiki-studio's audit trail of promoted proposals; ignore `.memkernel/wiki-proposals/` and wiki-studio's lock file next to the wiki directory, `.<wiki-dir>.wiki-studio.lock` (for `knowledge/wiki`, `knowledge/.wiki.wiki-studio.lock`).
339
+
340
+ Rationale: [`docs/adr/ADR-008-llm-wiki-as-semantic-layer.md`](docs/adr/ADR-008-llm-wiki-as-semantic-layer.md) and [`docs/adr/ADR-010-wiki-studio-as-wiki-maintainer.md`](docs/adr/ADR-010-wiki-studio-as-wiki-maintainer.md).
326
341
 
327
342
  ### Revalidation and store health
328
343
 
@@ -365,9 +380,15 @@ CI runs on every push and PR: lint, type check, tests (≥ 80% coverage), securi
365
380
 
366
381
  Releases are published to PyPI by [`.github/workflows/release.yml`](.github/workflows/release.yml) using trusted publishing (no token in the repo). To cut a release:
367
382
 
368
- 1. Set `version` in `pyproject.toml` and move the `[Unreleased]` entries in [`CHANGELOG.md`](CHANGELOG.md) under the new version.
383
+ 1. Set `version` in `pyproject.toml` and `__version__` in `src/memkernel/__init__.py` to `X.Y.Z`, and move the `[Unreleased]` entries in [`CHANGELOG.md`](CHANGELOG.md) under `[X.Y.Z] - <tag date>`.
369
384
  2. Merge to `main` with CI green.
370
- 3. Tag and push: `git tag -a v0.4.0 -m "memkernel 0.4.0" && git push origin v0.4.0`.
385
+ 3. Tag and push: `git tag -a vX.Y.Z -m "memkernel X.Y.Z" && git push origin vX.Y.Z`.
386
+ 4. Create the GitHub Release from the tag, with the version's CHANGELOG section as its notes:
387
+
388
+ ```bash
389
+ awk -v v=X.Y.Z 'index($0, "## [" v "]") == 1 {f=1; next} f && /^## \[/ {exit} f' CHANGELOG.md > release-notes.md
390
+ gh release create vX.Y.Z --title X.Y.Z --notes-file release-notes.md --verify-tag
391
+ ```
371
392
 
372
393
  The workflow refuses a tag that does not match the `pyproject.toml` version, builds the sdist and wheel, smoke-installs the wheel, and uploads through the `pypi` environment.
373
394
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "memkernel"
3
- version = "0.4.1"
3
+ version = "0.6.0"
4
4
  description = "Code-aware memory server for AI coding copilots"
5
5
  authors = ["EGOGE"]
6
6
  license = "Apache-2.0"
@@ -31,6 +31,11 @@ tree-sitter-python = ">=0.23"
31
31
  pydantic = ">=2.0,<3"
32
32
  pydantic-settings = ">=2.0,<3"
33
33
  fastapi = ">=0.115,<1"
34
+ # Optional integrations (ADR-010): installed only through an extra; imported lazily.
35
+ wiki-studio = { version = ">=0.7.0,<1", optional = true }
36
+
37
+ [tool.poetry.extras]
38
+ wiki = ["wiki-studio"]
34
39
 
35
40
  [tool.poetry.group.dev.dependencies]
36
41
  pytest = ">=8"
@@ -40,7 +45,7 @@ ruff = ">=0.9"
40
45
  mypy = ">=1.13"
41
46
  httpx = ">=0.27"
42
47
  pip-audit = "^2.10.0"
43
- wiki-studio = ">=0.1,<1"
48
+ wiki-studio = ">=0.7.0,<1"
44
49
 
45
50
  [build-system]
46
51
  requires = ["poetry-core"]
@@ -2,4 +2,4 @@
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
  """MemKernel — Code-aware memory server for AI coding copilots."""
4
4
 
5
- __version__ = "0.4.1"
5
+ __version__ = "0.6.0"
@@ -76,6 +76,9 @@ class MemKernelSettings(BaseSettings):
76
76
  # LLM wiki bridge: directory holding the markdown wiki (index.md, decisions/, ...).
77
77
  # Empty = no wiki configured; wiki_export/wiki_import then require an explicit path.
78
78
  wiki_dir: str = ""
79
+ # Where wiki-studio patch-sets are written before promote (memkernel[wiki], ADR-010).
80
+ # Empty = <project_root>/.memkernel/wiki-proposals; set it to request the gate explicitly.
81
+ wiki_proposals_dir: str = ""
79
82
 
80
83
  # Server
81
84
  transport: Literal["stdio", "streamable-http"] = "stdio"
@@ -259,6 +259,8 @@ class MemoryService:
259
259
  tags: list[str] | None = None,
260
260
  supersedes: str | None = None,
261
261
  promoted_from: str | None = None,
262
+ *,
263
+ dedup_source: bool = False,
262
264
  ) -> str:
263
265
  """Store content and return its ref_id.
264
266
 
@@ -277,7 +279,10 @@ class MemoryService:
277
279
  memkernel record keeps only a summary (``get`` never returns the
278
280
  body; use ``ask``). ``source`` names the document.
279
281
 
280
- Idempotent: identical content + type returns the existing ref_id.
282
+ Idempotent: identical content + type returns the existing ref_id. With
283
+ ``dedup_source`` a single record's dedup hit also requires the same ``source``
284
+ (memkernel-0-6 FR-012: one memory per wiki page, never merged with another
285
+ page's or an agent's identical text).
281
286
  """
282
287
  extra = _ExtraMeta(importance=importance, tags=tags or [], supersedes=supersedes, promoted_from=promoted_from)
283
288
  if type == MemoryType.DOCUMENT and self._document_store is not None:
@@ -285,7 +290,7 @@ class MemoryService:
285
290
  if type == MemoryType.CODE:
286
291
  return self._retain_code(content, session_id, checkpoint, source, extra)
287
292
 
288
- return self._retain_single(content, type, session_id, checkpoint, source, extra)
293
+ return self._retain_single(content, type, session_id, checkpoint, source, extra, dedup_source=dedup_source)
289
294
 
290
295
  def _retain_code(
291
296
  self,
@@ -450,13 +455,47 @@ class MemoryService:
450
455
  ):
451
456
  self._vector_store.update_metadata(chunk.ref_id, update)
452
457
 
458
+ def supersede(self, ref_id: str, by_ref: str) -> bool:
459
+ """Mark the existing record ``ref_id`` as replaced by the existing record ``by_ref``.
460
+
461
+ Sets only the durable ``superseded_by`` marker (on ``ref_id`` and its owned
462
+ chunks), as ``retain(supersedes=...)`` does; the record's content and every
463
+ other field stay as they are, so its provenance remains. Returns True when it
464
+ set the marker, False when ``ref_id`` was already superseded by ``by_ref``; that
465
+ call still marks any owned chunk left without the marker, so a retry completes
466
+ a partial mark.
467
+ Raises ``ValueError`` and changes nothing when either record is not in this
468
+ store (unknown, or another project's), when they are the same record, or when
469
+ ``ref_id`` is already superseded by a different record: an existing marker is
470
+ never overwritten.
471
+ """
472
+ if ref_id == by_ref:
473
+ raise ValueError(f"{ref_id} cannot supersede itself")
474
+ target = self._vector_store.get_by_id(ref_id)
475
+ if target is None:
476
+ raise ValueError(f"{ref_id} is not a memory in this store")
477
+ if self._vector_store.get_by_id(by_ref) is None:
478
+ raise ValueError(f"{by_ref} is not a memory in this store")
479
+ if target.superseded_by == by_ref:
480
+ self._mark_superseded(ref_id, by_ref) # idempotent: repairs chunks a failed earlier mark left
481
+ return False
482
+ if target.superseded_by:
483
+ raise ValueError(f"{ref_id} is already superseded by {target.superseded_by}")
484
+ self._mark_superseded(ref_id, by_ref)
485
+ return True
486
+
453
487
  def _mark_superseded(self, target_ref: str, by_ref: str) -> None:
454
- """Set the durable ``superseded_by`` marker on ``target_ref`` and its owned chunks."""
488
+ """Set the durable ``superseded_by`` marker on ``target_ref``'s owned chunks, then on ``target_ref``.
489
+
490
+ The record is marked last: it is what ``supersede`` checks, so a failure part-way leaves it unmarked and
491
+ a retry marks everything. Only chunks without a marker are written: an existing marker is never overwritten.
492
+ """
455
493
  if target_ref == by_ref or self._vector_store.get_by_id(target_ref) is None:
456
494
  return
457
- self._vector_store.update_metadata(target_ref, {SUPERSEDED_BY_KEY: by_ref})
458
495
  for chunk in self._owned_chunks(target_ref):
459
- self._vector_store.update_metadata(chunk.ref_id, {SUPERSEDED_BY_KEY: by_ref})
496
+ if not chunk.superseded_by:
497
+ self._vector_store.update_metadata(chunk.ref_id, {SUPERSEDED_BY_KEY: by_ref})
498
+ self._vector_store.update_metadata(target_ref, {SUPERSEDED_BY_KEY: by_ref})
460
499
 
461
500
  def _delete_owned_chunks(self, canonical: Memory) -> None:
462
501
  """Delete a canonical's owned chunk records; legacy shared chunk_refs only when nothing else references them."""
@@ -523,18 +562,21 @@ class MemoryService:
523
562
  source: str,
524
563
  extra: _ExtraMeta | None = None,
525
564
  dedup: bool = True,
565
+ dedup_source: bool = False,
526
566
  ) -> str:
527
567
  """Store a single content string and return its ref_id.
528
568
 
529
569
  ``dedup=False`` skips the content-hash check for records whose identity
530
- is not their text (document summaries).
570
+ is not their text (document summaries); ``dedup_source`` scopes it to
571
+ records with the same ``source``.
531
572
  """
532
573
  extra = extra or _ExtraMeta()
533
574
  resolved_session = session_id or self._settings.default_session_id
534
575
  content_hash = compute_content_hash(content)
535
576
 
536
577
  # Dedup check: same hash + same type -> update metadata, return existing id
537
- existing = self._vector_store.find_by_hash(content_hash, type.value) if dedup else None
578
+ scope = source if dedup_source else None
579
+ existing = self._vector_store.find_by_hash(content_hash, type.value, source=scope) if dedup else None
538
580
  if existing is not None:
539
581
  self._refresh_existing(existing, resolved_session, checkpoint, extra)
540
582
  return existing.ref_id
@@ -782,6 +824,25 @@ class MemoryService:
782
824
  memories = [m for m in memories if m.tier == tier]
783
825
  return memories
784
826
 
827
+ def checkpoints_newest_first(self) -> list[Memory]:
828
+ """Every live checkpoint record, newest first (ties by ``ref_id``), without content and without touching.
829
+
830
+ Selected by metadata, so the newest is found however many older checkpoints a query would rank higher.
831
+ Live as for :meth:`recall`, judged with this service's settings: not superseded, not flagged for review,
832
+ not expired. Code-chunk and document-part records are left out: they are parts of a checkpoint, not one.
833
+ Read the chosen record with :meth:`get`.
834
+ """
835
+ live = [
836
+ m
837
+ for m in self._vector_store.list_all({"checkpoint": True}, include_content=False)
838
+ if m.superseded_by is None
839
+ and not m.needs_review
840
+ and m.parent_ref is None
841
+ and not _is_document_part(m)
842
+ and not memory_is_expired(m, self._settings)
843
+ ]
844
+ return sorted(live, key=lambda m: (m.timestamp, m.ref_id), reverse=True)
845
+
785
846
  @property
786
847
  def project_id(self) -> str:
787
848
  """Return the project_id for this service instance."""