Deploy Poimen Memory K8s cluster with ArgoCD tracking (M2.2, M3.5-M3.7)

This commit is contained in:
Story Crater Bot
2026-08-22 23:13:42 -07:00
parent af9c5ba01b
commit 695e115212
67 changed files with 8438 additions and 24 deletions
+167 -11
View File
@@ -40,13 +40,14 @@ The 32K cap is the binding constraint and it fits: paper uses 5000-token chunks,
## The tier model
Memory is **levelled**, and every event in the log carries its level. The paper has one flat memory; a project knowledge base needs three.
Memory is **levelled**, and every event in the log carries its level. The paper has one flat memory; a project knowledge base needs three, plus a fourth tier that sits outside the recurrence entirely (**R**, below).
| Level | What it is | Produced by | Bounded |
|---|---|---|---|
| **L0** | evidence chunk — the verbatim source span the update gate accepted | update gate opening at L1 | no, but sparse (~17 of 412 chunks) |
| **L1** | per-query memory — GRU-Mem's `M_t` for one standing question | gated loop over L0 chunk stream | 1024 tokens |
| **L2** | project synthesis — memory across the L1 memories of one project | gated loop over L1 memories | 1024 tokens |
| **R** | reference text — documentation the models are weak at, not evidence of anything | corpus ingest, no gate | no, bounded by corpus size |
**The tiering is not new machinery.** L2 is the same controller, same prompt, same two gates — run with the L1 memories as its chunk stream and a project-level question. The recurrence is the algorithm applied to its own output, so `mem-core` implements one loop and the level is a parameter. Two consequences worth having on purpose:
@@ -232,24 +233,56 @@ One table across all levels, because retrieval wants to search them together and
```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,
embedding vector(768) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE memory_edge ( -- provenance: child <- parent
child_sha TEXT NOT NULL REFERENCES memory_node(sha256),
parent_sha TEXT NOT NULL REFERENCES memory_node(sha256),
PRIMARY KEY (child_sha, parent_sha)
-- No row may point at an R node as parent. R is not evidence; see
-- "Reference corpora" below and the M3.6.6 assertion that enforces it.
);
CREATE INDEX ON memory_node USING hnsw (embedding vector_cosine_ops);
CREATE INDEX ON memory_node (project, level);
-- One node, several vectors. 'symptom' is a generated projection describing the
-- failures a memory would explain -- see Retrieval below for why one vector per
-- node does not work.
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 per kind: one index over both forces post-filtering and starves
-- recall. The predicate must be a literal or the planner ignores the index.
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
node_sha TEXT NOT NULL REFERENCES memory_node(sha256) ON DELETE CASCADE,
tool TEXT NOT NULL,
raw TEXT NOT NULL,
seen_count INT NOT NULL DEFAULT 1, -- a fold over log occurrences, not state
last_seen TIMESTAMPTZ NOT NULL
);
-- A lesson about Kong is wrong now, not merely old.
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)
);
```
```mermaid
@@ -367,6 +400,97 @@ Two mechanical guards:
1. **Promotion is a human move** out of `_drafts/`, reviewable as a diff.
2. **Provenance marks derived text.** Every emitted skill carries `generated_from: <L2 sha>` in frontmatter, and `mem-ingest` tags chunks matching a known emitted artifact as `derived: true` and excludes them from evidence. Without this the corpus slowly becomes its own training data.
## Reference corpora — the non-evidential tier
`RecordSource` takes session transcripts, and the update gate asks "does this chunk contain evidence for Q". A `kubectl` or `tea` cheatsheet answers neither question: it has no session, no turn, and no evidence. Left alone the system faithfully retains *what happened when a model used a tool badly* and never learns the tool. Level **R** closes that gap, and the shape of the fix matters more than the fact of it.
**Reference text bypasses the gated loop entirely.** It is not evidence, so it gets no gate decision, never becomes L0, and never parents an L1. It is embedded, indexed, retrievable, and inert with respect to the recurrence. The update-rate that `M1.8` watches must not move when a corpus is added — `M3.6.6` asserts exactly that, because a design where documentation quietly enters the gate is the memory-explosion failure wearing a different hat.
Four rules, each with an assertion behind it:
1. **R is never a parent.** An `L1 -> R` or `L2 -> R` edge is a bug, not a provenance nuance — it lets upstream doc prose be cited as evidence for what happened in this cluster. `mem verify` rejects it.
2. **R is opt-in at query time.** Default levels stay `L1,L2`. `--levels R` is an explicit ask. A default that blends manual pages into project answers makes the memory sound like documentation, which is precisely what the tier model exists to prevent.
3. **The log stays authoritative.** Corpus ingest writes `Reference` records carrying source URI and content sha; the pgvector rows and vault notes are projections and must survive `mem rebuild --from-log` byte-identically, same as every other level.
4. **Re-ingest replaces, never appends.** Upstream docs change. Identity is `(source_uri, sha256)`: an unchanged sha is a no-op, a changed one tombstones the prior node in the log and writes its successor. Skip this and the index accumulates every historical revision of a cheatsheet, with recall drifting toward the oldest copy.
**The fork is structural, not a flag.** `run_loop` takes a `Query`, and M1.2 makes an empty question a *load error* — the update gate is defined as "does this chunk contain useful information about the problem", so with no `Q` it has no referent. A corpus has no standing question, therefore a corpus ingest cannot construct a legal call into the recurrence at all. `mem ingest` and `mem ref add` are separate pipelines sharing `RecordSource` and `mem-chunk` and diverging *before* the controller. Encode that in the types — a reference chunk has no `run_loop` overload — rather than a `skip_gate: bool`, which is one careless default away from feeding documentation to the gate.
**Why that matters to M1.8 specifically.** Update-rate is `chunks_used / chunks_seen` over a run, and the M1 gate fails above 30%. Documentation is evidence-free with respect to almost any project question, so routing a corpus through the controller would *lower* update-rate and make the threshold easier to clear while the system got worse. A gate metric that improves when you add unrelated text has stopped measuring what it claims. `M3.6.6` asserts that a corpus ingest leaves `chunks_seen` untouched, and that is the assertion protecting M1.8 from being gamed by accident.
**The retrieval cycle is the skills cycle wearing a different hat.** A retrieved R section lands in an agent's context, appears verbatim in that session's transcript, and returns as L0 evidence — the exact loop M4.2 exists to break, with upstream docs in place of emitted skills. R text therefore registers in the same artifact manifest M4.2 reads, and this is why the phase is ordered *after* skills rather than before: the shingle matcher already exists by then. Without it, memory learns the man page as though it were a project finding.
**No exit gate, no memory budget, no `M_t`.** R has none of the recurrence's state. Nothing about a corpus is bounded by 1024 tokens, nothing records `E_t`, and nothing appears in the M5 training corpus as a gate decision — because no decision was made. A corpus that shows up in `mem label` output is a bug in the export filter, not a labelling question.
**Abstention comes with it.** A corpus multiplies the documents that are *somewhat* related to any question, so an unconditional top-k starts returning confident-looking prose for questions the memory cannot answer. `mem query` gains a relevance floor: below threshold it reports insufficient recall and returns nothing rather than the least-bad row. Worth having independently of R; `M3.6.5` builds it here because R is what makes it urgent.
**What this does not do.** It does not make a 35B model competent at a tool in the abstract — retrieval puts the right page in context, nothing more. Skills (M4) remain the procedural path, and a skill drafted from a session where the tool actually failed still beats a retrieved man page. R is the floor, not the ceiling.
## Tool context — the assembly surface
The consumer that made this necessary is the orchestrator (`Poimen/workflows`): its `ImplementerActivity` renders a prompt ending in *"use the available tools to implement this task"* while naming none, and `PrepareSkillsActivity` clones one static skill list for every task regardless of what the task is. Both models behind it — `reasoning` and `ornith:35b` — are then asked to operate tooling they were never told about. Memory owns the fix because the alternative is a second retrieval stack inside the orchestrator, indexing the same corpus against the same embedder, drifting immediately.
**Memory describes tools. It never executes them.** The orchestrator holds the sandbox, the credentials and the blast radius. This repo holds the catalog, the knowledge and the history.
**No tool catalog lives here.** An earlier draft had memory serving MCP schemas so a prompt builder could enumerate tools. That is duplication — `pi` and any other caller already hold their own MCP connections and schemas, and a second copy drifts. Memory answers *what do we know about this*, keyed by a tool name, a task, or a raw failure. The caller knows what tools it has.
### Retrieval: three tiers, cheapest first
The input is usually not a question. It is a 50KB CI log, or a tool name. Three problems follow, and plain top-k cosine handles none of them.
**Query/document asymmetry.** An L1 is written as an *answer* — "requests over 10KB failed because Kong buffered the body; fixed with `proxy-body-size: 0`". The query arrives as a *symptom*`413 Request Entity Too Large`. Same incident, different register, mediocre cosine neighbours. This is the main reason retrieval that passes its unit tests disappoints in use. The fix is a second vector per memory (`kind='symptom'`, M3.7.8) generated at write time, describing the failures that memory would explain. Query-time HyDE solves the same problem by putting an LLM call on every lookup; writes are rare here because the gate keeps acceptance sparse, so paying once at write is the right side of the trade.
**The input needs reducing before it can be embedded.** Signature extraction (M3.7.7) strips run ids, timestamps, workspace paths, shas, line numbers and durations, then hashes. Normalisation quality decides whether the exact tier ever fires — and when it silently does not, vector search still returns *something*, so the failure is invisible without the ablation the gate runs.
**Failures repeat verbatim; prose does not.** `npm ERR! ERESOLVE unable to resolve dependency tree` is byte-identical across occurrences, so it deserves a hash lookup rather than a vector search.
| Tier | Mechanism | What a hit means |
|---|---|---|
| 1 | `sig_sha` primary key 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 leads, it does not short-circuit — an exact hit plus two related memories beats an exact hit alone, and the extra tiers cost milliseconds against the caller's own inference. **The tier is a field in the response**, because "we hit this in July" and "the manual says" must not arrive in the same register.
**Scope differs by tier.** Signature and symptom lookups federate across projects — an `ERESOLVE` lesson is not homelab-specific — while task-shaped queries stay project-scoped. Project match is a rank boost, not a filter.
**Superseded memories are excluded, not demoted.** Kong is retired; a lesson about its buffer settings is wrong rather than stale, and `memory_supersede` surfaces the successor instead.
### The bundle is a composition, and stores nothing new
`POST /memory/context` takes any of `tool`, `task`, `signature_source` and merges the tiers above with matched skills. Each leg degrades independently — a skills timeout returns `[]` and a 200. No leg failure justifies a 5xx: a thinner answer beats no answer when someone is mid-incident.
### Learned beats documented, and that ordering is the whole point
When a task mentions `kubectl`, the bundle must surface *"`--all` is not a flag — it failed on 2026-08-19, the working form was `--all-namespaces`"* **above** the generic cheatsheet section. L1/L2 outrank R at equal rerank score, deliberately and by rule.
Without that ordering this whole repo reduces to a documentation server, and the gated recurrence — the expensive part, the part with a 3B controller and a post-training phase behind it — contributes nothing at the moment a task is actually being implemented. R is the fallback for what nobody here has learned yet.
### The feedback loop is the actual answer to "make ornith better at tools"
A cheatsheet is a floor. The mechanism that improves is this one:
```
implementer emits a bad invocation
→ fails, judge rejects, lessons injected, retry
→ session ingested at end
→ standing query `tool-failures` gates it in as evidence <- real evidence, unlike docs
→ L1 memory: what failed, the error, the working form
→ next task's /memory/context surfaces it above the docs
```
Note where this sits relative to the gate: tool failures are *genuine evidence about what happened in this project*, so unlike reference corpora they belong **inside** the recurrence and pass through the update gate normally. No bypass, no special casing — the only new artifact is a standing question (`M3.7.5`) whose answers happen to be operationally useful at task time.
### Budget is a hard contract, not a hope
The bundle is injected into every implementer prompt and `OLLAMA_CONTEXT_LENGTH` is 32768 — a verified constraint above, not a theoretical one. The bundle carries an explicit token budget with a fixed truncation order:
1. drop **tier 3 (R)** first — upstream docs are the most replaceable content here
2. then trim **tier 2** toward the relevance floor
3. then drop **skills**
4. **never** drop **tier 1** — an exact prior occurrence is the smallest and most valuable thing in the response
A response that cannot fit its tier-1 hits inside the budget is an error, not a truncation.
## Phases
**P1 — Read-only spine.** `mem-ingest` implements `RecordSource` for pi sessions and Claude transcripts; `mem-chunk` chunks to 5000 tokens on message boundaries; `mem ingest --dry-run` prints the chunk plan with no model calls. Both sources are batch, but they go through the stream interface so the seam is exercised from the first commit rather than retrofitted.
@@ -379,6 +503,10 @@ Two mechanical guards:
**P5 — Skill drafting.** `mem skill draft --from <note>` emits `vault/skills/_drafts/<name>/SKILL.md` with `generated_from` provenance; `mem-ingest` grows the `derived: true` exclusion filter. Promotion stays manual. Cheap to build and it is the phase that makes the memory *do* something rather than only be read.
**P5.5 — Reference corpora (board `M3.6`, ordered after skills).** `DocCorpusSource` implements `RecordSource` over a documentation tree, chunked on heading boundaries rather than message boundaries; R nodes land in log, index and vault; `mem ref add/list/sync/rm` manages corpora with replace-on-change identity; R text registers in M4.2's artifact manifest so retrieved docs cannot re-enter as evidence; `mem query` gains filter-then-recall over levels and a relevance floor. Ordered after P5 for two reasons: skills are the better answer to the same problem and should be built first, and the cycle guard is an extension of M4.2 rather than a parallel mechanism. The gate proves update-rate is unmoved, no L1 acquired an R parent, and no R text reached the controller.
**P5.6 — Tool context (board `M3.7`).** Signature extraction reduces a failure log to a stable hash; a symptom projection gives every L1/L2 a second vector so an error message can find an answer written as prose; `POST /memory/context` serves the three tiers — exact signature, symptom similarity, reference docs — with skills matched alongside, under a hard budget. A `tool-failures` standing query feeds real invocation failures back through the gate, so tier 2 becomes tier 1 the second time something breaks. Consumers are `pi`, curl, or an MCP call; this phase ships no tool execution and no tool catalog.
**P6 — Post-training (separate, Python).** Boundary is the JSONL. `mem label` uses the 32B `reasoning` model as an offline evidence labeler to produce per-chunk `U_t` ground truth (the paper had synthetic NIAH labels; we do not, and this is the honest cheapest substitute). Then verl trains a LoRA with the paper's rewards: `r_update` ±1, `r_exit` {0, 0.5 late, 0.75 early}, strict `r_format`, `α=0.9` mixing trajectory- and turn-level advantage. Requires the vLLM decision above.
## Task breakdown
@@ -457,13 +585,14 @@ Note `agent-rust/.gitignore` excludes `tasks`, which silently untracks the whole
| id | task | size | deps |
|---|---|---|---|
| M3.5.1 | HTTP server + router (actix-web or axum), Kong auth hook, request metrics | M | M0.1 |
| M3.5.2 | `POST /ingest` endpoint — `ingest_id` dedup, async queue (redis or in-mem), job polling | M | M1.7, M3.5.1 |
| M3.5.2 | `POST /ingest` endpoint — `ingest_id` dedup, async queue, git context enrichment | M | M1.7, M3.5.1 |
| M3.5.3 | `GET /query` endpoint — embed query, HNSW recall by level, rerank, walk edges to L0 | M | M3.3, M3.5.1 |
| M3.5.4 | Federation: single query across projects, fan+merge results, deduplicate | M | M3.5.3 |
| M3.5.5 | `GET /skills` and `/skills/{name}` — loadable skills only, exclude _drafts, YAML frontmatter in JSON | M | M4.1, M3.5.1 |
| M3.5.6 | `GET /projects` and `/projects/{id}/status` — metadata, metrics, synthesis timestamps | S | M3.5.1 |
| M3.5.7 | Rate limiting (apikey-scoped per endpoint) + idempotency by sha256 | M | M3.5.2 |
| M3.5.8 | **M3.5 gate** — end-to-end ingest→query via HTTP, load from cli and from agent simul | M | gate |
| M3.5.8 | **M3.5 composition gate** — end-to-end ingest→query via HTTP, load from cli and from agent simul | M | gate |
| M3.5.9 | Git-aware references: lookup by code location (file:line, commit, author) | M | M3.5.2 |
**M5 — Post-training** (Python, separate from the Rust workspace; boundary is the JSONL)
@@ -476,7 +605,7 @@ Note `agent-rust/.gitignore` excludes `tasks`, which silently untracks the whole
| M5.5 | verl loop — `r_update` ±1, `r_exit` {0,0.5,0.75}, strict `r_format`, α=0.9 | L | M5.3, M5.4 |
| M5.6 | **M5 gate** — adapter beats prompted baseline on held-out update accuracy | L | gate |
Total 43 tasks, 7 gates. M0 and M2.2 have no model dependency and can start immediately; M5.4 is homelab work independent of everything else in M5 and can run in parallel. M3.5 depends on M2 (pgvector store exists) and M1 (ingest loop exists); can run in parallel with M4 and M5.
Total 52 tasks, 8 gates. M0 and M2.2 have no model dependency and can start immediately; M5.4 is homelab work independent of everything else in M5 and can run in parallel. M3.5 depends on M2 (pgvector store exists) and M1 (ingest loop exists); can run in parallel with M4 and M5. M3.5.9 (git-aware references) is optional, depends on M3.5.2.
## Verification
@@ -562,6 +691,7 @@ GET /memory/projects/{id}/status <- ingest/synthesis status
GET /memory/projects/{id}/notes <- L1/L2 notes (Obsidian export)
GET /memory/skills <- loadable skills (excludes _drafts)
GET /memory/skills/{name} <- one skill frontmatter + body
POST /memory/context <- 3-tier lookup: signature, symptom, docs
```
**Request/Response contract:**
@@ -622,13 +752,37 @@ GET /memory/skills/{name} <- one skill frontmatter + body
- Per-key limits: ingest 100 jobs/hour, query 1000 req/hour, skill fetch unlimited
- Burst allowance: 10 req/sec per key (ingest waits in queue; query returns 429 Retry-After if burst exceeded)
### Git-Aware References (M3.5.9)
Memory entries are anchored in code. Agents reference by git location, not sha256.
**Ingest enrichment (M3.5.2):** If repo.git available, auto-populate:
```json
"git_context": {
"file": "src/kong/buffer.rs",
"line": 42,
"commit_sha": "abc123def",
"commit_msg": "Increase body buffer to 16MB",
"author": "[email protected]",
"author_date": "2026-08-15T10:30:00Z"
}
```
**Lookup endpoints (M3.5.9):**
- `POST /memory/nodes/by-git` — find evidence by (file, line)
- `POST /memory/nodes/by-commit` — all discoveries in this commit
- `POST /memory/nodes/by-author` — what did this person find
- `GET /memory/query?git_repo=github.com/org/poimen` — enrich results with git context
**Agent citation:** "Per src/kong/buffer.rs:42 (commit abc123): ..." instead of sha256.
### Integration with Existing Flows
**From `mem-cli` (local or CI/CD):**
```bash
mem ingest --project poimen --query infra-root-causes --gateway https://api.riotpiao.com
mem ingest --project poimen --query infra-root-causes --gateway https://api.riotpiao.com --git-repo /path/to/repo/.git
```
Client computes `ingest_id` locally (sha256 of all records), submits as batch, polls `/memory/ingest/<job_id>` until done.
Client computes `ingest_id` locally (sha256 of all records), enriches with git context, submits batch, polls `/memory/ingest/<job_id>` until done.
**From agents (in-session via Pi or Claude):**
```bash
@@ -671,6 +825,8 @@ curl -H "apikey: $MEM_APIKEY" https://api.riotpiao.com/memory/skills?loadable=tr
- **Self-reinforcement through skills.** The only cycle in the system: emitted skill → future session context → ingested as evidence → reinforces the memory that emitted it. Guarded by manual promotion plus the `derived: true` ingest filter, and both must hold. Audit it by checking that no L0 evidence node's text matches an emitted artifact.
- **No ground-truth evidence labels.** `r_update` needs them. Distant supervision from the 32B labeler inherits its bias; hold out a hand-labelled set to measure agreement before trusting it.
- **Vault/log divergence.** Hand edits are overwritten on rebuild. Either make the vault read-only or add an `## Notes` region the projector preserves. Decide before anyone starts editing.
- **Reference corpora are inert by construction, and that is a real limit.** `M3.6` makes documentation retrievable as level R, but R never becomes evidence and never parents an L1, so it can improve recall and nothing else — synthesis quality is untouched by adding a corpus. Tool competence still arrives mainly through M4 skills drafted from real sessions. Skipping M3.6 entirely leaves a working system that simply has nothing to say about a tool until someone has used it badly in a logged session.
- **R inflates the index against a fixed recall width.** `mem query` recalls 10×k before reranking. A large corpus competes for those slots with genuine L1/L2 answers even when R is excluded by the level filter, unless the filter is pushed into the HNSW query rather than applied after it. Filter-then-recall, not recall-then-filter; `M3.6.4` asserts the ordering.
- **Ollama has no LoRA path.** P5 forces the vLLM decision. Do not discover this at P5.
- **API latency at scale.** Query federation fans requests to multiple projects; slowest project wins. Mitigation: query timeout 5s, client-side fallback to local JSONL search, async synthesis keeps L2 warm (cache hit 95%+).
- **Ingest race on concurrent writes.** Two agents submit overlapping session chunks to same project simultaneously. Mitigation: `ingest_id` based on content hash prevents duplicate evidence in log; gated loop is single-threaded per project, queues serialize. Allowed cost: cold-start ingest delay ~5m for backlog.