Feature Specification: Memory (Shared Agent Memory)
中文版: spec.zh.md
Feature Branch: feature/kb-memory-redesignCreated: 2026-05-22 Status: Accepted (redesign — in development) Input: Redesign of Coffer's memory feature — the memory face of one unified substrate shared with the knowledge base (spec 006). Memory is no longer a mem0 vector store with an LLM at write time; it becomes a single shared source of truth across agents (no divergent per-agent copies). Canonical storage is per-item markdown files under a per-scope knowledge/ lane (freshly-remembered items in knowledge/inbox/) under ~/.coffer/memory/, with a two-layer scope (global + per-project) and no derived index file (the prior MEMORY.md projection is removed — read by nothing in retrieval). Agents read and write memory only through Coffer's MCP gateway (coffer__recall/remember/list_memory); editing and deleting are user surfaces (REST/CLI/external editor), not MCP tools. Coffer keeps its own canonical format and does not touch agents' native memory files (native projection was removed — see ADR-026). The user does full CRUD in the Coffer UI. Retrieval uses the same engine as the knowledge base (grep / keyword FTS5+BM25 / vector sqlite-vec). See ADR-012 for the full design rationale.
User Scenarios & Testing
User Story 1 — One memory, every agent (Priority: P1)
The developer works on a project with Claude Code in the morning and Codex in the afternoon. While using Claude Code the agent learns "this repo deploys via make release, never git push --tags directly" and records it through Coffer's coffer__remember tool. In the afternoon, Codex — a different agent — recalls the same fact because both agents read and write one shared store. No copy diverges.
Why this priority: This is the core of the redesign. A per-agent silo that drifts across agents is the problem being solved; without a single shared source of truth there is no feature.
Independent Test: From a fresh install, run an MCP client in a git project, call coffer__remember with a project fact, then from a second MCP client (different agent identity) in the same project call coffer__recall and observe the fact returned. Confirm the same fact appears as a per-item markdown file under the project store's knowledge/inbox/ lane.
Covering scenarios:
- agent remembers a project fact
- agent recalls a project fact
- recall spans project and global scope
- remembered items are stored in the knowledge lane
- built-in memory tools appear in client tool list
- vector recall falls back when embedding is unconfigured
User Story 2 — Global and per-project memory (Priority: P1)
Some facts are about the developer everywhere ("prefers tabs over spaces"); others are about one repo ("this service's API base path is /api/v2"). The developer wants global facts available in every project and project facts confined to their project, with recall returning both by default.
Why this priority: Mixing personal preferences with project-specific facts pollutes recall and leaks repo details across projects. Two-layer scope is what makes the shared store trustworthy.
Independent Test: Remember one fact with scope=global and one with scope=project. From a different project, recall returns only the global fact; from the original project, recall returns both.
Covering scenarios:
- remember at global scope
- agent remembers a project fact
- project scope resolves from the agent's working directory
- recall spans project and global scope
User Story 4 — User curates memory in Coffer (Priority: P2)
The developer wants to see and correct what agents remember: browse facts per scope in a read-only viewer, then fix one that drifted in their own external editor (or via the API/CLI), add a fact by hand, delete a wrong one. The Coffer UI never edits fact content in-app; instead each fact and its containing folder offer "open in external editor" and "reveal in file manager" (daemon-backed on the web, native on the desktop), and any out-of-band correction is picked up by the existing lazy reindex-on-read (FR-010).
Why this priority: Memory without human curation is uncomfortable; agents sometimes record wrong things. Curation makes the feature safe to leave on — and routing edits through the user's own editor keeps the markdown files the sole source of truth without a second editing surface to keep in sync.
Independent Test: After agents have written facts, open the memory view (read-only) and confirm fact content renders but is not editable in-app. Open one fact in an external editor (or coffer memory edit/PATCH), correct its text outside Coffer, and observe the next recall returns the corrected version (lazy reindex-on-read). Add a fact (actor=user) via the CLI/API, then delete a different one and observe it gone from disk and recall.
Covering scenarios:
- user adds a fact
- user corrects a fact out-of-band
- user deletes a fact
- read-only viewer offers open/reveal affordances
User Story 6 — Distil insights from an agent's past conversations into shared memory (Priority: P2)
The developer has been working on a project with Claude Code for weeks. Many engineering decisions, failed approaches, and project conventions were discussed and settled in those sessions, but never explicitly recorded as memory facts. The developer runs coffer transcript distill claude_code --project /repo (or clicks "Distil to memory" in the Coffer UI). Coffer reads the local .jsonl transcript files, scrubs tool payloads and secrets, asks an LLM to extract durable insights, and appends them as project-scoped journal entries (episodic memory). From that point on, any agent — including Codex on a second machine — can recall those entries through coffer__recall (the journal lane participates in recall, FR-043), because memory is shared (Spec 007) and synced (Spec 010). No raw transcript content is ever stored or transmitted. When the transcript's working directory resolves to a git project, the insights are appended to that project's journal; a session whose path is not inside a git work-tree is skipped — there is no global journal.
Why this priority: Agents accumulate institutional knowledge in local transcripts that is otherwise siloed per-session and inaccessible to other agents. Distillation is the least-invasive mechanism to surface that knowledge: it produces journal entries, inheriting cross-agent sharing and multi-machine sync for free. It is P2 (not P1) because the core shared memory flow (Stories 1–2) must work first — distillation is additive on top of it. See ADR-020 for the full decision rationale and rejected alternatives.
Supported transcript readers: distillation reads each agent's native local store through a versioned, defensive per-agent reader. Claude Code and Codex read one .jsonl file per session under the agent's config dir; OpenCode reads its multi-file JSON storage tree under the XDG data dir (~/.local/share/opencode/storage/{project,session,message,part}), joining the records into sessions with the project working directory taken from the project record. Readers for Cursor, OpenClaw, and Hermes are deferred: their formats can't be read reliably for project-scoped distillation today — Cursor's agent-transcripts/*.jsonl are ephemeral (emptied on restart) with the durable state in an internal vscdb SQLite; OpenClaw's session format is undocumented; and Hermes sessions are cross-platform chat sessions that record no working directory, so they can't be scoped to a project. Distillation for those agents returns an explicit "unsupported agent" error rather than guessing.
Independent Test: From a project with at least one Claude Code, Codex, or OpenCode transcript in the agent's native store, run coffer transcript distill <agent> --project <path> --dry-run and observe at least one insight printed without any fact being written to disk. Then run without --dry-run and confirm via coffer memory recall <store> "<topic>" that at least one distilled journal entry is now retrievable and contains no tool payloads, file contents, or secret-like strings (the journal_append audit records the writing actor; the episodic entry text carries no frontmatter).
Covering scenarios:
- distill transcript to memory
User Story 5 — Inspect, name, and reset memory (Priority: P3)
The developer wants to know how much memory has accumulated per scope, to give a store a readable name when its originating folder is unknown, and to clear a scope without deleting the store.
Why this priority: Hygiene; not blocking the core flow.
Independent Test: View per-store metrics (fact count, disk bytes). Rename a store whose folder is unknown and confirm the chosen name shows in the list and survives a reload. Clear the project scope; confirm every fact is gone but the store remains, ready for new facts.
Covering scenarios:
- clear a memory scope
- user renames a memory store
(The per-store metrics HTTP route is exercised by the independent test but its dedicated acceptance test is deferred — see the note after the scenarios.)
User Story 7 — Continue the same work across agents and machines (Priority: P2)
The developer pauses mid-task with Claude Code — at a known step, with specific next steps and files in flight — and later resumes from a different agent (Codex) or a second machine. Before pausing the agent calls coffer__set_handoff with the current working state ("现场"): what it was doing, what's next, which files are open, and any unresolved questions. Coffer keys that scene by (project × git branch) and writes it as a file under the project's memory store, so it rides the existing git sync mirror. When work resumes, the agent calls coffer__resume, which returns the saved scene for the current branch (annotated with how stale it may be) — or reports that none exists for a fresh branch.
Why this priority: Continuity is the redesign's north star, but it builds on the shared-memory core (Stories 1–2): a handoff is an additive working-memory lane, not a prerequisite for recall. Branch-keying means parallel branches / worktrees keep independent scenes and never clobber each other; there is no global handoff (a global "current task" is meaningless), so a cwd outside any git project has nothing to resume.
Independent Test: From an MCP client inside a git project on branch work, call coffer__set_handoff with a body, then from a second client (different agent identity) in the same project + branch call coffer__resume and observe the same body returned with the branch and a freshness annotation. On a fresh branch with no prior handoff, coffer__resume reports found=false.
Covering scenarios:
- agent saves and resumes a working-state handoff
- resume reports no handoff for a fresh branch
User Story 8 — Rules arrive ambiently at the start of every session (Priority: P2)
The developer has accumulated behavioural rules in Coffer (global "always run the verify step before pushing", project "this repo deploys via make release") and wants them in front of the agent automatically at the start of every session — for both Claude Code in the morning and Codex in the afternoon — without the agent having to remember to call recall, and without Coffer writing into the agent's own memory or instruction files (ADR-026). When a session starts, a Coffer-installed SessionStart hook asks Coffer for a rules bundle (the always-on global rules plus the current project's rules when the cwd resolves to a git project) and injects it into the session as context only. The bundle also carries two Coffer-seeded built-in rules: call coffer__resume() to continue prior work, and prefer coffer__remember/coffer__recall over the agent's native memory. When work closes, a SessionEnd hook (Claude Code only) distils the just-finished session into the journal immediately; Codex — which has no session-end event — falls back to the FR-046 catch-up sweep. A developer who wants a clean separation can opt in to disable_native_memory, which turns the agent's own native memory off (and restores it on uninstall).
Why this priority: rules are useless if the agent never reads them. Ambient session-start injection is the non-intrusive way (ADR-026's anticipated path) to make Coffer's procedural memory present without recall-discipline and without touching native files. It is P2 (not P1) because it builds on the rules lane (FR-036) and the shared-memory core (Stories 1–2): injection delivers rules that already exist, and a hook-less or failed injection never blocks the agent.
Independent Test: Register a Claude Code agent, install its hooks, write one global rule and one project rule, then start a session inside that git project and observe the injected additionalContext contains both rules plus the two seeded built-in rules — with no write to ~/.claude/CLAUDE.md or the agent's native memory. Repeat for Codex (~/.codex/hooks.json); confirm the same bundle arrives at SessionStart and that no SessionEnd hook is installed for Codex. Stop the daemon and start a session: the hook prints nothing and exits 0, and the agent starts normally. Toggle disable_native_memory on and confirm the agent's native-memory setting is written off; toggle it off (or uninstall) and confirm the setting is restored.
Covering scenarios:
- rules bundle is injected at session start as context only
- the bundle carries the two seeded built-in rules
- session-end distils the closed session into the journal
- a failed or hook-less injection never blocks the agent
- disable_native_memory turns native memory off and restores it
User Story 9 — Read the whole store, lane by lane (Priority: P2)
The developer opens a memory store in Coffer and wants to see everything the store holds, not just the flat fact list: the semantic Knowledge facts, the procedural Rules document, the time-ordered Journal of episodic entries, the per-branch Handoff scenes, and the organizer's consolidation changelog. The memory store detail page presents the store as four lane sections (Knowledge / Rules / Journal / Handoff) plus a consolidation-changelog view. Each lane gets a shape-fit view: Knowledge keeps the fact/topic list + content (and remains what recall operates over), Rules is a single document, Journal is a time-ordered list (newest first), and Handoff is a per-branch list. Every view is read-only, renders through the unified file preview (no hand-styled <pre>), and offers open in external editor / reveal in file manager / copy path for the underlying lane files — files-as-truth (FR-017, FR-021), so the developer corrects content in their own editor and the change is picked up by lazy reindex-on-read (FR-010).
Why this priority: a flat fact list hides three of the four lanes — rules live outside recall, the journal is episodic, and handoff scenes are working state — so the store's procedural, episodic, and continuity memory is invisible in the UI even though it is all on disk. Surfacing each lane in a shape that fits it makes the whole store legible. It is P2 (not P1) because it is a read-only projection over lanes the shared-memory core (Stories 1–2), the journal (Story 6), the handoff (Story 7), and the rules/organizer lanes already populate — it adds visibility, never a new write path.
Independent Test: Populate a project store with a fact, a rule, a journal entry, a handoff scene, and an organizer run that writes a consolidation changelog. Open the store detail page and confirm five views: the Knowledge lane shows the fact, Rules shows the rules document, Journal lists the entry (newest first), Handoff lists the branch scene, and the changelog view shows the 固化 log — each read-only, each rendered via the unified file preview, each offering open-in-editor / reveal / copy-path on its lane file. Confirm recall still operates over the Knowledge lane only.
Covering scenarios (the READ ENDPOINTS the views consume):
- journal lane entries are readable for a store
- handoff scenes are listed per branch for a store
- the consolidation changelog is readable for a store
(The four-lane page rendering itself is verified by frontend tests; like the other desktop-view items its desktop acceptance is deferred to e2e. The three scenarios above pin the read endpoints the lane views consume.)
Edge Cases
- Vector unavailable but embedding unconfigured: when the store's resolved strategy needs vectors but no embedding provider is configured,
recallfalls back to keyword internally and returns results; it never blocks. The fallback is NOT surfaced as a query-time response flag. Default retrieval is keyword+grep (zero config, offline). - Resume on a fresh branch: when no handoff has been saved for the current (project × branch),
coffer__resumereturnsfound=falserather than erroring; nothing is fabricated. - Handoff outside a git project: a cwd not inside a git project has no project scope and no branch, so
coffer__resumereturnsfound=falseandcoffer__set_handoffis rejected (there is no global handoff). - Direct disk edit of a fact file: the next
recalllazily scans the small fact dir for deltas and reindexes, so out-of-band edits are picked up with no watcher. - Empty fact text: rejected at the API boundary; nothing written.
- Fact text too long: bounded at the API boundary (
max_fact_chars, default 8192); rejected before any write. - Project scope unresolved: if the agent's working directory is not inside a git project,
scope=projectis rejected with a clear error;scope=globalstill works. - Injection with the daemon down: when the SessionStart hook cannot reach the daemon (not running, timeout, or any error), it prints nothing and exits 0 — the session starts with no injected bundle and is never blocked.
- Injection cwd outside a git project: the bundle still carries the global rules and the two seeded built-in rules; no project rules are included (there is no project scope to resolve).
- SessionEnd on Codex: Codex emits no session-end event, so no SessionEnd hook is installed for it; its sessions are distilled by the FR-046 catch-up sweep instead, which remains the write guarantee.
- Disable-native-memory toggle off / uninstall: turning
disable_native_memoryoff (or uninstalling) restores the agent's native-memory setting to its prior state; the default (off) never touches native memory at all (ADR-026).
Acceptance Scenarios
Every scenario maps to at least one test marked @pytest.mark.acceptance(spec="007-memory", scenario="…").
Scenario: project memory follows the repository across checkout paths
- Given the same repository (same
originremote) checked out at different paths — e.g. on two synced machines with different usernames - When each checkout resolves its per-project memory store
- Then both resolve to the same store (same project ULID)
- And a store provisioned under the legacy path-derived id is adopted under the portable id on first resolve, keeping its facts, root mapping, and label
Scenario: agent remembers a project fact
- Given an MCP client running inside a git project,
- When it calls
coffer__rememberwith a fact andscope=project, - Then a per-item markdown file (YAML frontmatter
title/description/metadata.actor/origin_session_id+ body) is written under the project memory dir'sknowledge/inbox/subdir, the file is indexed intodocuments, and an audit entry is recorded.
Scenario: agent recalls a project fact
- Given a project memory store with facts,
- When an MCP client calls
coffer__recallwith a query, - Then ranked facts are returned with id, text, score, source, and time, after a lazy reindex scan of the fact dir picks up any out-of-band deltas.
Scenario: recall spans project and global scope
- Given facts exist at both global and project scope,
- When an MCP client calls
coffer__recallwithout a scope, - Then results are drawn from both the project store and the global (sentinel) store.
Scenario: remembered items are stored in the knowledge lane
- Given an MCP client running inside a git project,
- When it calls
coffer__rememberwith a fact, - Then the item is written as a markdown file under the project store's
knowledge/inbox/subdir (never at the store root and with noMEMORY.mdgenerated), it is indexed intodocuments, and a subsequentcoffer__recallreturns it.
Scenario: remember at global scope
- Given an MCP client,
- When it calls
coffer__rememberwithscope=global, - Then the fact is written to the global store keyed by
project_id = WORKSPACE_GLOBAL_PROJECT_IDand recall from any project returns it.
Scenario: project scope resolves from the agent's working directory
- Given the coffer-mcp-shim reports its launch cwd at session handshake,
- When the daemon resolves the project memory store,
- Then it computes the git-root of that cwd and resolves (lazily provisioning if absent) the per-project store keyed by that project's ULID.
Scenario: out-of-band fact-file edits are visible on recall
- Given a project memory store with facts,
- When a fact file is edited out-of-band directly on disk (frontmatter preserved),
- Then the next
coffer__recallreturns the edited content (lazy reindex-on-read), with no filesystem watcher running.
Scenario: user adds a fact
- Given a memory store,
- When the user adds a fact via the Coffer UI or CLI,
- Then the canonical markdown is written under the store's
knowledge/inbox/subdir withmetadata.actor = "user", the document is indexed, and an audit entry is recorded.
Scenario: user corrects a fact out-of-band
- Given a fact exists,
- When the user corrects its text outside the in-app viewer — via the REST/CLI write surface (
PATCH …/facts/{id}/coffer memory edit) or by editing the canonical markdown directly in an external editor, - Then the canonical markdown is rewritten, the document is reindexed (immediately for the REST/CLI path; on the next
recallvia lazy reindex-on-read for a direct file edit), and recall reflects the new text.
Scenario: user deletes a fact
- Given a fact exists,
- When the user deletes it,
- Then the markdown file and its index rows are removed, and recall no longer returns it.
Scenario: read-only viewer offers open/reveal affordances
- Given a fact viewed in the Coffer UI,
- When the user inspects the fact (and its containing folder),
- Then the content renders read-only (no in-app content editing), the read responses surface the fact's absolute on-disk
.mdpath and its containing folder's absolute path, and the UI offers "open in external editor" + "reveal in file manager" for both the file and the folder on both surfaces — desktop (Tauri) via the OS opener, web via the loopback daemon (spec 004 FR-039) — with no copy-path fallback; which editor opens is decided by the global preferred-editor preference (see 002-ui-shell).
Scenario: clear a memory scope
- Given a memory store with facts,
- When the user clears that scope,
- Then every memory item under
knowledge/is removed and its index rows dropped, but the store Resource is preserved.
Scenario: user renames a memory store
- Given a memory store (e.g. one whose originating folder was never recorded, so it would otherwise show as
project-<ULID>), - When the user sets a display label via
PATCH /memory_stores/{name}/label, - Then the label is trimmed, echoed back, surfaced on the store read + list as the readable name, and an empty / whitespace label clears it (reverting to the FR-017a derivation); renaming an unknown store is a 404, not an autocreate.
Scenario: built-in memory tools appear in client tool list
- Given an MCP client connects to coffer's gateway,
- When the client lists tools,
- Then
coffer__recall,coffer__remember,coffer__list_memory,coffer__set_handoff, andcoffer__resumeappear alongside other built-in and upstream tools.
Scenario: vector recall falls back when embedding is unconfigured
- Given a memory store with no embedding provider configured,
- When the engine resolves to a vector strategy but no embedder is available,
- Then the call runs a keyword search instead and returns results with no error; the degradation is NOT surfaced as a query-time response flag (the internal keyword fallback, like the KB face).
Scenario: agent saves and resumes a working-state handoff
- Given an MCP client running inside a git project on a branch,
- When it calls
coffer__set_handoffwith a body and later (possibly as a different agent) callscoffer__resume, - Then
set_handoffwrites a per-(project × branch)markdown file (frontmatterbranch/updated_at+ freeform body) under the project store'shandoff/subdir — overwriting any prior scene for that branch and recording ahandoff_setaudit entry — andresumereturnsfound=truewith the saved branch, body,updated_at, and a freshnessnote; the handoff is never returned bycoffer__recall.
Scenario: resume reports no handoff for a fresh branch
- Given an MCP client in a git project on a branch with no saved handoff (or a cwd outside any git project),
- When it calls
coffer__resume, - Then the call returns
found=false(never an error and nothing fabricated).
Scenario: distill-transcript-to-memory
- Given a registered agent (Claude Code, Codex, or OpenCode) with at least one local transcript in its native store containing natural-language turns,
- When
POST /api/v1/agents/{name}/transcripts/distillis called (orcoffer transcript distill <agent>in the CLI) withdry_run=false, - Then the transcript is read, tool payloads and secrets are scrubbed before the LLM call, the LLM returns structured insights (each just
name/description/body— distillation does NOT classify a per-insight type), and each insight is appended as a project-scoped journal entry (episodic memory,actor="agent"recorded in thejournal_appendaudit) — never a flat knowledge fact; a session whose path is not inside a git project is skipped (there is no global journal); no raw transcript content appears in any persisted entry;coffer__recallsubsequently returns the new journal entries (FR-043); and whendry_run=true, insights are returned but nothing is written to disk.
Scenario: browse an agent's transcript history with title, search, and sort
- Given a registered agent with several local transcript sessions across more than one project,
- When
GET /api/v1/agents/{name}/transcriptsis called with a search query, a project filter, and a sort key (started_atorlast_activity_at), - Then each returned session summary carries a derived title, message count,
started_at,last_activity_at, and the session file's absolute source path; only sessions whose title or project path matches the search and whose project matches the filter are returned, ordered by the requested sort key and direction, and paged bylimit/offsetalongside the matched total.
Scenario: the organizer drains the inbox into a topic document
- Given a memory store with two freshly-remembered items in its
knowledge/inbox/and an internal model configured, - When
POST /api/v1/memory_stores/{name}/organize(orcoffer memory organize <name>) is called, - Then the internal LLM organizer drains the inbox (no items remain), at least one
knowledge/<topic>.mdtopic document exists holding the merged content,knowledge/INDEX.mdlists that topic, amemory_organizedaudit entry is recorded, and a subsequentrecallreturns content from the topic document (not the now-empty inbox).
Scenario: organizing merges a note into an existing topic without clobbering it
- Given a memory store that already has a hand-edited topic document containing content X plus a new related item in its
knowledge/inbox/, - When
organizeis called and the organizer merges the new item into that topic, - Then the topic document still contains the original content X alongside the newly integrated information, the inbox item has been removed, and the consolidation changelog (
consolidation-log.md) records the merge — the organizer never regenerates from scratch and never clobbers a human edit.
Scenario: organize is a no-op when no internal model is configured
- Given a memory store with items in its
knowledge/inbox/but no internal model configured, - When
organizeis called, - Then the call returns
status="no_model", the inbox is left untouched, no topic document is written, and no error is raised.
Scenario: a topic document recalls at passage granularity
- Given an organized memory store whose
knowledge/lane holds a topic document with two distinct heading sections, each describing a different subject, indexed for recall, - When
coffer__recallis queried with terms that occur only in the second section, - Then the returned hit's text is that section's passage — not the whole document — so the first section's distinctive wording does not appear in the hit, confirming topic documents are chunked per passage (heading- and block-structure aware) rather than one chunk per file.
Scenario: the reorg pass consolidates duplicate topic documents
- Given a memory store with two overlapping topic documents (both about the same subject, one carrying extra detail) and an internal model configured,
- When
POST /api/v1/memory_stores/{name}/reorg(orcoffer memory reorg <name>) runs and the internal agentic loop reads both documents, writes the merged content into one, and supersedes the now-redundant other, - Then a single topic document holds the combined content, the redundant document no longer appears in
recallorINDEX.md, a subsequentrecallreturns the merged content, and amemory_reorganizedaudit entry is recorded.
Scenario: reorg never destroys content — a superseded topic stays recoverable
- Given a memory store with a topic document holding content X (possibly a human edit),
- When the reorg loop overwrites or supersedes that document,
- Then the prior content X is first archived to the store's
superseded/tombstone (so it is recoverable, never hard-deleted), the tombstone is excluded from recall (it lives outside theknowledge/lane), and the consolidation changelog records the supersession — the loop is an incremental edit, never a from-scratch regeneration.
Scenario: reorg is a no-op when no internal model is configured
- Given a memory store with topic documents but no internal model configured,
- When
reorgis called, - Then the call returns
status="no_model", no topic document is written, superseded, or archived, and no error is raised.
Scenario: 固化 promotes a recurring journal pattern into a knowledge topic
- Given a memory store whose journal lane holds several similar episodic entries spanning more than one day (and no pre-existing topic documents),
- When
reorgruns and the loop reads the journal and promotes the recurring pattern, - Then a knowledge topic document capturing the pattern is written and is returned by
recall, the promotion is recorded inconsolidation-log.md, and the journal entries are left intact (promotion copies, never deletes).
Scenario: 固化 promotes a recurring imperative pattern into the rules lane
- Given a memory store whose journal lane holds a recurring imperative ("always do X") pattern,
- When
reorgruns and the loop promotes it viaappend_rule, - Then the rule is appended to
rules/rules.md, the promotion is recorded inconsolidation-log.md, thememory_reorganizedaudit reports apromotedcount, and the journal entries are left intact.
Scenario: memory is auto-organized after the store goes idle
- Given the opt-in auto-organize trigger is enabled, an internal model is configured, and an item is freshly remembered into a store's
knowledge/inbox/, - When the store goes idle (no further memory writes) for the conservative debounce delay,
- Then the organizer runs automatically in the background — with no explicit
organizecall — draining the inbox into a topic document, and the background pass never blocks: cancelling the pending trigger (e.g. at daemon shutdown) before it fires simply leaves the inbox intact for a later pass.
Scenario: the organizer routes a rule-shaped note into the rules lane
- Given a memory store with an internal model configured and two freshly remembered inbox items — one a behavioural rule ("always run the verify step before pushing") and one an ordinary fact,
- When
organizeruns and the organizer classifies the first item as a rule and the second as ordinary knowledge, - Then the rule is appended to the store's procedural
rules/rules.mdlane (not written into aknowledge/<topic>.mddoc), the ordinary fact becomes a topic document, both inbox items are drained, andrecalldoes NOT surface the rule (therules/lane sits outside theknowledge/recall glob, likehandoff/andsuperseded/).
Scenario: the rules read surface returns the stored rules
- Given a memory store whose
rules/rules.mdholds one or more rules, - When
GET /api/v1/memory_stores/{name}/rules(orcoffer memory rules <name>) is called, - Then the response returns the rules text verbatim (the surface that a later session-start injection reads), and a store with no rules returns an empty/
nullbody rather than an error.
Scenario: journal lane entries are readable for a store
- Given a memory store whose
journal/lane holds one or morejournal/<YYYY-MM-DD>.mdfiles, - When
GET /api/v1/memory_stores/{name}/journalis called (addressed by store name, not cwd), - Then the response returns the journal files time-ordered newest period first, each with its
period,text, absolute on-diskpath, and its containingfolder_path; a store with no journal returns an empty list with HTTP 200 (never a 404).
Scenario: handoff scenes are listed per branch for a store
- Given a memory store whose
handoff/lane holds one or more per-branch scene files, - When
GET /api/v1/memory_stores/{name}/handoffis called (addressed by store name, not cwd), - Then the response lists one scene per branch, each carrying its
branch,text,updated_at, absolute on-diskpath, andfolder_path; a store with no handoff scenes returns an empty list with HTTP 200 (never a 404).
Scenario: the consolidation changelog is readable for a store
- Given a memory store whose organizer has written a
consolidation-log.mdat the store root, - When
GET /api/v1/memory_stores/{name}/consolidation-logis called (addressed by store name, not cwd), - Then the response returns the changelog
textwith its absolute on-diskpathandfolder_path; a store with no changelog returnstext = nullwith HTTP 200 (never a 404).
Scenario: rules bundle is injected at session start as context only
- Given a managed agent (Claude Code or Codex) with the Coffer SessionStart hook installed, a global rule, and a project rule in the cwd's git project,
- When a session starts and the hook calls
GET /api/v1/agents/{name}/session-context?cwd=<cwd>, - Then the daemon returns
additional_contextholding the project rules then the global rules, the hook emits it as the SessionStartadditionalContext(context only), and no write is made to the agent's native memory or instruction files (ADR-026). When the cwd is not inside a git project, the bundle carries the global rules only (no project rules).
Scenario: the bundle carries the two seeded built-in rules
- Given the session-context endpoint assembles a bundle (even when the store has no rules),
- When the bundle is returned,
- Then it always includes the two Coffer-seeded built-in rules — call
coffer__resume()to continue prior work, and prefercoffer__remember/coffer__recallover the agent's native memory — and the handoff body itself is NOT injected (it is pulled on demand viacoffer__resume).
Scenario: session-end distils the closed session into the journal
- Given a Claude Code agent with the Coffer SessionEnd hook installed and an internal model configured,
- When a session closes and the hook calls
POST /api/v1/agents/{name}/sessions/{session_id}/endwith the cwd, - Then the just-closed session is distilled into the project journal lane reusing the FR-045 distill path, recorded in the
distilled_sessionsidempotency ledger (FR-046) so it is never double-distilled, and the call is a no-op when the session was already distilled, no model is configured, or the cwd is not a git project. Codex installs no SessionEnd hook and degrades to the FR-046 catch-up sweep.
Scenario: a failed or hook-less injection never blocks the agent
- Given the SessionStart hook is installed but the daemon is unreachable (not running, timeout, or any error),
- When a session starts,
- Then the hook prints nothing and exits 0, the session starts with no injected bundle, and the agent is never blocked; an agent with no hook installed simply receives no injection.
Scenario: disable_native_memory turns native memory off and restores it
- Given a managed agent with
disable_native_memory=false(the default), - When the user sets
disable_native_memory=true, - Then Coffer writes the agent's native-memory off-switch (Claude Code
autoMemoryEnabled=false; Codexfeatures.memories=false+memories.generate_memories=false), and setting it back tofalse(or uninstalling) restores the prior setting; while it isfalseCoffer never touches the agent's native memory (ADR-026).
Scenario: merge scan proposes same-project stores
- Given two per-project stores that describe the same project — one pair provably (both roots locally readable and normalizing to the same origin remote) and one pair only plausibly (labels/paths/content align but no common remote is provable),
- When the user runs the merge scan with an internal engine configured,
- Then the provable pair is proposed with
confidence="certain"andjudged_by="remote"without consulting the engine, the plausible pair is proposed with the engine's verdict (judged_by="engine", a confidence and a reason), and each proposal suggests the surviving store per the direction heuristic — and nothing is mutated by the scan.
Scenario: merge scan degrades cleanly without an internal engine
- Given per-project stores including a provably-same pair, and no internal engine configured,
- When the user runs the merge scan,
- Then the response reports
engine="no_model"and still carries the deterministic (judged_by="remote") proposals; engine-tier pairs are simply absent — no error.
Scenario: merging two stores consolidates additively and retires the source
- Given two per-project stores each holding facts (with at least one overlapping journal period and one colliding non-journal filename),
- When the user merges the source into the target,
- Then every source lane file lands under the target (journal entries deduped by timestamp, the filename collision keeping both copies), the source's label and root mapping move to a target that lacked its own, the target's recall returns the merged facts, the source store (resource, index rows, on-disk dir) is retired, and a
memory_stores_mergedaudit entry is recorded.
Scenario: a merged identity resolves to the surviving store
- Given store
project-Xwas merged intoproject-Y(soXis listed inY'smerged_identities), - When an agent remembers a fact from a checkout whose computed project identity is
X, - Then the fact is written to
project-Yand no newproject-Xstore is provisioned.
Deferred to future test work (tests land with the e2e infrastructure;
make verify-acceptancedoes not gate on them): desktop memory list view per scope, the desktop read-only fact viewer's open-in-editor / reveal affordances, CLIcoffer memory …end-to-end with a running daemon, per-store metrics (HTTP route).
Requirements
Functional Requirements
Storage & scope
- FR-001: System MUST store every memory item as a per-item markdown file (YAML frontmatter
title/description/metadata.actor/origin_session_id+ body) under a per-scopeknowledge/lane — freshly-remembered items inknowledge/inbox/, organized topic documents (knowledge/<topic>.md) plus anINDEX.mdmaintained by the consolidation organizer (a later memory PR). The markdown files are the sole source of truth; SQLite is a rebuildable index. NoMEMORY.mdindex is generated (the prior derived index was a vestigial projection artifact, read by nothing in retrieval). - FR-002: System MUST support two memory scopes: global (one store keyed by
project_id = WORKSPACE_GLOBAL_PROJECT_ID, the existing sentinel00000000000000000000000000) and per-project (one store per project, keyed by the project's ULID), stored under~/.coffer/memory/global/knowledge/and~/.coffer/memory/projects/<project-ulid>/knowledge/respectively. - FR-003:
coffer__remember(and a user add) MUST append a memory item to the per-scope inbox (knowledge/inbox/) with no LLM at write time; organization into topic documents is performed asynchronously by the consolidation organizer (a later memory PR) and never blocks the write orrecall. - FR-004: System MUST resolve the per-project store from the agent's reported launch cwd at session handshake: the daemon computes the git-root and resolves — lazily provisioning if absent — the store for that project's ULID.
- FR-004a (spec 010 / ADR-043 amendment): the project ULID MUST be machine-portable — derived from the normalized
originremote URL when the repo has one (ssh/https/scp-like forms of the same repository normalize identically), falling back to the absolute-git-root-path hash for repos without a remote. The same repository therefore resolves to the same memory store on every synced machine, whatever its checkout path. A store provisioned under the legacy path-derived id is adopted once on first resolve: its files move to the portable id's dir, the resource is re-registered under the new name, and the root mapping and display label carry over (the old resource is deleted, which also propagates the rename through sync tombstones).
Fact lifecycle
- FR-005: Agents and users MUST be able to write a fact directly (no LLM at write time). Fact text MUST be at least 1 char and at most
max_fact_chars(default 8192); empty or over-long text is rejected at the API boundary with nothing persisted. - FR-006: Users and agents MUST be able to list facts (per scope), get a single fact by id, edit a fact's text, delete a single fact, and clear all facts in a scope. Fact edit/delete is via the REST/CLI write surface (
PATCH/DELETE …/facts/{id}/coffer memory edit/delete) and external-editor files-as-truth — the Coffer UI renders fact content read-only and does not edit it in-app; the MCPupdate_memory/forgettools are removed (the agent's write surface isremember+ the internal organizer). Clearing preserves the store Resource. - FR-007: Every fact carries
metadata.actor(agent|user); the writer sets it. There is no free-formtypefield —Laneis the single classification axis (FR-048), determined by internal routing (organizer / distillation), never supplied by the writer.
Retrieval
- FR-008: Recall MUST use the unified retrieval engine shared with the knowledge base:
grep(ripgrep over the store's fact files; essential for content FTS5 cannot tokenize, e.g. CJK),keyword(FTS5 BM25, the default), andvector(sqlite-vec with a configurable embedding provider). These engine modes are an internal detail — recall does NOT take an externalmode; the engine resolves the store's default strategy automatically. When the resolved strategy needs vectors but no embedding provider is configured, recall MUST fall back tokeywordinternally — never block, and the fallback is NOT surfaced as a query-time response flag (thefallbackfield is removed from the recall response). - FR-009:
coffer__recallMUST default to spanning both the project and global stores (an explicitscopenarrows recall to one store:project= the project store only,global= the global store only); cross-store results are merged by reciprocal rank fusion (per-store scores are not comparable across modes/stores; each hit keeps its per-store score, only the merged order comes from the fusion). Results carry id, text, score, source, and time —timeis the fact'supdated_atandsourceis<scope>:<fact file path>. Defaulttop_kis 5; callers MAY specify 1–20. - FR-010: Memory MUST use lazy reindex-on-read:
recallfirst scans the fact directory for deltas (added/changed/removed files by content hash) and reconciles the index before searching, so out-of-band edits — a human's corrections made in their own external editor, or any direct on-disk edit — are visible immediately with no filesystem watcher. This is the mechanism that makes external corrections appear, so the UI can stay a read-only viewer (FR-017) while curation happens in the user's editor.
Agent integration via MCP
- FR-015: Coffer's MCP gateway MUST expose built-in tools
coffer__recall(query, scope?, top_k?)(nomodeparameter — retrieval mode is internal),coffer__remember(text, scope?)(notypeparameter — retired per FR-048),coffer__list_memory(scope?),coffer__set_handoff(body), andcoffer__resume(), namespaced under the reservedcoffer__prefix.rememberdefaults toscope=project;recalldefaults to both scopes. There is no MCPupdate_memory/forgettool — fact edit/delete is a user surface (REST/CLI/external editor), per FR-006. - FR-016: Built-in memory tool invocations MUST share the existing invocation-logging surface (one
mcp_invocationsrow: tool name + who/when/duration/outcome only — no arguments or returned content).
Working-state handoff (continuity)
- FR-023: The system MUST provide a working-state handoff lane keyed by (project store × git branch): one file per branch under
~/.coffer/memory/projects/<project-ulid>/handoff/<branch-slug>.md, with YAML frontmatter (branch,updated_at) plus a freeform markdown body. The branch is resolved from the agent's reported cwd (its repo's current branch). Handoff is per-project only — there is no global handoff. - FR-024:
coffer__set_handoff(body)MUST overwrite the current branch's handoff file (no accumulation; one scene per branch), setupdated_at, and record ahandoff_setaudit entry. The handoff body is files-as-truth on disk (it rides the git sync mirror like other memory files) and MUST NOT be returned bycoffer__recall(it lives in thehandoff/subdir, outside the recall glob). - FR-025:
coffer__resume()MUST return the current branch's saved handoff —found=truewithbranch,body,updated_at, and a freshnessnoteannotating that the scene may be stale — orfound=falsewhen no handoff exists for the branch (a fresh branch) or the cwd is not inside a git project. It MUST never error on a missing handoff and MUST NOT fabricate content. - FR-026: When the agent's cwd does not resolve to a git project (no project scope, no branch),
coffer__set_handoffMUST be rejected (there is no store to write to and no global handoff) andcoffer__resumeMUST returnfound=false.
Consolidation — the internal organizer
- FR-027: The system MUST provide an internal memory organizer that, on an explicit trigger only (
POST /api/v1/memory_stores/{name}/organizeandcoffer memory organize <name>; no automatic/background firing in this PR), drains a store'sknowledge/inbox/of freshly-remembered items into a small set of coherent topic documents (knowledge/<topic-slug>.md, YAML frontmattertitle/description/updated_at+ a markdown body) using Coffer's internal LLM connection (the connection marked internal-default; configured on Settings → LLM Connections, spec 011) via a one-shot completion per item — never an agent-facing tool. The organizer MUST process items sequentially, and one item's LLM/parse failure MUST NOT abort the run (the other items still organize). - FR-028: For each inbox item the organizer MUST (a) retrieve up to the top-K (K=3) most-relevant existing topic docs via the shared retrieval engine (no LLM on this step) as merge candidates, (b) make one LLM call that either MERGES the item into the best-fitting candidate — preserving all existing content and human edits, integrating the new information, removing exact duplicates — or CREATES a new topic when none fits, and (c) write the returned full document body to
knowledge/<topic-slug>.md. The organizer MUST be an incremental merge into the existing document, never a from-scratch regeneration: the LLM is given the full existing topic content to merge into, so human corrections survive. The organizer MUST NOT hard-delete an existing topic doc (it only creates or overwrites with merged content; git history is the audit trail). - FR-029: An inbox item MUST be deleted only after its content is successfully written into a topic doc. A malformed or unparseable LLM response (missing/empty required keys, an unsafe
topic_slug, or non-JSON) MUST cause that item to be skipped — left in the inbox, no topic doc written or corrupted — and the run continues; the result reports the count of skipped items.organizeon an empty inbox is a no-op (status="empty"); when no internal connection is configuredorganizeis a clean no-op (status="no_model", inbox untouched, nothing written) rather than an error. - FR-030: After draining, the organizer MUST regenerate the store's
knowledge/INDEX.mdreview catalog from all topic docs' frontmatter (- [<title>](/reference/specs/007-memory/<slug>) — <description>), reconcile the index (dropping the removed inbox rows and (re)indexing the new/updated topic docs sorecallreturns content from the topic docs, not the drained inbox), and record onememory_organizedaudit entry (store + counts only — no item content).recallMUST surface organized topic-doc content and MUST NOT surfaceINDEX.md. - FR-031: The organizer MUST keep a non-blocking consolidation changelog at the store ROOT (
<store>/consolidation-log.md, append-only, human-readable: one line per merged/created topic with the timestamp and the source inbox item). The changelog is auditable, never a gate, and is excluded from recall (it lives outside theknowledge/lane) and from the sync mirror (machine-local, likeINDEX.md; topic docs themselves DO sync as source-of-truth). - FR-032: The memory reconciler MUST chunk a fact file's body into passage-granular, structure-aware chunks using the retrieval substrate's shared markdown chunker (
infrastructure/knowledge/chunking.chunk_markdown— splits on heading sections, keeps fenced code / tables atomic, and packs structural blocks up to a fixed window), with fixed memory chunk-size/overlap parameters (not a per-storeMemoryStoreConfigfield), so a multi-section organized topic document surfaces the most relevant passage onrecallrather than its entire body as a single chunk. A short single-passage fact (e.g. an inbox item) still chunks to one passage — so this changes only the granularity of large/organized topic docs, never whatrecallincludes or excludes: theINDEX.md, inbox-vs-topic, andhandoff/recall isolation (FR-024/030/031) and the legacy-root-fact abandonment (FR-019) are all unchanged. - FR-033: The system MUST provide an internal agentic reorganization pass that, on an explicit trigger only (
POST /api/v1/memory_stores/{name}/reorgandcoffer memory reorg <name>; no automatic/background firing in this PR), runs a bounded langgraphcreate_react_agentloop driven by Coffer's internal LLM connection (the connection marked internal-default; configured on Settings → LLM Connections, spec 011) over the store's existing topic documents to keep them coherent — consolidating duplicate/overlapping documents and splitting over-long ones. The loop is given a small, fixed tool surface over the topic docs — list topics, read a topic, write (create/overwrite) a topic, and supersede (retire) a topic — plus the journal-promotion tools of FR-047 (read journal, append rule), and is never an agent-facing tool (it is internal, like the organizer). The langchain/langgraph code MUST stay confined toinfrastructure.chat(importlinter Contract 9);application/memoryreaches it only through an injected memory-local port. When no internal connection is configured the pass is a clean no-op (status="no_model", nothing written/superseded/archived) rather than an error; a store with neither topic documents nor journal entries is likewise a no-op (status="empty"). After the loop the pass MUST regenerateINDEX.md, reconcile the index (sorecallreflects the consolidated docs), and record onememory_reorganizedaudit entry (store + counts only — no document content). - FR-034: The reorg pass MUST be non-destructive and incremental — it MUST NOT hard-delete or from-scratch-regenerate a topic document. Every mutation that removes or replaces existing topic-doc content MUST first archive the current version to the store-root
superseded/tombstone (<store>/superseded/<slug>-<timestamp>.md): awritethat overwrites an existing topic archives the prior version before writing the new one, and asupersedemoves the document there (it is never unlinked into the void). Thesuperseded/tombstone is excluded from recall (it lives outside theknowledge/lane, likehandoff/andconsolidation-log.md) and DOES sync as recoverable source-of-truth history (unlike the machine-localINDEX.md/changelog). Topic-doc writes remain atomic, and every write/supersede is appended to theconsolidation-log.mdchangelog. This is the data-loss guarantee: no byte ever leaves theknowledge/lane without first being recoverably archived, so a human edit can never be irrecoverably clobbered. - FR-035: The system MUST provide an auto session-end organize trigger that fires the
organizepass (FR-027) automatically, in the background, when a memory store goes idle — approximating "session end" without a per-agent disconnect signal. It is driven by the memory write-notify hook: each memory write (re)arms a single debounced timer; after the configured idle delay elapses with no further writes, the organizer runs for the changed store(s) as a background task. The trigger MUST be conservative and non-blocking: (a) it is default-ON — the write→organize→固化 consolidation pipeline runs automatically (the no-manual principle, §4.4), controlled by an environment off-switch; manualcoffer memory organizeremains a special-case override; (b) the background pass MUST NEVER block or break daemon shutdown — on shutdown any pending timer is cancelled (the un-fired inbox is simply left intact for a later idle pass or an explicit trigger; nothing is lost, sincerecallalready covers the inbox andorganizeis idempotent); (c) a background-pass failure MUST be suppressed + logged, never surfacing to a writer or aborting the daemon; (d) when no internal connection is configured the pass is a clean no-op (FR-027). It introduces no new REST/CLI surface (it is an internal trigger over the existing organizer) and reuses thememory_organizedaudit. The langchain/langgraph confinement (Contract 9) is unchanged: the trigger lives inapplication/surfacesand reaches the LLM only through the already-wired organizer. - FR-036: The system MUST provide a procedural
ruleslane —rules/rules.mdper memory store (global + per-project), holding "do this / don't do that" behavioural rules. Amendment 2026-06-22 (autonomous split): the lane stays a singlerules/rules.mdwhile small; once anyrules/*.mdfile exceeds 100 rules, the organizer's reorg/organize pass classifies its rules by topic via a one-shot LLM call and redistributes them into per-categoryrules/<slug>.mdfiles (applied recursively — an oversized category re-splits into finer slugs). New rules keep appending torules/rules.md; the read surface concatenates everyrules/*.mdfile. The rules lane is agent-written via the organizer's classification, never an explicit agent param: duringorganize(FR-027/028), the organizer's single per-item LLM call MAY additionally classify an inbox item as a rule; a rule item is appended torules/rules.md(the inbox item is drained only after the append succeeds) instead of being merged into aknowledge/<topic>.mdtopic document, and theorganizeresult/audit reports arules_appendedcount. Therules/lane sits at the store ROOT (a sibling ofknowledge/, likehandoff/andsuperseded/) so it is excluded fromrecallfor free (the recall glob and the reconciler only descend intoknowledge/; the grep guard keeps onlyknowledge/hits) — rules are delivered by ambient session-start injection, not byrecall. The lane is source-of-truth and DOES sync (likehandoff//topic docs; it is not a derived/machine-local file). The system MUST expose the stored rules read-only for the injection surface:GET /api/v1/memory_stores/{name}/rulesandcoffer memory rules <name>return the rules text (an empty/nullbody when there are no rules, never an error). The session-start injection that delivers these rules into each managed agent as context (ADR-026: injection only, never a native file write) is specified in FR-049–FR-052 (slice 6) — this rules-lane PR lands the lane, the classification, and the read surface that the injection consumes.
Journal lane (episodic)
Journal lane (episodic)
- FR-040: Coffer SHALL provide a per-project
journallane that stores episodic events as append-only, time-partitioned markdown files (projects/<ulid>/journal/<YYYY-MM-DD>.md— one file per day; amendment 2026-06-22). A day with no entry creates no file. There is NO global journal. - FR-041: Journal files SHALL be included in the sync mirror as source-of-truth history (like
rules/andsuperseded/). Unlikerules/andhandoff/, the journal lane also participates inrecall(FR-043). - FR-042: Coffer SHALL expose internal
JournalService.append(cwd, body, actor)andread_recent(cwd, limit). Appending outside a git project raisesScopeUnresolved; reading outside a git project returns an empty list.read_recentreturns the newest entries first, capped atlimit;limit=0returns an empty list (no implicit "all"). A blank/whitespace-only body is skipped —appendreturnsNone, writes no file, and records no audit event (amendment 2026-06-22). Thejournal_appendaudit entry recordschar_sizeonly — never the body. - FR-043: The journal lane SHALL participate in
recall. The memory reconciler MUST scan eachjournal/<YYYY-MM-DD>.mdfile and index it as one memory document (kind=memory), chunked with the same shared markdown chunker and fixed parameters as topic docs (FR-032), covered by lazy reindex-on-read (FR-010) so an out-of-band edit or a freshJournalService.appendbecomes searchable on the nextrecall. The grep recall guard (which keeps onlyknowledge/hits) MUST additionally keepjournal/hits, parsing them per journal file rather than as fact files. Journal documents MUST NOT count toward a store'sfact_count(which counts only theknowledge/lane). Therules/,handoff/, andsuperseded/lanes remain excluded fromrecall. - FR-044: A
recallhit from the journal lane MUST be distinguishable from aknowledge/hit: like every recall hit itssourcecarries the on-disk path of the matched file (per FR-022), and a journal hit's path is thejournal/<YYYY-MM-DD>.mdfile (containing thejournal/lane segment), so the agent can tell episodic events from semantic facts. - FR-045: Transcript distillation (User Story 6) SHALL write each extracted insight to the journal lane (episodic), NOT as a flat
knowledge/fact. Distillation stays "dumb": it extractsname/description/bodyonly and MUST NOT classify a per-insight type — the legacyInsightType(decision/gotcha/convention/todo) is retired (notypefield on the distilled insight, the distill prompt, or the distill response). Each insight is appended viaJournalService.appendto the session's project journal; a session whose path does not resolve to a git project is skipped (there is no global journal). The distill response reports the written journal entries (thefact_idsfield is renamedjournal_entries). Promotion of recurring journal patterns intoknowledge/rulesis the organizer's job (a later consolidation slice), never distillation's. - FR-046: Memory recording MUST be automatic, not dependent on a human running
coffer transcript distill. The system SHALL run an auto-distill catch-up sweep — a background worker that, on daemon start and then periodically, scans each managed agent's transcript sessions and distills any settled, not-yet-distilled session into the journal lane (FR-045). A session is eligible only when itslast_activity_atis (a) settled (older than a settle threshold — never an in-progress session) and (b) within a recency window (a catch-up net for recently-missed sessions, NOT a full historical backfill); each pass distills at most a bounded number of sessions (the remainder catch up on later passes, logged). Distilled sessions are tracked by(agent, session_id, content_sha256)in a machine-local ledger so a session is never double-distilled (re-distilled only if its content materially changed); this ledger is the idempotency key the future SessionEnd hook (slice 6) shares. The sweep is default-ON (it is the write guarantee) with an environment off-switch; it is non-blocking and failure-suppressed (one session's LLM/parse failure never aborts the sweep or the daemon, mirroring FR-035), a clean no-op when no internal connection is configured, and on shutdown the worker stops without firing. It introduces no new REST/CLI surface and reuses the FR-045 distill path + journal lane. (The immediate-on-close SessionEnd hook is slice 6; this sweep is the standalone guarantee.) - FR-047: The reorganization pass (FR-033) SHALL additionally perform consolidation (固化) — promoting recurring, durable episodic patterns from the journal lane into the semantic lane. The agentic loop gets two more internal tools alongside the topic tools: read the recent journal entries and append a rule (
rules/rules.md). It promotes a pattern that recurs across the journal — conservatively, roughly ≥3 similar entries spanning ≥2 distinct days (the LLM's judgment; never a one-off) — into a knowledge topic (viawrite_topic) or, when the pattern is clearly imperative/behavioural ("always do X"), into the rules lane (viaappend_rule). A one-off event is left in the journal (it ages out by prune, a later slice), never auto-promoted. Promotion copies the durable pattern into the semantic lane — it does NOT delete the journal entries. Every promotion is appended to the store-rootconsolidation-log.mdchangelog, and thememory_reorganizedaudit reports apromotedcount. 固化 is conservative — when in doubt the loop leaves the entry in the journal (avoid 固化-ing noise). - FR-048: The free-form fact
typefield is retired —Lane(knowledge/rules/journal/handoff) is the single classification axis. The system MUST NOT carry atypefield onMemoryFact, in fact-file frontmatter (metadata.type), in thedocuments.metadataJSON, in thecoffer__remembertool schema, or in the REST/CLI fact write surface (FactCreate/FactUpdate/FactOut,coffer memory add --type). A fact's lane is determined by internal routing (organizer / distillation), never supplied by the writer. Existing on-disk memory is drop + recreate (Coffer is unreleased): there is no Alembic migration —typelived inmetadataJSON, not a column, so a stalemetadata.typekey in an old fact file is simply ignored on parse and dropped on the next reindex-on-read; a clean install (or wiping~/.coffer/memory/) starts type-free.
Rules runtime injection & native-memory (session hooks)
- FR-049: The system MUST deliver the rules lane (FR-036) into each managed agent via a SessionStart hook that injects a rules bundle as context only — never a native file write (ADR-026). Coffer installs the hook into the agent's own hooks config (Claude Code →
~/.claude/settings.jsontop-levelhooks; Codex →~/.codex/hooks.jsontop-levelhooks— same JSON schema), recognising only its own entry (thecoffer-hookcommand basename) and leaving user hooks untouched; install/uninstall is idempotent and atomic (.bakbackup) and auditsAGENT_HOOK_INSTALLED/AGENT_HOOK_UNINSTALLED. On SessionStart the hook calls back to Coffer —GET /api/v1/agents/{name}/session-context?cwd=<cwd>with the daemon token — and the daemon returns the bundle = the global rules (always) plus the current-project rules (when the cwd resolves to a git project), project rules first. The hook emits the bundle as the SessionStartadditionalContextand exits 0. The hook MUST never block the agent: when no hook is installed, or the daemon is unreachable, times out, or errors, there is no injection and the hook still exits 0. The hook re-runs on resume/clear/compact (the matcher coversstartup|resume|clear|compact). - FR-050: The injected bundle (FR-049) MUST additionally carry two Coffer-seeded built-in rules, present even when the store's
rules/rules.mdis empty: (a) when the user wants to continue prior work ("continue", "where were we", "resume"), callcoffer__resume()to pull the saved working-state handoff for this project + branch; and (b) a soft steer to prefercoffer__remember(record durable facts) andcoffer__recall(retrieve them) over the agent's own native memory, because Coffer is the shared store across the user's agents. The handoff body itself is NOT injected — it is pulled on demand viacoffer__resume(FR-025), so the bundle stays small and a stale scene is never force-fed into context. - FR-051: The system MUST install a SessionEnd hook for Claude Code only that auto-distils the just-closed session into the journal lane immediately, lowering the latency of memory capture. On session end the hook calls
POST /api/v1/agents/{name}/sessions/{session_id}/endwith the cwd; the daemon reuses the FR-045 distill path and thedistilled_sessionsidempotency ledger (FR-046) —is_distilled? → distill → mark_distilled— so a session is never double-distilled, and the call is a clean no-op when the session was already distilled, no internal model is configured, or the cwd is not a git project. Codex has no session-end event, so no SessionEnd hook is installed for it; Codex sessions are captured by the FR-046 catch-up sweep, which remains the write guarantee — this hook only lowers latency and never replaces the sweep. - FR-052: The system MUST provide an opt-in per-agent
disable_native_memoryconfig (defaultfalse). When off (the default) Coffer never touches the agent's native memory (ADR-026). When the user turns it on, Coffer writes the agent's config to disable its native memory — Claude CodeautoMemoryEnabled=false(~/.claude/settings.json); Codexfeatures.memories=false+memories.generate_memories=false(~/.codex/config.toml) — atomically (.bakbackup) and audits the disable; turning it off again, or uninstalling, restores the agent's prior native-memory setting (audited). This is a cleanliness option (avoid a second, diverging memory copy), not required for the write guarantee: the rules bundle is injected and sessions are distilled regardless of this toggle.
Transcript history
- FR-037: The transcript reader MUST parse each supported agent's real on-disk session format. Codex rollout files (
~/.codex/sessions/**/*.jsonl) wrap every event in apayloadenvelope: the working directory and session id come fromsession_meta.payload.cwd/payload.id, and conversation turns areresponse_itemevents whosepayload.type == "message"(role + typed*_textcontent blocks). The reader MUST count turns from theseresponse_itemmessages only — the parallelevent_msguser_message/agent_messageUI events MUST NOT be double-counted — and MUST stay defensive (skip unrecognised / non-JSON lines, never raise on one bad line). Claude Code's flat top-level format is unchanged. (Before this slice the Codex parser read top-level fields the real format never carries, so every Codex session listed as 0 messages with no project.) - FR-038: Each transcript session summary MUST carry a human-readable title, a last-activity timestamp (
last_activity_at), and the session file's absolute source path. The title is the agent's own session title when present (Claude Code'sai-title, latest wins) and otherwise the first real user message — skipping non-conversational preambles (environment/instructions blocks, shell-command echoes, slash commands) — truncated to a single line; it MAY be null when none can be derived.started_atis the first event timestamp andlast_activity_atthe last. The source path powers a read-only "reveal in file manager" affordance via the sharedFileActionscomponent (desktop reveal / web copy-path), mirroring FR-021/FR-022 for memory facts — no new backend open/reveal endpoint is introduced. - FR-039: The transcript list surface (
GET /api/v1/agents/{name}/transcripts) MUST expose all of an agent's sessions (not only a recent window) with server-side search (a query matched against the title or project path), filtering (by exact project path and by astarted_attime range), and sorting (bystarted_at,last_activity_at, ormessage_count, ascending or descending), paged vialimit/offsetand returning the matchedtotal. The reader MUST back this with an in-process, mtime-aware cache so repeat listings of an agent with thousands of sessions stay responsive — a session file is re-parsed only when its mtime changes.
Surfaces
FR-017: Users MUST be able to perform full memory CRUD through the programmatic write surfaces — (a) a REST API under
/api/v1/memory_stores/and (b)coffer memory …subcommands. (The REST write endpoints are also what agents author facts through via the MCP gateway.) User writes setmetadata.actor = "user", write the canonical markdown under the store'sknowledge/inbox/subdir, reindex, and audit. The desktop/web UI surfaces facts read-only (it does not edit fact content in-app); humans curate by editing the canonical markdown in their own external editor (picked up by lazy reindex-on-read, FR-010) or via the REST/CLI write surface. The read-only viewer MUST render fact content at a comfortable reading max-width (centered), and the detail-page fact list MUST be a single scrollable list with no in-UI page-based pager (the UI fetches one page at the maxlimit; the facts API stays paginated bylimit/offset). Store names on these surfaces are validated: onlyglobalorproject-<26-char ULID>are legal — a well-formed name lazily provisions its store; anything else returns 404 (MEMORY_STORE_NOT_FOUND).FR-017a: Surfaces MUST present a per-project store by a human-readable identity derived from its
project_root— the root directory's basename as the primary label and the absolute root path as a secondary detail — never only the opaqueproject-<ULID>store name (the project ULID is a one-way digest of the root and is not human-recognisable). When the root is unknown (a store provisioned before the root was tracked) the surface falls back to the store name; the global store needs no derivation (its nameglobalis already readable). The underlying store name staysproject-<ULID>(FR-017) — this is a display concern. Verified by frontend tests; desktop acceptance is deferred to e2e like the other desktop-view items.FR-017c: A user MUST be able to set a display label for any memory store — a chosen name that takes precedence over the FR-017a
project_rootderivation in every surface. This gives a readable identity to a store whose originating folder was never recorded (where FR-017a would otherwise fall back to the opaqueproject-<ULID>name). Setting an empty / whitespace label clears it, reverting to the FR-017a derivation or fallback. The label is display metadata: it does not change the store name (FR-017) orproject_id, and is set viaPATCH /memory_stores/{name}/label. Verified by an HTTP acceptance test; the desktop rename view is deferred to e2e like the other desktop-view items. Labels sync across machines as thememory-labelsstate area (spec 010, amendment 2026-07-10): set, rename, AND clear all propagate (a clear travels as an empty-label marker so it cannot resurrect from a stale doc) — a store synced from another machine reads by its label instead of "unnamed store".FR-021: The read-only fact viewer MUST offer, for both a fact file and its containing folder, affordances to (a) open in external editor and (b) reveal in file manager / Finder. Both perform the real OS action on both surfaces: the desktop (Tauri) build via the OS opener, the web build via the loopback daemon's filesystem-action endpoints (spec 004 FR-039), since the daemon is on the user's own machine (ADR-033). There is no copy-path fallback. Which editor opens is decided by the global preferred-editor preference (specced in 002-ui-shell; not re-specified here). The read responses MUST surface the absolute paths these affordances act on (see FR-022).
FR-022: Read responses MUST surface the on-disk truth: the fact read endpoints (
GET …/facts,GET …/facts/{id}) MUST include each fact file's absolute.mdpath and its containing folder's absolute path, and the store read endpoint (GET …/{name}) MUST include the store's absolute on-disk directory. These power the FR-021 open/reveal affordances and let a human locate the canonical file to correct out-of-band.FR-053: The memory store detail page MUST present the store as four lane sections (Knowledge / Rules / Journal / Handoff) plus a consolidation-changelog view, replacing the flat fact list. Each lane gets a shape-fit view: knowledge = the fact/topic list + content; rules = a single document; journal = time-ordered entries (newest first); handoff = a per-branch list. All views are read-only, render via the unified file preview (no hand-styled
<pre>), and offer open in external editor / reveal in file manager / copy path for the underlying lane files (files-as-truth, FR-017/FR-021). Recall continues to operate over the Knowledge lane only (the rules/journal/handoff/changelog views are read projections, not recall surfaces).FR-054: The system MUST expose read endpoints for the lanes the UI needs:
GET /api/v1/memory_stores/{name}/journal(the time-ordered journal files, newest period first),GET /api/v1/memory_stores/{name}/handoff(the handoff scenes per branch, each carrying itsbranchandupdated_at), andGET /api/v1/memory_stores/{name}/consolidation-log(the organizer's 固化 changelog;nullwhen absent). These are read-only, addressed by store name (not cwd), and MUST return HTTP 200 with empty lists /nullfor an empty store (never a 404). (The Rules lane already has its read surface,GET /api/v1/memory_stores/{name}/rules, FR-036.)FR-055: The SessionStart context (FR-049) MUST additionally inject an ambient project-memory index — the "ambient loading" slice ADR-026 deferred — so an agent starts knowing what the project remembers without having to call
recall. TheGET /api/v1/agents/{name}/session-contextresponse appends, after the rules bundle, a "## Project memory (via Coffer)" section built from the cwd's project store: a title-only knowledge index ("Known topics" — each fact's short title, no bodies or descriptions) and a few recent journal lines ("Recent activity" — the newest episodic entries, one line each). It is deliberately an index, not the memory itself — an orientation pointer that tells the agent what exists and to callrecall <query>for any bodies it needs, keeping the injection light. The index is read-only and best-effort — a cwd outside a git project, an empty store, or any read error yields nothing (never an error; the hook must never block the agent) — and is budget-bounded: the combined bundle stays within the hook's ≤10k-char contract, the index taking whatever remains after the rules bundle so the seeded built-in rules (FR-050) are never truncated. Delivery rides the existing per-agent SessionStart hook (ADR-042ContextInjectionSpec), which is opt-in per agent (installed explicitly, not by default), so it is already the gate for whether Coffer injects: every agent whose hook is installed (Claude Code, Codex, Cursor) receives the index; injection-only, never a native-file write.
Store consolidation — AI-assisted (amendment 2026-07-10)
- FR-056: The system MUST provide an explicit merge scan —
POST /api/v1/memory_stores/merge_scanandcoffer memory merge-scan— that examines every pair of per-project stores and returns merge proposals. A pair whose locally-readable roots normalize to the same non-empty origin remote (FR-004a normalization) is proposed deterministically (confidence="certain",judged_by="remote") with no LLM involved; every other pair is judged by the internal engine (the FR-033 internal-default connection) via one one-shot completion returning a strict JSON verdict{same_project, confidence, reason}— a malformed response skips the pair, never errors. With no internal engine configured the scan returnsengine="no_model"and the deterministic proposals only. The engine tier is bounded (at most 50 judged pairs per scan,truncated=truewhen capped; per-store evidence samples are size-capped). Each proposal carries a suggested merge direction: the store with a locally-resolvable root survives, then the higher fact count, then the lexically smaller name. Scanning never mutates anything. - FR-057: The system MUST provide an explicit merge execution —
POST /api/v1/memory_stores/mergewith{source, target}andcoffer memory merge <source> <target>— that consolidates two per-project stores with the existing additive machinery (merge_store_dir): journal entries content-merged and deduped by timestamp, derived files skipped, any other collision keeping both copies (suffixed) — memory is gained, never lost. The source's display label andproject_rootmapping move to the target when the target lacks its own. The target is force-reconciled, the source store is retired (resource delete cascading documents/index/dir), and onememory_stores_mergedaudit entry (names + counts only) is recorded.sourceandtargetMUST be distinct, existing, per-project stores — the global store is never mergeable; violations are 4xx with no side effects. Merge execution serializes with resolve-time adoption on the same lock; fact writes do not hold that lock, so the merge re-sweeps the source immediately before retirement (the file merge is content-idempotent) to carry over anything remembered mid-merge. - FR-058: A merge MUST leave a no-resurrection alias: the surviving store's config gains
merged_identities(a system-managed list of project ULIDs, default empty) holding the source's ULID plus the source's own aliases (transitive across chained merges).ScopeResolverMUST consult the aliases only when the computed identity's store does not exist and resolve to the aliased survivor instead of re-provisioning an empty duplicate. Exactly one live store holds a given alias (recording aliases on a new holder strips them from every other store), and the boot consolidation pass MUST honor the aliases the same way — a merged-away canonical identity redirects to its holder rather than being re-provisioned, and boot/adoption merges carry the retired store's aliases onto the canonical store. Because the alias lives in the store'sconfig_json, it syncs with the resource (spec 010) so the redirect holds on every machine. - FR-059: Merge execution MUST accept
organize(defaulttrue): after a successful merge, when the internal engine is configured, the FR-033 reorg pass runs on the target store and its outcome is reported asreorg_statusin the merge response ("reorganized","no_model","empty","skipped"whenorganize=false, or"error: …"). A failed or unavailable organize step never fails the merge itself.
Substrate isolation
- FR-018: The retrieval/index engine (FTS5, sqlite-vec, embedding providers, converters) MUST be confined to infrastructure. Domain and application layers MUST NOT import index/engine types directly; interaction is via the shared retrieval port. mem0, chroma, and LlamaIndex MUST NOT be imported anywhere.
Migration
- FR-019: This branch is unreleased; there is no new schema migration for the lane layout (the
documents/chunksschema is unchanged — only the on-disk lane location changes). A single migration MUST dropmemory_recordsand create the fresh unified schema. Legacy on-disk engine directories (chroma/LlamaIndex) from pre-release builds are abandoned in place — nothing reads them — rather than deleted; old mem0/chroma text is not migrated. Legacy per-item files at a store root from pre-lane builds are likewise abandoned in place (not read, not deleted): the lazy reindex-on-read reconciles theknowledge/lane, so stale index rows for old root facts are reconciled away on the nextrecalluntil those items are re-remembered or seeded by the organizer. ExistingMEMORY.mdfiles on disk are left in place, unread.
Key Entities
- Memory Store (a resource of kind
memory): one store per scope — the global store (sentinel ULID) or a per-project store (project ULID). Config holds enabled retrieval modes, embedding config, andmax_fact_chars. - Memory Fact (one markdown file = one
documentsrow):id,name,description, body,metadata(actor,origin_session_id),path(absolute.mdpath),content_sha256,created_at,updated_at. The markdown file is the source of truth. Read responses additionally surface the containing folder's absolute path so the UI can open/reveal/copy it. - Memory Hit (recall result, not persisted):
id,text/passage,score,source,time.
Success Criteria
Measurable Outcomes
- SC-001: A fact written by one agent via
coffer__rememberis recalled by a different agent viacoffer__recallin the same project within one session, with no per-agent copy diverging. - SC-003: With 200 facts in a scope, recall latency for a typical keyword query is ≤ 300 ms wall-clock on a developer laptop.
- SC-004: Default retrieval works offline with zero configuration (keyword + grep); vector recall is opt-in and degrades to keyword (flagged) when unconfigured — it never errors.
- SC-006: Every Acceptance Scenario is covered by at least one test marked
acceptance(spec="007-memory", scenario="…"). - SC-007: Substrate isolation is enforced by importlinter: no module under
coffer.application.*orcoffer.domain.*imports the index engine, andmem0/chroma/llama_indexare imported nowhere. - SC-008:
make verifypasses locally and in CI.
Assumptions
- The user runs Coffer on their own machine; memory data stays local. Calling a configured cloud embedding provider for opt-in vector recall is allowed (local-first ≠ no remote API calls).
- The canonical format is per-item markdown files (YAML frontmatter + body) under a per-scope
knowledge/lane; there is no derivedMEMORY.mdindex. - The coffer-mcp-shim propagates its launch cwd to the daemon at session handshake on the supported agents (to verify in implementation).
- The knowledge base (spec 006) and memory share one unified substrate (
documentstable discriminated bykind+ JSONmetadata); they are two faces, not duplicated code. - Single-user concurrency is small.