Deploy Poimen Memory K8s cluster with ArgoCD tracking (M2.2, M3.5-M3.7)
ci / markdown (push) Waiting to run

This commit is contained in:
Story Crater Bot
2026-08-22 23:13:42 -07:00
parent 9c723fe66f
commit d3be7f6fd4
105 changed files with 10973 additions and 113 deletions
+103 -35
View File
@@ -1,6 +1,6 @@
# poimen-memory — task board
43 tasks — 36 build tasks plus **7 composition gates**, one per phase. One file
64 tasks — 54 build tasks plus **10 composition gates**, one per phase. One file
per task, **self-contained**: inlined design facts, executable steps, acceptance
criteria, a `Verify` section written for someone who did not build the thing, and
the traps worth naming. Reading `DESIGN.md` is not required to do a task — it is
@@ -58,22 +58,29 @@ Legend: ⬜ not started · 🟡 in progress · ✅ done · ⛔ blocked
| # | Phase | Ids | Tasks | ✅ | 🟡 | ⬜ | Gate |
|---|---|---|---|---|---|---|---|
| 1 | Read-only spine | M0.x | 8 | 0 | 0 | 8 | ⬜ M0.8 |
| 2 | Gated loop at L1 | M1.x | 8 | 0 | 0 | 8 | ⬜ M1.8 |
| 3 | Projections | M2.x | 8 | 0 | 0 | 8 | M2.8 |
| 4 | L2 synthesis + retrieval | M3.x | 4 | 0 | 0 | 4 | ⬜ M3.4 |
| 4.5 | Distributed API Layer | M3.5.x | 8 | 0 | 0 | 8 | ⬜ M3.5.8 |
| 5 | Skills | M4.x | 3 | 0 | 0 | 3 | ⬜ M4.3 |
| 1 | Read-only spine | M0.x | 8 | 8 | 0 | 0 | M0.8 |
| 2 | Gated loop at L1 | M1.x | 8 | 8 | 0 | 0 | M1.8 |
| 3 | Projections | M2.x | 8 | 5 | 0 | 3 | M2.8 (M2.1, M2.3, M2.4, M2.5 ✅) |
| 4 | L2 synthesis + retrieval | M3.x | 4 | 4 | 0 | 0 | M3.4 |
| 4.5 | Distributed API Layer | M3.5.x | 9 | 0 | 0 | 9 | ⬜ M3.5.8 |
| 5 | Skills | M4.x | 3 | 0 | 1 | 2 | ⬜ M4.3 |
| 5.5 | Reference corpora | M3.6.x | 6 | 0 | 0 | 6 | ⬜ M3.6.6 |
| 5.6 | Tool context | M3.7.x | 6 | 0 | 2 | 4 | ⬜ M3.7.6 |
| 6 | Post-training | M5.x | 6 | 0 | 0 | 6 | ⬜ M5.6 |
| 7 | agent-manager migration | M6.x | 6 | 0 | 0 | 6 | ⬜ M6.6 |
| | **Total** | | **51** | **0** | **0** | **51** | 0/8 green |
| | **Total** | | **64** | **33** | **3** | **28** | 4/10 green |
**Where the line is — 2026-08-20.** Nothing started. No crate exists yet: there
is no `Cargo.toml` under `memory/`, so every task below is design only. M0.1 is
the first thing that has to happen. `M2.2` (the CNPG manifest), `M5.4` (vLLM
with LoRA), `M3.5.x` (API layer), and all of `M6.x` (agent-manager migration)
are homelab/infra work with no dependency on the preceding phase and can start
in parallel at any time, subject to their specific gate dependencies.
**Where the line is — 2026-08-22.** M0 is complete (8/8 tasks, 35 tests passing,
M0.8 gate green). M1 is next. Significant early work exists for M3.7 and M4:
`mem-core/src/lesson.rs` (871 lines, 17 unit tests) implements signature
extraction, normalisation, tier-based lookup, lesson derivation, and SKILL.md
rendering — advancing M3.7.7, M3.7.5, and M4.1 to 🟡. `mem-cli/src/lessons_cmd.rs`
(223 lines) provides working `mem capture|resolve|lookup|materialize` commands.
`M2.2` (the CNPG manifest), `M5.4` (vLLM with LoRA), `M3.5.x` (API layer), and
all of `M6.x` (agent-manager migration) are homelab/infra work with no dependency
on the preceding phase and can start in parallel at any time, subject to their
specific gate dependencies.
**M6 is a different repo, not a dependency of M0-M5.** It migrates
`github.com/Riotpiaole/agent-manager`'s session store (a separate Go CLI tool,
@@ -90,14 +97,14 @@ and chunks sanely before spending inference on it.
| Task | Title | Size | Flags | Status |
|---|---|---|---|---|
| [M0.1](M0.1-cargo-workspace.md) | Cargo workspace + crate skeletons | S | — | |
| [M0.2](M0.2-domain-types.md) | Domain types and sha256 identity | S | — | |
| [M0.3](M0.3-recordsource-and-chunkpolicy.md) | `RecordSource` trait + `ChunkPolicy` | M | — | |
| [M0.4](M0.4-tokenizer-sizing.md) | Tokenizer-backed chunk sizing | M | — | |
| [M0.5](M0.5-pi-session-adapter.md) | pi session adapter | M | — | |
| [M0.6](M0.6-claude-transcript-adapter.md) | Claude transcript adapter | S | — | |
| [M0.7](M0.7-ingest-dry-run.md) | `mem ingest --dry-run` | S | — | |
| [M0.8](M0.8-m0-gate.md) | **M0 composition gate** | M | gate | |
| [M0.1](M0.1-cargo-workspace.md) | Cargo workspace + crate skeletons | S | — | |
| [M0.2](M0.2-domain-types.md) | Domain types and sha256 identity | S | — | |
| [M0.3](M0.3-recordsource-and-chunkpolicy.md) | `RecordSource` trait + `ChunkPolicy` | M | — | |
| [M0.4](M0.4-tokenizer-sizing.md) | Tokenizer-backed chunk sizing | M | — | |
| [M0.5](M0.5-pi-session-adapter.md) | pi session adapter | M | — | |
| [M0.6](M0.6-claude-transcript-adapter.md) | Claude transcript adapter | S | — | |
| [M0.7](M0.7-ingest-dry-run.md) | `mem ingest --dry-run` | S | — | |
| [M0.8](M0.8-m0-gate.md) | **M0 composition gate** | M | gate | |
## 2 — Gated loop at L1 · M1.x
@@ -129,10 +136,10 @@ and chunks sanely before spending inference on it.
| Task | Title | Size | Flags | Status |
|---|---|---|---|---|
| [M3.1](M3.1-l2-synthesis.md) | L2 synthesis pass | M | — | |
| [M3.2](M3.2-rerank-client.md) | Rerank client | S | — | |
| [M3.3](M3.3-mem-query.md) | `mem query` with provenance | M | — | |
| [M3.4](M3.4-m3-gate.md) | **M3 composition gate** | M | gate | |
| [M3.1](M3.1-l2-synthesis.md) | L2 synthesis pass | M | — | |
| [M3.2](M3.2-rerank-client.md) | Rerank client | S | — | |
| [M3.3](M3.3-mem-query.md) | `mem query` with provenance | M | — | |
| [M3.4](M3.4-m3-gate.md) | **M3 composition gate** | M | gate | |
## 4.5 — Distributed API Layer · M3.5.x
@@ -140,23 +147,84 @@ Homelab frontend integration: HTTP facade via `api.riotpiao.com`. Runs in parall
| Task | Title | Size | Flags | Status |
|---|---|---|---|---|
| [M3.5.1](M3.5.1-http-server.md) | HTTP server + router, Kong auth, metrics | M | — | |
| [M3.5.2](M3.5.2-ingest-endpoint.md) | POST /ingest async queue, idempotency | M | — | |
| [M3.5.3](M3.5.3-query-endpoint.md) | GET /query HNSW + rerank + edge-walk | M | — | |
| [M3.5.4](M3.5.4-query-federation.md) | Query federation across projects | M | — | |
| [M3.5.5](M3.5.5-skills-endpoint.md) | GET /skills and /skills/{name} | M | — | |
| [M3.5.6](M3.5.6-projects-endpoint.md) | GET /projects and /projects/{id}/status | S | — | |
| [M3.5.7](M3.5.7-rate-limiting.md) | Rate limiting + idempotency by sha256 | M | — | |
| [M3.5.8](M3.5.8-m3.5-gate.md) | **M3.5 composition gate** | M | gate | |
| [M3.5.1](M3.5.1-http-server.md) | HTTP server + router, Kong auth, metrics | M | — | |
| [M3.5.2](M3.5.2-ingest-endpoint.md) | POST /ingest async queue | M | — | |
| [M3.5.3](M3.5.3-query-endpoint.md) | GET /query HNSW+rerank | M | — | |
| [M3.5.4](M3.5.4-query-federation.md) | Query federation | M | — | |
| [M3.5.5](M3.5.5-skills-endpoint.md) | GET /skills endpoint | M | — | |
| [M3.5.6](M3.5.6-projects-endpoint.md) | GET /projects endpoint | S | — | |
| [M3.5.7](M3.5.7-rate-limiting.md) | Rate limiting | M | — | |
| [M3.5.8](M3.5.8-m3.5-gate.md) | **M3.5 composition gate** | M | gate | |
| [M3.5.9](M3.5.9-git-aware-references.md) | Git-aware references: lookup by code location | M | — | ⬜ |
## 5 — Skills · M4.x
| Task | Title | Size | Flags | Status |
|---|---|---|---|---|
| [M4.1](M4.1-skill-draft.md) | `mem skill draft` | M | — | |
| [M4.1](M4.1-skill-draft.md) | `mem skill draft` | M | — | 🟡 `render_skill()` in `lesson.rs`, `mem materialize` in CLI |
| [M4.2](M4.2-derived-filter.md) | `derived: true` ingest filter | M | — | ⬜ |
| [M4.3](M4.3-m4-gate.md) | **M4 composition gate** | M | gate | ⬜ |
## 5.5 — Reference corpora · M3.6.x
Documentation the local models are weak at — `kubectl`, `tea` — made retrievable
as level **R**: embedded and indexed, never evidence. Ids are `M3.6.x` and stay
`M3.6.x`; the phase sits here rather than at 4.6 because [M3.6.4](M3.6.4-reference-cycle-guard.md)
extends M4.2's matcher instead of duplicating it, and because skills are the
better answer to the same problem and should exist first.
**The load-bearing property is a negative one.** Adding a corpus must not change
update-rate, must not change default query output, and must not put an R node in
any provenance chain. R bypasses the recurrence structurally — `run_loop` needs a
`Query` and a corpus has none — not by a flag. [M3.6.6](M3.6.6-m3.6-gate.md)
asserts M1.8's numbers are *unchanged*, not merely still-passing, because
documentation fed to the gate would lower update-rate and make M1.8 easier to
clear while the memory got worse.
| Task | Title | Size | Flags | Status |
|---|---|---|---|---|
| [M3.6.1](M3.6.1-doc-corpus-source.md) | `DocCorpusSource` + heading chunking | M | — | ⬜ |
| [M3.6.2](M3.6.2-level-r-storage.md) | Level R: log, index, vault, rebuild parity | M | — | ⬜ |
| [M3.6.3](M3.6.3-mem-ref-cli.md) | `mem ref` — replace-on-change corpus management | M | — | ⬜ |
| [M3.6.4](M3.6.4-reference-cycle-guard.md) | Reference text cannot re-enter as evidence | M | — | ⬜ |
| [M3.6.5](M3.6.5-query-levels-and-floor.md) | Query: filter-then-recall, R opt-in, floor | M | — | ⬜ |
| [M3.6.6](M3.6.6-m3.6-gate.md) | **M3.6 composition gate** | M | gate | ⬜ |
## 5.6 — Tool context · M3.7.x
Answers *"what do we already know about this failure, tool or task"* over HTTP.
Consumers are `pi`, curl, or an MCP call — nothing here executes a tool, and
nothing here serves a tool catalog, because every caller already holds its own
MCP schemas.
**Three tiers, cheapest first.** An exact hash hit on a normalised failure
signature means *this happened here before*; a symptom-vector match means
*something similar did*; the R corpus means *nobody here has hit this, read the
docs*. The tier is a field in the response, because those three answers must not
arrive in the same register.
**The two tasks that make it work are the least obvious ones.**
[M3.7.7](M3.7.7-signature-extraction.md) decides whether tier 1 ever fires — if
normalisation leaves a timestamp in, the same failure never hashes twice and the
system silently degrades to vector search.
[M3.7.8](M3.7.8-symptom-projection.md) closes the gap between memories written as
answers and queries that arrive as stack traces. Both fail invisibly, which is why
[M3.7.6](M3.7.6-m3.7-gate.md) ablates them rather than trusting an end-to-end
green.
Ids are `M3.7.x` and frozen. `M3.7.1` and `M3.7.2` were a tool-catalog surface,
deleted before implementation once the consumer was settled; their ids are retired
rather than reused.
| Task | Title | Size | Flags | Status |
|---|---|---|---|---|
| [M3.7.3](M3.7.3-skill-matching.md) | `GET /memory/skills?task=` — match a subset | M | — | ⬜ |
| [M3.7.4](M3.7.4-context-endpoint.md) | `/memory/context` — three-tier lookup | M | — | ⬜ |
| [M3.7.5](M3.7.5-tool-failure-learning.md) | `tool-failures` standing query | M | — | 🟡 `derive_lessons()` + `tool_of_cmd()` in `lesson.rs`, `mem resolve` in CLI |
| [M3.7.6](M3.7.6-m3.7-gate.md) | **M3.7 composition gate** | M | gate | ⬜ |
| [M3.7.7](M3.7.7-signature-extraction.md) | Failure signature extraction + normalisation | M | — | 🟡 `extract()` + `normalise()` in `lesson.rs` (10 unit tests passing) |
| [M3.7.8](M3.7.8-symptom-projection.md) | Symptom projection at ingest | M | — | ⬜ |
## 6 — Post-training · M5.x
Python, separate from the Rust workspace. The boundary is the JSONL log.
+1 -1
View File
@@ -4,7 +4,7 @@
|---|---|
| Phase | M0 — Read-only spine |
| Size | S — under 1 day |
| Status | ⬜ Not started |
| Status | ✅ Done |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
+1 -1
View File
@@ -4,7 +4,7 @@
|---|---|
| Phase | M0 — Read-only spine |
| Size | S — under 1 day |
| Status | ⬜ Not started |
| Status | ✅ Done |
| Flags | — |
| Spec | inlined below |
| Blocks | M0.1 |
+1 -1
View File
@@ -4,7 +4,7 @@
|---|---|
| Phase | M0 — Read-only spine |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Status | ✅ Done |
| Flags | — |
| Spec | inlined below |
| Blocks | M0.2 |
+1 -1
View File
@@ -4,7 +4,7 @@
|---|---|
| Phase | M0 — Read-only spine |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Status | ✅ Done |
| Flags | — |
| Spec | inlined below |
| Blocks | M0.3 |
+1 -1
View File
@@ -4,7 +4,7 @@
|---|---|
| Phase | M0 — Read-only spine |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Status | ✅ Done |
| Flags | — |
| Spec | inlined below |
| Blocks | M0.3 |
+1 -1
View File
@@ -4,7 +4,7 @@
|---|---|
| Phase | M0 — Read-only spine |
| Size | S — under 1 day |
| Status | ⬜ Not started |
| Status | ✅ Done |
| Flags | — |
| Spec | inlined below |
| Blocks | M0.5 |
+1 -1
View File
@@ -4,7 +4,7 @@
|---|---|
| Phase | M0 — Read-only spine |
| Size | S — under 1 day |
| Status | ⬜ Not started |
| Status | ✅ Done |
| Flags | — |
| Spec | inlined below |
| Blocks | M0.4, M0.6 |
+1 -1
View File
@@ -4,7 +4,7 @@
|---|---|
| Phase | M0 — Read-only spine |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Status | ✅ Done |
| Flags | gate |
| Spec | inlined below |
| Blocks | all of M0 |
+76
View File
@@ -0,0 +1,76 @@
# M1 — Gated Loop at L1 · Phase Overview
## What M1 produces
```
crates/mem-llm/src/chat.rs ChatClient → POST /v1/qwen/chat/completions
crates/mem-core/src/query.rs QuerySet::load() → queries/poimen.yaml
crates/mem-core/src/prompt.rs PromptBuilder → paper Fig 10a template
crates/mem-core/src/gate_parser.rs parse_gate_response() → GateResponse
crates/mem-core/src/gated_loop.rs run_loop() → LoopEvent stream → RunOutcome
crates/mem-store/src/event_log.rs LogWriter → log/<project>/<query>/<run>.jsonl
crates/mem-cli/src/main.rs mem ingest --project P --query Q (extend existing stub)
tests/it_chat_client.rs 6 assertions (1 ignored, live smoke)
tests/it_query_loader.rs 7 assertions
tests/it_prompt.rs 7 assertions
tests/it_gate_parser.rs 9 assertions
tests/it_gated_loop.rs 10 assertions (scripted LLM, no network)
tests/it_event_log.rs 8 assertions
tests/it_ingest.rs 7 assertions (1 ignored, live smoke)
tests/it_m1_gate.rs 7 assertions (all ignored, live gateway)
```
## Existing code this phase builds on
| Crate | Module | Lines | What M1 uses from it |
|---|---|---|---|
| `mem-core` | `domain.rs` | 402 | `Chunk`, `Level`, `Sha256Hash`, `ProjectId`, `QueryId`, `RunId`, `Role`, `Record`, `MemoryNode` |
| `mem-core` | `lesson.rs` | 871 | **Not used by M1 directly.** Already has signature extraction, normalisation, tier lookup. M3.7 extends it. |
| `mem-chunk` | `chunker.rs` | 186 | `chunks()` — stream of `Chunk` from `RecordSource` |
| `mem-chunk` | `token_counter.rs` | 123 | `TokenCounter` trait, `CharsOverFourCounter`, `QwenTokenCounter` |
| `mem-chunk` | `record_source.rs` | 50 | `RecordSource` trait, `VecSource` |
| `mem-ingest` | `pi_session.rs` | 251 | `PiSessionSource` — parse pi session JSONL |
| `mem-ingest` | `claude_transcript.rs` | 178 | `ClaudeTranscriptSource` — parse claude transcript JSONL |
| `mem-cli` | `main.rs` | 184 | `Commands::Ingest` stub — replace body, keep CLI struct |
| `mem-llm` | `lib.rs` | 1 | **Empty placeholder** — replace entirely |
| `mem-store` | `lib.rs` | 1 | **Empty placeholder** — replace entirely |
## Task order and blocking
```
M1.1 (chat client) — no deps, start immediately
M1.2 (query loader) — no deps, start immediately (parallel with M1.1)
M1.3 (prompt template) — needs M1.2 for Query type
M1.4 (gate parser) — needs M1.3 for expected output format
M1.5 (gated loop) — needs M1.1 + M1.3 + M1.4
M1.6 (event log) — needs M1.5 for LoopEvent types
M1.7 (end-to-end) — needs all above
M1.8 (gate) — needs M1.7, runs against live gateway
```
**Parallel starts:** M1.1 and M1.2 can start immediately and in parallel.
## Key numbers
| Metric | Target | Source |
|---|---|---|
| Update-rate | < 30% | M1.8 gate, paper §4.2 |
| Memory budget | ≤ 1024 tokens | M1.5 config, paper default |
| Parse-failure rate | < 5% | M1.8 gate |
| Prompt budget | < 32768 - 2048 = 30720 tokens | Gateway OLLAMA_CONTEXT_LENGTH |
## Test convention
All integration tests live in workspace root `tests/` directory (matching `it_chunking.rs`, `it_pi_source.rs`, etc. from M0). Test command is always:
```bash
cargo test --test <test_file_name>
```
Not `cargo test -p <crate>` — that only finds tests inside the crate's own `tests/` directory, which this project doesn't use.
+65 -2
View File
@@ -4,7 +4,7 @@
|---|---|
| Phase | M1 — Gated loop at L1 |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Status | ✅ Done |
| Flags | — |
| Spec | inlined below |
| Blocks | M0.1 |
@@ -14,6 +14,69 @@
Talk to the homelab gateway, with the two non-obvious details that cost a day to
find already baked in.
## Files
| Action | Path |
|---|---|
| Create | `crates/mem-llm/src/chat.rs``ChatClient`, `Completion`, `Usage` |
| Replace | `crates/mem-llm/src/lib.rs` — replace `pub mod placeholder {}` with `pub mod chat;` + re-exports |
| Create | `tests/it_chat_client.rs` — integration tests (workspace root, matches existing convention) |
| Modify | `Cargo.toml` root — add `wiremock = "0.6"` to `[dev-dependencies]` |
## Dependencies
| Crate | Where | Already present? |
|---|---|---|
| `reqwest` (json feature) | `crates/mem-llm/Cargo.toml` | ✅ yes |
| `serde`, `serde_json` | `crates/mem-llm/Cargo.toml` | ✅ yes |
| `tokio` | `crates/mem-llm/Cargo.toml` | ✅ yes |
| `anyhow`, `thiserror` | `crates/mem-llm/Cargo.toml` | ✅ yes |
| `wiremock = "0.6"` | root `Cargo.toml` `[dev-dependencies]` | ❌ add |
## Existing code
- `crates/mem-llm/src/lib.rs` is an empty placeholder — replace entirely
- No existing HTTP client code to reuse; build from scratch
- `crates/mem-core/src/lesson.rs` has an unrelated events JSONL writer — ignore it here
## API shape
```http
POST https://api.riotpiao.com/v1/qwen/chat/completions
Headers:
apikey: <value of MEM_API_KEY env var>
Content-Type: application/json
Body (note: NO "tools" key not even an empty array):
{
"model": "qwen2.5:3b-instruct",
"messages": [
{"role": "system", "content": "<system prompt>"},
{"role": "user", "content": "<user prompt>"}
],
"max_tokens": 2048
}
Response 200:
{
"choices": [
{"message": {"role": "assistant", "content": "<model output>"}}
],
"usage": {
"prompt_tokens": 1234,
"completion_tokens": 567,
"total_tokens": 1801
}
}
Response 401 (wrong auth header):
{"message": "Unauthorized"}
Response 400 (body too large or malformed):
{"error": {"message": "[] is too short - 'messages'"}}
```
## Facts (inlined — no spec read needed)
```
@@ -84,7 +147,7 @@ live gateway.
6. `a6_live_smoke``#[ignore]`; real gateway, `qwen2.5:3b-instruct`, prompt
"reply with exactly: pong", assert the text contains `pong`.
**Command:** `cargo test -p mem-llm chat_client` (add `-- --ignored` for a6)
**Command:** `cargo test --test it_chat_client` (add `-- --ignored` for a6)
**False pass:**
- Testing only against the mock. The mock accepts whatever header you send it;
+29 -2
View File
@@ -4,7 +4,7 @@
|---|---|
| Phase | M1 — Gated loop at L1 |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Status | ✅ Done |
| Flags | — |
| Spec | inlined below |
| Blocks | M0.2 |
@@ -14,6 +14,33 @@
Load the standing questions that give the update gate its referent, and fail at
load rather than mid-run when one is wrong.
## Files
| Action | Path |
|---|---|
| Create | `crates/mem-core/src/query.rs``QuerySet`, `Query`, `SynthesisQuery`, `QueryLoadError` |
| Modify | `crates/mem-core/src/lib.rs` — add `pub mod query;` and re-exports |
| Create | `queries/poimen.yaml` — first real standing query file |
| Create | `tests/it_query_loader.rs` — integration tests (workspace root) |
| Create | `fixtures/query-valid.yaml` — test fixture (valid) |
| Create | `fixtures/query-empty-question.yaml` — test fixture (empty question) |
| Create | `fixtures/query-duplicate-id.yaml` — test fixture (duplicate id) |
| Create | `fixtures/query-bad-charset.yaml` — test fixture (invalid id chars) |
## Dependencies
| Crate | Where | Already present? |
|---|---|---|
| `serde_yaml` | workspace deps | ✅ yes (in workspace `[workspace.dependencies]`) |
| `serde` | `crates/mem-core/Cargo.toml` | ✅ yes |
| `regex` | `crates/mem-core/Cargo.toml` | ❌ add (for `[a-z0-9-]+` validation) or hand-roll |
## Existing code to reuse
- `ProjectId`, `QueryId` from `crates/mem-core/src/domain.rs`**use these newtypes**, don't create new ones
- `serde_yaml` already used by `mem-ingest` — same pattern
- Validation pattern: `QueryId::new()` already rejects empty strings; extend with charset validation
## Facts (inlined — no spec read needed)
```yaml
@@ -83,7 +110,7 @@ the log directory; renaming it orphans all three.
`true` truncates every extraction and looks like a model quality problem.
7. `a7_missing_question_field` — absent key behaves as empty, same error.
**Command:** `cargo test -p mem-core query_loader`
**Command:** `cargo test --test it_query_loader`
**False pass:**
- Testing only the happy path. Every assertion except 1 and 5 is a rejection
+26 -2
View File
@@ -4,7 +4,7 @@
|---|---|
| Phase | M1 — Gated loop at L1 |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Status | ✅ Done |
| Flags | — |
| Spec | inlined below |
| Blocks | M1.2 |
@@ -14,6 +14,30 @@
Assemble the memory-agent prompt exactly as the paper specifies, because the
model's ability to emit parseable gates depends on the format it was aligned to.
## Files
| Action | Path |
|---|---|
| Create | `crates/mem-core/src/prompt.rs``PromptBuilder` struct |
| Modify | `crates/mem-core/src/lib.rs` — add `pub mod prompt;` |
| Create | `templates/gru-mem.txt` — the prompt template (verbatim from paper Fig 10a) |
| Create | `fixtures/expected/prompt-t1.txt` — golden file for turn 1 |
| Create | `fixtures/expected/prompt-tn.txt` — golden file for turn N |
| Create | `tests/it_prompt.rs` — integration tests (workspace root) |
## Dependencies
**None new.** No template engine — the prompt has 3 substitutions (`{prompt}`,
`{memory}`, `{chunk}`). Use `str::replace()` or `format!()`. Adding `tera` for
3 variables is overengineering.
## Existing code to reuse
- `Chunk` from `domain.rs` — render its `records` vec
- `Role` from `domain.rs` — map to `[User]`, `[Assistant]`, `[ToolResult]`, `[System]` labels
- `Query` from `query.rs` (M1.2) — read `query.question` for the `{prompt}` substitution
- `TokenCounter` from `mem-chunk` — check assembled prompt fits budget
## Facts (inlined — no spec read needed)
Paper Figure 10a, reproduced verbatim — this is the contract, not a starting
@@ -98,7 +122,7 @@ change to the contract.
7. `a7_budget_headroom` — for the real fixture corpus, assert every assembled
prompt is under 32768 minus 2048.
**Command:** `cargo test -p mem-core prompt`
**Command:** `cargo test --test it_prompt`
**False pass:**
- Asserting the prompt "contains" the question. A template that dropped the
+43 -2
View File
@@ -4,7 +4,7 @@
|---|---|
| Phase | M1 — Gated loop at L1 |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Status | ✅ Done |
| Flags | — |
| Spec | inlined below |
| Blocks | M1.3 |
@@ -14,6 +14,47 @@
Turn the model's tagged output into `(U_t, M̂_t, E_t)`, strictly — because a
lenient parser silently fabricates gate decisions.
## Files
| Action | Path |
|---|---|
| Create | `crates/mem-core/src/gate_parser.rs``parse_gate_response()`, `GateResponse`, `ParseError` |
| Modify | `crates/mem-core/src/lib.rs` — add `pub mod gate_parser;` and re-exports |
| Create | `fixtures/gate-response-valid.txt` — well-formed model output |
| Create | `fixtures/gate-response-nested-think.txt` — nested `<think>` tags |
| Create | `tests/it_gate_parser.rs` — integration tests (workspace root) |
## Dependencies
**None new.** Use `str::find()` and `str::rfind()` for tag extraction. No regex
crate needed — the tags are simple XML-like delimiters, not a grammar.
## Existing code to reuse
- Pattern reference: `lesson.rs` uses similar string scanning for error markers
(`markers()`, `GENERIC_MARKERS`). Same technique, different tags.
- `thiserror` already in `mem-core` deps for error enum derivation.
## Expected input/output
```
Input:
<think>
This chunk shows a kubectl error. The user fixed it by adding --namespace.
</think>
<check>yes</check>
<update>kubectl get pods fails without --namespace; fixed by adding --namespace=kube-system</update>
<next>continue</next>
Output:
GateResponse {
think: "This chunk shows a kubectl error. The user fixed it by adding --namespace.",
update_gate: true,
candidate: "kubectl get pods fails without --namespace; fixed by adding --namespace=kube-system",
exit_gate: false,
}
```
## Facts (inlined — no spec read needed)
Expected response shape:
@@ -88,7 +129,7 @@ cases. Capture real ones with `MEM_LLM_RECORD` from M1.1.
silently-defaulted `GateResponse`.
9. `a9_real_responses` — every recorded real response parses.
**Command:** `cargo test -p mem-core gate_parser`
**Command:** `cargo test --test it_gate_parser`
**False pass:**
- A regex that finds the first `<check>` and stops. It passes 12 and silently
+40 -2
View File
@@ -4,7 +4,7 @@
|---|---|
| Phase | M1 — Gated loop at L1 |
| Size | L — 3+ days |
| Status | ⬜ Not started |
| Status | ✅ Done |
| Flags | — |
| Spec | inlined below |
| Blocks | M1.4 |
@@ -14,6 +14,44 @@
The recurrence itself: `U_t, M̂_t, E_t = φθ(Q, C_t, M_{t-1})`, with the level as a
parameter so L2 reuses it unchanged.
## Files
| Action | Path |
|---|---|
| Create | `crates/mem-core/src/gated_loop.rs``run_loop()`, `LoopConfig`, `LoopEvent`, `RunOutcome` |
| Modify | `crates/mem-core/src/lib.rs` — add `pub mod gated_loop;` and re-exports |
| Create | `tests/it_gated_loop.rs` — integration tests (workspace root, 10 assertions) |
## Dependencies
| Crate | Where | Already present? |
|---|---|---|
| `async-trait` | `crates/mem-core/Cargo.toml` | ❌ add — for `LlmClient` trait |
Or use `impl Future` return types and avoid the dependency.
## Existing code to reuse
- `Chunk`, `Level`, `Sha256Hash` from `domain.rs` — input/output types
- `Query` from `query.rs` (M1.2) — the standing question
- `PromptBuilder` from `prompt.rs` (M1.3) — assemble the prompt per turn
- `parse_gate_response` from `gate_parser.rs` (M1.4) — parse LLM output
- `ChatClient` from `mem-llm/src/chat.rs` (M1.1) — call the LLM
- `TokenCounter` from `mem-chunk/src/token_counter.rs` — measure candidate memory tokens
## Dependency injection
The loop needs an LLM client, but tests must use a scripted fake. Define a trait:
```rust
// in gated_loop.rs
pub trait LlmClient: Send + Sync {
fn complete(&self, system: &str, user: &str, max_tokens: usize)
-> impl std::future::Future<Output = anyhow::Result<mem_llm::Completion>> + Send;
}
```
`ChatClient` implements it. Tests use a `ScriptedClient` that returns canned
responses indexed by turn number.
## Facts (inlined — no spec read needed)
Paper Algorithm 1, transcribed:
@@ -97,7 +135,7 @@ the whole loop runs with no network and fully determined gate sequences.
10. `a10_level_is_a_parameter` — run the identical script at L1 and L2; assert the
only difference in emitted events is the `level` field.
**Command:** `cargo test -p mem-core gated_loop`
**Command:** `cargo test --test it_gated_loop`
**False pass:**
- Testing with a fake that always returns `yes`. Every assertion about the retain
+39 -2
View File
@@ -4,7 +4,7 @@
|---|---|
| Phase | M1 — Gated loop at L1 |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Status | ✅ Done |
| Flags | — |
| Spec | inlined below |
| Blocks | M1.5 |
@@ -14,6 +14,43 @@
Write the authoritative record — the one artifact everything else is derived
from, and the one that must survive a crash mid-run.
## Files
| Action | Path |
|---|---|
| Create | `crates/mem-store/src/event_log.rs``LogWriter`, `LogReader`, `RunStatus` |
| Replace | `crates/mem-store/src/lib.rs` — replace `pub mod placeholder {}` with `pub mod event_log;` + re-exports |
| Create | `tests/it_event_log.rs` — integration tests (workspace root, 8 assertions) |
## Dependencies
| Crate | Where | Already present? |
|---|---|---|
| `ulid` or `rusty_ulid` | `crates/mem-store/Cargo.toml` | ❌ add — for sortable run IDs |
| `tokio` (fs feature) | `crates/mem-store/Cargo.toml` | ✅ yes |
| `serde`, `serde_json` | `crates/mem-store/Cargo.toml` | ✅ yes |
## Existing code to reuse
- `LoopEvent` from `gated_loop.rs` (M1.5) — the events to serialize
- `Level` from `domain.rs` — carried in every record
- Pattern reference: `lessons_cmd.rs` has a JSONL writer for `Event` types (`events.jsonl`).
Same concept but **different event schema** and **different storage location**:
- `lessons_cmd.rs` writes `~/.mem/events.jsonl` (command execution events)
- M1.6 writes `log/<project>/<query>/<run-id>.jsonl` (gate decision events)
- Do not unify them. They serve different purposes.
## Output path convention
```
log/
poimen/
tool-failures/
01HXYZ....jsonl ← ULID, lexicographically sortable by time
architecture-decisions/
01HXYZ....jsonl
```
## Facts (inlined — no spec read needed)
Path: `log/<project>/<query-id>/<run-id>.jsonl`. Append-only, one object per line.
@@ -80,7 +117,7 @@ fixture.
8. `a8_run_id_sorts_by_time` — three runs, assert lexicographic order equals
chronological order.
**Command:** `cargo test -p mem-store event_log`
**Command:** `cargo test --test it_event_log`
**False pass:**
- Asserting the file parses. A writer that omits `evidence` events entirely
+52 -2
View File
@@ -4,7 +4,7 @@
|---|---|
| Phase | M1 — Gated loop at L1 |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Status | ✅ Done |
| Flags | — |
| Spec | inlined below |
| Blocks | M1.6 |
@@ -14,6 +14,56 @@
One command that reads a real project and produces a real log — and reports the
number that says whether the gate works.
## Files
| Action | Path |
|---|---|
| Modify | `crates/mem-cli/src/main.rs` — extend `Commands::Ingest` with `--query` and `--resume` flags; replace stub `cmd_ingest()` body with real pipeline |
| Create | `tests/it_ingest.rs` — integration tests (workspace root, 7 assertions) |
## Dependencies
**None new.** All crates already depend on what they need. This task wires existing
pieces together.
## Existing code to reuse
- `cmd_ingest()` in `main.rs`**replace the stub body**, keep the CLI struct
- `PiSessionSource` from `mem-ingest/src/pi_session.rs` — already works (M0.5)
- `ClaudeTranscriptSource` from `mem-ingest/src/claude_transcript.rs` — already works (M0.6)
- `chunks()` from `mem-chunk/src/chunker.rs` — already works (M0.3)
- `ChunkPolicy` from `mem-chunk/src/chunk_policy.rs` — already works
- `QuerySet::load()` from `mem-core/src/query.rs` — from M1.2
- `run_loop()` from `mem-core/src/gated_loop.rs` — from M1.5
- `LogWriter` from `mem-store/src/event_log.rs` — from M1.6
- `ChatClient` from `mem-llm/src/chat.rs` — from M1.1
## Wiring diagram
```
CLI: mem ingest --project poimen --query tool-failures
├─ QuerySet::load("queries/poimen.yaml") ← M1.2
│ └─ query = set.by_id("tool-failures")
├─ PiSessionSource::new(session_files) ← M0.5 (existing)
│ └─ source.records() → Stream<Record>
├─ chunks(source, policy) ← M0.3 (existing)
│ └─ Stream<Chunk>
├─ ChatClient::new(base_url, api_key, model) ← M1.1
├─ run_loop(L1, query, chunks, client, config) ← M1.5
│ └─ RunOutcome { events, chunks_seen, chunks_used, ... }
├─ LogWriter::open(project, query, run_id) ← M1.6
│ └─ write events to log/poimen/tool-failures/<ulid>.jsonl
└─ Print summary:
chunks 412 used 17 update-rate 4.1% memory 142tok elapsed 6m12s
```
## Facts (inlined — no spec read needed)
```
@@ -79,7 +129,7 @@ chunk costs a model call, so `--resume` is not a nicety.
7. `a7_live_smoke``#[ignore]`; real gateway, `--limit 20` on a real project;
assert `run_end` and **print** the update-rate for a human to read.
**Command:** `cargo test -p mem-cli ingest` (add `-- --ignored` for a7)
**Command:** `cargo test --test it_ingest` (add `-- --ignored` for a7)
**False pass:**
- Asserting only that the command exits 0. A run whose gate always answers `no`
+21 -2
View File
@@ -4,7 +4,7 @@
|---|---|
| Phase | M1 — Gated loop at L1 |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Status | ✅ Done |
| Flags | gate |
| Spec | inlined below |
| Blocks | all of M1 |
@@ -14,6 +14,25 @@
Answer the only question that matters at this stage: **did we build a gate, or an
expensive summarizer?**
## Files
| Action | Path |
|---|---|
| Create | `tests/it_m1_gate.rs` — integration tests (workspace root, all `#[ignore]`, 7 assertions) |
| Create | `fixtures/expected/m1-gate.txt` — committed expectation file (summary table) |
## Dependencies
**None new.** This task runs existing code against the live gateway.
## Existing code to reuse
The entire M1 pipeline:
- `QuerySet::load()` (M1.2), `ChatClient` (M1.1), `run_loop()` (M1.5), `LogWriter` (M1.6)
- `PiSessionSource` / `ClaudeTranscriptSource` (M0.5/M0.6) — existing
- `chunks()` (M0.3) — existing
- `LogReader::stats()` (M1.6) — to recompute update-rate independently
## Facts (inlined — no spec read needed)
This gate runs against the **live gateway on a real project** and asserts
@@ -75,7 +94,7 @@ per-push.
7. `a7_judge_audit` — sample 20 decisions, ask the 32B model, print agreement.
Advisory; does not fail the gate.
**Command:** `cargo test --workspace m1_gate -- --ignored --nocapture`
**Command:** `cargo test --test it_m1_gate -- --ignored --nocapture`
**False pass:**
- Running the gate on a tiny `--limit`. Update-rate on the first 20 chunks is
+71 -14
View File
@@ -19,15 +19,14 @@ malformed graph impossible rather than merely unlikely.
```sql
CREATE TABLE memory_node (
id BIGSERIAL PRIMARY KEY,
level TEXT NOT NULL CHECK (level IN ('L0','L1','L2')),
level TEXT NOT NULL CHECK (level IN ('L0','L1','L2','R')),
project TEXT NOT NULL,
query_id TEXT, -- NULL at L2
query_id TEXT, -- NULL at L2 and R
run_id TEXT NOT NULL,
t INT NOT NULL,
source TEXT, -- set at L0
source TEXT, -- set at L0; source URI at R
text TEXT NOT NULL,
sha256 TEXT NOT NULL UNIQUE, -- content identity, from M0.2
embedding vector(768) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE memory_edge (
@@ -35,10 +34,53 @@ CREATE TABLE memory_edge (
parent_sha TEXT NOT NULL REFERENCES memory_node(sha256) ON DELETE CASCADE,
PRIMARY KEY (child_sha, parent_sha)
);
CREATE INDEX ON memory_node USING hnsw (embedding vector_cosine_ops);
CREATE INDEX ON memory_node (project, level);
-- Vectors live outside the node: one node carries several, and a symptom
-- projection (M3.7.8) is what makes an answer findable from an error message.
CREATE TABLE memory_vector (
node_sha TEXT NOT NULL REFERENCES memory_node(sha256) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('text','symptom')),
embedding vector(768) NOT NULL,
PRIMARY KEY (node_sha, kind)
);
-- Partial index per kind. One index over mixed kinds forces post-filtering,
-- which starves recall exactly the way M3.6.5 describes.
CREATE INDEX ON memory_vector USING hnsw (embedding vector_cosine_ops) WHERE kind = 'text';
CREATE INDEX ON memory_vector USING hnsw (embedding vector_cosine_ops) WHERE kind = 'symptom';
-- Exact-match tier. Failures repeat verbatim; prose does not.
CREATE TABLE failure_signature (
sig_sha TEXT PRIMARY KEY, -- hash of the NORMALISED signature (M3.7.7)
node_sha TEXT NOT NULL REFERENCES memory_node(sha256) ON DELETE CASCADE,
tool TEXT NOT NULL, -- 'github-actions' | 'kubectl' | 'npm'
raw TEXT NOT NULL, -- pre-normalisation, for display
seen_count INT NOT NULL DEFAULT 1,
last_seen TIMESTAMPTZ NOT NULL
);
CREATE INDEX ON failure_signature (tool);
-- A lesson about Kong is actively harmful now that Kong is retired.
CREATE TABLE memory_supersede (
old_sha TEXT NOT NULL REFERENCES memory_node(sha256) ON DELETE CASCADE,
new_sha TEXT NOT NULL REFERENCES memory_node(sha256) ON DELETE CASCADE,
reason TEXT,
PRIMARY KEY (old_sha, new_sha)
);
```
**`embedding` is deliberately not a column on `memory_node`.** A node needs more
than one vector: the memory text as written, and a generated *symptom* projection
describing the errors it would explain. An L1 reads like an answer and a query
reads like a stack trace, and cosine between those two registers is mediocre —
the second vector is what closes that gap. One column cannot hold both, and
bolting on `embedding_2` later is worse than a junction table now.
**`seen_count` and `last_seen` are mutable and that does not break the authority
rule.** Occurrences are append-only records in the JSONL log; these two fields are
a fold over them, recomputed by `mem rebuild --from-log` like every other
projected value.
`sha256 UNIQUE` is what makes rebuild idempotent — re-inserting identical content
is a conflict to ignore, not a duplicate row. It is also why the hash must exclude
run ids and timestamps (M0.2).
@@ -47,8 +89,13 @@ run ids and timestamps (M0.2).
dangling edges. Rebuild drops everything anyway, but a partial cleanup should not
be able to corrupt the graph.
`query_id` is NULL at L2 by design — L2 spans queries. Enforce it:
`CHECK ((level = 'L2') = (query_id IS NULL))`.
`query_id` is NULL at L2 **and at R** — L2 spans queries, R answers none. Enforce
it: `CHECK ((level IN ('L2','R')) = (query_id IS NULL))`.
The `'R'` level ships here rather than arriving as an `ALTER` from M3.6. Nothing
is built yet, so widening a constraint that has never existed wrong is free, and
a migration that exists only because an earlier migration was knowingly
incomplete is debt taken on for no reason.
Cosine distance, not L2: these are normalised text embeddings and cosine is what
the model was trained for. `vector_cosine_ops` must match the operator the query
@@ -69,8 +116,9 @@ uses (`<=>`), or the index is silently ignored and every query is a seq scan.
- Migrations apply to a clean database and are idempotent.
- Inserting a duplicate `sha256` conflicts rather than duplicating.
- An `L2` row with a non-null `query_id` is rejected by the CHECK.
- The HNSW index is used by a cosine-distance query.
- An `L2` or `R` row with a non-null `query_id` is rejected by the CHECK.
- Both partial HNSW indexes are used by a kind-filtered cosine query.
- A node can carry a `text` and a `symptom` vector simultaneously.
## Verify
@@ -81,12 +129,21 @@ image tag as production, `ghcr.io/cloudnative-pg/postgresql:16.2`.
1. `a1_migrate_clean` — apply to an empty database, assert both tables exist.
2. `a2_migrate_idempotent` — apply twice, assert no error.
3. `a3_sha_unique` — insert the same sha twice, assert a unique violation.
4. `a4_level_check``level='L3'` rejected; `level='L2'` with a `query_id`
rejected; `level='L1'` without one rejected.
4. `a4_level_check``level='L3'` rejected; `level='L2'` and `level='R'` with a
`query_id` rejected; `level='L1'` without one rejected.
5. `a5_edge_fk` — an edge referencing a missing sha is rejected.
6. `a6_cascade` — delete a node, assert its edges are gone.
7. `a7_hnsw_is_used``EXPLAIN` a `ORDER BY embedding <=> $1 LIMIT 10` query and
assert the plan contains `Index Scan` on the HNSW index, not `Seq Scan`.
6. `a6_cascade` — delete a node, assert its edges, vectors and signatures are
gone.
7. `a7_hnsw_is_used``EXPLAIN` a `WHERE kind='text' ORDER BY embedding <=> $1
LIMIT 10` query; assert the plan contains an `Index Scan` on the **partial**
index, not `Seq Scan` and not a filter applied above a full-index scan.
8. `a8_symptom_index_separate` — same for `kind='symptom'`; assert the plan names
the other index, proving both were created and are distinguishable.
9. `a9_two_vectors_per_node` — insert both kinds for one node; assert both
persist and the composite primary key rejects a third of the same kind.
10. `a10_signature_unique` — inserting the same `sig_sha` twice conflicts.
11. `a11_supersede_pair` — a supersede row survives, and deleting either endpoint
cascades it away.
**Command:** `cargo test -p mem-store schema`
+38 -8
View File
@@ -17,10 +17,12 @@ time and produces the same rows.
## Facts (inlined — no spec read needed)
```rust
async fn upsert_node(&self, node: &MemoryNode, embedding: &[f32]) -> Result<()>;
async fn upsert_node(&self, node: &MemoryNode) -> Result<()>;
async fn upsert_vector(&self, sha: &Sha256Hash, kind: VectorKind, embedding: &[f32]) -> Result<()>;
async fn insert_edges(&self, child: &Sha256Hash, parents: &[Sha256Hash]) -> Result<()>;
async fn search(&self, q: &[f32], levels: &[Level], project: &ProjectId, k: usize)
-> Result<Vec<ScoredNode>>;
async fn search(&self, q: &[f32], kind: VectorKind, levels: &[Level],
project: Scope, k: usize) -> Result<Vec<ScoredNode>>;
async fn lookup_signature(&self, sig_sha: &str) -> Result<Option<SignatureHit>>;
async fn parents_of(&self, sha: &Sha256Hash) -> Result<Vec<MemoryNode>>;
async fn clear_project(&self, project: &ProjectId) -> Result<()>;
```
@@ -32,6 +34,23 @@ rows. Same for edges on the composite key.
`search` orders by `embedding <=> $1` — cosine distance, matching the
`vector_cosine_ops` index. Any other operator silently drops to a seq scan.
**`kind` must be a literal predicate in the SQL, not a bind parameter, and not a
filter applied to results.** The indexes are partial (`WHERE kind = 'text'`), and
the planner only uses a partial index when the query's predicate provably matches
it. A `WHERE kind = $2` defeats that and silently degrades to a scan over every
vector of both kinds — the same failure mode as the wrong opclass, and just as
invisible.
**`Scope` is not a `ProjectId`.** Tool-failure lookups federate across projects
because an `ERESOLVE` lesson is not project-specific, while ordinary standing-query
memories stay scoped. `Scope::Project(id)` filters; `Scope::AllProjects` does not
and lets project relevance act as a rank boost later instead of a hard filter.
`lookup_signature` is the exact-match tier: a primary-key hit on
`failure_signature`, no vector involved. It is the cheapest and highest-precision
answer the store can give, so it belongs in the repository rather than being
assembled from a `search` call by a caller who does not know it exists.
Edges are inserted **after** both endpoints exist, or the foreign key rejects
them. Rebuild therefore has two passes: all nodes, then all edges. This is not an
optimisation; a single-pass insert fails on the first forward reference.
@@ -44,12 +63,23 @@ rebuild — batch across nodes, not per node.
1. `PgRepo::connect(url)` with a pool; run migrations on connect.
2. Implement the five methods above.
3. `upsert_many(nodes)` batching embedding calls at 32 and inserting with a
multi-row statement.
4. Two-pass write: nodes, then edges.
multi-row statement. Batch across *both* vector kinds — a node with a symptom
projection contributes two texts to the same batch, not two batches.
4. Three-pass write: nodes, then vectors and signatures, then edges. Vectors and
signatures carry foreign keys to nodes, so they cannot precede them, and edges
still need both endpoints present.
5. Separate query builders per `kind` so the literal predicate is guaranteed at
compile time rather than by convention.
5. `clear_project` deletes nodes for one project; edges cascade.
6. Return `ScoredNode { node, distance }` — keep the raw distance, do not convert
to a similarity score here. The reranker (M3.2) wants the ordering, and a
lossy conversion hides ties.
6. Return `ScoredNode { node, distance, matched_kind }` — keep the raw distance,
do not convert to a similarity score here. The reranker (M3.2) wants the
ordering, and a lossy conversion hides ties. `matched_kind` tells the caller
whether the hit came from the memory text or its symptom projection, which is
the difference between "this is about your topic" and "this explains your
error".
7. Exclude superseded nodes by default: `LEFT JOIN memory_supersede` on
`old_sha`, filter where the join is null. An `include_superseded` flag exists
for audit, off everywhere else.
## Acceptance
+1 -1
View File
@@ -4,7 +4,7 @@
|---|---|
| Phase | M3 — L2 synthesis and retrieval |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Status | ✅ Done |
| Flags | — |
| Spec | inlined below |
| Blocks | M1.5 |
+1 -1
View File
@@ -4,7 +4,7 @@
|---|---|
| Phase | M3.5 — Distributed API Layer |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Status | ✅ Done |
| Flags | — |
| Spec | inlined below |
| Blocks | M3.5.2, M3.5.3, M3.5.5, M3.5.6 |
+14 -3
View File
@@ -12,7 +12,7 @@
## Goal
Async ingest endpoint that demultiplexes gated-loop submissions from CLI and agents. Idempotent by batch content hash (`ingest_id`). Prevent duplicate L0 evidence in the log.
Async ingest endpoint that demultiplexes gated-loop submissions from CLI and agents. Idempotent by batch content hash (`ingest_id`). Prevent duplicate L0 evidence in the log. Enrich records with git context (file, commit, blame) if repo.git available.
## Design
@@ -28,10 +28,17 @@ Content-Type: application/json
{"role":"assistant","text":"...","timestamp":"2026-08-20T...","source_position":0},
...
],
"ingest_id": "sha256(all_record_texts)"
"ingest_id": "sha256(all_record_texts)",
"git_repo_path": "/path/to/repo/.git",
"git_head": "abc123def789"
}
```
**Git enrichment (optional):** If `git_repo_path` and `git_head` provided:
- Walk repo blame for timestamps matching record timestamps
- Correlate evidence text with recent commits touching files
- Populate `git_context` on each L0 node (file, line, commit, author)
**Response (accepted):**
```
HTTP 202 Accepted
@@ -74,7 +81,11 @@ GET /memory/ingest/ingest-<job-id>
- Compute `estimated_wait_seconds` based on current queue depth and avg chunk processing latency (5000 tokens @ 812ms gate latency ≈ 4.2s per chunk).
3. Background task (tokio::spawn):
- Dequeue from project queue (FIFO per project)
- Call the M1.7 `mem::ingest()` function with records
- **Git enrichment (if git_repo_path provided):**
- Open repo.git with `git2::Repository`
- For each record, find blame line by timestamp + closest file match (via commit log)
- Populate `git_context: {file, line, commit_sha, commit_msg, author, author_date}`
- Call the M1.7 `mem::ingest()` function with enriched records
- Update status to `completed` with `chunks_seen` and `chunks_used` from the log
- On error, update status to `failed` with error message
4. `GET /memory/ingest/<job_id>` handler:
+178
View File
@@ -0,0 +1,178 @@
# M3.5.9 — Git-aware memory references: lookup by code location
| Field | Value |
|---|---|
| Phase | M3.5 — Distributed API Layer |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
| Depends | M3.5.2 (git enrichment in ingest), M3.5.3 (query endpoint) |
## Goal
Enable agents to find and cite memory entries by code location (file:line, commit, author). Unifies memory log with git history. Agents reference: `"Per src/kong/buffer.rs:42 (commit abc123)..."` → lookup via git blame, return L0 evidence + L1 memory.
## Design
**New database columns** (extend `memory_node` from M2.3):
```sql
ALTER TABLE memory_node ADD COLUMN git_context JSONB;
-- {file, line, commit_sha, commit_msg, author, author_date}
-- Index for git-based lookup
CREATE INDEX ON memory_node USING GIN (git_context);
```
**Three lookup modes:**
1. **By git location (file:line):**
```
POST /memory/nodes/by-git
{
"repo": "github.com/org/poimen",
"file": "src/kong/buffer.rs",
"line": 42,
"project": "poimen"
}
→ 200 {
"nodes": [
{
"sha256": "...",
"level": "L0",
"text": "Kong body buffer raised to 16MB...",
"git_context": {commit_sha, commit_msg, author},
"created_at": "2026-08-20T..."
}
]
}
```
2. **By commit (evidence from this commit):**
```
POST /memory/nodes/by-commit
{
"repo": "github.com/org/poimen",
"commit_sha": "abc123def",
"project": "poimen"
}
→ nodes from this commit + parent L1/L2 memories
```
3. **By author (what did person X discover):**
```
POST /memory/nodes/by-author
{
"author": "[email protected]",
"project": "poimen"
}
→ L0 nodes created during commits from alice
```
**Query endpoint extension** (M3.5.3):
Add optional `git_repo` param:
```
GET /memory/query?query=Kong&git_repo=github.com/org/poimen&project=poimen
→ results enriched with git_context (file, commit, author)
```
**Response format (all modes):**
```json
{
"nodes": [
{
"sha256": "abc...",
"level": "L0|L1|L2",
"brief": "Kong body buffer raised",
"git_ref": "src/kong/buffer.rs:42",
"git_commit": {
"sha": "abc123def",
"message": "Increase body buffer to 16MB",
"author": "[email protected]",
"date": "2026-08-15T10:30:00Z"
},
"parents": [...]
}
],
"repo": "github.com/org/poimen"
}
```
## Steps
1. `POST /memory/nodes/by-git` handler:
- Parse `file`, `line`, `project`
- Query: `SELECT * FROM memory_node WHERE project = $1 AND git_context->>'file' = $2 AND (git_context->>'line')::int = $3`
- Walk edges to include parent L1/L2 nodes
- Sort by created_at desc
2. `POST /memory/nodes/by-commit` handler:
- Parse `commit_sha`, `project`
- Query: `SELECT * FROM memory_node WHERE project = $1 AND git_context->>'commit_sha' = $2`
- Include all L0 from this commit + transitive parents (L1/L2)
3. `POST /memory/nodes/by-author` handler:
- Parse `author`, `project`
- Query: `SELECT * FROM memory_node WHERE project = $1 AND git_context->>'author' = $2 AND level = 'L0'`
- Walk edges to L1 parents
4. Extend M3.5.3 query handler:
- Add optional `git_repo` query param
- If provided, enrich response with git_context from each result node
- Include `git_ref` in brief (file:line) for agent citation
5. Deduplication by git:
- L0 evidence from same (file, line, commit) = same memory entry
- Idempotency: ingesting same commit twice doesn't duplicate L0 nodes
- Check: `(file, line, commit_sha)` tuple uniqueness constraint
## Acceptance
- `by-git` lookup returns correct L0 evidence + parent memories
- `by-commit` returns all evidence from that commit
- `by-author` returns all discoveries by that author
- Query results enriched with git_context when repo provided
- Same evidence never duplicated (idempotent by git tuple)
- Agents can cite by code location: "src/kong/buffer.rs:42 (commit abc123)"
## Verify
**Harness:** Integration tests with git history fixture.
**Setup:** Create test repo with commits:
- commit abc123: modify src/kong/buffer.rs:42 (message: "Increase buffer")
- commit def456: modify src/kong/handler.rs:10 (message: "Handle large bodies")
- Create memory nodes with git_context from these commits
**Integration test**`tests/it_git_references.rs`:
1. `a1_by_git_lookup` — POST /nodes/by-git with file=buffer.rs, line=42 returns L0 from commit abc123.
2. `a2_by_commit_lookup` — POST /nodes/by-commit with abc123 returns both L0 + parent L1/L2.
3. `a3_by_author_lookup` — POST /nodes/by-author with alice@org returns all L0 from alice's commits.
4. `a4_query_enriched_with_git` — GET /query?query=buffer&git_repo=... returns results with git_context populated.
5. `a5_git_ref_in_brief` — result.git_ref = "src/kong/buffer.rs:42" (human-readable).
6. `a6_idempotent_by_git_tuple` — ingest same commit twice, L0 nodes count stays 1 (no duplicates).
7. `a7_edge_walk_preserves_git` — L1 parent of L0 node includes L0's git_context in parents array.
8. `a8_cross_commit_correlation` — two commits affecting same file, both return from by-git lookup (line=0 or range?).
9. `a9_author_query_filters_correctly` — two authors, by-author for alice returns only alice's L0.
10. `a10_missing_git_context_graceful` — old L0 nodes without git_context (from before M3.5.2) still return but git_ref is null.
**Command:** `cargo test -p mem-cli git_references`
**False pass:**
- Git context populated in fixture but never actually extracted from repo.git during ingest (M3.5.2). Test only checks stored data, not enrichment.
- `by-git` returns results but never walks edges to L1. Parent L1 discoveries are invisible.
- Query enrichment tested only with one repo. Multiple repos with overlapping filenames may return wrong results.
- Idempotency tested with same commit but different git_repo URLs (github.com vs gitlab.com). Should be treated differently but test may not catch it.
## Traps
- Git blame is expensive. Caching blames by (file, commit_sha) pair is necessary for repeated queries.
- Line numbers shift with edits. Reference to "line 42" in commit ABC may not match "line 42" in HEAD. Store commit hash, not line number, as primary key.
- JSONB queries in PostgreSQL are slower than indexed columns. Consider denormalizing `git_file`, `git_commit`, `git_author` as separate columns if query volume is high.
- Author name varies (alice@org vs alice.smith@org). Normalize email in ingest or handle fuzzy matching in by-author.
- Cross-repo scenarios: same code in two repos (fork, mirror). git_repo must be part of uniqueness constraint.
---
Background: [DESIGN.md § Distributed API Layer](../DESIGN.md#distributed-api-layer-homelab-frontend)
+133
View File
@@ -0,0 +1,133 @@
# M3.6.1 — `DocCorpusSource` + heading-boundary chunking
| Field | Value |
|---|---|
| Phase | M3.6 — Reference corpora |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | M3.6.6 |
| Depends | M0.3, M0.4 |
## Goal
Read a tree of documentation into the same stream shape sessions use, split on
headings instead of messages, without the gate ever seeing it.
## Facts (inlined — no spec read needed)
```rust
pub enum Boundary {
Record, // existing — never split mid-Record (sessions)
Heading, // new — split on markdown ATX headings, never mid-section
}
```
`DocCorpusSource` is a third `RecordSource` alongside the pi and Claude adapters
(M0.5, M0.6). It walks a directory, reads `*.md` and `*.txt`, and emits one
`Record` per document section. The chunker never learns it came from a file tree
rather than a socket — that is the whole point of the trait.
**Heading boundary, not record boundary.** A session Record is a natural unit; a
markdown file is one Record of 8000 tokens with internal structure. Splitting a
cheatsheet mid-table produces two chunks that are each individually useless.
Split at `^#{1,6} ` and carry the heading path (`kubectl.md > Common Issues >
CrashLoopBackOff`) onto every chunk as breadcrumb text.
A section longer than `max_tokens` still has to split. Fall back to
`Boundary::Record` semantics within that section — paragraph boundaries, then
hard split — and mark the continuation chunks so the projector can rejoin them
for display.
**This task ends at the chunk stream.** No gate call, no embedding, no write. It
is the M0.7 `--dry-run` shape applied to a doc tree: `mem ref add --dry-run`
prints the plan and makes zero model calls.
**The divergence from the gated path is structural and belongs here.** `run_loop`
(M1.5) takes a `Query`, and M1.2 makes an empty question a load error because the
update gate is defined relative to `Q`. A corpus has no standing question, so the
reference path must be unable to call the recurrence — not merely choose not to.
Emit a distinct chunk type for this source so `run_loop` does not typecheck
against it. A `skip_gate: bool` threaded through the shared path is the wrong
shape: it defaults, and the default is one refactor away from feeding
documentation to the controller.
Source URI is the identity anchor for everything downstream: an absolute path or
`https://` URL, recorded per chunk, stable across re-ingest.
## Steps
1. Add `Boundary::Heading` to `ChunkPolicy` in `mem-chunk`.
2. Implement the heading splitter: parse ATX headings, build the heading path
stack, emit sections with breadcrumb prefix.
3. Implement over-long section fallback — paragraph split, then hard split, with
a `continuation: true` marker on chunks 2..n.
4. Implement `DocCorpusSource` in `mem-ingest`: walk dir, filter extensions, skip
dotfiles and anything over a size ceiling, emit `Record` per section.
5. Record `source_uri` and per-document `sha256` on every emitted record.
6. Wire `mem ref add --dry-run <path>` to print the chunk plan: file, heading
path, token count, chunk count.
## Acceptance
- A doc tree yields one chunk per heading section, breadcrumbs attached.
- No chunk crosses a heading boundary unless the section exceeded `max_tokens`.
- An 8000-token section splits and every piece after the first is marked as a
continuation.
- `--dry-run` makes zero HTTP calls.
- `DocCorpusSource` compiles against `RecordSource` with no trait change.
## Verify
**Harness:** a fixture doc tree under `fixtures/refcorpus/` — one small file, one
file with nested headings, one file with a single 8000-token section, one
non-markdown file that must be skipped.
**Integration test**`tests/it_doc_corpus.rs`:
1. `a1_section_per_heading` — nested-heading fixture yields exactly one chunk per
ATX heading; assert count and order.
2. `a2_breadcrumb_path` — a chunk under `## Common Issues > ### CrashLoopBackOff`
carries the full heading path, not just the leaf.
3. `a3_no_mid_section_split` — for every chunk, assert it contains at most one
heading line and that heading is its first line.
4. `a4_oversize_section_splits` — the 8000-token fixture yields >1 chunk, all
under `max_tokens`, with `continuation: true` on all but the first.
5. `a5_extension_filter` — the non-markdown file produces no chunks.
6. `a6_source_uri_stable` — running the walk twice yields identical
`(source_uri, sha256)` pairs.
7. `a7_dry_run_no_network` — run under a transport that panics on any request;
assert `--dry-run` completes.
8. `a8_trait_object_safe``DocCorpusSource` is usable everywhere the pi adapter
is, via the same `RecordSource` bound.
9. `a9_reference_chunks_reject_the_loop` — a compile-fail test (`trybuild`)
asserting `run_loop` cannot be called with this source's chunk type. The
guarantee is "impossible", so the test has to be a compile error; a runtime
assertion proves only that today's caller happens not to do it.
**Command:** `cargo test -p mem-ingest doc_corpus`
**False pass:**
- Asserting chunk count only. A splitter that emits the right number of chunks
by hard-splitting on token count hits the count and fails assertion 3, which
is the one that proves headings were used at all.
- Testing the walk on a single flat file. Nested heading paths are where the
breadcrumb logic breaks, and a flat fixture never exercises the stack.
## Traps
- Emitting the breadcrumb as metadata only. The embedding is computed over chunk
text; a heading path that is not *in* the text does not reach the vector, and
"CrashLoopBackOff" stops being findable from the section body alone.
- Treating setext headings (`===` underlines) as prose. They are rarer in
generated docs but they exist, and a file that uses them degrades silently to
one enormous chunk.
- Walking symlinks. A docs tree with a self-referential link makes the walk hang
with no output, which reads as a slow embed rather than a loop.
- Adding the corpus to `sources:` in a standing-query YAML. That list names the
*evidence* sources for a question; a corpus listed there is documentation
entering the gate, which is the one outcome this phase exists to prevent.
---
Background: [DESIGN.md](../DESIGN.md) — reference corpora, `mem-chunk`
+125
View File
@@ -0,0 +1,125 @@
# M3.6.2 — Level R: log record, index rows, vault notes, rebuild parity
| Field | Value |
|---|---|
| Phase | M3.6 — Reference corpora |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | M3.6.6 |
| Depends | M3.6.1, M1.6, M2.3, M2.4, M2.5, M2.6 |
## Goal
Land reference chunks in the log as their own record kind, project them into
Postgres and the vault, and prove the projections are still throwaway.
## Facts (inlined — no spec read needed)
```jsonl
{"kind":"reference","level":"R","project":"homelab","source":"file:///.../kubectl.md",
"heading_path":"kubectl.md > Common Issues > CrashLoopBackOff","doc_sha":"ab12…",
"sha256":"cd34…","t":7,"run_id":"ref-2026-08-21T10:02:11Z","text":"…"}
```
**No migration is needed.** `'R'` ships in M2.3's initial schema, along with the
`CHECK ((level IN ('L2','R')) = (query_id IS NULL))` constraint. Nothing was built
before this phase existed, so the level was never absent from the schema and an
`ALTER` here would only undo a deliberate omission that was never made.
`level = 'R'`, `query_id = NULL` (R answers no standing question), `source` holds
the source URI. `doc_sha` is the whole-document hash; `sha256` is the chunk hash
and stays the primary identity, same as every other level.
The embedding goes to `memory_vector(kind='text')`, not to a column on the node.
R gets **no symptom projection** — M3.7.8 generates those for L1 and L2 only,
since documentation headings already read like problems.
**R writes no edges.** Not to parents, not to siblings. A reference chunk has no
provenance inside this system — its provenance is the URI. The rule that makes
this safe is enforced in `mem verify` (M3.6.4), but nothing in this task should
ever be tempted to create an edge in the first place.
**Vault projection goes somewhere separate.** `vault/reference/<corpus>/<doc>.md`,
not into the project notes. The vault is browsed by a human; interleaving
upstream docs with synthesized project memory makes the vault untrustworthy at a
glance. One note per source document, sections as headings, each carrying its
chunk sha as an anchor so `mem query` output can deep-link.
**Rebuild parity is the whole point of the task.** `mem rebuild --from-log` must
drop and reconstruct R rows and R notes byte-identically. If it cannot, R has
hidden inputs and rule 3 of the design is broken — M2.8 already enforces this
property for L0/L1/L2 and this task extends the same harness rather than writing
a second one.
## Steps
1. Add the `Reference` variant to the log record enum in `mem-core`; serialize
with the field set above.
2. `mem-store`: insert R nodes with a single `kind='text'` vector; assert at the
repository boundary that no edge insert names an R sha as parent.
4. Obsidian projector: `vault/reference/<corpus>/<doc>.md`, one note per source
document, chunk shas as heading anchors.
5. Extend `mem rebuild --from-log` to replay `Reference` records.
6. Extend the M2.6 rebuild-parity harness to cover a log containing R records.
## Acceptance
- A `Reference` record round-trips through the log unchanged.
- R rows land with `query_id IS NULL` and `source` set to the URI.
- The widened constraint accepts `R` and still rejects `L3`.
- Reference notes land under `vault/reference/`, never in project note dirs.
- Drop database + vault, `mem rebuild --from-log`, and both come back
byte-identical.
## Verify
**Harness:** the M2.6 rebuild harness, extended with a log fixture that contains
L0/L1/L2 *and* R records. Deterministic fake embedder so shas are stable.
**Integration test** — `tests/it_level_r_storage.rs`:
1. `a1_record_roundtrip` — serialize then deserialize a `Reference` record;
assert field-for-field equality including `doc_sha` and `heading_path`.
2. `a2_r_inserts` — insert `level='R'` with a `kind='text'` vector; assert both
rows persist.
3. `a3_no_symptom_vector` — assert no R node acquires a `kind='symptom'` vector
after a full ingest.
4. `a4_query_id_null_at_r` — assert every R row has `query_id IS NULL`, and that
an R row with one is rejected by M2.3's CHECK.
5. `a5_no_edges_from_r` — after ingesting the fixture corpus, assert
`SELECT count(*) FROM memory_edge WHERE parent_sha IN (SELECT sha256 FROM
memory_node WHERE level='R')` is 0.
6. `a6_vault_path_isolation` — assert every emitted reference note path starts
with `vault/reference/` and no project note directory gained a file.
7. `a7_rebuild_byte_identical` — snapshot database rows and vault files, drop
both, `mem rebuild --from-log`, assert byte-identical including R.
8. `a8_rebuild_is_idempotent` — rebuild twice; assert the second run changes
nothing.
**Command:** `cargo test -p mem-store level_r && cargo test -p mem-cli rebuild`
**False pass:**
- Asserting rebuild parity on a log with no R records. It passes trivially and
proves nothing about this task; assertion 7 is only meaningful because the
fixture log is mixed-level.
- Checking edge count is zero *before* ingesting anything. Assertion 5 has to run
against a populated corpus or it is asserting that an empty table is empty.
- Comparing vault files with a normalizing diff. Byte-identical means bytes;
trailing-newline drift is exactly the class of hidden input this rule exists
to catch.
## Traps
- Reusing `run_id` semantics from the gated loop. R has no run in the recurrence
sense; use a synthetic `ref-<timestamp>` and do not let it collide with a real
ingest run in queries that group by `run_id`.
- Putting reference notes in the project vault "just for now". The vault is the
human surface and the mixing is not reversible by a later move — links written
against the old path rot.
- Dropping the check constraint instead of widening it. Assertion 3 exists
because `DROP CONSTRAINT` alone passes every other assertion in this file.
---
Background: [DESIGN.md](../DESIGN.md) — reference corpora, storage schemas
+118
View File
@@ -0,0 +1,118 @@
# M3.6.3 — `mem ref` — corpus management with replace-on-change
| Field | Value |
|---|---|
| Phase | M3.6 — Reference corpora |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | M3.6.6 |
| Depends | M3.6.2, M2.1 |
## Goal
Add, list, refresh and remove reference corpora, so that re-running an ingest
against changed upstream docs replaces what is there instead of stacking a second
copy beside it.
## Facts (inlined — no spec read needed)
```
mem ref add --project homelab --corpus kubectl ~/workplace/homelab/knowledge/cheatsheets
mem ref add --dry-run ... # chunk plan only, zero model calls (M3.6.1)
mem ref list --project homelab # corpus, docs, chunks, last ingest, drift
mem ref sync --corpus kubectl # re-walk, replace changed docs, report
mem ref rm --corpus kubectl # tombstone every doc in the corpus
```
**Identity is `(source_uri, doc_sha)`.** Same URI and same sha is a no-op: no
embed call, no write, exit 0 with "unchanged". Same URI and different sha is a
*replace*: tombstone the old chunks in the log, write the new ones. A URI that
has vanished from the tree on a `sync` is a tombstone with no successor.
**Tombstone, do not delete.** The log is append-only and authoritative. A
`{"kind":"reference_tombstone","sha256":"…","reason":"replaced"}` record is what
removal means; the projector drops the row and the note on replay. Deleting rows
from Postgres directly makes the index un-rebuildable, which is the one thing the
whole design refuses.
**Embedding is the expensive step, so skip it precisely.** A corpus of 400 chunks
where one document changed should issue embeddings for that document's chunks
only. Chunk-level sha comparison, not document-level re-embed.
**`list` reports drift.** For each corpus, re-stat the tree and compare doc shas
without writing anything: `3 docs changed, 1 removed, 12 unchanged`. Drift that
is only discoverable by running `sync` means nobody runs `sync`.
## Steps
1. `mem ref add [--project P] --corpus C [--dry-run] <path>` — walk via
`DocCorpusSource`, embed new chunks, write `Reference` records.
2. Persist corpus registration (name, root path, project, last ingest) in the
log as a `reference_corpus` record so `list` needs no side file.
3. Implement chunk-level diff: existing shas for the corpus vs freshly walked
shas → `{new, changed, unchanged, gone}`.
4. `mem ref sync` — apply the diff, embedding only `new` and `changed`, emitting
tombstones for `gone`.
5. `mem ref list` — table per corpus with counts plus a dry drift check.
6. `mem ref rm` — tombstone every live chunk in the corpus; leave the log intact.
7. Exit codes: 0 on success including no-op, non-zero on unresolvable corpus or
unreadable root.
## Acceptance
- `add` twice on an unchanged tree issues zero embedding calls the second time.
- Editing one file and running `sync` re-embeds that file's chunks only.
- Deleting a file and running `sync` tombstones its chunks, and it stops
appearing in query results.
- `rm` removes the corpus from results while leaving every record in the log.
- `list` reports drift without mutating anything.
## Verify
**Harness:** fixture tree copied to a temp dir so it can be mutated, a counting
embedder that records how many texts it was asked to embed, seeded database.
**Integration test** — `tests/it_mem_ref.rs`:
1. `a1_add_then_add_is_noop` — run `add` twice; assert the embedder call count is
zero on the second run and the row count is unchanged.
2. `a2_changed_doc_reembeds_only_itself` — edit one file of three, `sync`; assert
embed count equals that file's chunk count, not the corpus total.
3. `a3_replace_tombstones_predecessor` — after a change, assert the old chunk
sha has a tombstone record and no live row.
4. `a4_removed_doc_tombstoned` — delete a file, `sync`; assert its chunks are
gone from `memory_node` and present in the log.
5. `a5_rm_preserves_log` — count log lines before and after `rm`; assert the
count only grew.
6. `a6_rebuild_after_churn` — after add/change/sync/rm, `mem rebuild --from-log`;
assert the reconstructed state matches the live state exactly.
7. `a7_list_is_read_only` — snapshot database and log, run `list`, assert both
unchanged and that reported drift matches the mutations made.
8. `a8_unreadable_root_exits_nonzero` — point `add` at a missing path; assert
non-zero exit and no partial corpus registration.
**Command:** `cargo test -p mem-cli mem_ref`
**False pass:**
- Asserting "no duplicate rows" instead of counting embedder calls. A `sync` that
re-embeds everything and then upserts by sha produces a correct table and a
bill; assertion 1 and 2 are the only ones that see it.
- Verifying tombstones by querying `memory_node`. The row being absent is the
projector working; assertion 3 has to read the log to prove the tombstone was
actually written and the row was not just deleted.
- Running the churn test without a final rebuild. Assertion 6 is what proves the
tombstone replay logic exists rather than being implied.
## Traps
- Registering the corpus before the walk succeeds. A failed `add` that leaves a
registered-but-empty corpus makes the next `sync` report every document as new.
- Comparing document mtime instead of sha. Checkouts and rsync rewrite mtimes;
a corpus that re-embeds on every clone costs real money on the TEI endpoint.
- Making `rm` delete log records "because they are noise". That converts the log
from authoritative to advisory, and nothing downstream can tell.
---
Background: [DESIGN.md](../DESIGN.md) — reference corpora, JSONL event log
+142
View File
@@ -0,0 +1,142 @@
# M3.6.4 — Reference text cannot re-enter as evidence
| Field | Value |
|---|---|
| Phase | M3.6 — Reference corpora |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | M3.6.6 |
| Depends | M3.6.3, M4.2 |
## Goal
Stop a retrieved manual page from coming back through the front door as a project
finding.
## Facts (inlined — no spec read needed)
The cycle is M4.2's, with documentation substituted for emitted skills:
```
agent queries memory, gets an R section
section is pasted into the agent's context
appears verbatim in that session's transcript
transcript ingested; the gate sees upstream doc text
"kubectl describe shows events" becomes an L1 project memory
```
The gate is *right* to accept it — the chunk genuinely contains information about
the question. That is what makes this dangerous rather than merely noisy: no
threshold tuning catches it, because the text really is relevant. Only knowing
that the system emitted the text itself distinguishes the two cases.
**Reuse M4.2, do not rebuild it.** M4.2 already computes normalised shingle
overlap against an artifact manifest and tags matching records `derived: true`,
excluding them from evidence while keeping them in the log so the exclusion is
auditable. R chunks are a second artifact kind in that same manifest. A parallel
matcher would drift from it and double the tuning surface.
```jsonl
{"kind":"reference","name":"kubectl/common-issues","sha256":"cd34…","shingles":[…],"emitted_at":"…"}
{"kind":"skill","name":"infra-root-causes","sha256":"ab12…","shingles":[…],"emitted_at":"…"}
```
**Threshold pressure differs by kind and this is the real work.** A skill is
emitted once and quoted rarely. Documentation is quoted constantly and partially
— one command from a fifty-line cheatsheet. Shingle overlap against a whole R
chunk will sit far below M4.2's 0.8 default for exactly the case that matters, so
matching must be at section granularity with its own threshold, tuned and logged
separately. One shared matcher, two configured thresholds.
**A false positive here is costly and must stay visible.** Excluding a genuine
discussion *about* `kubectl` because it quotes two lines of the cheatsheet
silently drops real evidence. Every exclusion emits `derived_excluded` naming the
matched artifact, and `mem verify` can list them for audit.
## Steps
1. Generalise M4.2's manifest to `vault/.artifacts.jsonl` with a `kind` field;
keep skills writing to it unchanged.
2. `mem ref add`/`sync` append `kind: "reference"` entries per R chunk;
tombstones remove them.
3. Add per-kind thresholds to the matcher config; default reference threshold
lower than the skill threshold, and record the value in the exclusion event.
4. Extend `mem verify --derived-filter` to assert no L0 evidence node matches a
live R artifact.
5. `mem verify --exclusions` lists recent `derived_excluded` events with the
matched artifact and overlap score, for false-positive review.
## Acceptance
- A session transcript containing a verbatim R section is excluded from evidence.
- The same transcript still appears in the log, tagged, with the match named.
- A session that merely *mentions* the tool without quoting it is not excluded.
- Skill exclusion behaviour from M4.2 is unchanged.
- Removing a corpus removes its manifest entries; previously excluded text is not
retroactively rewritten in the log.
## Verify
**Harness:** fixture corpus ingested as R, plus three synthetic transcripts — one
quoting a section verbatim, one paraphrasing it heavily, one discussing the tool
without quoting. Deterministic embedder.
**Integration test** — `tests/it_reference_cycle.rs`:
1. `a1_verbatim_quote_excluded` — the quoting transcript produces zero L0
evidence nodes; assert a `derived_excluded` event naming the R artifact.
2. `a2_discussion_not_excluded` — the non-quoting transcript produces evidence
normally. This is the false-positive guard and it is the assertion that fails
when the threshold is set too low.
3. `a3_partial_quote_caught` — the transcript quoting ~10 lines of a 50-line
section is excluded, proving section-granularity matching rather than
whole-chunk overlap.
4. `a4_skill_path_unchanged` — run M4.2's own test fixtures; assert identical
results before and after the manifest generalisation.
5. `a5_exclusion_is_auditable` — every exclusion event carries artifact name,
overlap score and the threshold in force.
6. `a6_tombstone_removes_manifest_entry``mem ref rm`, then assert the R
entries are gone from the manifest and the same transcript now ingests
normally.
7. `a7_verify_catches_leak` — hand-insert an L0 node whose text matches an R
artifact; assert `mem verify --derived-filter` exits non-zero and names it.
8. `a8_no_retroactive_log_edit` — after `rm`, assert prior `derived_excluded`
records are still present and unmodified.
**Command:** `cargo test -p mem-ingest reference_cycle && cargo test -p mem-cli verify`
**False pass:**
- Testing only the verbatim case. Verbatim is easy and a whole-chunk hash catches
it; assertion 3 is the one that distinguishes a working matcher, and assertion
2 is the one that proves it is not simply excluding everything that mentions
the tool.
- Asserting exclusion by checking evidence count is zero. A filter that is
accidentally excluding *all* records also yields zero; assertion 2 has to run
in the same test binary.
- Reusing M4.2's threshold unchanged and declaring it done. The default is tuned
for whole-artifact quoting; assertion 3 fails against it, which is the point.
## Traps
- Registering R chunks in the manifest before they are committed to the log. A
failed ingest then leaves manifest entries that exclude evidence for a corpus
that does not exist, and the symptom is missing memories with no obvious cause.
- Normalising differently in the two paths. If the shingler treats markdown
tables differently at emit-time and at ingest-time, overlap collapses and the
filter silently stops firing — same failure M4.2 already warns about, now with
two producers to keep in step.
- Letting the exclusion event omit the threshold. A tuning change makes every
historical exclusion uninterpretable, and this filter will be tuned.
---
Background: [DESIGN.md](../DESIGN.md) — reference corpora, skills · [M4.2](M4.2-derived-filter.md)
+136
View File
@@ -0,0 +1,136 @@
# M3.6.5 — Query: filter-then-recall, R opt-in, relevance floor
| Field | Value |
|---|---|
| Phase | M3.6 — Reference corpora |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | M3.6.6 |
| Depends | M3.6.2, M3.3, M3.2, M2.7 |
## Goal
Make R reachable on request, unreachable by default, and stop the retriever
answering questions it has no evidence for.
## Facts (inlined — no spec read needed)
```
mem query "why did requests over 10KB fail?" # L1,L2 — unchanged
mem query --levels R "kubectl describe pod" # reference only
mem query --levels L1,L2,R "..." # both, R marked in output
mem query "…" --min-score 0.4 # override the floor
```
**Filter before recall, not after.** M3.3 recalls `10×k` from HNSW and reranks
down to `k`. A corpus is typically an order of magnitude larger than the project's
own memory, so R rows compete for those 50 candidate slots even when the caller
excluded them — and post-filtering then returns three results instead of five,
quietly. The level predicate belongs in the SQL that drives the HNSW scan. The
existing `(project, level)` index already supports it.
**Abstention.** With a corpus loaded, every question has *something* moderately
close, so unconditional top-k starts returning plausible prose for questions the
memory cannot answer — worse than an empty result, because it reads as an answer.
If the best post-rerank score is below the floor, return no hits and say why:
```
no hits above relevance floor (best 0.21 < 0.35 threshold)
try --min-score to lower it, or --levels R to search reference docs
```
The floor applies to the **reranked** score, not cosine distance. M3.2's own
fixture separates a relevant from an irrelevant passage by four orders of
magnitude; cosine distance does not, which is why the floor cannot live at the
recall stage.
**R is visually distinct in output.** A reference hit prints its source URI and
heading path where a project hit prints provenance. A caller must never have to
infer from wording whether an answer came from this cluster's history or from
upstream documentation.
**R has no provenance walk.** M3.3 walks `memory_edge` one hop for L1 and two for
L2. R has no edges by construction (M3.6.2), so the walk is skipped rather than
returning empty — and `mem verify` gains the assertion that makes that safe.
## Steps
1. Push the level filter into the recall query; assert candidate width is `10×k`
*after* filtering.
2. `--levels` accepts `R`; default remains `L1,L2`.
3. Apply the relevance floor to reranked scores; `--min-score` overrides,
`--min-score 0` disables.
4. Abstention message names the best score, the threshold, and the two escapes.
5. Render R hits with source URI and heading path; suppress the provenance walk.
6. Exit code: abstention is exit 0 with no hits, not an error — it is a valid
answer. Unresolvable project stays non-zero (M3.3 assertion 7).
7. `mem verify --edges` asserts no `memory_edge` row names an R sha as parent.
## Acceptance
- Default query over a database containing a large corpus returns exactly the
same hits as before the corpus was added.
- `--levels R` returns reference sections with URI and heading path.
- A question with no good match returns nothing and explains itself.
- Lowering `--min-score` surfaces the suppressed hits.
- `mem verify` rejects a hand-inserted `L1 -> R` edge.
## Verify
**Harness:** seeded database with the poimen log *plus* a reference corpus large
enough to dominate raw recall — at least 10× the project node count. Live
reranker for scoring assertions, deterministic embedder elsewhere.
**Integration test** — `tests/it_query_levels.rs`:
1. `a1_default_unchanged_by_corpus` — snapshot default query results before and
after ingesting the corpus; assert byte-identical output. This is the
headline assertion of the task.
2. `a2_filter_before_recall` — instrument the repository; assert the SQL driving
HNSW carries the level predicate and returns `10×k` rows post-filter, not
`10×k` pre-filter then fewer.
3. `a3_levels_r_returns_reference``--levels R` returns R nodes with source URI
and heading path populated.
4. `a4_floor_abstains` — a question with no relevant content returns zero hits,
exit 0, message naming best score and threshold.
5. `a5_floor_override_recovers` — same question with `--min-score 0` returns the
suppressed hits, proving abstention is a floor and not a bug upstream.
6. `a6_floor_applies_post_rerank` — construct a case where cosine is high and
rerank is low; assert it is suppressed. The reverse ordering passes every
other assertion here.
7. `a7_r_hits_visually_distinct` — assert R hits carry no provenance block and do
carry a URI, in both human and `--format json` output.
8. `a8_no_edge_to_r` — hand-insert an `L1 -> R` edge; assert `mem verify --edges`
exits non-zero and names the offending pair.
9. `a9_mixed_levels_ordering``--levels L1,L2,R` returns both kinds ranked
together with the level labelled on every row.
**Command:** `cargo test -p mem-cli query_levels`
**False pass:**
- Running assertion 1 against a small corpus. If the corpus is smaller than the
recall width, post-filtering and pre-filtering give the same answer and
assertion 2 is the only thing separating them — the fixture size is part of the
test.
- Testing abstention with a query that matches nothing at all. Zero recall
returns zero hits regardless of the floor; the fixture needs a *weak but
non-empty* match, or assertion 4 passes with the floor unimplemented.
- Asserting `--levels R` works without asserting the default excludes R. Both
directions are the contract.
## Traps
- Applying the floor to the first-stage cosine score. Cosine on `nomic` puts
unrelated text closer than intuition suggests; a floor there either suppresses
good hits or does nothing, depending on the corpus.
- Treating abstention as an error exit. Callers wrap `mem query` in scripts; a
non-zero exit for "no confident answer" turns a normal outcome into a pipeline
failure and the floor gets disabled within a week.
- Letting the reranker see 50 R candidates and 3 project candidates in a mixed
query. The reranker is not calibrated across levels, and the corpus wins on
fluency; recall per level, then merge.
---
Background: [DESIGN.md](../DESIGN.md) — reference corpora, pgvector, retrieval
+134
View File
@@ -0,0 +1,134 @@
# M3.6.6 — M3.6 composition gate
| Field | Value |
|---|---|
| Phase | M3.6 — Reference corpora |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Flags | gate |
| Spec | inlined below |
| Blocks | all of M3.6 |
| Depends | M3.6.1, M3.6.2, M3.6.3, M3.6.4, M3.6.5 |
## Goal
Answer the question no single task in this phase can: **did adding documentation
change the memory system?** It must not have.
## Facts (inlined — no spec read needed)
Every task here was verified alone. What none of them own is the property that
makes the phase safe: a corpus is additive to *retrieval* and invisible to
*everything else*. Three ways that can silently fail, and this gate exists for
them.
**1. The M1.8 metric can be gamed by accident.** Update-rate is
`chunks_used / chunks_seen`, and M1 fails above 30%. Documentation is
evidence-free against almost any standing question, so a corpus routed through
the controller would push the ratio *down* and make M1.8 easier to pass while the
memory got worse. Any implementation that improves a quality metric by adding
unrelated text has inverted it. The gate re-runs M1.8 and asserts the numbers are
**unchanged**, not merely still-passing.
**2. Retrieval quality can degrade without any test noticing.** Each task asserts
its own behaviour on its own fixture. The composite risk is a corpus that
outcompetes real project memory in the candidate pool — invisible to M3.6.5's
unit fixture, obvious on the real poimen log with a real corpus loaded.
**3. The cycle guard has two producers now.** M4.2 writes skills to the manifest,
M3.6.4 writes reference sections. They share a normaliser. Skills exclusion
regressing when a corpus is added is the failure that no test in either phase
catches, because each tests only its own kind.
**Swappable parts.** The phase claims two seams are real: `DocCorpusSource` is
just another `RecordSource`, and the corpus is just another projection input.
Prove both — swap the doc tree for a differently-shaped one and re-run, and
rebuild the whole store from the log with the corpus present.
## Steps
1. Establish the baseline: on a clean store, run `mem ingest --project poimen`
for all standing queries; record the M1.8 summary table.
2. `mem ref add --project poimen --corpus homelab-knowledge <tree>` against a
real corpus of at least 200 chunks.
3. Re-run the full ingest. Diff the M1.8 summary against the baseline.
4. Run the assertions below.
5. Emit `expected/m3.6-gate.txt` with the summary; commit it. Later runs diff
against it and a changed expectation is a reviewable claim, same rule as M1.8.
6. Sample 10 abstentions and 10 R hits; eyeball whether the floor is set sanely.
Advisory, as M1.8's judge audit is.
## Acceptance
- M1.8's numbers are identical before and after the corpus exists.
- Default query output is byte-identical before and after.
- No L1 or L2 node has an R parent.
- Skills exclusion behaviour is unchanged with a corpus loaded.
- Drop and rebuild reproduces the mixed store byte-identically.
## Verify
**Harness:** live gateway, real corpus, real poimen log. Long-running; nightly or
on-demand, `#[ignore]` by default, same posture as M1.8.
**Integration test** — `tests/it_m3_6_gate.rs`:
1. `a1_update_rate_identical` — per standing query, assert update-rate before and
after the corpus is added is equal, not merely both under 0.30. Equality is
the assertion; a threshold check here would pass the exact failure described
above.
2. `a2_chunks_seen_identical``chunks_seen` per run is unchanged, proving no R
chunk entered the recurrence.
3. `a3_no_controller_calls_during_ref_ingest` — run `mem ref add` under a chat
transport that panics on request; assert it completes. Embeddings are allowed,
controller calls are not, so the fake must distinguish the two endpoints.
4. `a4_default_query_byte_identical` — snapshot default `mem query` output for 10
fixed questions before and after; assert byte-identical.
5. `a5_no_r_parents` — `SELECT count(*) FROM memory_edge WHERE parent_sha IN
(SELECT sha256 FROM memory_node WHERE level='R')` is 0 on the live store.
6. `a6_l2_stream_excludes_r` — re-run L2 synthesis; assert its input stream
contained only L1 nodes and the resulting L2 memories have no R ancestor.
7. `a7_skill_exclusion_unregressed` — re-run M4.2's fixtures against the store
with the corpus loaded; assert identical exclusion decisions.
8. `a8_rebuild_mixed_store` — drop database and vault, `mem rebuild --from-log`,
assert byte-identical across all four levels.
9. `a9_source_seam_swappable` — point `mem ref add` at a structurally different
tree (deep nesting, no headings in one file, one non-UTF8 file) and assert it
ingests or fails cleanly, never partially.
10. `a10_corpus_does_not_starve_recall` — for 10 project questions, assert the
top-5 default hits are the same nodes as the pre-corpus baseline, with the
corpus present in the table.
11. `a11_m5_export_excludes_r` — run the M5.3 training-corpus export shape; assert
zero R records appear. R carries no gate decision, so its presence would
poison `r_update` labels with rows that have no ground truth.
**Command:** `cargo test --workspace m3_6_gate -- --ignored --nocapture`
**False pass:**
- Asserting update-rate is still below 30% instead of unchanged. That is the
precise shape that goes green while the gate is being fed documentation —
assertion 1 must be equality.
- Running the gate with a corpus small enough not to matter. 200 chunks is a
floor, not a suggestion; below it, assertions 4 and 10 pass because the corpus
never reaches the candidate pool.
- Allowing `a3`'s fake transport to reject all HTTP. Reference ingest legitimately
calls the embeddings endpoint; a blanket panic passes the assertion for the
wrong reason and would also pass if ingest did nothing at all.
- Rebuilding into a fresh database rather than dropping the live one. A rebuild
that never exercises deletion has not proved the projections are droppable.
## Traps
- Comparing M1.8 summaries by eye. The numbers move in the third decimal when the
gateway is under load; the committed `expected/` file plus an explicit
tolerance is the only version of this that stays honest over months.
- Treating a changed baseline as a corpus problem. If update-rate shifts, first
confirm the gateway model has not changed underneath — `reasoning` and the 3B
controller are both moving targets, and misattributing that to this phase burns
a day.
- Skipping assertion 11 because M5 is not built. The export *shape* is checkable
now, and discovering R in the training corpus during M5.3 means re-running an
expensive labelling pass.
---
Background: [DESIGN.md](../DESIGN.md) — reference corpora, the tier model · [M1.8](M1.8-m1-gate.md) · [M4.2](M4.2-derived-filter.md)
+115
View File
@@ -0,0 +1,115 @@
# M3.7.3 — `GET /memory/skills?task=` — match a subset to the work
| Field | Value |
|---|---|
| Phase | M3.7 — Tool context |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | M3.7.6 |
| Depends | M3.5.5, M3.2, M2.1 |
## Goal
Given a task, return the few skills that apply, so the orchestrator stops cloning
the same static list for every piece of work.
## Facts (inlined — no spec read needed)
```
GET /memory/skills?task=fix+the+kubectl+parsing+in+the+pod+debugger&project=homelab
→ 200 [{"name":"infra-root-causes","score":0.81,
"matched_on":"when_to_use","when_to_use":"When troubleshooting cluster…"}]
```
**Match on `description` and `when_to_use`, never on the body.** Anthropic's own
skill guidance, encoded in the installed `grafana-core:skill-authoring` rubric,
makes `description` the field that decides whether a skill fires. Bodies are long,
full of example output, and match everything — a skill whose body mentions
`kubectl` in passing would be selected for every Kubernetes task. Matching the
field the author wrote *for this purpose* also gives authors a lever they can
reason about.
**Embed the metadata, rerank the shortlist.** Same two-stage shape as `mem query`
(M3.3): embed `description + when_to_use`, cosine-recall a shortlist, then rerank
against the task text with `bge-reranker-base` (M3.2). The corpus is small enough
that recall could be exhaustive, but the reranker is what separates "mentions
Kubernetes" from "is about diagnosing a failing pod".
**Empty is a valid answer and must stay cheap.** Most tasks match no skill. The
endpoint returns `[]`, not the closest thing it found, and the caller proceeds
with tools and knowledge alone. A floor applies here for the same reason it does
in M3.6.5: a plausible-but-wrong skill actively steers the implementer.
**`_drafts/` stays excluded.** M3.5.5's rule is unchanged and load-bearing —
matching must not become a side channel that loads an unpromoted skill.
**Deterministic ties.** Two skills at the same score sort by name, so an
orchestrator that caches on the response is not invalidated by rank flapping
between identical requests.
## Steps
1. Extend the M3.5.5 handler with `?task=` and `?limit=` (default 3).
2. Build the match index over `description + when_to_use` for promoted skills.
3. Recall then rerank against the task text; apply the score floor.
4. Return `score` and `matched_on` so a bad match is diagnosable without a rerun.
5. Rebuild the index on skill promotion; no restart required.
6. `?task=` absent keeps the existing full-catalog behaviour exactly.
## Acceptance
- A Kubernetes debugging task matches the infra skill; an unrelated task does not.
- Draft skills never appear.
- No match returns `[]` with 200.
- Omitting `task` returns the full catalog, byte-identical to today.
- Equal scores order deterministically.
## Verify
**Harness:** vault fixture with 6 promoted skills across distinct domains plus 2
drafts. Live reranker for scoring; deterministic embedder elsewhere.
**Integration test** — `tests/it_skill_matching.rs`:
1. `a1_relevant_match` — a pod-debugging task returns the infra skill first.
2. `a2_irrelevant_no_match` — "update the README changelog" returns `[]`.
3. `a3_drafts_excluded` — a task whose text matches a draft's description
verbatim returns `[]`.
4. `a4_body_not_matched` — a skill whose *body* mentions `kubectl` but whose
description is about something else is not returned for a `kubectl` task.
This is the assertion that proves the field restriction.
5. `a5_no_task_unchanged` — omit `task`; assert byte-identical to M3.5.5's
existing fixture output.
6. `a6_floor_applies` — a weakly-related task returns `[]` rather than the
best-of-bad.
7. `a7_deterministic_ties` — two identically-described skills; assert stable
name-ordered output across 10 calls.
8. `a8_reranker_reorders` — capture pre- and post-rerank order; assert they
differ on at least one fixture task, proving the reranker is wired.
9. `a9_promotion_visible` — promote a draft, re-query without restart; assert it
is now matchable.
**Command:** `cargo test -p mem-api skill_matching`
**False pass:**
- Fixtures whose descriptions share no vocabulary. Any embedder separates
unrelated topics; assertion 4 needs a deliberate body/description conflict, and
assertion 6 needs a genuinely borderline task, or both pass with a keyword
`LIKE`.
- Asserting only that the right skill is *present*. Returning all 6 sorted also
contains the right one; assert the length and the floor.
## Traps
- Indexing skill bodies "for better recall". It inverts the design: bodies are
where every skill looks alike, and the author's `description` stops being the
control surface it was written to be.
- Tuning the floor against the same fixtures used to assert matching. It converges
on a threshold that fits six skills and fails on sixty; hold out tasks.
- Rebuilding the index per request. It is small, but this endpoint sits in the
path of every task the orchestrator runs.
---
Background: [DESIGN.md](../DESIGN.md) — tool context, skills · [M3.5.5](M3.5.5-skills-endpoint.md)
+183
View File
@@ -0,0 +1,183 @@
# M3.7.4 — `/memory/context` — three-tier lookup
| Field | Value |
|---|---|
| Phase | M3.7 — Tool context |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | M3.7.6 |
| Depends | M3.7.3, M3.7.7, M3.7.8, M3.5.3, M3.6.5 |
## Goal
One call that answers “what do we already know about this failure, tool or task”,
cheapest tier first, and says which tier the answer came from.
## Existing code to build on
**`crates/mem-core/src/lesson.rs`** already implements the tier-1 and tier-2 lookup pattern:
| Function | What it does | Reuse plan |
|---|---|---|
| `lookup(sig, lessons, floor)` | Exact hash match (tier 1) then trigram similarity (tier 2) with abstention floor | **Extend**: add vector search (M2.1) as tier 2, keep trigrams as offline fallback |
| `Tier` enum (`Exact`, `Similar(f32)`) | Tier labeling | **Extend**: add `Reference` variant for tier 3 |
| `Hit` struct | `{ lesson, tier }` | **Extend**: generalize from `Lesson` to `MemoryNode` |
| `similarity(a, b)` | Jaccard over character trigrams | **Keep** as fallback when embeddings unavailable |
| `render_injection(hit, max_chars)` | Capped injection text | **Reuse** for budget management |
**`crates/mem-cli/src/lessons_cmd.rs`** already implements:
| Command | What it does | Reuse plan |
|---|---|---|
| `mem lookup --tool T --file F` | CLI tier-based lookup with floor | **Model for** the HTTP endpoint |
## Files
| Action | Path |
|---|---|
| **Exists** | `crates/mem-core/src/lesson.rs``lookup()`, `Tier`, `Hit`, `similarity()` |
| **Exists** | `crates/mem-cli/src/lessons_cmd.rs``mem lookup` CLI |
| Create | HTTP endpoint in `mem-api` crate (M3.5.1 server) |
| Modify | `crates/mem-core/src/lesson.rs` — extend `Tier` enum with `Reference` variant |
| Create | `tests/it_context_endpoint.rs` — integration tests (12 assertions) |
## Facts (inlined — no spec read needed)
```
POST /memory/context
{ "tool": "github-actions",
"signature_source": "<50KB run log>",
"project": "homelab", "scope": "all-projects", "budget": 6000 }
→ 200 {
"tier": 1,
"lessons": [
{"tier":1,"level":"L1","seen_count":3,"last_seen":"2026-07-02",
"text":"peer dep conflict @types/react 18 vs 19; npm ci --legacy-peer-deps
unblocks, real fix is pinning in overrides",
"parents":[{"level":"L0","source":"pi:…"}]},
{"tier":2,"level":"L2","score":0.71,"matched_kind":"symptom","text":"…"}
],
"skills": [{"name":"ci-triage","score":0.77}],
"budget": {"limit":6000,"used":2140,"dropped":[]}
}
```
Three accepted inputs, any combination: `tool`, `task`, `signature_source`. At
least one is required; `signature_source` without `tool` is allowed and the tool
is inferred by the extractor's rule match.
**Tiers, cheapest first:**
| Tier | Mechanism | Meaning |
|---|---|---|
| 1 | `sig_sha` primary-key hit on `failure_signature` | this exact failure happened here before |
| 2 | vector over `kind='symptom'` then `kind='text'`, reranked | something similar happened |
| 3 | R reference corpus | nobody here has hit this; here are the docs |
Tier 1 does not short-circuit tiers 2 and 3 — it *leads*. An exact hit plus two
related memories is a better answer than an exact hit alone, and the tiers cost
milliseconds relative to the caller's own inference.
**`tier` is in the response and this matters.** The caller, and the human reading
its output, must be able to tell "we hit this exact error in July" from "here is
what the manual says". Presenting tier 3 in the register of tier 1 is how a
retrieval system becomes untrustworthy.
**Ordering is by tier, then rerank, and never by raw score.** A cheatsheet is
written to match the phrasing of a question and will routinely out-score the
terse memory that actually solved it. Precedence is a rule: tier 1 > L1/L2 > R.
**Scope defaults differ by tier.** Signature and symptom lookups federate across
projects — `ERESOLVE` is not homelab-specific — while task-shaped queries stay
project-scoped unless asked otherwise. Project match becomes a rank boost, not a
filter.
**Superseded memories are excluded, not demoted.** A lesson about Kong config is
wrong now, not merely old. M2.4 filters them at the repository; this endpoint
surfaces the successor if one is linked.
**Budget, fixed truncation order:** drop R, then trim tier-2 results toward the
floor, then drop skills. Tier-1 hits are never dropped — they are the smallest and
most valuable content in the response.
**Legs degrade independently.** A skills timeout returns `"skills":[]` with a
`degraded` note and a 200. There is no leg whose failure justifies a 5xx here; a
thinner answer beats no answer when someone is mid-incident.
## Steps
1. Route in the M3.5.1 server; accept `tool`, `task`, `signature_source`.
2. If `signature_source` present, extract and normalise (M3.7.7), then tier 1.
3. Tier 2 concurrently: symptom-vector search, then text-vector, merge, rerank.
4. Tier 3 only if tiers 12 leave budget unfilled.
5. Skills leg (M3.7.3) concurrently with tier 2.
6. Order by tier, apply precedence, apply budget, record drops.
7. On tier-1 hit, append an occurrence record to the log so `seen_count` grows.
8. One structured log line: tiers fired, latencies, scores, drops.
## Acceptance
- A previously-seen failure returns `tier: 1` with `seen_count` ≥ 2.
- An unseen but similar failure returns tier 2 with `matched_kind: "symptom"`.
- A wholly unknown failure returns tier 3 and says so.
- Tier 1 outranks a higher-scoring R result.
- Identical requests return byte-identical bodies apart from the occurrence side
effect.
## Verify
**Harness:** M3.5.1 test server over a store seeded with the poimen log, a
reference corpus, promoted skills, and signatures from replayed real failures.
Fault injection per leg.
**Integration test** — `tests/it_context_endpoint.rs`:
1. `a1_tier1_exact` — replay a failure already in `failure_signature`; assert
`tier: 1` and the correct memory.
2. `a2_tier1_counts_occurrence` — call twice; assert `seen_count` incremented and
an occurrence record is in the log.
3. `a3_tier2_symptom` — a novel wording of a known incident returns tier 2 with
`matched_kind: "symptom"`.
4. `a4_tier2_beats_text_only` — same query with symptom vectors deleted ranks the
correct memory lower; assert the symptom path strictly improves it.
5. `a5_tier3_fallback` — an unknown failure returns tier 3 and no lesson claims a
lower tier.
6. `a6_precedence_over_score` — seed an R node that reranks above a tier-1 hit;
assert the tier-1 hit still leads and the response exposes both raw scores.
7. `a7_superseded_excluded` — mark a memory superseded; assert it is absent and
its successor is present.
8. `a8_budget_order` — shrink the budget stepwise; assert drops occur R, then
tier 2, then skills, and that tier 1 is never dropped.
9. `a9_skills_degrade` — inject a skills timeout; assert 200, `[]`, `degraded`.
10. `a10_signature_without_tool` — omit `tool`; assert the extractor infers it.
11. `a11_scope_federation` — a signature seeded under another project is found
with `scope: all-projects` and not with `scope: project`.
12. `a12_used_matches_actual` — tokenize the body independently; assert equality
with `budget.used`.
**Command:** `cargo test -p mem-api context_endpoint`
**False pass:**
- Asserting tier 1 fires without asserting tiers 2 and 3 still populate. A
short-circuit passes assertion 1 and produces a thin answer in exactly the case
where the most context is available.
- Testing precedence on a fixture where the tier-1 hit also scores highest.
Assertion 6 is only meaningful when score and tier disagree.
- Testing degradation with an empty leg instead of a failing one. Timeouts take
the path that 500s in production.
## Traps
- Running tier 3 unconditionally. Reference chunks are long, they fill the budget,
and they push real memories out of a response that had better answers available.
- Incrementing `seen_count` on a retry. The orchestrator retries activities; an
occurrence should key on the caller's request id or the count inflates and
`last_seen` stops meaning anything.
- Returning tier as a label the caller has to interpret from ordering. It is a
field; if it is implicit, every consumer reimplements the inference differently.
---
Background: [DESIGN.md](../DESIGN.md) — tool context, retrieval tiers
+162
View File
@@ -0,0 +1,162 @@
# M3.7.5 — `tool-failures` standing query — the loop that makes it improve
| Field | Value |
|---|---|
| Phase | M3.7 — Tool context |
| Size | M — 13 days |
| Status | 🟡 In progress — lesson derivation implemented in `lesson.rs` |
| Flags | — |
| Spec | inlined below |
| Blocks | M3.7.6 |
| Depends | M3.7.4, M1.2, M1.5 |
## Goal
Turn invocations that failed into memory that prevents them, and prove the
prevention actually reaches the next tasks prompt.
## Existing code (already implemented)
**`crates/mem-core/src/lesson.rs`** already contains:
| Function | What it does | Tests |
|---|---|---|
| `derive_lessons(events, tool_of)` | Pairs fail→success from command events, filters opaque edits, captures resolution | `derives_lesson_from_fail_then_success`, `opaque_edits_do_not_become_a_resolution`, `failed_attempts_are_not_the_resolution`, `bare_retry_is_not_a_lesson` |
| `tool_of_cmd(cmd)` | Infers tool name from command (npm, cargo, kubectl, etc.) | used by `derive_lessons` |
**`crates/mem-cli/src/lessons_cmd.rs`** already contains:
| Command | What it does |
|---|---|
| `mem capture --cmd ... --exit ...` | Records command execution events to `~/.mem/events.jsonl` |
| `mem resolve` | Derives lessons from events, preserves human confirmations |
## What remains to complete this task
The existing code operates on **command execution events** (individual tool invocations with exit codes). This task requires integration with the **GRU-Mem gated loop** (M1.5) which operates on full session transcripts:
1. **Standing query YAML** — add `tool-failures` query to `queries/<project>.yaml` with the verbatim-invocation requirement
2. **Gate-based extraction** — the gated loop (M1.5) decides which transcript chunks contain tool-failure evidence, not `derive_lessons()` from command events
3. **End-to-end test** — ingest a failure session, then verify `/memory/context` returns the failure ranked above docs
4. **Orchestrator lesson ingestion** — ingest `Poimen/workflows` lesson artifacts as a source
5. **Derived filter interaction** — verify lessons are NOT caught by M4.2s derived filter
The existing `derive_lessons()` remains useful as a **complementary path** for command-level failures, while the standing query handles full-transcript extraction.
## Files
| Action | Path |
|---|---|
| **Exists** | `crates/mem-core/src/lesson.rs``derive_lessons()`, `tool_of_cmd()` |
| **Exists** | `crates/mem-cli/src/lessons_cmd.rs``mem capture`, `mem resolve` |
| Modify | `queries/<project>.yaml` — add `tool-failures` standing query |
| Create | `tests/it_tool_failure_learning.rs` — integration tests (8 assertions) |
## Facts (inlined — no spec read needed)
```yaml
# queries/<project>.yaml
- id: tool-failures
question: >
Which tool or command invocations failed, what was the exact error,
and what was the working alternative? Record the invocation verbatim.
```
This is the only leg of the tool-context bundle that **goes through the update
gate**, and it should. A failed `kubectl` invocation is genuine evidence about
what happened in this project — unlike reference text (M3.6), which has no
evidence to gate on. No bypass, no special casing, no new machinery: one standing
question whose answers happen to be operationally useful at task time.
**The gate's discrimination is the feature here.** Sessions are full of commands
that failed for uninteresting reasons — a typo the model immediately fixed, a
transient 503. The question asks for the *working alternative*, which is what
separates a durable lesson from noise, and the gate is what enforces it. If
update-rate on this query runs high, the question is too permissive, not the gate.
**Verbatim invocation matters.** "Use the right namespace flag" is unusable. The
memory has to carry `kubectl get pods --all``error: unknown flag: --all`
`kubectl get pods --all-namespaces`, because the next model needs the exact
string to pattern-match against what it was about to emit.
**This is where the orchestrator's lessons should end up.** `Poimen/workflows`
already generates lessons on judge rejection (`action/lessons.go`) and discards
them at task end. Ingesting those artifacts gives this query a dense, pre-filtered
source — failures already judged consequential by a second model.
**Success is measured end to end, not at L1.** An L1 memory nobody retrieves is
worthless. The acceptance criterion is that a task mentioning the tool gets the
failure in its `/memory/context` bundle, ranked above the cheatsheet.
## Steps
1. Add `tool-failures` to the shipped query templates, with the verbatim
requirement in the question text.
2. Ingest orchestrator lesson artifacts as a source alongside session
transcripts.
3. Confirm no interaction with M3.6.4's manifest: lessons are project output, not
emitted artifacts, and must not be excluded as derived.
4. Measure update-rate for this query separately; it should sit well under the
30% M1.8 threshold.
5. End-to-end check: ingest a failure session, then request `/memory/context` for
a related task and assert the failure is present and ranked above R.
## Acceptance
- A session containing a failure-then-fix yields an L1 memory with both forms
verbatim.
- A session with only transient errors yields none.
- The memory appears in `/memory/context` for a related task, above the docs.
- Lesson artifacts are not caught by the derived filter.
- Update-rate for this query stays under the M1.8 threshold.
## Verify
**Harness:** three fixture sessions — one clean failure-then-fix, one transient
503 with no lesson, one where the model tried three wrong forms before succeeding.
Live gateway for the gate decisions.
**Integration test** — `tests/it_tool_failure_learning.rs`:
1. `a1_failure_becomes_memory` — fixture 1 yields an L1 under `tool-failures`
containing both the failing and the working invocation, verbatim.
2. `a2_transient_rejected` — fixture 2 produces no L1. This is the assertion that
proves the gate is discriminating rather than recording every non-zero exit.
3. `a3_multi_attempt_keeps_final` — fixture 3's memory names the working form,
not merely the last error.
4. `a4_reaches_the_bundle` — after ingest, `GET /memory/context?task=…kubectl…`
contains the memory.
5. `a5_outranks_documentation` — in that same bundle, assert it sorts above the
R cheatsheet section covering the same command.
6. `a6_lessons_not_derived` — ingest a lesson artifact; assert no
`derived_excluded` event fires for it.
7. `a7_update_rate_bounded` — update-rate for this query is under 0.30, reported
alongside the other standing queries.
8. `a8_provenance_resolves` — the memory's parents resolve to the L0 span
containing the actual error text.
**Command:** `cargo test --workspace tool_failure -- --ignored --nocapture`
**False pass:**
- Asserting only `a1`. A gate that accepts every chunk also produces the right
memory for fixture 1; `a2` is the one that distinguishes a filter from a
recorder, and it must run in the same binary.
- Stopping at L1. Assertions 4 and 5 are the task — an L1 that never reaches a
prompt has changed nothing about how the implementer behaves.
- Fixtures written by the same model that will be judged on them. Use real
session transcripts; synthetic failures are unnaturally clean and the gate
accepts them at a rate real sessions will not reproduce.
## Traps
- Writing the question to ask for "errors". Every tool result containing the word
error becomes evidence, update-rate climbs, and M1.8 goes red for reasons that
look unrelated to this task. The working-alternative clause is what bounds it.
- Ingesting lessons without a project key. They arrive from the orchestrator, not
from a session with a `cwd`, so project resolution has to be explicit or they
land in the wrong memory.
- Treating a high update-rate here as success. It means the question is loose;
the paper's failure mode is a memory that accepts everything.
---
Background: [DESIGN.md](../DESIGN.md) — tool context, standing queries · [M1.8](M1.8-m1-gate.md)
+124
View File
@@ -0,0 +1,124 @@
# M3.7.6 — M3.7 composition gate
| Field | Value |
|---|---|
| Phase | M3.7 — Tool context |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Flags | gate |
| Spec | inlined below |
| Blocks | all of M3.7 |
| Depends | M3.7.3, M3.7.4, M3.7.5, M3.7.7, M3.7.8 |
## Goal
Prove the lookup answers real failures from history rather than handing back
documentation, and that adding it changed nothing upstream.
## Facts (inlined — no spec read needed)
The phase's claim is narrow and testable: **given a failure this project has
solved before, the lookup returns the fix.** Everything else is machinery in
service of that.
**The measurement is a replay, not an A/B.** Collect real failures with known
resolutions from session history and CI. Hold out half. Ingest the first half,
then replay *all* of them against `/memory/context` and score:
| Signal | What it proves |
|---|---|
| tier-1 hit rate on ingested failures | signature normalisation actually stabilises (M3.7.7) |
| tier-2 recall on held-out failures | symptom projections generalise beyond exact repeats (M3.7.8) |
| tier-3 rate on ingested failures | how often the system falls back to docs when it should have known |
A high tier-3 rate on failures already in the corpus is the phase failing, and it
is the number to watch. It means retrieval exists and does not fire.
**No dependency on the orchestrator.** The consumer is any HTTP client — `pi`,
curl, an MCP call. Nothing here requires `Poimen/workflows` to execute tool calls,
which is what made the earlier version of this gate unrunnable.
**Upstream must be undisturbed.** This phase adds a standing query
(`tool-failures`), a second vector kind, and two tables. Each can perturb things
that were green: update-rate for M1.8, recall width for M3.6.6, rebuild parity for
M2.8. Re-assert all three.
**Cost belongs in the result.** The lookup sits in front of real work. Report p50
and p95 for each tier separately — a 900ms tier-2 is a different product than a
40ms tier-1, and the averages hide it.
## Steps
1. Assemble ≥40 real failures with known resolutions across ≥4 tools; commit the
set before running anything.
2. Split 50/50 into ingested and held-out.
3. Ingest the first half through the normal path — sessions, `tool-failures`
standing query, gate, symptom projections, signatures.
4. Replay all 40 against `/memory/context`; record tier, rank of the correct
answer, latency.
5. Re-run M1.8, M2.8 and M3.6.6.
6. Emit `expected/m3.7-gate.txt` with per-tool tier rates and latency
percentiles; commit it, same rule as M1.8.
## Acceptance
- Tier-1 hit rate on ingested failures ≥ 0.80.
- Tier-2 returns the correct memory in the top 3 for ≥ 0.50 of held-out failures.
- Tier-3 rate on ingested failures ≤ 0.10.
- M1.8, M2.8 and M3.6.6 unchanged except for the added standing query.
- Tier-1 p95 under 50ms; tier-2 p95 under 500ms.
## Verify
**Harness:** live gateway, real database, the committed failure set. Long-running,
`#[ignore]` by default, same posture as M1.8.
**Integration test** — `tests/it_m3_7_gate.rs`:
1. `a1_tier1_hit_rate` — replay the ingested half; assert ≥ 0.80 return tier 1,
print per-tool.
2. `a2_tier3_rate_bounded` — on that same half, assert ≤ 0.10 fall through to
tier 3. This is the "retrieval exists but never fires" detector.
3. `a3_heldout_recall` — the held-out half; assert the correct memory is in the
top 3 for ≥ 0.50, proving symptom projections generalise rather than memorise.
4. `a4_symptom_ablation` — delete `kind='symptom'` vectors, re-run `a3`; assert
recall drops measurably. Without this, `a3` could be satisfied by the text
vector alone and M3.7.8 would be dead weight.
5. `a5_signature_stability` — for failures appearing more than once in the set,
assert every occurrence produced the same `sig_sha`.
6. `a6_precedence_held` — across the whole replay, assert no response placed an R
result above a tier-1 or tier-2 lesson.
7. `a7_m1_8_unchanged` — re-run M1.8; per-query numbers match the committed
baseline, `tool-failures` the only addition.
8. `a8_m2_8_rebuild` — drop and rebuild with vectors, signatures and supersede
rows present; assert byte-identical.
9. `a9_m3_6_6_still_green` — re-run the M3.6 gate in full.
10. `a10_latency_by_tier` — p50/p95 per tier; assert the two thresholds.
11. `a11_no_orchestrator_dependency` — run the whole gate with `Poimen/workflows`
absent; assert it completes.
**Command:** `cargo test --workspace m3_7_gate -- --ignored --nocapture`
**False pass:**
- Replaying the ingested half only. It measures memorisation; `a3` on held-out
data is the one that says anything about a failure you have not seen before.
- Skipping `a4`. A symptom index that is empty, or full of paraphrase, passes
every other assertion here — the ablation is the only proof it contributes.
- Counting a tier-1 hit without checking the returned memory is the *right* one.
A signature collision produces a confident wrong answer, which is worse than
tier 3.
- Building the failure set from failures the system already handles well.
Fix the set first, commit it, then run.
## Traps
- Curating resolutions after seeing what retrieval returns. The known-good answer
for each failure has to be written down before the first replay.
- Reading a low tier-1 rate as a retrieval problem. It is almost always
normalisation (M3.7.7); check `mem sig explain` on the misses before touching
anything downstream.
- Letting the ingested half leak into the held-out half through near-duplicate
failures. Split by incident, not by log file.
---
Background: [DESIGN.md](../DESIGN.md) — tool context · [M1.8](M1.8-m1-gate.md) · [M3.6.6](M3.6.6-m3.6-gate.md)
+172
View File
@@ -0,0 +1,172 @@
# M3.7.7 — Failure signature extraction and normalisation
| Field | Value |
|---|---|
| Phase | M3.7 — Tool context |
| Size | M — 13 days |
| Status | 🟡 In progress — core implemented in `lesson.rs` |
| Flags | — |
| Spec | inlined below |
| Blocks | M3.7.6 |
| Depends | M0.2, M2.3 |
## Goal
Reduce 50KB of failure output to a short string that is byte-identical the next
time the same thing breaks.
## Existing code (already implemented)
**`crates/mem-core/src/lesson.rs`** (871 lines) already contains:
| Function | Lines | What it does | Tests |
|---|---|---|---|
| `extract(tool, output)` | 200260 | Rule-based signature extraction per tool | `same_failure_different_runs_same_hash`, `different_failures_differ`, `cascade_lines_are_skipped`, `code_declaration_does_not_split_a_failure`, `tool_is_part_of_identity`, `unknown_tool_falls_back` |
| `normalise(raw)` | 60170 | Strips ANSI, timestamps, paths, shas, line:col, durations, addresses | `strips_ansi`, `normalises_volatiles_but_keeps_exit_codes`, `error_lines_still_keep_basenames` |
| `normalise_cmd(cmd)` | 179195 | Harsher normalisation for commands (drops basenames) | `cmd_key_ignores_temp_file_names` |
| `strip_ansi(s)` | 3050 | ANSI SGR sequence removal | `strips_ansi` |
| `markers(tool)` | — | Per-tool error line markers: npm, cargo, go, kubectl, gha, docker, terraform | — |
| `is_cascade(line)` | — | Suppresses consequence lines (`##[error]Process completed...`) | `cascade_lines_are_skipped` |
| `is_code_declaration(line)` | — | Handles `npm ERR! code ERESOLVE` prefix dedup | `code_declaration_does_not_split_a_failure` |
| `Signature` struct | 199208 | `{ tool, raw, normalised, sig_sha, rule }` | — |
All 10 relevant unit tests pass: `cargo test -p mem-core -- lesson`
## What remains to complete this task
1. **`mem sig explain` CLI command** — not yet in `main.rs`
2. **`fixtures/failures/` directory** — real captured logs from different runs (hand-written fixtures exist as inline test strings only)
3. **Integration test file** `tests/it_signature.rs` — the 9 assertions listed in Verify below (current tests are unit tests inside `lesson.rs`, not integration tests)
4. **Latency test (a7)** — 50KB log extracts in under 50ms
5. **`mem sig explain` output** that names the matching rule (a9)
## Files
| Action | Path |
|---|---|
| **Exists** | `crates/mem-core/src/lesson.rs` — core logic already here |
| Modify | `crates/mem-cli/src/main.rs` — add `Commands::Sig { Explain }` subcommand |
| Create | `tests/it_signature.rs` — integration tests (9 assertions) |
| Create | `fixtures/failures/npm-run-a.txt`, `npm-run-b.txt`, `npm-different.txt` — real logs |
| Create | `fixtures/failures/cargo-run-a.txt`, etc. — per-tool pairs |
## Facts (inlined — no spec read needed)
```
in: <14000 lines of GitHub Actions log>
out: { tool: "github-actions",
signature: "npm ERR! ERESOLVE unable to resolve dependency tree",
sig_sha: "7f3a…",
context: { job: "build", step: "npm ci", exit_code: 1 } }
```
**Normalisation is the whole task.** Two runs of the same failure differ in run
id, timestamps, durations, temp paths, container ids, commit shas, line numbers
and memory addresses. Every one of those must be stripped or the hash never
matches twice and tier 1 of the lookup never fires — the feature silently
degrades to vector search and nobody notices, because vector search still returns
*something*.
Substitution list, applied before hashing:
```
/home/runner/work/<org>/<repo>/… -> <WORKSPACE>/…
2026-08-21T10:02:11.482Z -> <TS>
[0-9a-f]{7,40} -> <SHA>
:[0-9]+:[0-9]+ -> :<LINE>:<COL>
0x[0-9a-f]+ -> <ADDR>
took 4m21s / in 132ms -> <DUR>
/tmp/[A-Za-z0-9]+ -> <TMP>
```
**Deterministic first, model second.** Most tools have a findable error line —
`npm ERR!`, `error:`, `Error:`, `FAILED`, a non-zero exit with the last stderr
block. Extract with rules per tool and fall back to the 3B controller only when
the rules find nothing. A model in the hot path of every lookup is both slow and
non-deterministic, and non-determinism here means the same failure hashes two
ways.
**One signature, not a fingerprint set.** Take the *first* error that is not a
consequence of an earlier one. Cascading failures produce twenty error lines and
matching on the last one keys the memory to a symptom of a symptom.
**Keep `raw` alongside `sig_sha`.** The normalised form is unreadable to a human
and the display path needs the original. Store both; hash only the normalised.
**Unknown tools must degrade, not fail.** No rule set for a tool means: take the
last non-empty stderr block, normalise, hash. A worse signature is still a
signature, and a lookup that 500s because the tool is unrecognised is useless in
exactly the situation someone needs it.
## Steps
1. `mem-core::signature``extract(tool, raw) -> Option<Signature>`.
2. Rule sets for `github-actions`, `kubectl`, `npm`, `cargo`, `go`, `docker`;
a generic fallback for everything else.
3. Normalisation pipeline as above, ordered and documented; each substitution
named so a mismatch is debuggable.
4. `sig_sha = sha256(tool + "\n" + normalised)` — tool is part of identity, since
`exit status 1` means different things in different tools.
5. Cascade suppression: prefer the earliest error line not preceded by another.
6. `mem sig explain <file>` — print extracted signature, normalised form, hash
and which rule fired. This is the debugging surface for the whole tier.
## Acceptance
- The same failure from two different runs produces the same `sig_sha`.
- Two genuinely different failures from the same tool produce different hashes.
- An unrecognised tool still produces a signature.
- Extraction on a 50KB log completes in under 50ms with no model call.
- `mem sig explain` names the rule that fired.
## Verify
**Harness:** `fixtures/failures/` — for each of six tools, **two real logs of the
same failure from different runs**, plus one log of a different failure from the
same tool. Real captured output, not hand-written.
**Integration test** — `tests/it_signature.rs`:
1. `a1_same_failure_same_hash` — for each tool, the two same-failure logs produce
identical `sig_sha`. This is the assertion the tier depends on.
2. `a2_different_failure_different_hash` — the third log hashes differently.
3. `a3_normalisation_removes_volatiles` — assert the normalised string contains
no timestamp, path, sha, line number or duration, by regex.
4. `a4_cascade_picks_first` — a log with a root error followed by five induced
ones yields the root.
5. `a5_unknown_tool_fallback` — a log from an unlisted tool yields a signature and
names the generic rule.
6. `a6_no_model_calls` — run under a transport that panics on request; assert
every fixture extracts.
7. `a7_latency` — 50KB log extracts in under 50ms.
8. `a8_tool_in_identity` — the same normalised text under two different tools
hashes differently.
9. `a9_explain_names_rule``mem sig explain` output identifies the matching
rule for each fixture.
**Command:** `cargo test -p mem-core signature`
**False pass:**
- Fixtures generated by re-running the same command in the same directory at
nearly the same time. Paths and timestamps barely differ and assertion 1 passes
with normalisation disabled. The two logs must come from genuinely different
runs — different machine, different day, different workspace.
- Asserting only 1 and 2. A hash of the whole log satisfies 2 and fails 1; a
constant satisfies 1 and fails 2. Both are required, and 3 is what proves the
mechanism rather than the outcome.
- Hand-written fixture logs. They omit exactly the volatile noise the task
exists to strip.
## Traps
- Normalising too hard. Replacing every number makes `exit status 1` and
`exit status 137` collide, and OOM stops being distinguishable from a test
failure. Numbers that are part of the error's meaning must survive.
- Anchoring on the last line. It is usually `##[error]Process completed with exit
code 1`, which is identical across every failure GitHub Actions ever produced.
- Letting the fallback silently handle a tool that has a rule set. If a rule set
exists and does not match, that is a signal the tool changed its output format;
report it rather than quietly degrading.
---
Background: [DESIGN.md](../DESIGN.md) — tool context, retrieval tiers
+141
View File
@@ -0,0 +1,141 @@
# M3.7.8 — Symptom projection: make an answer findable from an error
| Field | Value |
|---|---|
| Phase | M3.7 — Tool context |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | M3.7.6 |
| Depends | M3.7.7, M2.4, M1.5 |
## Goal
Give every memory a second vector describing the failures it would explain, so a
stack trace can find an answer written in prose.
## Facts (inlined — no spec read needed)
The asymmetry this exists to fix:
```
L1 memory (how it is written):
"Requests over 10KB failed because Kong buffered the whole body before
proxying; resolved with proxy-body-size: 0 on the ingress."
Query (how it arrives):
"413 Request Entity Too Large" + a curl trace
```
Same incident. Embedded with the same model and compared by cosine, they are
mediocre neighbours — one is an explanation, the other is a symptom. This is the
main reason retrieval that looks correct in a unit test disappoints in use.
**Fix at write time, not read time.** When the gated loop accepts a memory,
generate a short *symptom projection* — the errors, messages and observable
behaviour this memory would explain — and embed that as a second vector:
```
symptom projection for the memory above:
"413 Request Entity Too Large; large POST bodies rejected at the ingress;
uploads over 10KB fail while small ones succeed; nginx/Kong body buffer limit"
```
The alternative, HyDE, generates a hypothetical answer per *query* and puts an
LLM call on every lookup. Writes are rare here — the gate keeps acceptance under
30% by design — and lookups should be fast, so paying once at write is the right
side of that trade.
**It is a projection, so it obeys the projection rules.** Regenerated by
`mem rebuild --from-log`, never authoritative, and byte-identical on replay —
which means the generation call must be deterministic: temperature 0, pinned
prompt, and the model id recorded in the log record so a model change is visible
as a rebuild difference rather than silent drift.
**Only L1 and L2 get one.** L0 is raw evidence already phrased as symptoms; R is
documentation and its headings already read like problems. Generating projections
for those doubles the index for no gain.
**Empty is allowed.** Not every memory explains a failure — an architectural
decision has no symptoms. The controller returns nothing, no vector is written,
and the memory remains findable by its text vector alone. A model that invents
symptoms for a memory that has none pollutes the symptom index with plausible
nonsense, which is worse than a smaller index.
**Signature linking.** Where the L0 evidence behind an accepted memory contains a
parseable failure (M3.7.7), write a `failure_signature` row pointing at the L1.
That is what turns the next occurrence into an exact-match tier-1 hit instead of
a vector search.
## Steps
1. Prompt template `prompts/symptom.tmpl` — memory text in, symptom lines out,
explicit "return nothing if this describes no failure".
2. Hook into the gated loop after a memory is accepted; L1 and L2 only.
3. Temperature 0; record model id and prompt sha on the log record.
4. Embed and write `memory_vector(kind='symptom')`.
5. Extract signatures from the memory's L0 parents; write `failure_signature`
rows keyed to the memory.
6. `mem rebuild --from-log` regenerates projections; assert stability.
7. `mem symptoms show <sha>` prints the projection for inspection.
## Acceptance
- An accepted L1 that explains a failure gains a symptom vector.
- A memory describing a decision gains none.
- Symptom text retrieves its memory from a raw error string that shares no
vocabulary with the memory text.
- Rebuild regenerates projections byte-identically.
- Signature rows link to the right memory.
## Verify
**Harness:** fixture memories — five that explain failures, three that do not —
plus raw error strings for the five, deliberately worded with no vocabulary
overlap with the memory text.
**Integration test** — `tests/it_symptom_projection.rs`:
1. `a1_projection_generated` — the five failure memories each gain a
`kind='symptom'` vector.
2. `a2_no_projection_for_non_failure` — the three others gain none.
3. `a3_retrieval_by_symptom` — searching `kind='symptom'` with each raw error
string returns its memory first. This is the assertion the task exists for.
4. `a4_beats_text_vector` — the same query against `kind='text'` ranks the
correct memory *lower*; assert the symptom search strictly improves rank.
Without this the projection could be doing nothing.
5. `a5_deterministic` — generate twice; assert byte-identical projections.
6. `a6_rebuild_stable` — drop and rebuild; assert projections and their
embeddings match the originals.
7. `a7_signature_linked` — a memory whose evidence contains a parseable error has
a `failure_signature` row pointing at it, with the tool set.
8. `a8_l0_and_r_skipped` — assert no L0 or R node has a symptom vector.
9. `a9_model_id_recorded` — the log record names the model and prompt sha.
**Command:** `cargo test -p mem-core symptom && cargo test -p mem-cli rebuild`
**False pass:**
- Test queries that reuse the memory's own wording. The text vector already finds
those, assertion 3 passes, and the projection is never exercised. The error
strings must share no meaningful vocabulary — that constraint is the test.
- Asserting 3 without 4. If the text vector already ranked it first, assertion 3
is satisfied by a projection that is empty or useless.
- Skipping determinism because output "looks stable". A default temperature makes
it stable for ten runs and different on the eleventh, and the symptom is a
rebuild diff nobody can explain.
## Traps
- Generating projections for rejected chunks. The gate rejected them; embedding
their symptoms puts evidence-free content in the index through a side door.
- Letting the projection restate the memory. If the model paraphrases the answer
instead of naming the symptoms, the second vector duplicates the first and
assertion 4 fails — which is the correct outcome, but the cause is the prompt,
not the plumbing.
- Treating an empty projection as an error and retrying. It is the right answer
for most non-incident memories, and a retry loop turns it into invented
symptoms.
---
Background: [DESIGN.md](../DESIGN.md) — tool context, write path
+36 -2
View File
@@ -4,7 +4,7 @@
|---|---|
| Phase | M4 — Skills |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Status | 🟡 In progress — `render_skill()` and `mem materialize` implemented |
| Flags | — |
| Spec | inlined below |
| Blocks | M3.1 |
@@ -14,6 +14,40 @@
Turn a memory note into a draft skill — the step that makes the memory *do*
something rather than only be read.
## Existing code (already implemented)
**`crates/mem-core/src/lesson.rs`** already contains:
| Function | What it does | Tests |
|---|---|---|
| `render_skill(tool, lessons)` | Generates `SKILL.md` with YAML frontmatter (`name`, `description`), per-lesson sections with `seen`/`last_seen`/`confidence`/`resolution`, and recurring-failure warnings | `skill_description_lists_symptoms_not_summary` |
| `render_injection(hit, max_chars)` | Generates capped injection text for prompts | `injection_is_capped` |
**`crates/mem-cli/src/lessons_cmd.rs`** already contains:
| Command | What it does |
|---|---|
| `mem materialize` | Writes `skills/<tool>-failures/SKILL.md` per tool + `MEMORY.md` digest. Creates dirs, prints symlink instructions for Claude Code / pi. |
## What remains to complete this task
The existing code generates skills from **command-level lessons** (`Lesson` struct). This task requires:
1. **Draft from L1/L2 memory notes**`mem skill draft --from <project>/<query-id>` reads a GRU-Mem memory node, not a lesson
2. **LLM-assisted conversion** — prompt the model to convert descriptive memory into procedural instruction using the rubric
3. **`_drafts/` enforcement** — existing `mem materialize` writes directly to `skills/`; this task must write to `_drafts/` only
4. **`generated_from: <sha>` provenance** — link back to the memory node
5. **Integration tests**`tests/it_skill_draft.rs` (7 assertions)
## Files
| Action | Path |
|---|---|
| **Exists** | `crates/mem-core/src/lesson.rs``render_skill()` |
| **Exists** | `crates/mem-cli/src/lessons_cmd.rs``mem materialize` |
| Modify | `crates/mem-cli/src/main.rs` — add `Commands::Skill { Draft }` subcommand |
| Create | `tests/it_skill_draft.rs` — integration tests (7 assertions) |
## Facts (inlined — no spec read needed)
```
@@ -92,7 +126,7 @@ frontmatter flag, because a directory cannot be accidentally globbed into
7. `a7_idempotent` — same input twice produces identical bytes apart from
`generated_at`.
**Command:** `cargo test -p mem-cli skill_draft`
**Command:** `cargo test --test it_skill_draft`
**False pass:**
- Asserting the file was written without asserting *where*. The entire safety
+9 -1
View File
@@ -47,10 +47,18 @@ threshold.
Threshold is a tradeoff and should be logged, not hidden: too low excludes
genuine discussion *about* a skill, too high lets the cycle run.
**A second producer arrives in M3.6.** Reference corpora hit the identical cycle
with upstream docs in place of emitted skills, and [M3.6.4](M3.6.4-reference-cycle-guard.md)
reuses this matcher rather than building a parallel one — generalising the
manifest to `vault/.artifacts.jsonl` with a `kind` field and adding a per-kind
threshold. Build the manifest record with that in mind: a `kind: "skill"` field
from the first line costs nothing now and avoids a migration of an append-only
file later.
## Steps
1. `vault/skills/.manifest.jsonl` — one line per emitted artifact:
`{name, sha256, shingles, emitted_at}`.
`{kind: "skill", name, sha256, shingles, emitted_at}`.
2. `mem skill draft` appends to it.
3. `mem-ingest` loads the manifest and computes shingle overlap per record.
4. Overlap > threshold (default 0.8): tag `derived: true`, exclude from chunking.