git:20260818.f6e1cb5 to git:20260906.dcf82c1

263 added, 30 removed. Audit A to A.

# Memory Plugin — Agent Instructions
Rules for code under `assistant/src/plugins/defaults/memory/` (`MEM/` below).
For the user-facing architecture narrative — what each tier does and how the
write and read paths fit together — read `assistant/docs/architecture/memory.md`
first. This file is the layering contract: where code goes, which names can
never be renamed, and how to retire a tier.
## Tier movement is one-way
**v1 and v2 are legacy. Assistants move v1 → v2 → v3 and never back.** There is
no supported path from v3 to an earlier tier, so "what happens when an assistant
returns to v1 (or v2)" is not a scenario this code serves.
That rules out a whole class of plausible-sounding work: reconciling state that
a lower tier would need, keeping a lower tier's derived data warm while a higher
tier is live, or postponing a lower tier's jobs so they survive a downgrade.
Review feedback will periodically ask for exactly that — the reasoning looks
sound in isolation, because the state genuinely is missing. Decline it and point
here. The correct fix for a lower tier's stale derived data is to delete the
lower tier (see the runbooks below), not to maintain it.
Two consequences worth stating plainly:
- The return-to-v1 path has been **removed**, not merely left vestigial: the
`v1_entry_reconcile_done` checkpoint, its re-arm in `jobs-worker.ts`, the boot
claim in `startup.ts`, and `reconcileCapabilityEmbeddings` in
`graph/capability-seed.ts` are all gone. Do not reintroduce them. (A stale
`v1_entry_reconcile_done` row may still sit in `memory_checkpoints` on an
upgraded install; nothing reads it, so it is inert and needs no migration.)
- Gaps that only manifest after a downgrade are **not bugs**. Record them here
if they are non-obvious; do not build machinery to close them.
## Directory / tier map
The plugin is layered into **tier directories** plus a **spine** that composes
them. Everything not listed as a tier directory is spine.
| Directory | What lives there | Gate predicate |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
| `substrate/` | The shared concept-page substrate (v2 + v3): `page-store`, `page-index`, `edge-index`, `qdrant`, `sim`, `sparse-bm25`, `spread`, `skill-store`, `cli-command-store`, `skill-content`, `cli-command-content`, `injected-block-slugs`, `static-context`, `boot-maintenance`, `frontmatter-sweep`, `consolidation-lock`, `consolidation-job`, `ingest`, `sweep-job`, `reembed-job`, `tuning`, `types`, `page-links`, `prompts/`. | `usesConceptPageMemory()` |
| `v1/` | The legacy PKB/graph engine: `pkb/`, `graph/` (retriever, triggers, scoring, serendipity, extraction, extraction-job, decay, consolidation, pattern-scan, narrative, bootstrap, injection, and the hybrid node-search half of `graph-search`), `jobs/embed-pkb-file.ts`, `job-handlers/{backfill,embedding,index-maintenance}.ts`, `pkb-schedule.ts`, `filing-jobs.ts`, `semantic-search.ts`, `identity-context.ts`, `README.md`. | `isMemoryV1Active()` |
| `v2/` | The v2 activation/router injection engine: `activation`, `activation-store`, `activation-log-store`, `concept-frequency`, `injection`, `injection-events`, `router`, `reranker`, `rerank-local`, `migration`, `now-text`, `backfill-jobs` (migrate + activation-recompute), `harness/`, `prompts/router`. | `isV2InjectionEngineActive()` |
| `v3/`, `v3-eval/` | The v3 lanes, orchestrator, injectors, maintain job, and the eval siblings. | `isMemoryV3Live()` |
`substrate/page-links` is the one owner of `links:`-entry and `[[wikilink]]`
parsing; every reader in any tier imports it (the same single-matcher rule
`buffer-format` follows for buffer entries, below). Do not add a second
wikilink matcher or `links:` splitter anywhere.
Everything else under the plugin root is **spine**:
- `graph/` — the **all-tier** legacy-graph store plus the tier dispatcher:
`conversation-graph-memory.ts` (the v1↔v2 dispatch spine; also owns
compaction eviction of v2 activation rows and v3 `everInjected` state, and
the injected-block strip identity), `store.ts`, `tool-handlers.ts`,
`tools.ts`, `capability-seed.ts`, `in-context-tracker.ts`, `graph-search.ts`
(node/trigger **embedding** plumbing — the v1 search half lives in
`v1/graph/graph-search.ts`), `graph-memory-state-store.ts`, `types.ts`,
`image-ref-utils.ts`. Despite the `memory_graph_*` naming, this directory is
not v1: capability seeding writes graph nodes on every tier.
- `graph-topology/` — the backend-agnostic memory-graph view served to the web
Memory tab (`build-memory-graph.ts`, `pending-buffer.ts`).
- Shared infra at the root: `logging`, `paths`, `embeddings`, `anisotropy`,
`frontmatter`, `validation`, `llm-helpers`, `config`, `memory-db`,
`host-utils`, `path-containment`, `prompt-override`, `memory-marker`,
`buffer-format` (the sole owner of the `memory/buffer.md` entry format:
the writer plus the one matcher every reader uses. It lives at the root
precisely so `substrate/`, `graph/`, and `graph-topology/` can all reach it
without a tier importing spine. Do not add a second matcher anywhere),
`segmenter`, `message-media`, `worker`, `worker-control`,
`memory-recall-log-store`, `activation-session-store` (the onboarding
activation rail — **not** a memory tier despite the name),
`tail-reinjection-strip`, `task-memory-cleanup`, `conversation-memory-purge`,
`conversation-memory-orphan-sweep`, `fork-conversation-memory`,
`find-most-recent-retrospective-for`.
- The `memory-retrospective-*` family — tier-agnostic, gated on
`memory.enabled` alone. The one exception is
`memory-retrospective-skill-card.ts`, whose upstream enqueue only fires when
`isV3TierActive()` holds (memory on and v3 live).
- The rest of the spine: `startup.ts`, `jobs-worker.ts`, `job-handlers.ts`,
`job-handler-registration.ts`, `indexer.ts`, `injectors.ts`, `tools.ts`,
`hooks/`, `src/` (HTTP routes), `context-search/`.
### The layering rule
**Tier directories never import each other.** The only sanctioned edge is
`substrate/` upward: `v2/` and `v3/`/`v3-eval/` may import `substrate/`, and
`substrate/` imports no tier. `v1/` imports no tier at all.
**Multi-tier composition happens only at the spine.** A spine file that imports
from two or more tier directories is a composition point and must be on the
frozen `SPINE_ALLOWLIST`, which today is exactly:
```
fork-conversation-memory.ts
graph-topology/build-memory-graph.ts
graph/conversation-graph-memory.ts
injectors.ts
job-handlers.ts
jobs-worker.ts
src/memory-v2-routes.ts
startup.ts
```
The list is frozen: new multi-tier composition belongs in one of these files,
and shrinking the list is how the deletion runbooks below retire an engine.
Inside them the tier arms carry `// V1 — delete with v1` /
`// V2 ENGINE — delete with v2` / `// SUBSTRATE (v2+v3)` banners, so
`grep -rn 'delete with v1' src/plugins/defaults/memory/` finds every v1 arm.
Two conventions keep that grep honest, and both are load-bearing:
- **The banner phrase is exact.** `startup.ts` labels its sections
`// ---- <tier> ... ----` rather than `// V1 …`, so its v1 labels spell out
`— delete with v1` too (`// ---- v1 (legacy engine) — delete with v1 ----`);
otherwise its three v1 blocks would be invisible to the grep.
- **Dispatch call sites are banner-marked, not just function bodies.** A v1
helper is typically an `else` arm calling a banner-marked function
(`jobs-worker.ts`'s `enqueueV1MaintenanceJobs`, `indexer.ts`'s
`enqueueV1IndexTriggers`). Deleting only the marked bodies leaves the calls
dangling, so the call sites carry their own banner.
`indexer.ts`, `injectors.ts`, and `context-search/sources/memory.ts` follow the
same convention even where the file touches a single tier.
**`injectors.ts` is the plugin's single injector entry point.** Its exported
`memoryInjectors` is the complete set `defaultMemoryPlugin` contributes —
including the v3 pair defined in `v3/injector.ts` — so `plugins/defaults/index.ts`
imports the array and nothing else. A new tier injector is added to that array,
never registered from the host.
+ **v3 injection invariants.** The mechanics are documented in the modules
+ that own them (`v3/injector.ts`, `v3/prune.ts`, `v3/ever-injected-store.ts`,
+ `v3/plugin-schema.ts`, `substrate/injected-block-slugs.ts`,
+ `hooks/injection-metadata.ts`); these are the rules an outside change must
+ not break.
+
+ - **The section is the unit.** Each turn the `memory-v3` injector renders
+ every section selected for each selected page (its lead when the page was
+ selected with no section; a capability slug renders its whole capability
+ content) into one frozen `<memory>` block, net-new only: a
+ `(conversation, slug, section key)` pair resident in
+ `memory_v3_injected_sections` is never re-rendered. The key is
+ `v3/types.ts`'s `sectionKey()` (`""` for the lead, the trimmed heading title
+ otherwise, `title#<n>` for the n-th repeat of a heading and `~<n>` appended
+ for the n-th chunk of an over-long section, with literal `#` and `~` in
+ titles doubled so the encoding is a bijection), and `sectionRefId()` there
+ is the one string encoding of a `(slug, key)` pair for keying maps and
+ sets. Do not add another.
+ - **The block grammar has one owner.** `substrate/injected-block-slugs.ts`
+ writes the `# memory/concepts/<slug>.md § <key>` section header (the bare
+ page header for a lead), escapes every chunk body (`escapeInjectedBody`:
+ one leading backslash on a line that would otherwise read as a boundary,
+ so page or skill text can never forge one) and parses blocks
+ (`parseInjectedSections`). The prune valve, the `loadFromDb` rehydration
+ filter, the truncated-fork seed, and the retry anchor merge all read
+ through it; do not add a second header matcher or chunk splitter. The one
+ exception is `LEGACY_CARD_HEADER_REGEX` in the same file, the pre-stamp
+ card grammar that `parseLegacyCards` alone reads for legacy blocks.
+ - **A block's format is explicit provenance, never inferred from its
+ content.** The persisting build stamps `memoryV3InjectedBlockFormat`
+ beside `memoryV3InjectedBlock`; a row is current exactly when its stamp
+ equals the plugin's `MEMORY_V3_INJECTED_BLOCK_FORMAT`, and a row carrying
+ the block without the stamp is legacy (`InjectedBlockFormat` in
+ `v3/types.ts`). A legacy block is read with the card grammar of the build
+ that rendered it (`filterLegacyCards`), each card under its lead ref
+ `(slug, "")`: a card whose lead is tombstoned, or whose lead a current
+ block re-injected after a prune, leaves; nothing else in it is parsed,
+ pruned, counted, indexed, or fork-seeded, and it ages out at compaction.
+ - **A block claims its sections only through its commit.** The section
+ record (`recordInjected`) and the prune-valve schedule ride the commit
+ callback the block carries (`memoryV3Commit`), which runtime assembly
+ invokes only where the block persists; re-entry, run-messages-replacement,
+ and post-compaction assemblies attach blocks in memory only and claim
+ nothing, so a turn's sections are recorded once.
+ - **Pointers are per message.** Sections re-selected while resident are
+ listed, paths only, in the `memory-v3-pointer` injector's `<memory_pointer>`
+ block, persisted under `memoryV3PointerBlock` on the user message that was
+ sent with it and rehydrated with it; a fresh pointer is spliced only onto
+ the new tail, no historical message is ever rewritten, and the hook's
+ metadata update states the turn's layout including its deletions (a retry
+ re-runs onto its anchor row). The legacy `memoryV3SpotlightBlock` is never
+ written and rehydrates verbatim.
+ - **Pruning is tombstones plus filters, never a metadata rewrite.** The prune
+ valve (`v3/prune.ts`, `memory.v3.prune`) evicts by each section row's own
+ `last_selected_at` (the injector stamps every section a turn selects) with
+ no lane exemptions, marks rows `pruned_at`, and strips the pruned sections
+ from the live history and at rehydration; both filter points also keep only
+ each section's newest persisted copy. The live strip owns a `<memory>`
+ block by object identity (`markV3LiveBlock` / `isV3LiveBlock` in
+ `v3/types.ts`), never by text, so a pre-cutover v2 block byte-identical to
+ a v3 entry is left alone.
+ - **The store resets only behind a durable strip marker.** The reset
+ (`clearConversation`, through `ConversationGraphMemory.onCompacted`) runs
+ whenever the durable history is injection-stripped, not only when a summary
+ lands: `resetInjectionLedgersForStrip` (in
+ `daemon/conversation-agent-loop-handlers.ts`) runs it from the loop's
+ `compaction_completed` dispatch on a run that compacted nothing and from
+ `applyCompactionResult` on a real compaction, `/clean` runs it the same way,
+ and every reset precedes the re-injection. The reset is gated on the
+ `historyStrippedAt` marker being durable, since a reload with no marker
+ rehydrates the frozen blocks the reset unclaimed: the loop's
+ `history_stripped` dispatch writes the marker at the strip, a write that
+ succeeds there counts for the pair, and when no write succeeds a run that
+ compacted nothing keeps the store intact and commits the injected
+ pre-compaction history instead (the dispatcher reports the outcome to the
+ loop through its run's `injectionLedgerResets`, and the loop continues from
+ the injected history), so residency and the live history agree either way.
+ A compacted result has already lost its frozen blocks to the summary, so
+ there, once the compaction commit has landed, the store resets even without
+ the marker (a reload may rehydrate the
+ kept tail's blocks unclaimed once, until the newest-copy filter retires them
+ behind a re-injected copy), and a reset whose ledger clear fails reports
+ that the same way a missing marker does.
+ - **The store is plugin-owned.** `memory_v3_injected_sections`,
+ `memory_v3_pools`, and the `section_key` column of `memory_v3_selections`
+ are created by `v3/plugin-schema.ts` (`ensureMemoryV3PluginSchema` from the
+ `init` hook, and each store's once-per-connection ensure on first use),
+ never by the global migration chain, and every read goes through its
+ fail-soft `memoryReader`, so an unavailable memory database degrades a
+ store to no-ops rather than failing a turn or readiness. The superseded
+ `memory_v3_ever_injected` has exactly one reader, the sections ensure's
+ one-time copy of its rows into the section store as zero-byte lead entries
+ (checkpoint `memory_v3_injected_sections:legacy_copy_done`, below), and
+ two writers, the compaction reset (`clearConversation`) and the
+ conversation purge, which delete a conversation's rows so no later copy
+ re-imports leads whose blocks are gone.
+ - **Lanes and caps.** The finder lanes surface in a fixed order and only ever
+ add lines; a page carries at most `memory.v3.finderSectionsPerPage` capped
+ lines plus its entity and rare-term lines (the lane rules are under
+ `memory_v3_pools` below).
+
Both rules are enforced by `__tests__/memory-tier-boundary-guard.test.ts`, which
also carries a reverse stale-exemption test: an allowlist entry whose multi-tier
import disappears fails loudly instead of lingering. Related guards:
- `src/__tests__/plugin-import-boundary-guard.test.ts` — relative-specifier
baseline for the whole plugin tree.
- `src/__tests__/persistence-layering-guard.test.ts` — one-way
memory → persistence direction. It holds **two** separate maps, and they mean
opposite things:
- `PERSISTENCE_TO_MEMORY_ALLOWLIST` — genuine tech debt, ratcheting to zero.
Today it holds exactly one file, `persistence/conversation-crud.ts`
(`fork-conversation-memory`, `indexer`).
- `MIGRATION_REGISTRY_MEMORY_IMPORTS` — a **permanent** exception, not tech
debt: `persistence/steps.ts` is the migration registry and references each
migration in the domain that owns it, today
`{"v1/graph/bootstrap"}`. Deleting v1 does not retire the exemption, it
moves the **pointer**: the v1 runbook relocates `migrateToolCreatedItems`
to a surviving home (the registration is append-only and must keep
running), and this entry is repointed at that module — or drops out only
if the function lands inside `persistence/` itself.
- `src/plugins/__tests__/plugin-state-boundary-guard.test.ts` — the
grandfathered main-DB exception for this plugin (do not grow its surface).
## Which predicate gates what
All predicates live in `assistant/src/config/memory-v3-gate.ts`. **Nothing else
may read raw `memory.v2.*` / `memory.v3.*` tier keys** — those reads are a
layering leak, and the boundary guard's `TIER_KEY_READ_ALLOWLIST` pins the
frozen (path, namespace) exemptions.
| Predicate | True when | Gates |
| ------------------------------ | ------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `isMemoryEnabled` | `memory.enabled !== false` (master switch, defaults on) | the whole memory system, the retrospective family, the jobs worker's memory drain |
| `isMemoryV1Active` | memory on AND no concept-page consumer | everything under `v1/`, the v1 job handlers, the v1 maintenance/index triggers |
| `isV2InjectionEngineActive` | memory on AND `memory.v2.enabled === true` AND v3 not live | v2's turn-time selection — the ONLY correct "should v2 select this turn" check |
| `isMemoryV2ExplicitlyDisabled` | `memory.v2.enabled === false` (deliberately not the negation above) | `daemon/embedding-reconcile.ts`'s explicit-opt-out suppression |
| `usesConceptPageMemory` | memory on AND (`memory.v3.live` OR `memory.v2.enabled`) | everything under `substrate/`, the graph tool handlers, the static `<info>` block |
| `isMemoryV3Live` | `memory.v3.live === true` | everything under `v3/`, and the v2 suppression at runtime assembly |
| `isV3TierActive` | memory on AND v3 live (the v3 TIER — `memoryTier() === "v3"`) | `GET /memory-graph` + the `graph_supported` bit, AND procedural-memory-as-skills |
`memory.v2.enabled` defaults **true** and typically stays set on v3-live
assistants, so a direct read of it misbehaves under v3. That is exactly why
`isV2InjectionEngineActive` exists — reach for the predicate, never the key.
**`isMemoryV1Active` is not the only v1 gate.** Several v1 code paths key on
`!usesConceptPageMemory(config.memory)` instead — notably the v1 Qdrant
collection lifecycle and the PKB index reconcile in `startup.ts`, and the
tier dispatch in `jobs-worker.ts` / `indexer.ts` / `context-search/sources/`.
The two agree everywhere except when memory is OFF: `isMemoryV1Active` is
false, but `!usesConceptPageMemory` is **true**, so those paths still run.
That is observable in `startup.ts`, which has no memory-enabled guard of its
own — a `memory.enabled: false` assistant still ensures/migrates the v1
collection on boot (only the follow-up `rebuild_index` enqueue is
`isMemoryEnabled()`-gated). Read the actual gate before assuming a v1 block
is inert on an off assistant.
`assistant/src/config/memory-tier.ts`'s `memoryTier()` is fully DERIVED from
these predicates (`off` → `v3` → `v2` → `v1`, in that precedence), so the
telemetry buckets and the runtime gates can never disagree.
`persistence/jobs-store.ts`'s config-singleton `isMemoryEnabled()` delegates to
the gate predicate for the same reason.
## Config namespaces
| Namespace | Role |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `memory.enabled` | Master switch. `false` turns the whole system off regardless of tier keys. |
| `memory.substrate.*` | **Override-only** substrate tuning. Fifteen keys, every one optional with no default. |
| `memory.v2.*` | The v2 engine's own tunables (activation weights, router, rerank) **plus** the historical substrate twins that supply the effective defaults. |
| `memory.v3.*` | v3 lane/orchestrator tuning, plus `memory.v3.live`. |
| `memory.retrospective.*` | Retrospective cadence and triggers — tier-agnostic. |
`substrate/tuning.ts`'s `resolveSubstrateTuning` is the substrate's **single
config choke point**: each key resolves as `memory.substrate.X ?? memory.v2.X`
(an explicit `null` on a nullable key counts as present and wins).
`spread_k` / `spread_hops` / `ann_candidate_limit` map onto `memory.v2.k` /
`memory.v2.hops` / `memory.v2.ann_candidate_limit`; every other key shares its
name with its twin.
Because a single substrate weight forms its effective pair with the v2 twin,
the dense/sparse sum-to-1 invariant is checked in three places for three
disjoint cases: the substrate schema's own refinement (both set), the v2
schema's (neither set), and the parent `MemoryConfigSchema.superRefine` (the
mixed case, over the RESOLVED pair). Keep all three when touching the weights.
Twelve of the fifteen twins reach the runtime through `resolveSubstrateTuning`
and nothing else, so once the `memory.substrate` key exists a `config set` on
the `memory.v2` twin persists but changes nothing. The other three — `k`,
`hops`, `ann_candidate_limit` — are read a **second** time by the live v2
injection engine straight off `config.memory.v2` (`v2/injection.ts`,
`v2/activation.ts`, `v2/backfill-jobs.ts`), so a write there retunes v2
injection while substrate recall stays on the substrate twin: the two
namespaces hold two effective values at once.
`config/substrate-twin-shadowing.ts` pairs the namespaces and carries the
`alsoReadByV2Engine` flag that separates the two classes, so neither case reads
as the other: the `config_set` route returns a `warning` (a no-op notice for a
substrate-only twin, a divergence notice for an engine-read one) and
`assistant config get` prints the matching `Shadowed:` / `Split:` line.
Workspace migration 135 copies explicitly-set NON-DEFAULT `memory.v2` substrate
tunables into `memory.substrate`. It skips loader-seeded defaults — including
`bm25_b`'s earlier `0.75` default — because raw presence in `config.json` is not
user intent (the loader serializes the fully-parsed config), and copying seeded
values would permanently pin those assistants when substrate defaults are
retuned.
## FROZEN NAMES
These names are durable or wire-visible. **Never rename them**, including
during a tier deletion — several are spelled `v2` but belong to the substrate,
which outlives the v2 engine.
### SQLite tables
Persisted rows; a rename orphans every existing install.
- | Table | Owner |
- | --------------------------------- | ----------------------------------- |
- | `memory_graph_nodes` | all-tier (`graph/store.ts`) |
- | `memory_graph_edges` | all-tier |
- | `memory_graph_triggers` | all-tier |
- | `memory_graph_node_edits` | all-tier |
- | `memory_segments` | v1 indexing |
- | `memory_summaries` | v1 indexing |
- | `memory_embeddings` | shared embedding cache |
- | `memory_checkpoints` | shared (all durable checkpoints) |
- | `memory_jobs` | shared job queue |
- | `memory_recall_logs` | shared recall audit |
- | `conversation_graph_memory_state` | `graph/graph-memory-state-store.ts` |
- | `activation_state` | v2 per-conversation activation |
- | `memory_v2_activation_logs` | v2 inspector/harness |
- | `memory_v2_injection_events` | v2 scoring feedback |
- | `memory_v3_selections` | v3 selection log |
- | `memory_v3_ever_injected` | v3 card dedup |
- | `memory_retrospective_state` | retrospective (tier-agnostic) |
- | `activation_sessions` | onboarding activation rail |
+ | Table | Owner |
+ | --------------------------------- | --------------------------------------------------------------- |
+ | `memory_graph_nodes` | all-tier (`graph/store.ts`) |
+ | `memory_graph_edges` | all-tier |
+ | `memory_graph_triggers` | all-tier |
+ | `memory_graph_node_edits` | all-tier |
+ | `memory_segments` | v1 indexing |
+ | `memory_summaries` | v1 indexing |
+ | `memory_embeddings` | shared embedding cache |
+ | `memory_checkpoints` | shared (all durable checkpoints) |
+ | `memory_jobs` | shared job queue |
+ | `memory_recall_logs` | shared recall audit |
+ | `conversation_graph_memory_state` | `graph/graph-memory-state-store.ts` |
+ | `activation_state` | v2 per-conversation activation |
+ | `memory_v2_activation_logs` | v2 inspector/harness |
+ | `memory_v2_injection_events` | v2 scoring feedback |
+ | `memory_v3_selections` | v3 selection log (`section_key` column plugin-added, see below) |
+ | `memory_v3_pools` | v3 selector pool audit (plugin-created, see below) |
+ | `memory_v3_injected_sections` | v3 section dedup, prune bytes + recency (plugin-created, below) |
+ | `memory_v3_ever_injected` | v3 card dedup (superseded, frozen) |
+ | `memory_retrospective_state` | retrospective (tier-agnostic) |
+ | `activation_sessions` | onboarding activation rail |
+ `memory_v3_pools` and `memory_v3_injected_sections` are the plugin's own
+ tables, created by the plugin rather than by the global migration chain
+ (`v3/plugin-schema.ts`): the memory plugin's `init` hook ensures both on every
+ boot, and each store ensures again on the first use of a connection in its
+ process (the memory worker is a separate process), idempotently and fail-open,
+ so a memory database that cannot be opened degrades the stores to no-ops
+ instead of failing database readiness. The sections ensure adds the
+ `last_selected_at` column (the prune valve's recency stamp) to a table created
+ before it existed, and copies the legacy `memory_v3_ever_injected` rows in as
+ zero-byte lead entries, once per database: the first ensure that finds the
+ legacy table copies (`INSERT OR IGNORE`) and records
+ `memory_v3_injected_sections:legacy_copy_done`
+ in `memory_checkpoints`, later ensures skip on that record, and a ledger that
+ cannot be read skips the copy rather than repeating it. The compaction reset
+ (`clearConversation`) and the conversation purge delete a conversation's
+ legacy rows with its section rows, so a copy can never bring back leads whose
+ blocks compaction stripped. `memory_v3_selections` itself is created by migration
+ 338, but its
+ `section_key` column is plugin-owned the same way: the selection log's writer
+ (`writeTurnLog`) and the inspector's reader (`v3/selection-log-store.ts`) add
+ it on the first use of a connection, and the `init` hook at boot. Every
+ selection row records one section per slug, the selection's first selected
+ section, as its `sectionKey` beside the title and ordinal (an inspector and
+ frecency record only; the prune valve's recency lives on the section store's
+ rows, see the v3 injection invariants above), and the inspector
+ resolves a row by that key first (exactly, so a repeated heading's other
+ occurrence is never substituted), falling back to title-then-ordinal only for
+ rows written before the column existed.
+
+ `memory_v3_pools` (`v3/pool-log-store.ts`) holds one row per turn: the
+ selector's full candidate pool (every stable-prefix card and finder line, in
+ pool order, with lane, matched section, and a per-line verdict) for the
+ inspector's Memory tab, plus `selector_ran`. A page carries one finder line
+ per distinct matched section, at most `memory.v3.finderSectionsPerPage` in
+ surfacing order (needle, dense, reply, span) plus its entity and rare-term
+ lines, and selecting a line selects that section; the selection log keeps
+ one row per slug, so the pool row is where the per-section verdicts live. A
+ turn whose selector never judged a pool (the injection gate hard-skipped it,
+ or nothing was pooled) persists an empty pool with `selector_ran = 0`, and a
+ turn that logged no selections is still reachable by its stamped
+ `message_id`, so the inspector shows negative verdicts too. The pool row and
+ the turn's `memory_v3_selections` rows are
+ written in one transaction (`writeTurnLog` in `v3/shadow-plugin.ts`), and a
+ turn observed again replaces both, so the pool's `chosen` flags and the
+ selection rows always describe the same observation. Rows are per-turn
+ diagnostics, roughly 10KB each, with no retention job; a conversation delete
+ purges them with the other conversation-keyed tables
+ (`conversation-memory-purge.ts`).
+
+ **v3 finder lanes.** The per-turn finder lanes (`v3/orchestrate.ts`) surface
+ candidates in a fixed order, needle, dense, reply, span, entity, rare, then
+ edge and learned, and each only ever adds lines. The entity lane
+ (`v3/entity-lane.ts`, `memory.v3.entity`) and the rare-term lane
+ (`v3/rare-term-lane.ts`, `memory.v3.rareTerm`) key on one strong token rather
+ than the whole message: a distinctive `## ` heading token the message names,
+ and a query word rare across the corpus, which surfaces its top `perTerm`
+ sections by single-term BM25F (`SectionNeedle.df` and `scoreTerm`), rarest
+ words first up to `cap` per turn, with no token-shape filtering (a word the
+ corpus does not hold has df 0 and drops out on its own; bigram terms are never
+ eligible). Rare is corpus-relative: a word qualifies when its df is at most
+ `min(maxDf, max(1, floor(sectionCount * maxDfFraction)))` (defaults 12 and
+ 0.002), so a corpus of 6,000 sections or more runs at `maxDf` and a corpus of
+ a few hundred sections counts only a word unique to one section as rare; an
+ absolute ceiling would make most ordinary words of a small corpus rare and
+ fill `cap` with noise every turn. Both are synchronous in-memory passes that
+ feed neither the injection gate nor the edge seeds, and pool through the same
+ `(page, section key)` dedupe as every other lane, so a section another lane
+ already pooled is a no-op. Both lanes' lines are outside the per-page cap
+ (`memory.v3.finderSectionsPerPage`), neither counted against it nor
+ displaced by it, because each lane runs after every lane that fills the cap
+ and a page already carrying that many bulk-theme lines would otherwise drop
+ exactly the line the lane exists to surface. The lanes' own caps bound what
+ they add instead (`entityCap` articles per turn for the entity lane,
+ `perTerm` and `cap` for the rare-term lane), so a page carries at most
+ `finderSectionsPerPage` capped lines plus its entity and rare lines,
+ `finderSectionsPerPage + entityCap + rareTerm.cap` in all. The entity lane runs
+ at every corpus size (the lean profile does not switch it off); the
+ rare-term lane runs only when the selector does (`v3/shadow-plugin.ts`
+ threads `rareTerm` only
+ with `selectorEnabled`), because rare lines are candidates for a judge, not
+ evidence strong enough to inject unjudged, and with the selector off every
+ pooled line is injected. A rare hit's pool line is
+ tagged `(rare: <word>)` with a keyword-in-context snippet centered on the
+ word, and its selection records `source = 'rare'`; the pool record and the
+ selection log carry the lane name with no other change.
+
### Job types (`memory_jobs.type`)
Persisted in job rows; unknown types either throw or get silently drained.
- Substrate: `memory_v2_consolidate`, `memory_v2_sweep`, `memory_v2_reembed`,
`embed_concept_page`
- v2 engine: `memory_v2_migrate`, `memory_v2_activation_recompute`
- v3: `memory_v3_maintain`
- Retrospective: `memory_retrospective`, `memory_retrospective_sweep`,
`skill_card_insert`
- v1: `graph_extract`, `graph_decay`, `graph_consolidate`, `graph_pattern_scan`,
`graph_narrative_refine`, `graph_bootstrap`, `embed_pkb_file`, `pkb_filing`,
`pkb_compaction`, `rebuild_index`, `backfill`, `delete_qdrant_vectors`,
`sweep_orphaned_graph_node_points`
- All-tier embedding: `embed_graph_node`, `graph_trigger_embed`,
`embed_segment`, `embed_summary`, `embed_media`, `embed_attachment`
- Retired, still in the union: `memory_v3_consolidate`,
`memory_v3_index_maintenance`, `memory_v3_edge_learning` (v3-rip casualties —
handlers removed) and `conversation_analyze` — never enqueued, but they stay
`MemoryJobType` members **and** `LEGACY_JOB_TYPES` members so pre-upgrade rows
drain silently
- Retired, `LEGACY_JOB_TYPES` only (already out of the union):
`memory_v2_rebuild_edges`, `memory_proc_distill`, and the pre-memory batch
(`embed_item`, `extract_items`, `batch_extract`, `extract_entities`,
`cleanup_stale_superseded_items`, `backfill_entity_relations`,
`refresh_weekly_summary`, `refresh_monthly_summary`, `journal_carry_forward`,
`generate_capability_cards`, `generate_thread_starters`)
Retired types are never deleted from the `MemoryJobType` union — they move to
`LEGACY_JOB_TYPES` in `jobs-worker.ts` so pre-upgrade rows drain instead of
throwing. Dropping a name from **either** list strands the persisted rows that
still carry it: the worker's dispatch falls through to
`Unknown memory job type: <type>` and the row fails forever instead of
completing. The `LEGACY_JOB_TYPES` set is therefore append-only in practice —
prune it only after you can prove no install still holds a pending row.
### Checkpoint keys (`memory_checkpoints.key`)
`GRAPH_MAINTENANCE_CHECKPOINTS` in `jobs-worker.ts`, plus the consolidation
failure record and the section re-embed high-water:
- `memory_v2_consolidate_last_run`, `memory_v3_maintain_last_run` — cadence
- `memory_v2_consolidate_failure_state` (`substrate/consolidation-job.ts`) —
the consolidation backoff record
- `memory_v3_maintain:sections_embedded_through_ms`
(`v3/section-dense-store.ts`'s `MAINTAIN_EMBED_HIGH_WATER_KEY`) — durable
epoch-ms high-water of the last successful section re-embed pass; when it is
absent the maintain job re-embeds EVERY page, so losing or renaming it forces
a full rebuild. Distinct from the `memory_v3_maintain_last_run` cadence key
despite the shared prefix
+ - `memory_v3_maintain:section_chunker_version`
+ (`v3/section-dense-store.ts`'s `SECTION_CHUNKER_VERSION_KEY`, current value
+ `SECTION_CHUNKER_VERSION`): the chunker version the stored section vectors
+ were built with. `ensureSectionChunkerVersion` (run at dense lane init and
+ by the maintain job and the backfill before an embed pass) clears the
+ high-water above on a mismatch so the collection is rebuilt from every
+ page, since point ids and dense hits key on `(article, ordinal)`. A store
+ with no version on record counts as stale, not fresh, when a high-water or
+ the pending marker below is on record or the collection already holds
+ points; only an empty collection records the version with nothing to
+ rebuild
+ - `memory_v3_maintain:section_rebuild_pending`
+ (`v3/section-dense-store.ts`'s `SECTION_REBUILD_PENDING_KEY`): written
+ before that high-water reset (the marker is the only signal that survives
+ the reset, so an interruption between the two writes leaves the hold in
+ place and the next check finishes the transition) and cleared by
+ `commitSectionEmbedHighWater`, the commit that ends a zero-failure re-embed
+ pass (the maintain job's and the backfill's). While it is set the dense lane
+ serves no hits (`holdSectionDenseReadsUntilRebuilt` at lane init,
+ `sectionDenseReadsHeld` on every dense read), the check that first reports
+ it in this process (at lane init, or retried from the dense read path)
+ enqueues `memory_v3_maintain` at once instead of waiting out the
+ cadence key, and the maintain pass it names re-embeds every capability row
+ the store holds (the change delta never names one) before its commit
+ clears the marker: a stored row whose body renders empty in that process
+ holds the commit until the deleted-page prune removes it, so the marker
+ never clears over points built by the previous chunker. The pass runs in
+ the memory worker, whose skill and CLI caches are never seeded, so a
+ version rebuild there prunes every capability row (its page index lists
+ none) and commits over a store with no capability points, which
+ `backfill-sections` (run in the daemon, caches seeded) restores. A version
+ check that throws (Qdrant unreachable for the collection
+ probe, an unreadable ledger) holds dense reads as indeterminate: the hold
+ ignores the marker's absence, which is what the failed check left unproven,
+ and the dense read path (`settleSectionDenseReadHold`) retries the check at
+ most once a minute (`SECTION_VERSION_CHECK_RETRY_MS`) until one completes,
+ which marks the rebuild pending and kicks it, or releases the hold
+ - `memory_v3_injected_sections:legacy_copy_done`
+ (`v3/plugin-schema.ts`'s `SECTIONS_LEGACY_COPY_DONE_KEY`): the legacy
+ `memory_v3_ever_injected` rows were copied into
+ `memory_v3_injected_sections` once on this database, and every later
+ sections ensure skips the copy on it. Losing it repeats the copy, which is
+ idempotent (`INSERT OR IGNORE`) and cannot resurrect a conversation the
+ compaction reset or a purge cleared, since both delete its legacy rows too
- v1: `graph_maintenance:{decay,consolidate,pattern_scan,narrative}:last_run`,
`pkb_filing_last_run`, `pkb_compaction_last_run`,
`graph_bootstrap:*`, `memory:backfill:*`
- `retro_sweep:last_run` — retrospective sweep cadence
### On-disk / vector-store names
| Name | Where | Why frozen |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `memory_v2_concept_pages` | `substrate/qdrant.ts` `MEMORY_V2_COLLECTION` | Qdrant collection on disk. **Duplicated** in `persistence/embeddings/embedding-identity.ts` — change neither. |
| `memory_v3_sections` | `v3/section-dense-store.ts` `SECTION_COLLECTION` | Qdrant collection on disk |
| `.memory-v2-reembed-required` | `substrate/qdrant.ts` | on-disk sentinel read across upgrades |
| `memory/.v2-state/` | `getConsolidationLockPath` in `substrate/consolidation-lock.ts`, `v2/migration.ts`, workspace migration 060 | persisted workspace path (consolidation lock, migration sentinel) |
### Wire-visible strings
- | Name | Kind | Why frozen |
- | --------------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------- |
- | `memory_v2_consolidation` | `conversations.source` value | persisted on every consolidation run |
- | `memory_retrospective` | request origin / `TitleOrigin` member | the permission checker's skill-authoring auto-grant is scoped to it |
- | `skill-authored-card` | message kind | persisted on skill-card messages |
- | `memoryV2Consolidation`, `memoryRetrospective` | LLM call-site ids | logging/attribution buckets |
- | `memoryInjectedBlock`, `memoryV2StaticBlock`, `memoryV3InjectedBlock`, `memoryV3Commit` | message-metadata keys | persisted on messages; drives re-injection and the strip |
- | `memory-v2-static` | injector + block id | `daemon/conversation-runtime-assembly.ts` matches on it |
- | `MEMORY_V2_DISABLED` | error code | clients branch on it |
- | `memory.v2.sweep` | notification job identifier | surfaced in `activity.failed` |
+ | Name | Kind | Why frozen |
+ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------- |
+ | `memory_v2_consolidation` | `conversations.source` value | persisted on every consolidation run |
+ | `memory_retrospective` | request origin / `TitleOrigin` member | the permission checker's skill-authoring auto-grant is scoped to it |
+ | `skill-authored-card` | message kind | persisted on skill-card messages |
+ | `memoryV2Consolidation`, `memoryRetrospective` | LLM call-site ids | logging/attribution buckets |
+ | `memoryInjectedBlock`, `memoryV2StaticBlock`, `memoryV3InjectedBlock`, `memoryV3InjectedBlockFormat`, `memoryV3PointerBlock`, `memoryV3SpotlightBlock` (legacy, read-only), `memoryV3Commit` | message-metadata keys | persisted on messages; drives re-injection and the strip |
+ | `memory-v2-static` | injector + block id | `daemon/conversation-runtime-assembly.ts` matches on it |
+ | `MEMORY_V2_DISABLED` | error code | clients branch on it |
+ | `memory.v2.sweep` | notification job identifier | surfaced in `activity.failed` |
### HTTP surface
Operation ids and endpoints are published in `assistant/openapi.yaml` and
consumed by generated clients:
- `memory-items`, `memory-items/:id` (`listMemoryItems`, `getMemoryItem`,
`createMemoryItem`, `updateMemoryItem`, `deleteMemoryItem`)
- `memory-nodes`, `memory-nodes/delete`, `memory-nodes/update`
(`listMemoryNodes`, `deleteMemoryNode`, `updateMemoryNode`)
- `memory/remember` (`createMemory`), `memory/stats` (`getMemoryStats`)
- `memory-graph`, `memory-graph-node` (`getMemoryGraph`, `getMemoryGraphNode`)
- `memory/v2/*` — `backfill`, `compare-retrievers`, `concept-frequency`,
`concept-page`, `ema-scores`, `list-concept-pages`, `now-text`,
`reembed-skills`, `router-prompt-template`, `simulate-router`, `validate`
(operation ids `memory_v2_*`)
- `memory/v3/*` — `backfill-sections`, `rebuild-index`
- `memory/ingest` (IPC method `memory_ingest`; the generated OpenAPI/HTTP
operation id is `memory_ingest_post`): POST, deterministic batch
concept-page ingestion
- `memory/worker/*`, `memory/eval/*`
- `consolidation/config`, `consolidation/run-now`, `consolidation/runs`
- `filing/config`, `filing/run-now` (v1)
### Telemetry
| Name | Kind | Consumer |
| ----------------------------------------------------------------------------- | -------------------- | ------------------- |
| `memory.enabled`, `memory.v2.enabled`, `memory.v3.live` | config-setting keys | platform dashboards |
| `memory_tier` check_name + `detail.tier` ∈ `off`/`v1`/`v2`/`v3` | watchdog event | platform dashboards |
| `memory_v3_injection_gate`, `memory_v3_selection`, `memory_retrospective_run` | watchdog check names | platform dashboards |
Renaming any of these silently breaks a dashboard rather than failing a build —
coordinate with the platform before touching them. The `memory_tier` contract
(check name plus the exact `off`/`v1`/`v2`/`v3` bucket strings) is specified in
`src/telemetry/AGENTS.md` — that is the authoritative statement; do not restate
its rules here.
### Log component names
`getLogger("<scope>")` tags every line the module emits with that scope, so a
scope name is **observable**: saved log queries, `vellum logs` filters, and
support runbooks grep on it. Renaming one does not fail a build and does not
lose data — it silently empties whatever was filtering on the old name. Not
frozen the way a table or a wire string is, but rename deliberately and expect
to update queries.
Two scopes changed in the tier split and are called out here so a stale query
is diagnosable:
| Module | Scope now | Was |
| ---------------------------- | ------------------- | ----------------------------------------------------- |
| `substrate/boot-maintenance` | `boot-maintenance` | `memory-v2-startup` |
| `substrate/reembed-job` | `memory-v2-reembed` | `memory-v2-backfill` (shared with `v2/backfill-jobs`) |
The reembed job was extracted out of `v2/backfill-jobs.ts`, so its lines moved
from the shared `memory-v2-backfill` scope to a dedicated one; the surviving
`memory_v2_migrate` / `memory_v2_activation_recompute` jobs still log under
`memory-v2-backfill`. Note that most substrate scopes keep their `memory-v2-*`
spelling (`memory-v2-qdrant`, `memory-v2-page-index`, `memory-v2-consolidate`,
`memory-v2-sweep`, …) — same rule as the frozen names above: the `v2` spelling
does not make them the v2 engine's, and they must not be renamed
opportunistically with it.
## Deletion runbook: v1
### Prerequisites that BLOCK deletion
Do not start until both are resolved. Each is a live all-tier dependency on
v1-era machinery.
1. **The graph node surface is all-tier.** `graph/store.ts`,
`graph/capability-seed.ts`, and `graph/tool-handlers.ts` write and read
`memory_graph_nodes` on **every** tier. The `handleListMemory` /
`handleDeleteMemory` / `handleUpdateMemory` handlers are gated on
`usesConceptPageMemory()` yet operate on that v1-era store — so the node
surface (and the `memory-nodes` routes and `memory items` CLI over it) must
be ported to the substrate or retired **before** v1 can go. Deleting `v1/`
does not remove these tables.
2. **`graph/conversation-graph-memory.ts` owns all-tier duties.** Beyond the
v1↔v2 dispatch it does compaction eviction of v2 activation rows and v3
`everInjected` state (`onCompacted`), and it defines the injected-block strip
identity that `daemon/conversation-runtime-assembly.ts` matches on. Those
move to a tier-neutral home first.
### External consumers to repoint or retire
The steps below cover the plugin's own spine. These three production files sit
**outside** `MEM/` and import `v1/` directly, so a deletion that skips them
leaves broken imports and an unbuildable tree. Handle them in the same change,
before or alongside step 1.
1. **`runtime/routes/global-search-routes.ts`** → `semanticSearch` from
`v1/semantic-search.ts`. **Retire the route arm.** It backs only
`searchMemoriesSemantic`, the `deep=true` half of the `memories` category,
and `semanticSearch` already returns `[]` on any concept-page assistant (its
own `usesConceptPageMemory` early return), so the arm is dead on every
non-v1 install. Delete `searchMemoriesSemantic` and its `deep` call site.
Do **not** repoint it at the substrate: the sibling `searchMemoryItems` is a
lexical scan of `memory_graph_nodes` — all-tier, and it keeps the category
answering — while surfacing concept pages here is a new feature over a
different id space (page slugs, not node ids) and belongs in its own change.
Both the `deep` query parameter and the response `source` enum
(`lexical` | `semantic`) are published in `assistant/openapi.yaml`: `deep`
becomes a no-op and `semantic` an unreachable variant, and narrowing either
is a separate, client-coordinated spec change.
2. **`tools/filesystem/write.ts`** → `enqueuePkbIndexJob` from
`v1/jobs/embed-pkb-file.ts`. **Delete the call site.** It fire-and-forgets a
PKB re-index after a `file_write` under `workspace/pkb/**.md`, and
`enqueuePkbIndexJob` already returns `""` when `usesConceptPageMemory()`
holds — the call is inert under the substrate. Drop the whole `pkbRoot` /
`isInsidePkbRoot` try block; keep the local `isInsidePkbRoot` helper, which
the apps-dir containment check earlier in the file still uses. There is no
substrate equivalent to repoint to — concept pages are re-embedded by
`jobs/embed-concept-page.ts` off the page-write path.
3. **`persistence/steps.ts`** → `migrateToolCreatedItems` from
`v1/graph/bootstrap.ts`. **MOVE THIS FUNCTION — DO NOT DELETE IT.** It is a
registered entry in the append-only migration list (slot "101b", between
`migrateMemoryGraphImageRefs` and `migrateDropMemoryItemsTables`) that
copies legacy `memory_items` rows into `memory_graph_nodes` — an all-tier
table that outlives v1. Every fresh install replays the whole chain, so
deleting the function strands those rows on upgrade paths and defeats
`migrateDropMemoryItemsTables`'s "already migrated" safety check. Three
things to preserve when moving it:
- **The exported name.** Bare-function steps are identified by
`Function.name` for checkpointing, and `migrateMoveMemoryGraphTablesToMemoryDb`
names the string `"migrateToolCreatedItems"` in its `dependsOn`. Renaming
it re-runs the migration on existing installs and breaks that dependency
edge.
- **Its position** in the ordered `steps.ts` list — it must still run before
`migrateDropMemoryItemsTables`.
- **The persistence-layering guard's exemption.**
`assistant/src/__tests__/persistence-layering-guard.test.ts` maps
`assistant/src/persistence/steps.ts` → `{"v1/graph/bootstrap"}` in
**`MIGRATION_REGISTRY_MEMORY_IMPORTS`** — NOT in
`PERSISTENCE_TO_MEMORY_ALLOWLIST`, which holds only
`persistence/conversation-crud.ts`. Editing the wrong map leaves the
exemption in place and fails the guard. The exemption itself is permanent
(a migration registry referencing the domain that owns each step is the
registry's job); what moves is the pointer. Repoint the entry at the new
module, or drop it only if the function lands inside `persistence/`; the
guard's stale-entry test fails on a left-behind exemption either way.
The function reads and writes only `memory_items`, `memory_graph_nodes`, and
its own memory checkpoint, so `persistence/migrations/` or the all-tier
`graph/` are both viable homes. `bootstrap.ts`'s other exports
(`bootstrapFromHistory`, `maybeEnqueueGraphBootstrap`) are genuinely v1 and
go with the tier.
**Re-run the scan at deletion time** rather than trusting this list — consumers
accrue:
`grep -rnE '(from|import\() *"[^"]*v1/' assistant/src --include='*.ts' --include='*.tsx' --exclude-dir=__tests__ --exclude-dir=v1 | grep -v '\.test\.'`
(spine hits are the composition points the steps below already cover; anything
else is a new external consumer needing its own repoint-or-retire decision).
**Then re-run it WITHOUT the test exclusions** — drop `--exclude-dir=__tests__`
and the `grep -v '\.test\.'`. Test files import `v1/` too, and not all of them
are v1 tier tests that die with the tier; the non-tier ones get **repointed,
not deleted**. Today the test-only hits are:
- v1 tier tests, deleted with the tier: `src/__tests__/pkb-autoinject.test.ts`,
`injection-block.test.ts`, `graph-extraction-event-date.test.ts`,
`memory-identity-context-parity.test.ts`,
`rebuild-index-graph-nodes.test.ts`, and
`injector-pkb-v2-silenced.test.ts` (its subject is the v1 PKB pair being
silenced under the substrate — keep its one tier-agnostic `now-md` case by
moving it, rather than losing the coverage).
- **not** v1 tests — repoint: `src/__tests__/injector-chain.test.ts` and
`src/__tests__/conversation-runtime-assembly.test.ts` both pull `getPkbRoot`
from `v1/pkb/types.js` purely to place a temp workspace; they cover the
injector chain and runtime assembly on every tier.
- a false positive: `MEM/__tests__/memory-tier-boundary-guard.test.ts` matches
on `"./v1/a.js"`-style string literals in its own parser tests.
### Steps
1. Delete `MEM/v1/**` (including `v1/README.md` and `v1/graph/graph-search.ts`,
the hybrid node-SEARCH half) — **after** `migrateToolCreatedItems` has been
moved out of `v1/graph/bootstrap.ts` per the external-consumers section
above. `graph/graph-search.ts` is the all-tier embedding plumbing and
**stays**.
2. Delete `runtime/routes/filing-routes.ts` and drop its `ROUTES` entry from
`runtime/routes/index.ts`.
3. Retire the `memory-items` route family (`MEM/src/memory-item-routes.ts`'s
item half) and `cli/commands/memory/items.ts`. Re-verify callers at deletion
time with `grep -rl 'memory-items' --exclude-dir=node_modules .` — today the
only hits outside the assistant are the generated `assistant/openapi.yaml`
and one web test (`clients/web/src/lib/api-interceptors.test.ts`); there are
no hand-written web callers.
4. Remove the v1 arm of `graph/conversation-graph-memory.ts` (its
`../v1/graph/injection.js` and `../v1/graph/retriever.js` imports and the
branches they feed).
5. Remove the v1 branch of `context-search/sources/memory.ts` — it is the file's
only non-substrate path (`searchGraphNodes` from `v1/graph/graph-search.ts`),
so the source collapses to its `usesConceptPageMemory` early return.
6. Delete the v1 sections of the spine files. Every one is banner-marked, so
`grep -rn 'delete with v1' src/plugins/defaults/memory/` is the authoritative
list — it covers `startup.ts`'s `---- v1 (legacy engine) ----` section labels
(which spell out the banner phrase for exactly this reason) and the dispatch
call sites, not only the function bodies:
`startup.ts` (**three** blocks — the v1 Qdrant ensure and the PKB reconcile,
both covered by step 8, and the graph-bootstrap tail),
`jobs-worker.ts` (`enqueueV1MaintenanceJobs` **and** the `else` arm
that calls it), `job-handlers.ts`, `indexer.ts` (`enqueueV1IndexTriggers`
**and** its call site), `injectors.ts` (the PKB injector pair). The same grep
also returns `graph/conversation-graph-memory.ts` and
`context-search/sources/memory.ts` — steps 4 and 5 above.
7. Delete `V1_QDRANT_JOB_TYPES` and the `SweepPostponedOffV1Error` postpone
path in `jobs-worker.ts`, plus the v1 job handlers
(`v1/job-handlers/{backfill,embedding,index-maintenance}.ts`) and their
`job-handlers.ts` registrations. Move retired job types into
`LEGACY_JOB_TYPES`; do NOT remove them from `MemoryJobType`.
8. Delete the v1 Qdrant collection lifecycle in `startup.ts` (the
`config.memory.qdrant.collection` `ensureCollection` block and the PKB index
reconcile). Both are gated on `!usesConceptPageMemory(config.memory)`, **not
on `isMemoryV1Active`** — and `runMemoryStartup` has no memory-enabled guard
of its own, so today they run on `memory.enabled: false` assistants too.
Delete them outright rather than assuming step 9 already reaches them. Leave
the lexical-messages collection alone — it is independent.
9. Collapse `isMemoryV1Active` call sites, then delete the predicate. Sweep for
`!usesConceptPageMemory(...)` separately: it is the actual v1 gate in
`startup.ts`, `jobs-worker.ts`, `indexer.ts`, and
`context-search/sources/memory.ts`, and those arms collapse the other way
(the substrate branch becomes unconditional).
10. Repoint or delete the tests pinned to v1 — the test-inclusive re-scan above
lists the ones the import scan finds. One more it cannot find:
`src/__tests__/memory-jobs-worker-lanes.test.ts` sets
`memory.v2.enabled: false` so v1 is the live tier and its slow-lane job
(`graph_consolidate`) genuinely runs instead of no-opping under the
substrate. Repoint it at a surviving slow-lane job type — the lane
independence it covers is tier-agnostic and worth keeping.
11. `memoryTier()`'s `"v1"` bucket becomes unreachable. **Coordinate with the
platform telemetry dashboard before removing the bucket** — see
`src/telemetry/AGENTS.md` for the `memory_tier` contract.
12. Shrink the boundary guard: drop `v1` from `TIER_DIRS` /
`FORBIDDEN_TIER_IMPORTS`, and drop any `SPINE_ALLOWLIST` entry whose second
tier was v1 (the reverse stale-exemption test will name them for you).
## Deletion runbook: v2
**Read this first:** the frozen substrate names merely SPELLED "v2" — the
`memory_v2_concept_pages` collection, the `memory_v2_consolidate` /
`memory_v2_sweep` / `memory_v2_reembed` job types, the `memory_v2_consolidation`
conversation source, the `memoryV2Consolidation` call-site id, the
`memory/.v2-state/` path, the `.memory-v2-reembed-required` sentinel, the
`memory-v2-static` injector id, and `memoryV2StaticBlock` — belong to the
**substrate**, which outlives the v2 engine. They survive this deletion and must
not be renamed opportunistically.
### External consumers to repoint or retire
Eight production files import `v2/` today. Three are composition points the
steps below already name (`graph/conversation-graph-memory.ts`,
`job-handlers.ts`, `MEM/src/memory-v2-routes.ts`); the remaining **five** are
itemized here, and **four of the five sit outside the plugin entirely**.
Deleting the tier without handling them cannot produce a buildable tree.
1. **`MEM/fork-conversation-memory.ts`** (spine, on the `SPINE_ALLOWLIST`) →
`forkActivationState` / `seedForkActivationState` from
`v2/activation-store.ts`. Remove the v2 arm; the module survives as a
substrate + v3 fork path (injected-block slugs, `v3/ever-injected-store`,
graph state, retrospective state). It stays on the `SPINE_ALLOWLIST` after
the cut — `substrate` is itself a tier directory in the guard's `TIER_DIRS`,
so substrate + v3 is still multi-tier and step 10 must **not** drop this
entry.
2. **`runtime/routes/conversation-query-routes.ts`** →
`getMemoryV2ActivationLogByMessageIds` from `v2/activation-log-store.ts`.
This is the route end of the inspector chain below: it fills the
`memoryV2Activation` field of the LLM-context response. It is **deleted**
with that surface, not replaced — the tier-agnostic
`getMemoryRecallLogByMessageIds` and the v3
`getMemoryV3SelectionForInspectorByMessageIds` reads alongside it stay.
3. **`daemon/conversation-agent-loop-handlers.ts`** →
`backfillMemoryV2ActivationMessageId` from `v2/activation-log-store.ts`. The
turn-end stamp that keys the activation row to the assistant `messageId` so
the inspector can find it. **Delete the try block**; no replacement is
needed, because with the engine gone nothing writes the log in the first
place (`v2/injection.ts`'s `recordMemoryV2ActivationLog` call dies with
step 1). Its two siblings — the memory-recall-log and v3-selection backfills
— are unaffected.
4. **`cli/commands/memory/memory-v2.ts`** → `import type { ComparisonReport }`
from `v2/harness/runner.ts`. Type-only, and it feeds `memory v2 compare`,
which step 1 already retires with the engine. Drop the import together with
the subcommand and its `memory-v2-compare-render.js` imports; the surviving
`reembed` / `reembed-skills` / `validate` subcommands never touch
`harness/`.
5. **`cli/commands/memory/memory-v2-compare-render.ts`** → the same
`import type { ComparisonReport }`. The renderer for `memory v2 compare`;
deleted whole in step 1, so it needs no separate decision — it is listed
only so the scan below reconciles against a complete list.
**The activation-log chain, end to end.** Retiring `memory_v2_activation_logs`
spans five layers, split across this section and steps 7–8. Land them together
or the tree and the published spec go out of sync:
- **writer** — `v2/injection.ts`'s `recordMemoryV2ActivationLog`, deleted with
`MEM/v2/**` (step 1), plus the daemon's turn-end `messageId` backfill
(item 3 above);
- **route** — `conversation-query-routes.ts`'s read (item 2 above);
- **wire contract** — `assistant/src/api/responses/memory-v2-activation-log.ts`
(`MemoryV2ActivationLogSchema`, re-exported from `src/api/index.ts` and so
published through `@vellumai/assistant-api`), embedded in
`api/responses/llm-context-response.ts`. `memoryV2Activation` is a
**required** property on both LLM-context responses in
`assistant/openapi.yaml`, so removing it regenerates the spec and the
generated client types — coordinate it with the web change;
- **web consumer** — the inspector's `memoryV2Activation` surface (step 8);
- **table** — the `memory_v2_activation_logs` drop (step 7).
**Re-run the scan at deletion time** rather than trusting this list — consumers
accrue:
`grep -rnE '(from|import\() *"[^"]*v2/' assistant/src --include='*.ts' --include='*.tsx' --exclude-dir=__tests__ --exclude-dir=v2 | grep -v '\.test\.'`
(hits inside `MEM/` are the composition points the steps below cover; anything
else is a new external consumer needing its own repoint-or-retire decision).
**Then re-run it WITHOUT the test exclusions** — drop `--exclude-dir=__tests__`
and the `grep -v '\.test\.'`. Test files import `v2/` too, and not all of them
are v2 tier tests that die with the tier; the non-tier ones get **repointed,
not deleted**. Today the test-only hits are:
- v2 tier tests and fixtures, deleted with the tier:
`MEM/__tests__/memory-v2-activation-log-store.test.ts`,
`MEM/__tests__/memory-v2-concept-frequency.test.ts`,
`MEM/__tests__/fixtures/memory-v2-activation-fixtures.ts` (a fixture module,
not a `.test.` file — the `grep -v '\.test\.'` misses it either way),
`MEM/graph/__tests__/conversation-graph-memory-v2-routing.test.ts` (the v2
half of the dispatch spine), and
`cli/commands/memory/__tests__/memory-v2-compare-render.test.ts`.
- **not** v2 tests — repoint by dropping their v2 arms:
`src/__tests__/injector-v3-suppression.test.ts` (pulls `INJECTION_HEADER`
from `v2/injection.js` to assert the v3 suppression),
`src/__tests__/conversation-fork-crud.test.ts` (pulls `hydrate` from
`v2/activation-store.js` while covering all-tier fork behavior),
`MEM/__tests__/memory-log-stores-degraded.test.ts` (degraded-mode coverage
spanning the recall log, the v2 activation log, and v3 stores), and
`runtime/routes/__tests__/conversation-query-routes.test.ts` (the route test
whose sibling recall-log and v3-selection assertions stay).
- a false positive: `MEM/__tests__/memory-tier-boundary-guard.test.ts` matches
on `"./v2/a.js"`-style string literals in its own parser tests.
### Steps
1. Delete `MEM/v2/**` and `cli/commands/memory/memory-v2-compare-render.ts`.
Two modules **split** rather than delete:
- `MEM/src/memory-v2-routes.ts` — the only multi-tier route module. Four of
its handlers read the substrate and must survive —
`memory/v2/concept-page`, `memory/v2/list-concept-pages`,
`memory/v2/reembed-skills`, `memory/v2/validate` — so move them (endpoint
strings, operation ids, and the `MEMORY_V2_DISABLED` code unchanged; they
are frozen wire surface) into a substrate-owned routes module.
`memory/v2/backfill` (operation id `memory_v2_backfill`) splits **by
operation** rather than dying whole: `MemoryV2BackfillParams`'s `op` enum
accepts three values, and `OP_TO_JOB_TYPE` sends `migrate` →
`memory_v2_migrate` and `activation-recompute` →
`memory_v2_activation_recompute` (both v2-engine jobs, retired in step 9)
but `reembed` → `memory_v2_reembed`, a **substrate** job whose handler
lives at `substrate/reembed-job.ts` and survives this deletion. Carry the
`reembed` arm across with its frozen operation id and endpoint intact —
narrow the `op` enum to `["reembed"]` and move the route with the other
survivors — or land a compatibility-preserving replacement first. Deleting
the route wholesale removes the only on-demand concept-page reembed
command an operator has; every other enqueue of that job is automatic
(consolidation follow-ups, boot maintenance, the embedding reconciler,
workspace migrations). Delete the remaining engine-only routes:
`compare-retrievers`, `concept-frequency`, `ema-scores`, `now-text`,
`router-prompt-template`, `simulate-router`. Publishing substrate-named
aliases and retiring the `memory/v2/*` spellings is a separate,
client-coordinated change.
- `cli/commands/memory/memory-v2.ts` — follows its routes rather than being
deleted outright. `memory v2 reembed` (which posts the surviving `reembed`
backfill op) and `memory v2 reembed-skills` keep working and move with
the substrate routes; `memory v2 validate` and the top-level
`memory validate` (`cli/commands/memory/memory-validate.ts`) share
`runMemoryValidate`, which lives in `memory-v2.ts` and moves with the
survivors; `memory v2 activation` (the
`activation-recompute` op), `memory v2 ema`, `memory v2 simulate`, and
`memory v2 compare` go with the engine. There is no `migrate` subcommand —
the route is that job's only enqueue path.
2. Remove the v2 arm of `graph/conversation-graph-memory.ts` (activation-store,
injection, now-text, router-pair imports) and the
`shouldRunLegacyMemoryRetrieval` path in `hooks/user-prompt-submit.ts`.
3. Remove the v2-suppression strip in `daemon/conversation-runtime-assembly.ts`
(`suppressV2MemoryForV3`, the `memory-v2-static` capture, and the
`memoryV2StaticBlock` plumbing) — with no v2 engine there is nothing to
suppress, but the static `<info>` injector itself stays.
4. Delete the v2 sections of `job-handlers.ts` marked
`// V2 ENGINE — delete with v2`.
`context-search/sources/memory-v2.ts` is misnamed, not v2-owned — it reads
only `substrate/` and survives; rename it (the filename is not a frozen
name) rather than deleting it.
5. Collapse `usesConceptPageMemory()` to `isMemoryEnabled()` and delete
`isV2InjectionEngineActive`. For `isMemoryV2ExplicitlyDisabled`, decide what
`daemon/embedding-reconcile.ts`'s explicit-opt-out branch should mean once
`memory.v2.enabled` no longer exists — either drop the branch or re-express
it against `memory.enabled`.
6. Drop the `memory.v2` schema subtree and the `orV2` fallback in
`substrate/tuning.ts`, giving `memory.substrate` **real defaults** (the
values currently in `config/schemas/memory-v2.ts`). Migration 135 copied
user overrides, but it deliberately skips values equal to a shipped default
— including `bm25_b: 0.75`, the pre-migration-075 default. On a workspace
still persisting `0.75`, that value reaches the runtime **only** through the
`orV2` fallback, so dropping the fallback silently retunes it to `0.4`.
**Land a sweep migration first**: for every substrate tunable, copy the
resolved effective value into `memory.substrate` wherever it is absent there
and present under `memory.v2`, this time regardless of whether it matches a
shipped default. Only then remove the fallback. Fold the parent
`MemoryConfigSchema.superRefine`'s mixed-case weight check back into the
substrate schema's own refinement.
7. Drop tables `memory_v2_activation_logs`, `memory_v2_injection_events`, and
`activation_state` via a normal `persistence/migrations/` step (append-only —
never edit an existing migration). Keep the `conversation-memory-purge.ts`
deletes in sync.
8. Web surfaces to retire or repoint:
- the inspector memory tab's `memoryV2Activation` context
(`clients/web/src/domains/chat/inspector/inspector-api.ts`,
`components/tabs/memory-tab.tsx`, `inspector-export.ts`) — this is the web
end of the activation-log chain spelled out in the external-consumers
section above; land it with the route, the daemon backfill, the
`api/responses/memory-v2-activation-log.ts` wire contract, and the step-7
table drop,
- the router playground (`memory-router-playground-page.tsx`,
`memory-router-simulator-api.ts`, and its `routes.tsx` entry),
- `concept-page-api.ts` and
`domains/intelligence/memory-v2/list-concept-pages.ts` — these call the
two substrate-reading routes that survive step 1, so they keep working
unchanged; only rename the modules/directory if you want to.
9. Move the retired v2-engine job types (`memory_v2_migrate`,
`memory_v2_activation_recompute`) into `LEGACY_JOB_TYPES`.
10. Shrink the boundary guard: drop `v2` from `TIER_DIRS` /
`FORBIDDEN_TIER_IMPORTS` and from `TIER_KEY_READ_ALLOWLIST`, and remove the
`SPINE_ALLOWLIST` entries that were v2 composition points — only those left
importing fewer than two tier directories. `fork-conversation-memory.ts`
still spans `substrate/` + `v3/` and stays; let the reverse
stale-exemption test tell you which entries actually went single-tier.
## Where to add new code
- New shared concept-page capability (retrieval, consolidation, page/edge
storage, capability seeding) → `substrate/`.
- New lane, scorer, or injector behavior for the live engine → `v3/`.
- Tier-agnostic capture (retrospective, sweep triggers) → the plugin root.
- Multi-tier wiring → an existing spine composition point on the
`SPINE_ALLOWLIST`; do not create a new one.
- **Never add to `v1/` or `v2/`.** Both are in retirement; a new feature there
is work that has to be deleted twice.