(MOT-4018) feat(memory): durable cross-session agent memory worker + console page - #496
Conversation
Named banks of always-injected markdown blocks and auto-extracted facts. Append-only fsynced facts.jsonl per bank as the source of truth; the BM25 + entity index is a RAM-only cache rebuilt at boot so store and index can never diverge. Supersede-never-delete history, content-fingerprint idempotent extraction (one router::complete call per completed turn, off the hot path), pinning, per-session bank selection via metadata, fail-open pre-generate injection that appends recalled facts as one message to keep the provider prompt cache warm, and an end-to-end doctor roundtrip that reports degraded states explicitly.
Extraction jobs now flow through the queue surface (engine::queue::enqueue to memory::extract-job with a per-turn receipt id, durable:subscriber binding with retries + DLQ) and fall back to inline extraction when no queue is installed. Every public function doubles as a REST route via the http worker (best-effort registration, worktree pattern). Extraction model, window, and the turn-completed handler validated against a live engine with harness, session-manager, and llm-router.
Extraction now keeps a per-session cursor (last processed entry id) in the state worker under scope memory_cursor and reads only messages that arrived since the previous pass, walking session pages to the tail. This cuts extraction input tokens by roughly the conversation depth (the old fixed-window fetch also silently read the OLDEST page on long sessions, so the cursor fixes a correctness bug at the same time). The cursor advances only after a successful pass, is dropped on session::deleted, and any state miss degrades to the previous newest-window behavior. Injected blocks now respect a max_block_chars budget (default 6000): blocks are injected into every turn's system prompt, so over-budget content is truncated with a visible marker, a warning log, and a memory_blocks_truncated annotation instead of taxing every call forever. Memory hook spans are no longer trace_hidden: seeing which bank and facts fed each turn in the trace timeline is a product requirement.
Models asked to 'remember X' apologized that they cannot save to memory (no save function exposed), telling users capture failed while background extraction had already banked the fact. The injected memory section now always carries a short notice that capture is automatic and needs no function call — stable per session, so the provider prompt cache is unaffected. The system-prompt mutation now applies whenever a bank resolves (previously only when blocks existed).
New presence-gated Memory view following the Worktrees page patterns: worker-presence probe gates the nav entry, typed zod wrappers over the memory worker functions, live refresh off memory::item-changed and memory::bank-changed with a poll fallback while bindings are unavailable. Panels: bank rail with counts and inline create; facts with save, pin, in-place edit, tombstone delete, and a show-history toggle over superseded records; a graph view drawing entity hubs with facts as spokes (deterministic golden-angle layout, render-time projection of the flat store, pinned facts in accent, click a node for an inspect card); markdown block editors equivalent to editing the files on disk; and a recall dry-run using the exact scorer the injection hook uses, with scores shown.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a durable memory worker with banked memories, rules, recall, extraction, events, filesystem persistence, optional embeddings, a consolidation worker, and console integration for routing, bank selection, live management, and assistant memory metadata. ChangesMemory worker foundations
Consolidation worker
Console and session integration
Documentation and schemas
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
skill-check — worker0 verified, 44 skipped (no docs/).
Four for four. Nicely done. |
The graph now uses level-of-detail: entity hubs only by default (sized by fact count, capped to the top 40 by degree with the hidden count named), click a hub to expand its facts as spokes (pinned first, then newest, capped with a +N more node linking to the facts tab), small banks auto-expand. Wheel zoom around the cursor, drag pan, fit reset, all pure SVG viewBox math. Hub labels sit above the node with a halo so spokes cannot overlap them, long entity names truncate, and a banner names when only the newest page of facts is being mapped. The facts panel pages server-side (100 per page with explicit controls over the reported total) and gains a search box that runs memory::recall with scores instead of filtering client-side, so finding one fact among ten thousand costs one ranked call. Bank switches and history toggles reset paging. Block editors cap at 24 visible rows.
Boot-order race: in an orderly startup wave the siblings owning harness::hook::pre-generate, harness::turn-completed, session::deleted, and durable:subscriber may register after this worker, so the boot-time binding requests died with trigger_type_not_found — silently, since registration acks are async — and injection plus extraction never ran until a manual restart. A retry task now polls engine::triggers::info and re-requests each binding (plus the idempotent queue::define) every 15s until all are confirmed, mirroring approval-gate's hook retry. Identity questions recalled nothing: 'who am i' shares no words with facts phrased third-person about the user, so BM25 returned empty and turns got no memory. First-person pronouns in a recall query now expand to the user entity handle, and injection pads thin recall results with the bank's strongest facts (pinned, then corroboration, then recency) up to a small ambient floor — still bounded by the same count and token budgets.
Layout: few entities sit on a centered ring sized from the widest cluster (a single entity sits dead center), spokes distribute evenly around the full circle, and spacing derives from cluster extents, so small banks read as a tight constellation instead of dots scattered across empty space. Many entities keep the golden-angle spiral. Visuals: entity labels move into bordered chips above their squares, fact dots grow with a background stroke, hovering a dot shows the fact text inline (with a halo) so the map is readable without clicking, non-active edges dim while one fact is focused, and a subtle dot-grid pattern gives the canvas the schematic paper feel. The refresh button now does something live events cannot: it calls memory::reload to re-read every bank from disk before refetching, so hand-edited blocks and facts files show up. Relabeled reload from disk with a tooltip saying exactly that.
Hubs and fact dots are draggable: dragging a hub carries its whole dandelion (spokes and edges follow), dragging a fact moves just that dot, and the background stays pan. Manual positions live as offsets over the deterministic layout, so live refreshes never snap moved nodes back; a reset layout button appears once anything has been moved. Pointer travel under a few pixels still counts as a click (expand or inspect), so drag and click coexist on the same nodes.
The panel now states the actual contract (facts are recalled only when they match the question; blocks ride the system prompt on every turn, guaranteed) and the empty state walks through a first block that demonstrably changes behavior.
The 'which memory am I using' control from the design discussion: writing blogs, pick the blog bank; switch to coding, a different memory applies; contexts never bleed. One compact dropdown next to the working-directory picker, gated on the memory worker's presence. Bank choice rides the console's existing session-metadata convention (memory_bank in metadataFor, parsed back in conversationFromMeta), so it persists with the session, applies to drafts on creation, takes effect on the next turn for mid-conversation switches, and round-trips with selections made from the CLI or other clients. 'auto' defers to the memory worker's configured default bank; options show fact and block counts.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@memory-consolidate/src/functions.rs`:
- Around line 82-85: Update the public memory-consolidate::run entrypoint to
acquire and hold run_gate for the full execution, matching tick’s serialization
behavior. Ensure all manual and scheduled calls share this same mutex before
performing budget checks or memory::save/memory::supersede writes, while
avoiding a second lock acquisition if run is invoked from an already-locked
path.
In `@memory-consolidate/src/main.rs`:
- Around line 168-173: Update the configuration-fetch error branch in the cfg
initialization to use default WorkerConfig values with enabled explicitly set to
false, keeping dry_run and all other defaults unchanged. Preserve the
successfully loaded configuration path, including normal defaults for an empty
valid configuration.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 7b8f308f-5872-458e-b9e0-993cc2d68e87
📒 Files selected for processing (7)
memory-consolidate/README.mdmemory-consolidate/iii-permissions.yamlmemory-consolidate/src/consolidate.rsmemory-consolidate/src/functions.rsmemory-consolidate/src/main.rsmemory/src/functions/items.rstech-specs/2026-06-agentic/memory.md
🚧 Files skipped from review as they are similar to previous changes (4)
- memory-consolidate/iii-permissions.yaml
- memory-consolidate/README.md
- tech-specs/2026-06-agentic/memory.md
- memory-consolidate/src/consolidate.rs
memory-consolidate::run now holds the run gate for the whole pass (shared with the scheduled tick via a non-reentrant run_locked body), so manual and scheduled passes can never interleave writes. A failed config load registers with the schedule disabled instead of running default settings against memory history; hot reload re-enables it.
Brings memory and memory-consolidate up to the repo's test standard (llm-router, provider-openai, session-manager): every catalog entry is snapshotted to a committed tests/golden/schemas/<id>.json — 15 files for memory, 2 for memory-consolidate — with the shared UPDATE_GOLDENS harness and an orphan-golden check, so any wire or description change lands as an explicit reviewed diff. memory-consolidate's catalog now uses the same draft07 generator settings iii-sdk applies at registration, pinning exactly what registration emits.
Survey: memory sat at 36 tests and memory-consolidate at 13 while siblings run 100-224 (session-manager 141, queue 131, harness 224). memory 36 -> 73: pure-logic extraction makes the hook path testable — select_turn_bank, build_rules_section (budget, visible truncation marker, char-boundary cut, omit-by-name), apply_ambient_floor, memories_message (token budget, body/ids lockstep) — plus a harness envelope contract test (real pre-generate shape tolerated, mutations wire shape pinned), save_transition invariants (reinforce, resurrect revision continuity, entity normalization cap), extract boundary clamp + parse edge cases, BM25 scoring properties (CJK bigrams, idf, pin/corroboration/entity boosts, monotonic decay), and store behaviors (vector staleness on edit and on reload, model-mismatch backfill, lexical history recall, top-memories ordering, hand-edit reload, paging). memory-consolidate 13 -> 25: schedule policy extracted pure (is_due slack boundaries, clock regression, catch-up-on-boot; checkpoint requires every bank clean) plus plan properties (unicode folding, winner precedence chain, pinned-vs-corroboration, reorder isolation from writable groups, input-order determinism).
…tion Tags (team request): memories carry normalized topical labels for filtering WITHIN a bank — organization, not ranking. Set on save/update (re-observation adds, never removes), suggested automatically by extraction, new memory::tags surface returns a bank's tag cloud, and memory::list / memory::recall take a tag filter. Old records load untagged; no migration. Console: tag chips on rows, a clickable tag cloud that filters the memories tab, tags rendered with counts. Consolidate LLM tier (off by default, llm_assist_enabled): one router::complete judge per bank after the deterministic pass. Reorder groups merge only when the judge confirms identical meaning; memories re-observed promote_corroboration_threshold+ times can be promoted as one-line standing instructions into the bank's learned rule — append-only, fingerprint-deduped, judge restricted to offered candidates. Fail-soft: router trouble skips the tier and names it in the report. Recall tab DX: explains itself in chat terms and offers clickable example questions derived from the bank's own tags and entities, so the first recall is one click. Live-verified end to end: tag save/cloud/filters on the rig; judge merged a same-meaning reorder (retired with pointer, winner reinforced), left the Alice-manages-Bob role swap alone, and promoted a 4x-observed instruction into learned.md.
…nest copy Mike's review: one-line memories read as low-value because nothing showed WHERE they came from or THAT capture is continuously working. - Every extracted memory row now links to the conversation it came from (opens in the chat dock) and carries a relative capture time; explicit saves are labeled as such. - A capture-activity strip heads the memories tab: 30-day per-day bars plus a captured-this-week count, so the bank visibly grows as you chat. - Explainer copy on rules and memories rewritten in concrete product terms (what happens, where files live, what a correction in chat does) replacing the generic filler; empty states now tell you the exact first action to take and what you will see happen.
The memories tab already has ranked recall search, which made the recall tab redundant. It is now the thing neither surface did: a full turn preview. New memory::preview composes everything a turn on the bank would be given — the system-prompt memory section with rules, budgets, and truncation markers applied, the memories after the ambient floor and token budget, and the appended message verbatim — by running the hook's own helpers, so the preview cannot drift from production. Console tab renamed to preview: collapsible system-prompt card, appended memories in injection order with ambient entries labeled, and the try-suggestions retained.
create-tag options and release tag patterns gain memory and memory-consolidate; memory joins the skills-publish allowlist (it ships skills/). Changed workers bumped for the surface this PR adds: llm-router 1.2.0 and provider-openai 1.2.0 (embed), console 1.7.0 (memory page, bank picker, chips), session-manager 1.0.6 (entry origin on messages).
# Conflicts: # console/Cargo.lock # console/Cargo.toml
console 1.6.5 (latest released 1.6.4), llm-router 1.1.2 and provider-openai 1.1.2 (latest released 1.1.1), session-manager stays 1.0.6 over 1.0.5. Versioning convention: always the next patch over the newest release, never a minor jump.
MOT-4018
memory: durable cross-session agent memory
New worker family member: named banks of always-injected markdown rules plus auto-extracted memories, with hybrid recall and a console page. Fills the slot the context-manager spec reserves: "durable cross-session memory belongs in a dedicated sibling worker."
Vocabulary (per team review): rules are markdown documents injected whole into every turn's system prompt; memories are extracted or saved records recalled on demand. Function ids, response fields, annotations, config keys, and the disk layout all use these two names.
Design
rules/*.md(injected whole into the system prompt every turn) andmemories.jsonl(append-only, fsync before RAM, last-wins replay by id). The BM25 index is a RAM-only cache rebuilt at boot, so store and index cannot diverge across restarts. An unwritable data dir is boot-fatal: a memory worker must never silently run in RAM..trash/. Pinned memories are untouchable by every automatic path.router::complete, content-fingerprinted so redelivery and re-observation reinforce instead of duplicating, and incremental: a per-session cursor in the state worker (scopememory_cursor) means each pass reads only messages that arrived since the previous pass. Recall is BM25 (unicode tokenizer with CJK bigrams) + entity match + corroboration + pinned bonus + recency half-life, fused with a semantic cosine signal when embeddings are available, no LLM.router::embed(new llm-router surface) resolves an embed-capable provider from the provider registry;provider-openaigrowsprovider::openai::embed. Memory keeps per-bank vector sidecars (vectors.jsonl, derived data), embeds queries at recall time with a hard 2s budget, and backfills unembedded memories in the background. No provider configured = clean degrade to lexical;memory::recall.retrievalnames the mode that actually ran.learnedrule, fingerprint-deduped, capped per pass,rule_learning_enabledflag. Correcting the agent in chat updates the system prompt for every later turn; hand-authored rules are never touched, and the learned rule is reviewable and editable in the rules tab.memory_bank, then session metadata, then the configured default. A session-lookup failure injects nothing rather than falling back across banks.memory::doctorruns a real save, recall, trash roundtrip plus sibling reachability, and recall responses name the retrieval mode that ran. Hook spans are deliberately not trace hidden: which bank and which memories fed a turn is product surface, not plumbing.Reuse
session-manager (transcripts, bank metadata,
session::deletedGC, and entryoriginnow exposed onsession::messagesso the console can render per-reply memory chips), llm-router (extraction, model catalog,router::embed), provider-openai (provider::openai::embed), state (extraction cursors only; memories never live in KV), queue (durable extraction jobs with retries and DLQ viaengine::queue::enqueueplus a receipt id per turn; inline fallback when absent), http (every public function doubles as a REST route), configuration (Path B schema + hot reload, including a livedata_dirstore swap).Surface
14 public functions (
memory::bank::create/list/delete,memory::save/get/list/update/delete/pin,memory::recall,memory::rule::list/set,memory::doctor,memory::reload), 2 trigger types (memory::item-changed,memory::bank-changed, filterable by bank), bindings onharness::hook::pre-generate(fail-open, priority 100),harness::turn-completed,session::deleted, anddurable:subscriber. Injection budget:max_rule_charstruncates over-budget rules with a visible marker and an annotation; recalled memories are capped by count and token budget and appended as one message so the provider prompt cache stays warm. Hook annotations (memory_bank,memory_recalled,memory_ids,memory_rules,memory_rules_truncated,memory_retrieval) land on the entry origin for traces and the console chip.Console
Presence-gated Memory view following the Worktrees patterns: bank rail with inline create; a rules tab (markdown editors, first tab) and a memories tab (save, pin, in-place edit, tombstone delete, show-history toggle, recall-powered search, server-side paging); a graph panel drawing entity hubs with memories as spokes (level-of-detail for large banks, draggable nodes, wheel zoom/pan); and a recall dry-run using the exact scorer the injection hook uses. Live refresh off both trigger types with a poll fallback. In chat: a bank picker in the composer (session metadata
memory_bank) and a memory chip on each assistant reply showing which bank, how many rules and memories fed the turn, and whether recall ran semantic — click to expand the exact records.Validation
Live on a full rig (engine + harness + session-manager + llm-router + providers): injection proven (a bank-scoped session answered style questions from the rule verbatim), extraction proven (memories stated in conversation banked without explicit saves and recalled in later turns), bank isolation proven (a memory in one bank invisible to sessions on another), semantic recall proven (zero-vocabulary queries land: "when do I publish articles" recalls the Tuesday-mornings memory), doctor green end to end, config hot-reload exercised live. 32 Rust tests plus the console page typecheck/tests/build.
cargo fmt --check,clippy -D warnings,cargo testall green.Tags and the judge tier
Memories carry normalized topical tags for filtering within a bank (team request): set on save/update, suggested by extraction,
memory::tagsreturns the bank's tag cloud, andmemory::list/memory::recalltake atagfilter. Console renders tag chips and a clickable filter cloud. Old records load untagged — no migration.memory-consolidate grows an optional LLM tier (
llm_assist_enabled, off by default): onerouter::completejudge per bank confirms which word-order reorder groups really are equivalent (role swaps like "Alice manages Bob" stay untouched) and promotes standing instructions observedpromote_corroboration_threshold+ times into the bank'slearnedrule — append-only, fingerprint-deduped, judge restricted to offered candidates, fail-soft when the router is unavailable.The memory family
memory-consolidateships alongside as the hygiene sibling (same decomposition as llm-router + providers): deterministic dedup of near-duplicate memories, applied strictly through the newmemory::supersedeseam (tombstone + pointer, pinned rejected, agent-denied) plus fingerprint-matched reinforcement of the survivor. Self-scheduled with catch-up-on-boot semantics (last pass persisted in the state worker);dry_runplans without writing. Removable without touching stored memory.Out of scope, tracked separately
Release wiring (
create-tag.yml,release.yml, registry publish).