memkernel 0.2.0__tar.gz → 0.3.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 (42) hide show
  1. {memkernel-0.2.0 → memkernel-0.3.0}/PKG-INFO +54 -9
  2. {memkernel-0.2.0 → memkernel-0.3.0}/README.md +51 -8
  3. {memkernel-0.2.0 → memkernel-0.3.0}/pyproject.toml +3 -1
  4. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/__init__.py +1 -1
  5. memkernel-0.3.0/src/memkernel/adapters/__init__.py +3 -0
  6. memkernel-0.3.0/src/memkernel/adapters/rlmstudio.py +123 -0
  7. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/config.py +6 -0
  8. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/core/models.py +37 -0
  9. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/core/ports.py +36 -2
  10. memkernel-0.3.0/src/memkernel/core/services.py +797 -0
  11. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/factory.py +37 -1
  12. memkernel-0.3.0/src/memkernel/lifecycle.py +189 -0
  13. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/mcp/tools/memory.py +22 -7
  14. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/retrieval/chunking.py +20 -0
  15. memkernel-0.3.0/src/memkernel/retrieval/reasoner.py +83 -0
  16. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/retrieval/search.py +31 -2
  17. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/storage/chroma.py +10 -3
  18. memkernel-0.3.0/src/memkernel/storage/documents.py +185 -0
  19. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/wiki/sync.py +2 -1
  20. memkernel-0.2.0/src/memkernel/core/services.py +0 -485
  21. {memkernel-0.2.0 → memkernel-0.3.0}/LICENSE +0 -0
  22. {memkernel-0.2.0 → memkernel-0.3.0}/NOTICE +0 -0
  23. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/api/__init__.py +0 -0
  24. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/api/app.py +0 -0
  25. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/api/routes/__init__.py +0 -0
  26. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/api/routes/admin.py +0 -0
  27. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/core/__init__.py +0 -0
  28. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/mcp/__init__.py +0 -0
  29. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/mcp/server.py +0 -0
  30. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/mcp/tools/__init__.py +0 -0
  31. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/mcp/tools/wiki.py +0 -0
  32. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/memory/__init__.py +0 -0
  33. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/memory/dedup.py +0 -0
  34. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/memory/lifecycle.py +0 -0
  35. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/memory/types.py +0 -0
  36. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/py.typed +0 -0
  37. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/retrieval/__init__.py +0 -0
  38. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/retrieval/embeddings.py +0 -0
  39. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/retrieval/sparse.py +0 -0
  40. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/storage/__init__.py +0 -0
  41. {memkernel-0.2.0 → memkernel-0.3.0}/src/memkernel/wiki/__init__.py +0 -0
  42. {memkernel-0.2.0 → memkernel-0.3.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.2.0
3
+ Version: 0.3.0
4
4
  Summary: Code-aware memory server for AI coding copilots
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -22,6 +22,8 @@ Requires-Dist: rank-bm25 (>=0.2)
22
22
  Requires-Dist: sentence-transformers (>=4.0,<6)
23
23
  Requires-Dist: tree-sitter (>=0.22,<1)
24
24
  Requires-Dist: tree-sitter-python (>=0.23)
25
+ Project-URL: Documentation, https://memkernel.dev
26
+ Project-URL: Homepage, https://memkernel.dev
25
27
  Project-URL: Repository, https://github.com/gosha70/memkernel
26
28
  Description-Content-Type: text/markdown
27
29
 
@@ -32,6 +34,8 @@ Description-Content-Type: text/markdown
32
34
  [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)](pyproject.toml)
33
35
  [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-green)](LICENSE)
34
36
 
37
+ **Site:** [memkernel.dev](https://memkernel.dev) · **Package:** [pypi.org/project/memkernel](https://pypi.org/project/memkernel/)
38
+
35
39
  Code-aware memory server for AI coding copilots.
36
40
 
37
41
  MemKernel gives Claude Code (and any MCP-compatible copilot) persistent memory across sessions. When Claude compacts context or a session ends, project decisions, conventions, and code understanding are normally lost. MemKernel stores them in a local vector database and retrieves them on demand.
@@ -99,7 +103,7 @@ Copy the hook scripts from [`templates/hooks/`](templates/hooks/) to `.claude/ho
99
103
  | `pre-compact.py` | `PreCompact` | Retains a checkpoint episode, runs a consolidation pass |
100
104
  | `post-compact.py` | `PostCompact` | Re-injects the checkpoint, long-term context and promotion candidates |
101
105
 
102
- See the Prerequisites section inside each script for the Python environment requirement.
106
+ 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.
103
107
 
104
108
  ---
105
109
 
@@ -112,8 +116,8 @@ See the Prerequisites section inside each script for the Python environment requ
112
116
  | `get(ref_id)` | Fetch full content of a specific memory. Records an access. |
113
117
  | `forget(ref_id)` | Delete a memory from all indexes. |
114
118
  | `promote(ref_id, type, content?, importance?, tags?)` | Turn a short-term/working memory into a long-term one (you supply the distilled text). Keeps provenance via `promoted_from`. |
115
- | `consolidate(session_id?, limit?)` | Purge expired short-term memories and list the ones most worth promoting (ranked by importance × how often they were recalled). |
116
- | `forget_expired()` | Delete every short-term/working memory whose retention window lapsed. |
119
+ | `consolidate(session_id?, limit?, synthesize?)` | 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`). |
120
+ | `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`. |
117
121
  | `wiki_export(wiki_dir?)` | Compile long-term memories without a wiki page into draft markdown pages (`index.md`, `log.md` updated). |
