memkernel 0.5.0__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.5.0 → memkernel-0.6.0}/PKG-INFO +32 -19
  2. {memkernel-0.5.0 → memkernel-0.6.0}/README.md +30 -17
  3. {memkernel-0.5.0 → memkernel-0.6.0}/pyproject.toml +3 -3
  4. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/__init__.py +1 -1
  5. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/core/services.py +68 -7
  6. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/lifecycle.py +92 -22
  7. memkernel-0.6.0/src/memkernel/mcp/tools/wiki.py +127 -0
  8. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/storage/chroma.py +53 -13
  9. memkernel-0.6.0/src/memkernel/wiki/studio.py +555 -0
  10. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/wiki/sync.py +201 -122
  11. memkernel-0.5.0/src/memkernel/mcp/tools/wiki.py +0 -84
  12. memkernel-0.5.0/src/memkernel/wiki/studio.py +0 -193
  13. {memkernel-0.5.0 → memkernel-0.6.0}/LICENSE +0 -0
  14. {memkernel-0.5.0 → memkernel-0.6.0}/NOTICE +0 -0
  15. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/adapters/__init__.py +0 -0
  16. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/adapters/rlmstudio.py +0 -0
  17. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/api/__init__.py +0 -0
  18. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/api/app.py +0 -0
  19. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/api/routes/__init__.py +0 -0
  20. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/api/routes/admin.py +0 -0
  21. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/config.py +0 -0
  22. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/core/__init__.py +0 -0
  23. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/core/models.py +0 -0
  24. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/core/ports.py +0 -0
  25. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/factory.py +0 -0
  26. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/mcp/__init__.py +0 -0
  27. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/mcp/server.py +0 -0
  28. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/mcp/tools/__init__.py +0 -0
  29. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/mcp/tools/memory.py +0 -0
  30. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/memory/__init__.py +0 -0
  31. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/memory/dedup.py +0 -0
  32. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/memory/lifecycle.py +0 -0
  33. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/memory/types.py +0 -0
  34. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/py.typed +0 -0
  35. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/retrieval/__init__.py +0 -0
  36. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/retrieval/chunking.py +0 -0
  37. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/retrieval/embeddings.py +0 -0
  38. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/retrieval/reasoner.py +0 -0
  39. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/retrieval/search.py +0 -0
  40. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/retrieval/sparse.py +0 -0
  41. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/storage/__init__.py +0 -0
  42. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/storage/documents.py +0 -0
  43. {memkernel-0.5.0 → memkernel-0.6.0}/src/memkernel/wiki/__init__.py +0 -0
  44. {memkernel-0.5.0 → 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.5.0
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
@@ -23,7 +23,7 @@ Requires-Dist: rank-bm25 (>=0.2)
23
23
  Requires-Dist: sentence-transformers (>=4.0,<6)
24
24
  Requires-Dist: tree-sitter (>=0.22,<1)
25
25
  Requires-Dist: tree-sitter-python (>=0.23)
26
- Requires-Dist: wiki-studio (>=0.3.1,<1) ; extra == "wiki"
26
+ Requires-Dist: wiki-studio (>=0.7.0,<1) ; extra == "wiki"
27
27
  Project-URL: Documentation, https://memkernel.dev
28
28
  Project-URL: Homepage, https://memkernel.dev
29
29
  Project-URL: Repository, https://github.com/gosha70/memkernel
@@ -65,7 +65,7 @@ MemKernel gives Claude Code (and any MCP-compatible copilot) persistent memory a
65
65
  - **An index of the whole repository.** `code` memories are the snapshots the agent chose to retain, not an automatic scan of every file.
66
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.
67
67
 
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 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.
69
69
 
70
70
  ---
71
71
 
@@ -88,7 +88,7 @@ pipx install memkernel
88
88
  pipx install --python python3.11 memkernel
89
89
  # optional: wiki-studio lint and health findings on wiki_export (ADR-010)
90
90
  pipx install "memkernel[wiki]"
91
- pipx inject memkernel "wiki-studio>=0.3.1,<1" # add it to an existing install
91
+ pipx inject memkernel "wiki-studio>=0.7.0,<1" # add it to an existing install
92
92
  ```
93
93
 
94
94
  **As a library inside a project**, install it into that project's virtual environment:
@@ -147,9 +147,11 @@ Copy the hook scripts from [`templates/hooks/`](templates/hooks/) to `.claude/ho
147
147
 
148
148
  | Hook | Claude Code event | What it does |
149
149
  |------|-------------------|--------------|
150
- | `session-start.py` | `SessionStart` | Consolidates, recovers the latest checkpoint and long-term context, lists promotion candidates |
151
- | `pre-compact.py` | `PreCompact` | Retains a checkpoint episode, runs a consolidation pass |
152
- | `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.
153
155
 
154
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.
155
157
 
@@ -181,7 +183,7 @@ Kiro (and the Kiro CLI, formerly Amazon Q Developer CLI) connects to MemKernel a
181
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. |
182
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`. |
183
185
  | `wiki_export(wiki_dir?)` | Compile long-term memories without a wiki page into draft markdown pages (`index.md`, `log.md` updated). |
184
- | `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). |
185
187
 
186
188
  ### Memory types and tiers
187
189
 
@@ -201,13 +203,13 @@ Retention: `ttl = base_ttl × (1 + access_boost × access_count)`, measured from
201
203
 
202
204
  ### Session protocol
203
205
 
204
- 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.
205
207
  2. **While working** — `retain(type="task")` when the plan changes; `retain(type="observation")` for findings; durable knowledge as `decision` / `convention` / `fact` / `procedure`.
206
- 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.
207
209
  4. **After compaction / end of phase** — `promote(ref_id, type, content)` for the candidates worth keeping.
208
210
  5. **Large artefacts** — `retain(type="document", content=<body>, source=<name>)` then `ask(ref_id, question)`; never `get` a document to read it.
209
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.
210
- 6. **Curate** — `wiki_export` → review the drafts → `wiki_import`.
212
+ 6. **Curate** — `wiki_export` → review and approve the drafts in wiki-studio → `wiki_import`.
211
213
 
212
214
  ### Quick examples
213
215
 
@@ -251,7 +253,7 @@ setting is logged as a warning at startup.
251
253
  |----------|---------|-------------|
252
254
  | `MEMKERNEL_PROJECT_ID` | `default` | Project namespace — memories are isolated per project |
253
255
  | `MEMKERNEL_DEFAULT_SESSION_ID` | `default` | Session tag applied when `session_id` is omitted from `retain` |
254
- | `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 |
255
257
  | `MEMKERNEL_CHROMA_COLLECTION_PREFIX` | `memkernel` | Prefix for ChromaDB collection names (`{prefix}_{project_id}`) |
256
258
  | `MEMKERNEL_EMBEDDING_MODEL` | `all-MiniLM-L6-v2` | Sentence-transformers model name |
257
259
  | `MEMKERNEL_EMBEDDING_DIMENSION` | `384` | Output dimension of the embedding model |
@@ -323,7 +325,7 @@ report = svc.consolidate() # {"purged": n, "candidates": [...]}
323
325
 
324
326
  ### The session protocol as a component
325
327
 
326
- `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:
327
329
 
328
330
  ```python
329
331
  from memkernel.factory import build_service
@@ -332,9 +334,13 @@ from memkernel.lifecycle import MemoryLifecycle
332
334
  memory = MemoryLifecycle(build_service())
333
335
  system_context = memory.on_session_start() # checkpoint, long-term context, promotion candidates, wiki pointer
334
336
  ...
335
- memory.on_pre_compact(summary) # checkpoint episode + consolidation, before the context is compacted
336
- 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
337
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
338
344
  ```
339
345
 
340
346
  ### Documents and `ask`
@@ -355,11 +361,12 @@ memory.on_session_end(summary) # final checkpoint + consolidat
355
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`).
356
362
 
