Agentic memory: making a runtime remember.
An agent that forgets is a chatbot with a good vocabulary. To be useful across sessions, across shifts, across the whole span of a project, an agent needs two things it usually does not get.
The first is persistent, searchable storage. Not a scratchpad the operator has to paste into every prompt. Not a vector store that returns whatever ranked closest to the query, whether or not the answer was in there. Real storage that keeps facts, that keeps notes, that keeps them per user and per tenant, that survives a runtime restart, that can be corrected without being destroyed, and that a retrieval call answers with the right thing.
The second is transparent access to the project's own documentation. Agents working inside a real codebase should be able to read the project's RFCs, its runbooks, its architecture decisions, its design notes, the same way a new engineer would. Not by having the operator paste those documents into every system prompt one at a time. Not by re-uploading them as tool arguments on every turn. As addressable resources the agent can name, search, follow, and cite.
Loomcycle now ships both, as a first-class capability. This post is a tour of what that means in practice: what memory looks like, what documents look like, what one search API buys you when it spans them, and what the operator sees.
What ships. A tenant-isolated per-scope memory that holds three kinds of content in one plane (facts, notes, documents), reached through one search API that lets you ask for exactly the kinds you want. A background consolidator that turns chat transcripts into distilled facts with provenance and bi-temporal validity, and that inherits the spans context compaction discards so nothing useful is thrown away. Documents that are addressable by path or name, findable by content (prose bodies, Mermaid diagrams, and images all embed on write), and that link, transclude, back-reference, and version themselves. Hybrid vector plus full-text retrieval with Reciprocal Rank Fusion. A human-facing operator console (@loomcycle/memory-view). GDPR-shaped subject erasure on every transport. Every plane is tenant-folded on every read.
The mental model: one memory, three kinds
Everything an agent needs to remember lives in one per-scope memory. A scope is a namespace: an agent's own scope, an end-user's scope, a tenant's shared scope. Within a scope, three kinds of content sit next to each other.
| Kind | What it is | Written by |
|---|---|---|
fact | A distilled statement the runtime believes true (or was true, in the bi-temporal case). Carries provenance, entity type, and temporal validity. | The consolidator, from conversations. Explicit writes possible. |
note | Anything an agent jotted down mid-turn. Free-form, cheap to write, cheap to read. Not promoted to fact automatically. | Agents, through Memory op=set. |
document | Structured project knowledge: RFCs, guides, plans, research notes. Chunked, typed, linked, versioned. | Operators and agents, through the Document tool. |
One search API reaches them all. An agent asking "what have I been told about this" gets facts and notes by default. An agent asking "where did we write this down" can span everything, or narrow to just documents, or just facts, or just notes, and the runtime respects that narrowing.
The keying is the load-bearing detail. Facts vs notes is decided by origin, a column the server stamps from the writer's identity, not by a class label the model supplies. An agent cannot promote its own note to a fact by labelling it. The distinction is enforced at the boundary, not in the calling code.
Facts, extracted while the agent works
The most useful memory an agent can have is the memory the operator did not have to write. Loomcycle runs a background consolidator that reads the transcripts of finished (and paused, and compacted) turns and produces distilled facts with structure.
What the consolidator does, at a glance:
- Reads new session content on a cursor, so a scope is processed forward exactly once.
- Extracts statements with an ontology-typed entity tier: each fact carries a subject, an entity type (from the operator's ontology), a class (
preference,observation,fact, etc.), and a body. - Resolves duplicates through configurable consolidation bands (near-identical facts fold; borderline ones queue for review; distinct ones write).
- Persists each write with provenance: the run, the session, the agent, the model, and the compaction generation that produced it. Every fact carries the receipt of how it got there.
The consolidator is a deterministic code-js agent. That matters. The runtime does not want the consolidator hallucinating a merge; it wants the same input to produce the same output. Extraction runs against a real model; consolidation is code. The two responsibilities are separated so the parts that require a model are gated by effort, budget, and cost attribution, while the parts that require correctness are code that runs the same way every time.
Bi-temporal validity: correct, not destroy
A fact on loomcycle has two clocks. valid_at is when the fact became true in the world. expired_at is when the runtime stopped believing it. The pair is called bi-temporal, and it means a correction never has to erase the old record.
When the truth changes ("the user moved to a new city," "the contract's end date shifted"), the runtime writes a new fact and marks the old one superseded. The old fact stays queryable at any past instant: "what was true on this date" answers correctly, because the record was not destroyed. A default recall filters on "as of now," so callers who do not care about the timeline just see the current view.
A known future end date does not delete the fact, either. "The contract runs until 2027" stays visible in every default recall today, because the filter compares to the current time, not to any future time. That is a rule small teams do not always think through until a fact vanishes on a Tuesday morning; loomcycle enforces the invariant at the retrieval layer.
The compaction bridge: nothing is lost silently
Loomcycle has long-running interactive agents. Their context compacts on a threshold. When context compacts, some part of the transcript is distilled into a summary and the rest is dropped from the active window. In most runtimes that dropped content is gone.
Chat agents on loomcycle can opt into compaction.memory_flush. When they do, the span that compaction would have discarded gets banked onto the consolidation queue. The consolidator picks it up on its next tick, extracts whatever facts were in there, and writes them to memory before that content is truly gone. What would have been thrown away becomes candidate fact material.
Every fact carries a compaction_generation stamp, so a fact born from a compaction bank says so. The receipt survives.
Documents: the project's knowledge, addressable and searchable
A loomcycle Document is a chunked graph. Each chunk has a UUID, a type, a status, structured fields, a Markdown body, and edges to other chunks. Documents live under a Path tree (the same tree Volumes and Memory names live under), so a document is addressable both by ID and by name: /loomcycle/rfcs/agent-teams resolves the same way an agent might reach any other resource.
What makes a Document useful for agents:
Every chunk is searchable
Chunk bodies embed on write. Prose bodies embed as-is, no model call. Mermaid chunks embed their node and edge labels (extracted across ten dialects, with the diagram kind included). Image chunks embed the author's caption plus a persisted vision description generated by an explicit operator pass. Even heading-only chunks embed their title, so the section headers of a document are findable directly. A retrieval call across the scope can reach any chunk of any document by its content.
The rule that fell out of the design is worth naming: use a model only when the content is not already text. Prose is text; Mermaid extracts to text; only an image needs vision. And a vision description is persisted, not regenerated on every embed, because generating fresh text on every call would silently re-rank the index every time.
Prose is a graph
Inline [[name]] links in a chunk body are materialised as references edges, re-derived on every body write and resolved through the Path tree. Write "see [[agent-teams]]" in a chunk, save, and the graph knows that chunk points at the agent-teams document.
Transclusion works too. Write ![[target]] and export inlines the target's content at export time. Cycles, depth caps, and unresolved targets degrade to the literal text; a rendering nicety must never abort an export.
Backlinks, related, unlinked mentions
For any chunk, the runtime answers three discovery questions in one call each:
backlinks: what chunks link here? (Both parser edges from[[name]]and manual edges.)related: what chunks are semantically close to this one? (A straight reuse of the body embeddings.)unlinked_mentions: what chunks name this target without linking to it? (A cheap surface for "your writing forgot to link back.")
An agent researching a topic can pull the relevant chunk, ask for its neighbours, and follow the graph. A human editing a document can see who cites it before renaming it. Same three ops, two audiences.
History and diff
Every chunk carries a body-change log. history lists the revisions; get_version returns one revision's exact body; diff returns a unified diff between two revisions. An agent auditing a change can see what changed and when. An operator recovering from a mistake can rewind one chunk without rewinding the document.
Tags and document-level metadata
Tags and per-document type / status are first-class query axes. "Show me every draft RFC," "list every completed research note," and "everything tagged area:memory" are single queries, not joins across chunk metadata.
JSON Canvas round-trip
A document imports and exports as JSON Canvas, the open spatial-graph format Obsidian Canvas uses. A knowledge graph built in loomcycle can move into a tool a human already reads with, and back.
One search, three sources, honest defaults
Retrieval is the seam where memory earns its keep. Loomcycle exposes two related ops.
Memory op=recall scope=user query="which medicine do I use" → facts + notes (default; documents excluded) Memory op=search scope=user query="…" sources=[facts] → facts only Memory op=search scope=user query="…" sources=[documents] → document chunks only Memory op=search scope=user query="…" → everything (facts + notes + documents)
recall is the "what have I been told about X" call. It defaults to facts and notes because a distilled fact and a written note are what an agent asked "what do you remember about the user" should get first. Document prose is available on request, not by default.
search is the wider call: "where did I record this, across every plane." Explicit source selectors narrow it. A backend that ignores the selector must say so via a sources_applied: false flag, and the zero value is false on purpose so a silently-widened result cannot pass as filtered.
Under the hood, retrieval is hybrid: a vector search against per-scope embeddings and a full-text search against the same rows, combined with Reciprocal Rank Fusion. A row that both routes rank well surfaces above rows either alone would have preferred. The combination is generally more resilient than either alone against the failure modes each has (vector search on unusual vocabulary, full-text on paraphrase).
The performance shape is worth naming: filtering to facts on a scope with three thousand chunks is faster, not slower, than a full-plane search. On the reference deployment, thirty milliseconds against nine hundred, because narrowing the candidate pool from ~3,000 to ~42 cuts the vector-scoring work proportionally.
Provenance, on every write
Every memory row (fact, note, document chunk) carries a provenance envelope: tenant_id, source_session_id, source_run_id, source_agent, source_model, and (for facts) the compaction_generation that produced them. Read a memory row and you can trace exactly where it came from.
That is what makes the whole system auditable. An operator asking "why does the runtime believe this fact about a user" can follow the row back to the conversation the fact was extracted from, the model that extracted it, the compaction generation the extractor saw. Nothing is a black box.
A human-facing console
Loomcycle ships @loomcycle/memory-view, a reusable React component scoped under .loomcycle-memory-view. The Web UI consumes it. Any React host can embed it. What it does:
- Browse a scope by
scopeandscope_id: pick a namespace, walk its keys. - Read a fact with its full
entityblock: entity type, class, subject, valid_at, expired_at, retired flag, provenance. - List all facts in a scope newest-first, filterable by type, class, and document ID.
- Run unified search (facts, notes, documents) with source selectors and labelled results.
- Re-embed a scope on demand, with a dry-run preview and a resumable execute pass.
- Edit a fact or a note; the operator's identity is stamped as the writer.
A tenant operator sees only their tenant's data. Cross-tenant reads fold to opaque not-found. The security posture rides on the same principal-resolved identity the rest of the substrate uses, so the console never has to trust a tenant field passed from the caller.
Every plane erasable, on every transport
Memory that cannot be deleted is a liability. Loomcycle ships a subject-erasure surface that runs across every plane a subject has touched: chats, memory k/v, the subject's SQL Memory scope, path entries, credentials, token limits, interrupts.
The erasure runs on HTTP, MCP, gRPC, and both adapters, all through one shared implementation, so which transport an operator picks does not change what the erasure reaches. A pre-erasure report lists what will be removed by tier, with a "residue" section that names what is unreachable and states explicitly that after erasure the report is the only durable record of what was not removed. The residue counter for cross-scope facts is written before deletion, because after the subject's chats are gone the trace handle is gone too. The response is durable proof of the outcome.
Every destructive path is guarded. A live run requires confirm to equal subject: a wrong subject ID that matches nothing is harmless, but a wrong subject ID that matches the wrong person is not. An admin token that names no tenant is refused rather than defaulting to the empty tenant.
Retention: data that ages out on schedule
The other half of memory hygiene is retention. Loomcycle's retention subsystem sweeps three families independently (retired def versions, aged chat sessions, retired-agent memory) with per-family off | prune | export+prune modes and per-family cutoffs. Every family defaults off. Export-then-delete writes a JSON archive before the row leaves the database, so a failed export never deletes anything.
Retention runs as a cluster-gated goroutine, not a schedule, so it does not consume run-admission slots or show up as spurious agent activity. It reads consistently with the same tenant-fold every read op uses.
Composing: what this looks like in practice
A chat agent talking to a user about their symptoms answers "which medicine do I use" and the fact ranks first, not fifth behind a shell-fence chunk. The fact came from a conversation the same user had three weeks ago; it was extracted by the consolidator, ranked at class: preference, subject the user, entity type medication, and it survives the retrieval because recall defaults to facts and notes, not to documents.
An engineering agent working in the codebase reads the RFC on agent teams because the operator wrote it once into /loomcycle/rfcs/agent-teams. The agent found it by name. Six months from now, another agent researching a related question does not read the RFC by name; it asks related on a nearby chunk and finds it as a neighbour in the graph.
A code-review agent asked to check a diff for regressions in the retention subsystem gets a hit on backlinks that points at the RFC BM chunk describing the design; a second hit on unlinked_mentions points at three older reviews that reference the retention behaviour but never linked to the source RFC.
An operator asked to erase a user runs POST /v1/_erasure, gets a report that names every plane the subject touched, and holds a durable receipt after execution that names anything unreachable. The consolidator's provenance chain shows exactly which facts came from that subject's conversations, so nothing hides.
What every write and every read carries
One table, because it captures the shape of the whole system.
| Property | Every write | Every read |
|---|---|---|
| Tenant isolation | Server-stamped from principal | Server-folded from principal |
| Provenance | run, session, agent, model, compaction generation | Available on get_chunk's entity block |
| Temporal validity | valid_at, expired_at | Filtered on "as of now" by default |
| Origin | Server-stamped (fact vs note) | Selectable in search |
| Scope | agent / user / tenant | Confined to the caller's scope |
| Erasability | Every plane addressable by subject | Traceable via source_session_id |
What's next
The pieces above are the whole system today. What comes next lives on top:
- Cross-scope discovery UI: the memory-view console gains a "find everything about this subject across every scope" surface for compliance and audit workflows.
- Rich retention policies: per-tenant retention configuration, and a retention family for Documents once a cross-scope "last read" enumeration surface makes it safe.
- Ontology tooling: making the entity-type ontology first-class in the operator console, with a batch reclassifier for legacy rows.
- Multi-modal facts: extracting facts from images and diagrams via the same consolidator that already handles chat transcripts.
Companion reading: data retention arrives for the sweeper design and the export-then-delete guarantee; the Document viewer for the reading surface that presents this memory; the History tool for the chat surface that talks to it. Reference docs: docs/DOCUMENTS.md, docs/MEMORY-BACKENDS.md.