118
122
  | `wiki_import(wiki_dir?)` | Index curated wiki pages as long-term memories (idempotent; changed pages supersede). |
119
123
 
@@ -126,6 +130,7 @@ See the Prerequisites section inside each script for the Python environment requ
126
130
  | `fact` | long-term | Durable project facts: build commands, environments, contracts | 0.7 | Never |
127
131
  | `procedure` | long-term | How-to that worked: workflows, recipes, verified skills | 0.8 | Never |
128
132
  | `code` | long-term | Function/class snapshots, API contracts | 0.6 | Never |
133
+ | `document` | long-term | Large artefacts (specs, transcripts, files): the body is stored and chunk-indexed, `get` returns a summary, `ask` answers from the indexed chunks (local reasoner) or the body (RLM Studio) | 0.6 | Never |
129
134
  | `episode` | short-term | Session events, compaction checkpoints | 0.4 | 30 days, extended by every recall |
130
135
  | `observation` | short-term | Findings, tool outputs, things noticed | 0.3 | 7 days, extended by every recall |
131
136
  | `task` | working | The current goal, plan state, open questions | 0.5 | 24 hours |
@@ -138,7 +143,8 @@ Retention: `ttl = base_ttl × (1 + access_boost × access_count)`, measured from
138
143
  2. **While working** — `retain(type="task")` when the plan changes; `retain(type="observation")` for findings; durable knowledge as `decision` / `convention` / `fact` / `procedure`.
139
144
  3. **Before compaction** — `retain(type="episode", checkpoint=true)` (the `pre-compact.py` hook) and `consolidate()`.
140
145
  4. **After compaction / end of phase** — `promote(ref_id, type, content)` for the candidates worth keeping.
141
- 5. **Curate** — `wiki_export` → review the drafts → `wiki_import`.
146
+ 5. **Large artefacts** — `retain(type="document", content=<body>, source=<name>)` then `ask(ref_id, question)`; never `get` a document to read it.
147
+ 6. **Curate** — `wiki_export` → review the drafts → `wiki_import`.
142
148
 
143
149
  ### Quick examples
144
150
 