357
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.
358
- - `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.
359
366
 
360
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.
361
368
 
362
- With wiki-studio 0.3.1 or later installed, `wiki_export` writes through wiki-studio's promote gate: it first writes `index.md` and `log.md` if they are missing (the gate cannot create them), then proposes every new page with its index and log lines as one patch-set and promotes it. A rejected export writes nothing and the response carries `gate_error`; so does an installed wiki-studio that fails to import or to build the patch-set, rather than falling back to the direct writer. 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/`.
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`).
363
370
 
364
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).
365
372
 
@@ -404,9 +411,15 @@ CI runs on every push and PR: lint, type check, tests (≥ 80% coverage), securi
404
411
 
405
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:
406
413
 
407
- 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>`.
408
415
  2. Merge to `main` with CI green.
409
- 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
+ ```
410
423
 
411
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.
412
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). 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 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
 
@@ -57,7 +57,7 @@ pipx install memkernel
57
57
  pipx install --python python3.11 memkernel
58
58
  # optional: wiki-studio lint and health findings on wiki_export (ADR-010)
59
59
  pipx install "memkernel[wiki]"
60
- pipx inject memkernel "wiki-studio>=0.3.1,<1" # add it to an existing install
60
+ pipx inject memkernel "wiki-studio>=0.7.0,<1" # add it to an existing install
61
61
  ```
62
62
 
63
63
  **As a library inside a project**, install it into that project's virtual environment:
@@ -116,9 +116,11 @@ Copy the hook scripts from [`templates/hooks/`](templates/hooks/) to `.claude/ho
116
116
 
117
117
  | Hook | Claude Code event | What it does |
118
118
  |------|-------------------|--------------|
119
- | `session-start.py` | `SessionStart` | Consolidates, recovers the latest checkpoint and long-term context, lists promotion candidates |
120
- | `pre-compact.py` | `PreCompact` | Retains a checkpoint episode, runs a consolidation pass |
121
- | `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.
122
124
 
123
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.
124
126
 
