Repository navigation
Fragmented memories: links, composites, and taxonomy-as-data #537
Description
Follow-up to #358 and to our two earlier comments there ("Our Experience with Custom 6-Category Taxonomy", 2026-09-16, and "Follow-up: 6-category field report + an idea for the pack-format spec", 2026-09-19). This proposal consolidates what we learned running a custom taxonomy in production for two weeks into a single coherent picture: memory entries fall into four kinds — content, intent, relation, and policy — and each kind deserves first-class storage in the memory store itself.
Summary
Everything below reduces to one framing: the memory store should hold four kinds of blocks, each with its own lifecycle, instead of treating "a memory" as a single undifferentiated row type plus out-of-band prompt constants.
| Kind | What it holds | Today's home | Proposed home |
|---|---|---|---|
| Content blocks | Project knowledge (facts, rules, state) | memories table, six categories |
unchanged (Proposal 3 refines granularity) |
| Intent blocks | Session notes, smart notes, pending intentions | notes table |
unchanged |
| Relation blocks | Links between memories: sequences, themes, causes, alternatives | nothing (no link primitive exists) | new composites (Proposal 2) |
| Policy blocks | The taxonomy definition text itself — per-category definitions and exclusions | hardcoded source constants | DB, user-overridable (Proposal 1) |
Three concrete proposals follow. They are independent but reinforce each other, and all three are meant to build on — not work around — the two hard constraints the maintainers laid out in #358. Where this proposal argues at length, it argues for one thing: why the runtime criteria should live in the database rather than only in a pack file.
Proposal 1: Taxonomy-as-data — store the category criteria, keep the category names closed
The pain point, measured
We have run a custom six-category taxonomy (OPS_RULES / ENV_STATE / CONFIG_VALUES / INCIDENTS / RESEARCH / MODEL_KNOWLEDGE) in a live project for about two weeks. The results split cleanly in two:
- Write side: 92.7% adoption (38 of 41 new memories in the last measured week used the new categories). The historian, the write tool, and our users all handle new category names without friction.
- Tool recognition side: 0%. Not a single one of the new category strings appears in the shipped
dist/index.js.CATEGORY_PRIORITYand friends still hardcode the old development-oriented set.
The reason is structural. In v0.43.2, the category vocabulary lives in at least three compiled constants in packages/plugin/src/features/magic-context/memory/constants.ts — V2_MEMORY_CATEGORIES, PROMOTABLE_CATEGORIES, and CATEGORY_PRIORITY — and is then re-asserted in 6+ independent class-name whitelists scattered across the code: the tool schema enum in tools/ctx-memory/tools.ts, the historian output parser regex in hooks/magic-context/compartment-parser.ts (which silently drops any <facts> block tagged outside the whitelist), the retrospective parse whitelist in dreamer/retrospective-learnings.ts, the curate rotation list in dreamer/curate-category-rotation.ts, the project-scoped safety gate in dreamer/curate-memory-safety.ts, the directive-memory guard in dreamer/memory-claim-safety.ts, and the injection render whitelist in hooks/magic-context/inject-compartments.ts. Changing the taxonomy therefore means editing source and rebuilding — which is exactly the outcome #358 set out to eliminate, but at the level of criteria rather than just category lists.
The design: closed names, data-backed criteria
That custom categories are wanted is not in question — the accepted policy-pack direction already settles it; what is missing is a plan for where criteria live at runtime. And that the category names must stay closed is likewise settled: as magic-alfonso put it, category names key memory dedup (UNIQUE(project_path, category, normalized_hash)), workspace sharing gates, cross-category merge protection, and classification calibration. We take both as given and state the design that follows from them:
- Category names remain closed, owned by the pack, exactly as Dreamer policy is hardcoded to software-development workflows — make per-project policy configurable (task prompts, memory categories, maintained docs) #358 concluded.
- Criteria text becomes data: a definition, positive and negative examples, and an exclusion list per category — stored in the database (a new table, or a namespaced slot in the existing
context_store_metakey/value table, the only lightweight generic KV surface in the current schema), editable by the user, with a fallback to the built-in pack defaults when no override exists. - Prompt assembly reads the DB: the three sites that currently splice criteria constants into prompts — historian system prompt construction, dreamer curate/retrospective prompt construction, and the
ctx_memorytool description — read the stored criteria instead. - Change detection already exists:
session_meta.system_prompt_hashalready fingerprints the prompt surface; criteria changes flowing into prompts are picked up by that mechanism with no new invalidation machinery.
Why the database, not (just) a pack file
Given the design above, one question remains open: should the criteria's runtime home be the database, or a pack file the runtime reads? We argue for the database, on four grounds.
a. Single source of truth. Today the criteria are scattered across at least three compiled constants (V2_MEMORY_CATEGORIES, PROMOTABLE_CATEGORIES, CATEGORY_PRIORITY) plus the build-script-generated prompt artifacts that restate them. Nothing asserts that all these copies agree — and our own build tree shows what follows. historian-prompt.source.md, the editable source for the historian system prompt, still contains the upstream five categories, while the generated artifact in the same tree has already been patched to six; the build script's validation guards check the title and several structural markers but do not validate category names. Anyone re-running the build silently reverts six-category criteria to five; the historian starts emitting <PROJECT_RULES> tags; and the parser whitelist silently drops those fact blocks — memories written into a category that is then never rendered. This is a live rollback risk in our own patched tree, and it is structural, not careless: criteria that live outside the store they govern will always be able to drift from it. One row per category in the database makes agreement the default rather than something every build must re-verify by hand.
b. Same store, same lifecycle. Policy blocks are one of the four kinds this proposal puts forward, and they should get the same treatment as the other three. The criteria govern the very memories that sit in the memories table; they are consulted in the same sessions, edited in the same workflows, and should be versioned and audited alongside the data they shape. A compiled constant or an out-of-band file touches the store's data without being part of the store — a parasite by construction. Putting criteria in the database completes the four-kind picture: no kind of block lives outside the store.
c. Right-sized iteration. Adjusting a category's wording, adding one exclusion, or sharpening an example is a high-frequency, one-line-sized act. Under a pack-file scheme, every such tweak is a pack revision — version bump, review, distribution — a heavyweight ritual for a small edit; or users bypass the ritual by forking packs, and the versioning benefit is lost anyway. With criteria in the DB, a one-row update takes effect immediately, and session_meta.system_prompt_hash — an existing mechanism — already notices that the prompt surface changed and reacts accordingly. The cost of the change matches the size of the change, with no new invalidation machinery.
d. Complement, not compete. None of the above displaces the pack. The pack remains the source of versioned, reviewed default criteria, written into the DB at install and migration events; the DB is the runtime authority thereafter. Viewed this way, criteria-in-DB is not a rival to the pack mechanism but its natural runtime carrier: a pack's prompt fragments must land somewhere at runtime, and the DB is the place they land — this gives the pack mechanism somewhere to land. We offer that observation as a design contribution to the pack-format spec, not as a petition for exemption from it.
Proposal 2: Composites — one primitive for memory relations
Our second #358 comment argued that memories behave like entities, not wiki pages: "a model-config issue discovered during an incident" is simultaneously INCIDENTS, ENV_STATE, CONFIG_VALUES, and MODEL_KNOWLEDGE. Today there is no way to express any of that structure. We verified the absence directly: the production database (v0.43.2 schema) contains no link, edge, or composite table among its ~100 tables, and a repo-wide search for link/composite/edge primitives returns nothing. The only memory-to-memory pointers are superseded_by_memory_id and merged_from — lifecycle bookkeeping, not knowledge structure.
Schema
Two tables:
composites (
id, type, head_memory_id, cursor, created_at, updated_at
)
composite_members (
composite_id, member_memory_id, position
)type is a small closed enum: sequence (an SOP chain), theme (a topical aggregation), cause (one head, many causes — e.g. a decision ← the errors that led to it), alternatives (an unordered peer set).
Star topology
- The head → members shape is naturally directed.
- One memory may head several composites (no uniqueness constraint at the data layer).
- Members relate n:n across composites freely — cross-cutting entities are the norm, not the exception, so membership must not be exclusive.
- The head is never a member of its own composite (breaks trivial cycles).
- Mechanism exemption: composites do not key dedup, do not participate in workspace sharing gates or cross-category merge protection. They sit entirely outside the closed-taxonomy machinery — the boundary our comment 2 already located. The "open sets fragment memory pools" argument therefore does not apply: composites add structure without touching the invariants that structure protects.
The cursor
For cyclic composites (rotations, recurring procedures), cursor records the read position — where in the cycle are we now. This is the payload that makes a rotation meaningful as a single retrievable object rather than a bag of memories that must be re-ordered by inference every time.
Query paths
- Primary: head → groups (
head_memory_idindex). This is the injection-driven path: when the head block is injected, its composites come with it. - Secondary: member → groups (
member_memory_idindex). This serves compaction bookkeeping (find every composite touching a memory about to be merged) and provenance (trace which structures cite this memory).
Relationship to arbitrary typed edges
A full graph with arbitrary typed edges (the part-of / caused-by / supersedes / … vocabulary sketched in comment 2) would cover the long tail, but the star topology absorbs the large majority of observed use cases. We suggest shipping composites now and leaving general typed edges as a future extension — one sentence in the design doc is enough for now.
Compression fate
When the historian merges memories A + B → C, member rows pointing at A or B are repointed to C. The existing superseded_by_memory_id column is the natural hook: repointing is the same lifecycle event the store already tracks. Composites need no new subsystem for compaction — they ride the one that exists.
Proposal 3: Fragmentation with header blocks
The pain point
Long-form memories — a full SOP as one blob, a config dossier in one entry — inject inefficiently and are maintained coarsely: any small change means rewriting (and re-embedding, and re-hashing) the whole block. Our production numbers make the injection side acute: OPS_RULES alone holds 230 entries, against a default injection context of roughly 4k tokens. The default injection must therefore triage, and it currently triages over monoliths.
Design
- Fragment: split large knowledge into small memories. An SOP becomes several step-sized entries chained by a
sequencecomposite. Configuration becomes one entry per setting. - Header blocks: each fragmented structure gets a header — an ordinary memory row (no new type), with high
importance, containing a summary. Headers are what the default injection carries. - Lazy details: detail entries are not injected by default; they are retrieved on demand, by traversing the composite edges from the header.
The graph
Header blocks link to each other, forming a two-layer map: theme headers → category headers → details. Deliberately shallow — two layers, no deeper. This is not a new node type; it is ordinary memories plus ordinary composites, so the graph self-maintains under the existing historian/dreamer lifecycle.
The injection ledger
The default 4k context changes character: from a crowded body (a few large entries, most of their content irrelevant to the current turn) to a sparse table of contents (many headers, each a few lines, covering the whole memory surface). Coverage goes up at the same token budget; details arrive only when a header earns the retrieval.
Dreamer needs the sync tool
Fragmentation makes one gap unavoidable: when a detail changes, its header must be updated too. This lands exactly on the pain point from our #358 comment 2 — the dreamer currently has no association or reorganization tools, only free-text ctx_memory write and embedding search. Composites (Proposal 2) give the dreamer something to traverse; a small "propagate detail change to header" capability gives it something to do there. We raise the two together deliberately: fragmentation without dreamer sync just multiplies stale summaries.
Open questions
- Precedence and migration semantics for pack defaults vs. DB overrides. When a pack updates its default criteria and a project has DB overrides, what wins — and does the pack update trigger the explicit migration event even if only criteria (not names) changed? We'd lean toward: overrides always win until the user clears them; pack updates that touch names are migration events, updates that touch only criteria text are not — but this needs a ruling.
- Composites and Feature request / design discussion: task-scoped memory prefetch by ID for subagent delegation #449's frozen reference sets. A composite (an ordered, typed set of memory IDs with a head) looks like the natural unit for handing a sub-agent a frozen reference set. Should composites carry whatever metadata Feature request / design discussion: task-scoped memory prefetch by ID for subagent delegation #449 needs, or should the reference set be a read-time snapshot over a composite (immutability vs. live-structure tension)?
- The
classify-memoriesdecision point. Optional decision-provider for memory importance classification (cache-neutral, default off) #474 is adjacent here. Todayclassify-memorieswrites onlyimportance/scope/shareable;categoryremains 100% write-side (viactx_memory write, historian<facts>tags, and retrospective learnings — with no automated pipeline ever rewriting it). Should classification of category stay write-side-only by design, and should Proposal 1's criteria-as-data be surfaced to classify-style tasks if that ever changes? We'd propose documenting the boundary now, before Optional decision-provider for memory importance classification (cache-neutral, default off) #474's decision-provider seam hardens around it.
Empirical figures above come from a two-week production run of the custom six-category taxonomy (v0.43.2 base, production DB ~482 active memories) and a source-level audit of v0.43.2 (2c3654bf). Happy to share the audit details, the criteria text we used, or the injection accounting for Proposal 3 in whatever shape is useful for the pack-format spec.
Activity
magic-alfonso commentedon Sep 26, 2026
Thanks. This is a careful write-up, and the two weeks of production numbers are exactly what's useful here.
As we said when closing #509, we're redesigning the memory model: the current one is shaped around software projects, and we want it to work as well for other uses. All three of your proposals bear directly on that work: criteria as data, composites for relations, and fragmented memories with header rows. We're taking them in as input rather than answering them one by one now. The open questions you list, especially pack defaults versus database overrides and whether category classification stays write-side, are ones the redesign has to settle anyway. I'll leave this issue open and link the design issue here once it exists.
Your audit also found a defect we'll fix now rather than wait for the redesign. The historian's parser silently drops a
<facts>block tagged with a category it doesn't recognise, and the category list is restated in several places with nothing checking that they agree. That can lose facts on a stock install too, whenever a model emits a wrong tag. We'll make the drop visible in the log and move the category list to one source.G0-0000 commentedon Sep 26, 2026
Thanks for the careful read — glad the production numbers were useful.
Taking the three proposals as input for the memory-model redesign sounds like the right call; no need to answer them one by one. I'll follow the design issue once it's linked here.
On the parser bug: good catch confirming it affects stock installs too, not just custom categories. Making the drop visible in the log and consolidating the category list to a single source sounds like exactly the right fix. Since you've already claimed it, we'll wait for your fix to land — but if it ends up being something you'd rather hand off, we're happy to pick it up or help test a PR.
I'll add the audit/injection ledger details here shortly as promised.
magic-alfonso commentedon Sep 26, 2026
Thanks. The parser fix is already on master:
<facts>block with an unknown category is now logged instead of silently dropped;It ships in the next release. The audit and injection ledger details will be useful for the memory redesign whenever you have them.
magic-alfonso commentedon Sep 28, 2026
Status: the parser fix is in 0.44.0. Facts the historian tags with a category it doesn't recognise are still not stored, but the log now names the category and how many facts it held, in TypeScript and Rust mode. The category list comes from one shared constant, with a test that fails if the copies disagree. The three proposals stay open as input to the memory redesign. I'll link the design issue here once it exists.