@@ -164,6 +170,10 @@ retain(content="Use arq, not Celery", type="decision", supersedes="<old decision
164
170
  get(ref_id="<ref_id from recall>")
165
171
 
166
172
  forget(ref_id="<ref_id>")
173
+
174
+ retain(content=<the whole spec>, type="document", source="specs/auth/spec.md")
175
+
176
+ ask(ref_id="<document ref_id>", question="Which endpoints need rate limiting?", mode="auto")
167
177
  ```
168
178
 
169
179
  ---
@@ -185,6 +195,10 @@ All settings are read from environment variables with the `MEMKERNEL_` prefix, o
185
195
  | `MEMKERNEL_PREVIEW_LENGTH` | `200` | Max characters in the `preview` field of `ArtifactRef` |
186
196
  | `MEMKERNEL_CHUNK_SIZE` | `1500` | Max characters per AST chunk (code memories) |
187
197
  | `MEMKERNEL_CHUNK_OVERLAP` | `200` | Character overlap between token-based fallback chunks |
198
+ | `MEMKERNEL_DOCUMENT_STORE` | `local` | Where `document` bodies live (`local` = the project's own ChromaDB collection) |
199
+ | `MEMKERNEL_DOCUMENT_SUMMARY_CHARS` | `1000` | Characters of the body kept in a `document` memory's summary record |
200
+ | `MEMKERNEL_REASONER` | `local` | `ReasonerPort` behind `ask`: `local` (hybrid search over chunks, no LLM) or `rlmstudio` (falls back to `local` when `rlmstudio` is not importable) |
201
+ | `MEMKERNEL_ASK_TOP_K` | `5` | Chunks the local reasoner returns per `ask` |
188
202
  | `MEMKERNEL_EPISODE_TTL_DAYS` | `30` | Base TTL for `episode` memories (short-term) |
189
203
  | `MEMKERNEL_OBSERVATION_TTL_DAYS` | `7` | Base TTL for `observation` memories (short-term) |
190
204
  | `MEMKERNEL_TASK_TTL_HOURS` | `24` | Base TTL for `task` memories (working) |
@@ -237,6 +251,35 @@ report = svc.consolidate() # {"purged": n, "candidates": [...]}
237
251
 
238
252
  `build_service()` is the supported entry point; the MCP server, the admin API and the hook templates all use it. Integration notes for `code-copilot-team` and `haa` are in [`docs/integration/harness-integration.md`](docs/integration/harness-integration.md).
239
253
 
254
+ ### The session protocol as a component
255
+
256
+ `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:
257
+
258
+ ```python
259
+ from memkernel.factory import build_service
260
+ from memkernel.lifecycle import MemoryLifecycle
261
+
262
+ memory = MemoryLifecycle(build_service())
263
+ system_context = memory.on_session_start() # checkpoint, long-term context, promotion candidates, wiki pointer
264
+ ...
265
+ memory.on_pre_compact(summary) # checkpoint episode + consolidation, before the context is compacted
266
+ recovered = memory.on_post_compact() # what to re-inject after compaction
267
+ memory.on_session_end(summary) # final checkpoint + consolidation
268
+ ```
269
+
270
+ ### Documents and `ask`
271
+
272
+ `retain(type="document")` keeps large artefacts out of the context window: the body goes to the document store (built-in: the project's own ChromaDB collection), its chunks are indexed for hybrid search, and the memkernel record is a summary. `ask(ref_id, question, mode)` answers through a `ReasonerPort`:
273
+
274
+ - `MEMKERNEL_REASONER=local` (default) — no LLM: the question is matched against the document's chunks with dense + BM25 + RRF and the best chunks come back as the answer, `mode="rag"`.
275
+ - `MEMKERNEL_REASONER=rlmstudio` — delegates to [rlm-studio](https://github.com/gosha70/rlm-studio)'s `interact(content, question, mode=mode)`, so `direct`, `rag`, `rlm` and `auto` select how the document is read (ADR-009). `rlm-studio` is not a memkernel dependency and is not on PyPI yet; install it from git into the same environment:
276
+
277
+ ```bash
278
+ pip install "rlm-studio @ git+https://github.com/gosha70/rlm-studio"
279
+ ```
280
+
281
+ When it is not importable, `build_service()` logs a warning and uses the local reasoner. With an external reasoner, `consolidate(synthesize=True)` also drafts long-term statements from the session's short-term memories.
282
+
240
283
  ## LLM wiki bridge
241
284
 
242
285
  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`).
@@ -281,7 +324,7 @@ Releases are published to PyPI by [`.github/workflows/release.yml`](.github/work
281
324
 
282
325
  1. Set `version` in `pyproject.toml` and move the `[Unreleased]` entries in [`CHANGELOG.md`](CHANGELOG.md) under the new version.
283
326
  2. Merge to `main` with CI green.
284
- 3. Tag and push: `git tag -a v0.2.0 -m "memkernel 0.2.0" && git push origin v0.2.0`.
327
+ 3. Tag and push: `git tag -a v0.3.0 -m "memkernel 0.3.0" && git push origin v0.3.0`.
285
328
 
286
329
  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.
287
330
 
@@ -293,12 +336,14 @@ The workflow refuses a tag that does not match the `pyproject.toml` version, bui
293
336
  src/memkernel/
294
337
  ├── mcp/ # MCP server + tool definitions (memory tools, wiki tools)
295
338
  ├── core/ # Domain models (tiers, types), MemoryService, ports (interfaces)
296
- ├── storage/ # ChromaDB adapter
297
- ├── retrieval/ # AST chunking, embeddings, hybrid search (dense + BM25 + RRF + rescoring)
339
+ ├── adapters/ # Optional external systems (RLM Studio reasoner), imported lazily
340
+ ├── storage/ # ChromaDB adapter, local document store
341
+ ├── retrieval/ # AST/token chunking, embeddings, hybrid search (dense + BM25 + RRF + rescoring), local reasoner
298
342
  ├── memory/ # Lifecycle: TTL/decay policies, retention, deduplication
299
343
  ├── wiki/ # LLM-wiki bridge: page model, export, import
300
344
  ├── api/ # FastAPI admin endpoints
301
345
  ├── factory.py # build_service() — the supported embedding entry point
346
+ ├── lifecycle.py # MemoryLifecycle — the session protocol as a component
302
347
  └── config.py # Pydantic settings
303
348
  ```
304
349
 
@@ -315,7 +360,7 @@ MemKernel is the memory component of a small family of projects by the same auth
315
360
  | Project | Role | Relationship |
316
361
  |---|---|---|
317
362
  | [ai-wiki](https://github.com/gosha70/ai-wiki) | LLM-maintained markdown wiki (ingest / promote / query / lint) | the human-readable form of long-term memory; `wiki_export` / `wiki_import` speak its page schema (ADR-008) |
318
- | [rlm-studio](https://github.com/gosha70/rlm-studio) | Recursive Language Models workbench: Direct / RAG / RLM / auto over large documents | the document layer; planned `document` memory type and `ask()` tool (ADR-009) |
363
+ | [rlm-studio](https://github.com/gosha70/rlm-studio) | Recursive Language Models workbench: Direct / RAG / RLM / auto over large documents | the document layer behind `document` memories and the `ask()` tool (`MEMKERNEL_REASONER=rlmstudio`, ADR-009) |
319
364
  | [code-copilot-team](https://github.com/gosha70/code-copilot-team) | Enforceable harness configuration for Claude Code, Pi and other copilots | consumes MemKernel through MCP and hooks |
320
365
  | [haa](https://github.com/gosha70/haa) | Home-built AI coding harness, future component assembler | consumes MemKernel in-process through `build_service()` |
321
366
 
@@ -5,6 +5,8 @@
5
5
  [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)](pyproject.toml)
6
6
  [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-green)](LICENSE)
7
7
 
8
+ **Site:** [memkernel.dev](https://memkernel.dev) · **Package:** [pypi.org/project/memkernel](https://pypi.org/project/memkernel/)
9
+
8
10
  Code-aware memory server for AI coding copilots.
9
11
 
10
12
  MemKernel gives Claude Code (and any MCP-compatible copilot) persistent memory across sessions. When Claude compacts context or a session ends, project decisions, conventions, and code understanding are normally lost. MemKernel stores them in a local vector database and retrieves them on demand.
@@ -72,7 +74,7 @@ Copy the hook scripts from [`templates/hooks/`](templates/hooks/) to `.claude/ho
72
74
  | `pre-compact.py` | `PreCompact` | Retains a checkpoint episode, runs a consolidation pass |
73
75
  | `post-compact.py` | `PostCompact` | Re-injects the checkpoint, long-term context and promotion candidates |
74
76
 
75
- See the Prerequisites section inside each script for the Python environment requirement.
77
+ 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.
76
78
 
77
79
  ---
78
80
 
@@ -85,8 +87,8 @@ See the Prerequisites section inside each script for the Python environment requ
85
87
  | `get(ref_id)` | Fetch full content of a specific memory. Records an access. |
86
88
  | `forget(ref_id)` | Delete a memory from all indexes. |
87
89
  | `promote(ref_id, type, content?, importance?, tags?)` | Turn a short-term/working memory into a long-term one (you supply the distilled text). Keeps provenance via `promoted_from`. |
88
- | `consolidate(session_id?, limit?)` | Purge expired short-term memories and list the ones most worth promoting (ranked by importance × how often they were recalled). |
89
- | `forget_expired()` | Delete every short-term/working memory whose retention window lapsed. |
90
+ | `consolidate(session_id?, limit?, synthesize?)` | 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`). |
91
+ | `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`. |
90
92
  | `wiki_export(wiki_dir?)` | Compile long-term memories without a wiki page into draft markdown pages (`index.md`, `log.md` updated). |
91
93
  | `wiki_import(wiki_dir?)` | Index curated wiki pages as long-term memories (idempotent; changed pages supersede). |
92
94
 
@@ -99,6 +101,7 @@ See the Prerequisites section inside each script for the Python environment requ
99
101
  | `fact` | long-term | Durable project facts: build commands, environments, contracts | 0.7 | Never |
100
102
  | `procedure` | long-term | How-to that worked: workflows, recipes, verified skills | 0.8 | Never |
101
103
  | `code` | long-term | Function/class snapshots, API contracts | 0.6 | Never |
104
+ | `document` | long-term | Large artefacts (specs, transcripts, files): the body is stored and chunk-indexed, `get` returns a summary, `ask` answers from the indexed chunks (local reasoner) or the body (RLM Studio) | 0.6 | Never |
102
105
  | `episode` | short-term | Session events, compaction checkpoints | 0.4 | 30 days, extended by every recall |
103
106
  | `observation` | short-term | Findings, tool outputs, things noticed | 0.3 | 7 days, extended by every recall |
104
107
  | `task` | working | The current goal, plan state, open questions | 0.5 | 24 hours |
@@ -111,7 +114,8 @@ Retention: `ttl = base_ttl × (1 + access_boost × access_count)`, measured from
111
114
  2. **While working** — `retain(type="task")` when the plan changes; `retain(type="observation")` for findings; durable knowledge as `decision` / `convention` / `fact` / `procedure`.
112
115
  3. **Before compaction** — `retain(type="episode", checkpoint=true)` (the `pre-compact.py` hook) and `consolidate()`.
113
116
  4. **After compaction / end of phase** — `promote(ref_id, type, content)` for the candidates worth keeping.
114
- 5. **Curate** — `wiki_export` → review the drafts → `wiki_import`.
117
+ 5. **Large artefacts** — `retain(type="document", content=<body>, source=<name>)` then `ask(ref_id, question)`; never `get` a document to read it.
118
+ 6. **Curate** — `wiki_export` → review the drafts → `wiki_import`.
115
119
 
116
120
  ### Quick examples
117
121
 
@@ -137,6 +141,10 @@ retain(content="Use arq, not Celery", type="decision", supersedes="<old decision
137
141
  get(ref_id="<ref_id from recall>")
138
142
 
139
143
  forget(ref_id="<ref_id>")
144
+
145
+ retain(content=<the whole spec>, type="document", source="specs/auth/spec.md")
146
+
147
+ ask(ref_id="<document ref_id>", question="Which endpoints need rate limiting?", mode="auto")
140
148
  ```
141
149
 
142
150
  ---
@@ -158,6 +166,10 @@ All settings are read from environment variables with the `MEMKERNEL_` prefix, o
158
166
  | `MEMKERNEL_PREVIEW_LENGTH` | `200` | Max characters in the `preview` field of `ArtifactRef` |
159
167
  | `MEMKERNEL_CHUNK_SIZE` | `1500` | Max characters per AST chunk (code memories) |
160
168
  | `MEMKERNEL_CHUNK_OVERLAP` | `200` | Character overlap between token-based fallback chunks |
169
+ | `MEMKERNEL_DOCUMENT_STORE` | `local` | Where `document` bodies live (`local` = the project's own ChromaDB collection) |
170
+ | `MEMKERNEL_DOCUMENT_SUMMARY_CHARS` | `1000` | Characters of the body kept in a `document` memory's summary record |
171
+ | `MEMKERNEL_REASONER` | `local` | `ReasonerPort` behind `ask`: `local` (hybrid search over chunks, no LLM) or `rlmstudio` (falls back to `local` when `rlmstudio` is not importable) |
172
+ | `MEMKERNEL_ASK_TOP_K` | `5` | Chunks the local reasoner returns per `ask` |
161
173
  | `MEMKERNEL_EPISODE_TTL_DAYS` | `30` | Base TTL for `episode` memories (short-term) |
162
174
  | `MEMKERNEL_OBSERVATION_TTL_DAYS` | `7` | Base TTL for `observation` memories (short-term) |
163
175
  | `MEMKERNEL_TASK_TTL_HOURS` | `24` | Base TTL for `task` memories (working) |
@@ -210,6 +222,35 @@ report = svc.consolidate() # {"purged": n, "candidates": [...]}
210
222
 
211
223
  `build_service()` is the supported entry point; the MCP server, the admin API and the hook templates all use it. Integration notes for `code-copilot-team` and `haa` are in [`docs/integration/harness-integration.md`](docs/integration/harness-integration.md).
212
224
 
225
+ ### The session protocol as a component
226
+
227
+ `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:
228
+
229
+ ```python
230
+ from memkernel.factory import build_service
231
+ from memkernel.lifecycle import MemoryLifecycle
232
+
233
+ memory = MemoryLifecycle(build_service())
234
+ system_context = memory.on_session_start() # checkpoint, long-term context, promotion candidates, wiki pointer
235
+ ...
236
+ memory.on_pre_compact(summary) # checkpoint episode + consolidation, before the context is compacted
237
+ recovered = memory.on_post_compact() # what to re-inject after compaction
238
+ memory.on_session_end(summary) # final checkpoint + consolidation
239
+ ```
240
+
241
+ ### Documents and `ask`
242
+
243
+ `retain(type="document")` keeps large artefacts out of the context window: the body goes to the document store (built-in: the project's own ChromaDB collection), its chunks are indexed for hybrid search, and the memkernel record is a summary. `ask(ref_id, question, mode)` answers through a `ReasonerPort`:
244
+
245
+ - `MEMKERNEL_REASONER=local` (default) — no LLM: the question is matched against the document's chunks with dense + BM25 + RRF and the best chunks come back as the answer, `mode="rag"`.
246
+ - `MEMKERNEL_REASONER=rlmstudio` — delegates to [rlm-studio](https://github.com/gosha70/rlm-studio)'s `interact(content, question, mode=mode)`, so `direct`, `rag`, `rlm` and `auto` select how the document is read (ADR-009). `rlm-studio` is not a memkernel dependency and is not on PyPI yet; install it from git into the same environment:
247
+
248
+ ```bash
249
+ pip install "rlm-studio @ git+https://github.com/gosha70/rlm-studio"
250
+ ```
251
+
252
+ When it is not importable, `build_service()` logs a warning and uses the local reasoner. With an external reasoner, `consolidate(synthesize=True)` also drafts long-term statements from the session's short-term memories.
253
+
213
254
  ## LLM wiki bridge
214
255
 
215
256
  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`).
@@ -254,7 +295,7 @@ Releases are published to PyPI by [`.github/workflows/release.yml`](.github/work
254
295
 
255
296
  1. Set `version` in `pyproject.toml` and move the `[Unreleased]` entries in [`CHANGELOG.md`](CHANGELOG.md) under the new version.
256
297
  2. Merge to `main` with CI green.
257
- 3. Tag and push: `git tag -a v0.2.0 -m "memkernel 0.2.0" && git push origin v0.2.0`.
298
+ 3. Tag and push: `git tag -a v0.3.0 -m "memkernel 0.3.0" && git push origin v0.3.0`.
258
299
 
259
300
  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.
260
301
 
@@ -266,12 +307,14 @@ The workflow refuses a tag that does not match the `pyproject.toml` version, bui
266
307
  src/memkernel/
267
308
  ├── mcp/ # MCP server + tool definitions (memory tools, wiki tools)
268
309
  ├── core/ # Domain models (tiers, types), MemoryService, ports (interfaces)
269
- ├── storage/ # ChromaDB adapter
270
- ├── retrieval/ # AST chunking, embeddings, hybrid search (dense + BM25 + RRF + rescoring)
310
+ ├── adapters/ # Optional external systems (RLM Studio reasoner), imported lazily
311
+ ├── storage/ # ChromaDB adapter, local document store
312
+ ├── retrieval/ # AST/token chunking, embeddings, hybrid search (dense + BM25 + RRF + rescoring), local reasoner
271
313
  ├── memory/ # Lifecycle: TTL/decay policies, retention, deduplication
272
314
  ├── wiki/ # LLM-wiki bridge: page model, export, import
273
315
  ├── api/ # FastAPI admin endpoints
274
316
  ├── factory.py # build_service() — the supported embedding entry point
317
+ ├── lifecycle.py # MemoryLifecycle — the session protocol as a component
275
318
  └── config.py # Pydantic settings
276
319
  ```
277
320
 
@@ -288,7 +331,7 @@ MemKernel is the memory component of a small family of projects by the same auth
288
331
  | Project | Role | Relationship |
289
332
  |---|---|---|
290
333
  | [ai-wiki](https://github.com/gosha70/ai-wiki) | LLM-maintained markdown wiki (ingest / promote / query / lint) | the human-readable form of long-term memory; `wiki_export` / `wiki_import` speak its page schema (ADR-008) |
291
- | [rlm-studio](https://github.com/gosha70/rlm-studio) | Recursive Language Models workbench: Direct / RAG / RLM / auto over large documents | the document layer; planned `document` memory type and `ask()` tool (ADR-009) |
334
+ | [rlm-studio](https://github.com/gosha70/rlm-studio) | Recursive Language Models workbench: Direct / RAG / RLM / auto over large documents | the document layer behind `document` memories and the `ask()` tool (`MEMKERNEL_REASONER=rlmstudio`, ADR-009) |
292
335
  | [code-copilot-team](https://github.com/gosha70/code-copilot-team) | Enforceable harness configuration for Claude Code, Pi and other copilots | consumes MemKernel through MCP and hooks |
293
336
  | [haa](https://github.com/gosha70/haa) | Home-built AI coding harness, future component assembler | consumes MemKernel in-process through `build_service()` |
294
337
 
@@ -1,11 +1,13 @@
1
1
  [tool.poetry]
2
2
  name = "memkernel"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Code-aware memory server for AI coding copilots"
5
5
  authors = ["EGOGE"]
6
6
  license = "Apache-2.0"
7
7
  readme = "README.md"
8
8
  repository = "https://github.com/gosha70/memkernel"
9
+ homepage = "https://memkernel.dev"
10
+ documentation = "https://memkernel.dev"
9
11
  classifiers = [
10
12
  "Programming Language :: Python :: 3",
11
13
  "Programming Language :: Python :: 3.11",
@@ -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.2.0"
5
+ __version__ = "0.3.0"
@@ -0,0 +1,3 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Adapters to optional external systems. Each imports its package lazily and degrades cleanly."""
@@ -0,0 +1,123 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """RLM Studio adapter implementing ReasonerPort (ADR-009).
4
+
5
+ ``rlmstudio`` is imported inside :class:`RLMStudioReasoner.__init__`, never at
6
+ package import time, because it pulls LiteLLM in. It is not a dependency of
7
+ memkernel (FR-016); install it from git::
8
+
9
+ pip install "rlm-studio @ git+https://github.com/gosha70/rlm-studio"
10
+
11
+ and set ``MEMKERNEL_REASONER=rlmstudio``. When the package is missing the
12
+ adapter raises :class:`ReasonerUnavailable` and ``build_service()`` falls
13
+ back to the LLM-free :class:`~memkernel.retrieval.reasoner.LocalReasoner`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Mapping
19
+ from typing import Any
20
+
21
+ from memkernel.core.models import Answer, ReasonerError
22
+ from memkernel.retrieval.reasoner import ReasonerUnavailable
23
+
24
+ INSTALL_HINT = 'pip install "rlm-studio @ git+https://github.com/gosha70/rlm-studio"'
25
+
26
+ # Field names tried, in order, when mapping an ``interact`` result onto Answer.
27
+ # ``InteractResult`` (rlmstudio.api) carries ``answer``, ``mode_used``,
28
+ # ``metrics`` and ``raw_result`` (a RunResultDTO with ``success`` / ``error``);
29
+ # the other names cover dict results and older shapes.
30
+ _ANSWER_FIELDS = ("answer", "response", "text", "content", "output")
31
+ _MODE_FIELDS = ("mode_used", "mode", "strategy")
32
+ _CITATION_FIELDS = ("citations", "sources", "references")
33
+ _USAGE_FIELDS = ("metrics", "usage", "cost", "budget")
34
+ _RAW_RESULT_FIELDS = ("raw_result",)
35
+ _SUCCESS_FIELDS = ("success",)
36
+ _ERROR_FIELDS = ("error",)
37
+
38
+
39
+ def _field(result: Any, names: tuple[str, ...]) -> Any:
40
+ """Read the first present field from a mapping or an object; None when absent."""
41
+ for name in names:
42
+ if isinstance(result, Mapping):
43
+ if name in result:
44
+ return result[name]
45
+ elif hasattr(result, name):
46
+ return getattr(result, name)
47
+ return None
48
+
49
+
50
+ def _usage(raw: Any) -> dict[str, float | int]:
51
+ """Keep the numeric entries of a usage mapping (tokens, cost, steps, ...)."""
52
+ usage: dict[str, float | int] = {}
53
+ items = raw.items() if isinstance(raw, Mapping) else vars(raw).items() if hasattr(raw, "__dict__") else ()
54
+ for key, value in items:
55
+ if isinstance(value, bool):
56
+ continue
57
+ if isinstance(value, int | float):
58
+ usage[str(key)] = value
59
+ return usage
60
+
61
+
62
+ def _failure(result: Any) -> str | None:
63
+ """Return the error text when the result (or its raw result) reports ``success=False``."""
64
+ for candidate in (result, _field(result, _RAW_RESULT_FIELDS)):
65
+ if candidate is None:
66
+ continue
67
+ if _field(candidate, _SUCCESS_FIELDS) is False:
68
+ error = _field(candidate, _ERROR_FIELDS)
69
+ return str(error) if error else "execution reported success=False"
70
+ return None
71
+
72
+
73
+ def to_answer(result: Any, requested_mode: str) -> Answer:
74
+ """Map whatever ``rlmstudio.interact`` returned onto :class:`Answer`.
75
+
76
+ A result whose ``success`` (or ``raw_result.success``) is False raises
77
+ :class:`ReasonerError`: RLM Studio puts formatted error text in ``answer``
78
+ on failure, and that text must never be taken for an answer. The execution
79
+ ``trace`` is not provenance and is never turned into a citation.
80
+ """
81
+ if result is None:
82
+ return Answer(answer="", mode=requested_mode)
83
+ if isinstance(result, str):
84
+ return Answer(answer=result, mode=requested_mode)
85
+ failure = _failure(result)
86
+ if failure is not None:
87
+ raise ReasonerError(f"rlmstudio {requested_mode} run failed: {failure}")
88
+ raw_answer = _field(result, _ANSWER_FIELDS)
89
+ answer = str(raw_answer) if raw_answer is not None else str(result)
90
+ raw_mode = _field(result, _MODE_FIELDS)
91
+ mode = str(raw_mode) if raw_mode else requested_mode
92
+ raw_citations = _field(result, _CITATION_FIELDS)
93
+ citations = [str(c) for c in raw_citations] if isinstance(raw_citations, list | tuple) else []
94
+ raw_usage = _field(result, _USAGE_FIELDS)
95
+ usage = _usage(raw_usage) if raw_usage is not None else {}
96
+ return Answer(answer=answer, mode=mode, citations=citations, usage=usage)
97
+
98
+
99
+ class RLMStudioReasoner:
100
+ """``ReasonerPort`` delegating to ``rlmstudio.interact(content, question, mode=mode)``."""
101
+
102
+ def __init__(self) -> None:
103
+ try:
104
+ import rlmstudio # type: ignore[import-not-found] # optional, never a dependency (FR-016)
105
+ except ImportError as exc:
106
+ raise ReasonerUnavailable(f"rlmstudio is not importable ({exc}); {INSTALL_HINT}") from exc
107
+ self._rlmstudio: Any = rlmstudio
108
+
109
+ def ask(self, content: str, question: str, mode: str) -> Answer:
110
+ """Pass ``mode`` through unchanged (direct | rag | rlm | auto); report the mode actually used.
111
+
112
+ Raises :class:`ReasonerError` when RLM Studio reports a failed run or
113
+ when the call itself raises (provider down, timeout, budget exceeded):
114
+ callers degrade on that one exception type instead of on whatever the
115
+ provider stack throws.
116
+ """
117
+ try:
118
+ result = self._rlmstudio.interact(content, question, mode=mode)
119
+ except ReasonerError:
120
+ raise
121
+ except Exception as exc:
122
+ raise ReasonerError(f"rlmstudio {mode} run raised {type(exc).__name__}: {exc}") from exc
123
+ return to_answer(result, mode)
@@ -34,6 +34,12 @@ class MemKernelSettings(BaseSettings):
34
34
  chunk_size: int = 1500 # max chars per chunk
35
35
  chunk_overlap: int = 200 # char overlap between token chunks (fallback only)
36
36
 
37
+ # Documents and ask() (ADR-009)
38
+ document_store: Literal["local"] = "local" # where `document` bodies live
39
+ document_summary_chars: int = 1000 # characters of the body kept in the document's summary record
40
+ reasoner: Literal["local", "rlmstudio"] = "local" # ReasonerPort behind ask(); falls back to local
41
+ ask_top_k: int = 5 # chunks the local reasoner returns per ask()
42
+
37
43
  # Lifecycle / decay (see docs/architecture/memory-tiers.md)
38
44
  episode_ttl_days: int = 30 # base TTL for short-term `episode` memories
39
45
  observation_ttl_days: int = 7 # base TTL for short-term `observation` memories
@@ -42,6 +42,8 @@ class MemoryType(StrEnum):
42
42
  CODE = "code"
43
43
  # Long-term, procedural
44
44
  PROCEDURE = "procedure"
45
+ # Long-term, a large artefact whose body lives in the document store (ADR-009)
46
+ DOCUMENT = "document"
45
47
  # Short-term, episodic
46
48
  EPISODE = "episode"
47
49
  OBSERVATION = "observation"
@@ -55,6 +57,7 @@ TYPE_TIER: dict[MemoryType, MemoryTier] = {
55
57
  MemoryType.FACT: MemoryTier.LONG_TERM,
56
58
  MemoryType.CODE: MemoryTier.LONG_TERM,
57
59
  MemoryType.PROCEDURE: MemoryTier.LONG_TERM,
60
+ MemoryType.DOCUMENT: MemoryTier.LONG_TERM,
58
61
  MemoryType.EPISODE: MemoryTier.SHORT_TERM,
59
62
  MemoryType.OBSERVATION: MemoryTier.SHORT_TERM,
60
63
  MemoryType.TASK: MemoryTier.WORKING,
@@ -69,12 +72,30 @@ DEFAULT_IMPORTANCE: dict[MemoryType, float] = {
69
72
  MemoryType.PROCEDURE: 0.8,
70
73
  MemoryType.FACT: 0.7,
71
74
  MemoryType.CODE: 0.6,
75
+ MemoryType.DOCUMENT: 0.6,
72
76
  MemoryType.TASK: 0.5,
73
77
  MemoryType.EPISODE: 0.4,
74
78
  MemoryType.OBSERVATION: 0.3,
75
79
  }
76
80
 
77
81
 
82
+ # Tags that mark the records a document store keeps beside the memkernel summary
83
+ # record: the canonical body and its indexed chunks. Both are hidden from
84
+ # top-level recall / list results; a chunk hit resolves to the document summary.
85
+ DOCUMENT_BODY_TAG = "document_body"
86
+ DOCUMENT_CHUNK_TAG = "document_chunk"
87
+ # Boolean metadata key set on body/chunk records so search can exempt them
88
+ # from owner-specific filters (session, type, tier, checkpoint): one body may
89
+ # be owned by several summaries in different sessions, so the filters are
90
+ # applied to the owning summary a hit resolves to, not to the shared part.
91
+ DOCUMENT_PART_KEY = "document_part"
92
+
93
+ # Reasoning modes accepted by ask(): RLM Studio's Direct / RAG / RLM / auto
94
+ # selection, passed through to the ReasonerPort unchanged (ADR-009).
95
+ ASK_MODES: tuple[str, ...] = ("direct", "rag", "rlm", "auto")
96
+ DEFAULT_ASK_MODE = "auto"
97
+
98
+
78
99
  def tier_of(memory_type: MemoryType) -> MemoryTier:
79
100
  """Return the tier a memory type belongs to."""
80
101
  return TYPE_TIER[memory_type]
@@ -103,6 +124,22 @@ class Memory(BaseModel):
103
124
  tags: list[str] = []
104
125
  supersedes: str | None = None
105
126
  promoted_from: str | None = None
127
+ # Set on a ``document`` summary record and on the body/chunk records the
128
+ # document store keeps for it (ADR-009).
129
+ document_id: str | None = None
130
+
131
+
132
+ class ReasonerError(RuntimeError):
133
+ """A ReasonerPort implementation ran but did not produce an answer (provider error, timeout, budget)."""
134
+
135
+
136
+ class Answer(BaseModel):
137
+ """Result of ``ask``: the answer text, the reasoning mode actually used, and provenance."""
138
+
139
+ answer: str
140
+ mode: str
141
+ citations: list[str] = []
142
+ usage: dict[str, float | int] = {}
106
143
 
107
144
 
108
145
  class ArtifactRef(BaseModel):
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
  from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
12
12
 
13
13
  if TYPE_CHECKING:
14
- from memkernel.core.models import Memory, SearchResult
14
+ from memkernel.core.models import Answer, Memory, SearchResult
15
15
 
16
16
 
17
17
  @runtime_checkable
@@ -47,7 +47,9 @@ class VectorStorePort(Protocol):
47
47
  """Fetch-and-merge: only overwrites caller-specified keys."""
48
48
  ...
49
49
 
50
- def count(self) -> int: ...
50
+ def count(self, where: dict[str, Any] | None = None) -> int:
51
+ """Number of records, optionally only those matching a metadata filter (ids-only fetch)."""
52
+ ...
51
53
 
52
54
  def list_collections(self) -> list[dict[str, Any]]:
53
55
  """List all collections visible to this adapter with their record counts."""
@@ -93,3 +95,35 @@ class ChunkerPort(Protocol):
93
95
  """Code/text chunking strategy (stub for future US3)."""
94
96
 
95
97
  def chunk(self, content: str, metadata: dict[str, Any]) -> list[str]: ...
98
+
99
+
100
+ @runtime_checkable
101
+ class DocumentStorePort(Protocol):
102
+ """Document bodies: stored whole, chunk-indexed, searchable per document (ADR-009)."""
103
+
104
+ def put(self, content: str, name: str) -> str:
105
+ """Store a document body and return its doc_id (idempotent for identical content)."""
106
+ ...
107
+
108
+ def get(self, doc_id: str) -> str | None:
109
+ """Return the stored body, or None if ``doc_id`` is unknown."""
110
+ ...
111
+
112
+ def index(self, doc_id: str, chunks: list[str]) -> int:
113
+ """Index the chunks of a stored document; returns the number of chunks indexed."""
114
+ ...
115
+
116
+ def indexed(self, doc_id: str) -> int:
117
+ """Number of chunks currently indexed for ``doc_id`` (0 for an unknown or un-indexed body); ids-only."""
118
+ ...
119
+
120
+ def search(self, doc_id: str, query: str, top_k: int) -> list[str]:
121
+ """Return the top_k chunk texts of one document ranked against ``query``."""
122
+ ...
123
+
124
+
125
+ @runtime_checkable
126
+ class ReasonerPort(Protocol):
127
+ """Answers a question about a body of text in a given mode (direct | rag | rlm | auto)."""
128
+
129
+ def ask(self, content: str, question: str, mode: str) -> Answer: ...