@@ -150,7 +152,7 @@ Kiro (and the Kiro CLI, formerly Amazon Q Developer CLI) connects to MemKernel a
150
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. |
151
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`. |
152
154
  | `wiki_export(wiki_dir?)` | Compile long-term memories without a wiki page into draft markdown pages (`index.md`, `log.md` updated). |
153
- | `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). |
154
156
 
155
157
  ### Memory types and tiers
156
158
 
@@ -170,13 +172,13 @@ Retention: `ttl = base_ttl × (1 + access_boost × access_count)`, measured from
170
172
 
171
173
  ### Session protocol
172
174
 
173
- 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.
174
176
  2. **While working** — `retain(type="task")` when the plan changes; `retain(type="observation")` for findings; durable knowledge as `decision` / `convention` / `fact` / `procedure`.
175
- 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.
176
178
  4. **After compaction / end of phase** — `promote(ref_id, type, content)` for the candidates worth keeping.
177
179
  5. **Large artefacts** — `retain(type="document", content=<body>, source=<name>)` then `ask(ref_id, question)`; never `get` a document to read it.
178
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.
179
- 6. **Curate** — `wiki_export` → review the drafts → `wiki_import`.
181
+ 6. **Curate** — `wiki_export` → review and approve the drafts in wiki-studio → `wiki_import`.
180
182
 
181
183
  ### Quick examples
182
184
 
@@ -220,7 +222,7 @@ setting is logged as a warning at startup.
220
222
  |----------|---------|-------------|
221
223
  | `MEMKERNEL_PROJECT_ID` | `default` | Project namespace — memories are isolated per project |
222
224
  | `MEMKERNEL_DEFAULT_SESSION_ID` | `default` | Session tag applied when `session_id` is omitted from `retain` |
223
- | `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 |
224
226
  | `MEMKERNEL_CHROMA_COLLECTION_PREFIX` | `memkernel` | Prefix for ChromaDB collection names (`{prefix}_{project_id}`) |
225
227
  | `MEMKERNEL_EMBEDDING_MODEL` | `all-MiniLM-L6-v2` | Sentence-transformers model name |
226
228
  | `MEMKERNEL_EMBEDDING_DIMENSION` | `384` | Output dimension of the embedding model |
@@ -292,7 +294,7 @@ report = svc.consolidate() # {"purged": n, "candidates": [...]}
292
294
 
293
295
  ### The session protocol as a component
294
296
 
295
- `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:
296
298
 
297
299
  ```python
298
300
  from memkernel.factory import build_service
@@ -301,9 +303,13 @@ from memkernel.lifecycle import MemoryLifecycle
301
303
  memory = MemoryLifecycle(build_service())
302
304
  system_context = memory.on_session_start() # checkpoint, long-term context, promotion candidates, wiki pointer
303
305
  ...
304
- memory.on_pre_compact(summary) # checkpoint episode + consolidation, before the context is compacted
305
- 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
306
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
307
313
  ```
308
314
 
309
315
  ### Documents and `ask`
@@ -324,11 +330,12 @@ memory.on_session_end(summary) # final checkpoint + consolidat
324
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`).
325
331
 
326
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.
327
- - `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.
328
335
 
329
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.
330
337
 
331
- With wiki-studio 0.3.1 or later installed, `wiki_export` writes through wiki-studio's promote gate: it first writes `index.md` and `log.md` if they are missing (the gate cannot create them), then proposes every new page with its index and log lines as one patch-set and promotes it. A rejected export writes nothing and the response carries `gate_error`; so does an installed wiki-studio that fails to import or to build the patch-set, rather than falling back to the direct writer. 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/`.
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`).
332
339
 
333
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).
334
341
 
@@ -373,9 +380,15 @@ CI runs on every push and PR: lint, type check, tests (≥ 80% coverage), securi
373
380
 
374
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:
375
382
 
376
- 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>`.
377
384
  2. Merge to `main` with CI green.
378
- 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
+ ```
379
392
 
380
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.
381
394
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "memkernel"
3
- version = "0.5.0"
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"
@@ -32,7 +32,7 @@ pydantic = ">=2.0,<3"
32
32
  pydantic-settings = ">=2.0,<3"
33
33
  fastapi = ">=0.115,<1"
34
34
  # Optional integrations (ADR-010): installed only through an extra; imported lazily.
35
- wiki-studio = { version = ">=0.3.1,<1", optional = true }
35
+ wiki-studio = { version = ">=0.7.0,<1", optional = true }
36
36
 
37
37
  [tool.poetry.extras]
38
38
  wiki = ["wiki-studio"]
@@ -45,7 +45,7 @@ ruff = ">=0.9"
45
45
  mypy = ">=1.13"
46
46
  httpx = ">=0.27"
47
47
  pip-audit = "^2.10.0"
48
- wiki-studio = ">=0.3.1,<1"
48
+ wiki-studio = ">=0.7.0,<1"
49
49
 
50
50
  [build-system]
51
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.5.0"
5
+ __version__ = "0.6.0"
@@ -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."""