Skip to content

Data Model —— 007 Memory(跨 agent 共享记忆)

English: data-model.md

memory 面的实体、端口、统一 SQLite schema(与 knowledge base 共享)以及落盘规范化布局。

Domain 实体 (backend/coffer/domain/memory/)

MemoryStoreConfig (domain/memory/config.py)

Pydantic v2 BaseModel。当 kind == "memory" 时存于 Resource.config。与 KB 面共享检索模式词汇与 embedding 语义;字段布局刻意不同 —— 见下文。

字段类型说明
retrieval_modeslist[Literal["grep","keyword","vector","hybrid"]]启用的模式。默认 ["grep","keyword"](零配置、离线)。vector 为可选项;hybrid(对 keyword+vector 做 RRF)与 KB 面共享。
default_modeLiteral["grep","keyword","vector","hybrid"]默认 "keyword"
embedding_providerstr | NoneOpenAI 兼容 provider id(如 openaivoyagelocal)。vector 必填。
embedding_modelstr | Nonebge-m3(本地)或某云端模型。vector 必填。
embedding_base_urlstr | NoneOpenAI 兼容 provider 的 base URL 覆盖。
embedding_credential_refstr | Noneembedding API key 的 keychain ref(绝不明文)。
embedding_dimensionsint默认 768;范围 1–8192。决定该 store 的 vec_chunks 表宽;随线上契约传输。
max_fact_charsint默认 8192;范围 64–32768。可变。
merged_identitieslist[str]已合并本库的项目 ULID(FR-058,修订 2026-07-10)。系统管理(用户从不设置);resolve 算出的身份在此列表中、且其自身库已不存在时,落到本库。随资源同步。默认 []

embedding 模型 可变 —— 改它会重嵌整个 store(文件是真相)。没有不可变锁。

与 spec 006 的形状差异是刻意的:007 把 embedding 字段保持扁平,让 memory 表单保持轻薄;006 则把它们嵌套在一个 EmbeddingConfig 对象里。自全局 embedding 重设计起,扁平字段已是遗留字段——为兼容性在 wire 上继续接受但被忽略;索引与 recall 都解析全局 embedding 配置。同理,007 recall 响应里的 fallback布尔值 —— recall 跨多个 store,单一的回退模式字符串没有良定义;而 006 的单 store 搜索报告一个可空的模式枚举(fallback: "keyword" | null)。

MemoryFact (domain/memory/fact.py)

frozen dataclass;一个每条事实 markdown 文件(frontmatter + 正文)的内存视图。

字段类型说明
idstr文档 id(ULID);也是 <fact-slug>.md 文件名的基础。
titlestrfrontmatter title(短标题;旧 name key 仍兼容解析)。
descriptionstrfrontmatter description(一行摘要)。
bodystrmarkdown 正文 = 事实文本。
actorLiteral["agent","user"]frontmatter metadata.actor —— 谁写的。
origin_session_idstr | Nonefrontmatter origin_session_id
created_atdatetimeUTC。
updated_atdatetimeUTC(编辑前 == created_at)。

MemoryScope (domain/memory/scope.py)

python
class MemoryScope(StrEnum):
    GLOBAL = "global"     # project_id = WORKSPACE_GLOBAL_PROJECT_ID
    PROJECT = "project"   # project_id = <project ULID> resolved from cwd

@dataclass(frozen=True)
class ResolvedScope:
    scope: MemoryScope
    project_id: str       # ULID; sentinel for GLOBAL
    store_dir: Path       # ~/.coffer/memory/global | projects/<ulid>

MemoryHitdomain/knowledge/retrieval.py,共享)

frozen dataclass;recall 结果。

字段类型说明
idstr事实(document)id。
textstr事实正文 / 命中的 passage。
scorefloat逐 store 的相关性分数(保留在线上契约里;见下文 RRF)。
sourcestr来源事实文件的 <scope>:<fact file path>
timedatetime事实的 updated_at

跨 store 的 recall 用倒数排名融合(reciprocal rank fusion,k=60)合并逐 store 的命中列表:不同 store/模式的原始分数不可比(翻转后的 bm25 无上界、vector ≤ 1、grep 是平坦分数),所以 RRF 按逐 store 的名次排序 —— 每条命中保留原始分数,只有合并后的顺序来自融合。grep recall 是真实服务的:ripgrep 扫该 store 的事实文件(对 FTS5 无法分词的内容必不可少,如 CJK)。store 名会被校验(global | project-<26 字符 ULID>):形状合法的名字会惰性 provision 对应 store;其余一律 404。

端口

检索与 KB 面共享。值对象(StoreRefPassageGrepHitGrepResultMemoryHitSearchResultRetrievalMode)在 domain/knowledge/retrieval.py;协议(KnowledgeIndexGrepPortRetrievalPort)在 domain/knowledge/index.py。具体门面是 KnowledgeRetrievalapplication/knowledge/retrieval.py):它组合 chunk 索引(infrastructure/knowledge/sqlite_index.py + vec_index.py)、ripgrep 包装器(grep.py)与 embedder 客户端(embeddings.py),并持有 keyword↔vector 的决策(包括带标注的 vector→keyword 回退)—— 两个面都不重复这段逻辑。读时惰性 reindex 的对账由 memory 侧的 MemoryReconcilerapplication/memory/sync.py)驱动单一 re-index 例程(application/knowledge/reindex.py)完成。

agent 只通过 MCP 网关工具(coffer__recall/remember/list_memory)读写记忆;事实编辑/删除是用户面(REST/CLI/外部编辑器),不是 MCP 工具。Coffer 从不改动 agent 的原生记忆文件(原生投影已移除 —— 见 ADR-026)。

Domain 错误(规范类在 domain/errors.py,经 domain/knowledge/errors.py 再导出)

  • MemoryStoreNotFound —— code "MEMORY_STORE_NOT_FOUND"(HTTP 404);store 名形状非法时抛出(除 global / project-<26 字符 ULID> 之外的任何名字)。
  • MemoryNotFound —— code "MEMORY_NOT_FOUND"
  • MemoryRejected —— code "MEMORY_REJECTED";reason:"empty""too_long"
  • ScopeUnresolved —— code "SCOPE_UNRESOLVED";当 scope=project 但 cwd 不在 git 项目里时抛出。
  • EmbeddingUnavailable —— 对调用方不是错误:vector recall 降级为 keyword 并在结果里设置 fallback(绝不抛给用户)。

统一 SQLite schema(Alembic —— 一个重设计 revision)

重设计 revision 删除 memory_records 与所有 chroma/LlamaIndex 目录,然后创建与 KB 共享的、以 documents 为核心的统一 schema。没有数据迁移。

下面的 schema 与 KB 重设计迁移创建的是同一份统一 schema(迁移归 spec 006 所有;这里是它的 memory 视角)。重设计 revision 删除 memory_records 并创建这些表。

sql
-- Shared across KB (kind='knowledge_base') and memory (kind='memory').
CREATE TABLE documents (
    id             TEXT NOT NULL,               -- ULID (KB + memory), minted at first write
    kind           TEXT NOT NULL,               -- 'knowledge_base' | 'memory'
    resource_name  TEXT NOT NULL,               -- store name (memory: scope store)
    project_id     TEXT NOT NULL,               -- WORKSPACE_GLOBAL sentinel | project ULID
    path           TEXT NOT NULL,               -- canonical .md path on disk = truth
    title          TEXT NOT NULL,               -- memory: frontmatter `title`
    description    TEXT,                         -- memory: frontmatter `description`
    metadata       TEXT NOT NULL DEFAULT '{}',   -- JSON; memory: {actor, origin_session_id}
    content_sha256 TEXT NOT NULL,               -- for lazy-reindex delta detection
    source_mode    TEXT NOT NULL DEFAULT 'native', -- memory: 'native'
    locked         BOOLEAN NOT NULL DEFAULT 0,  -- KB co-management lock (ADR-028); memory ignores it
    created_at     TIMESTAMP NOT NULL,
    updated_at     TIMESTAMP NOT NULL,
    PRIMARY KEY (kind, resource_name, id)        -- composite (memory ULIDs are globally unique too)
);
CREATE INDEX idx_documents_kind_res_time ON documents(kind, resource_name, updated_at DESC);
CREATE INDEX idx_documents_project ON documents(project_id);

CREATE TABLE chunks (
    id           TEXT PRIMARY KEY,              -- '<store-scope>:<doc-id>:<position>'
    -- store-scope = 12-hex digest of (kind, resource_name); keeps ids unique across stores
    document_id  TEXT NOT NULL,                 -- app-level cascade (not a FK; KB+memory share the table)
    kind         TEXT NOT NULL,
    resource_name TEXT NOT NULL,
    position     INTEGER NOT NULL               -- memory: per-passage chunks (1 for a short inbox fact; N for a multi-section topic doc)
);
CREATE INDEX idx_chunks_document ON chunks(document_id);

-- FTS5 keyword index; the chunk text lives once inside the FTS index (not
-- duplicated into a base table), with chunk_id mapping a hit back to its row.
CREATE VIRTUAL TABLE documents_fts USING fts5(
    text, resource_name UNINDEXED, chunk_id UNINDEXED, tokenize='trigram'  -- CJK-capable (migration 0033)
);

-- sqlite-vec virtual table (only when a vector mode is enabled); created lazily
-- per store at the configured width.
CREATE VIRTUAL TABLE vec_chunks USING vec0(
    chunk_id TEXT PRIMARY KEY,                  -- bare '<doc-id>:<position>' (the table itself is per-store)
    embedding FLOAT[<dim>]
);

document 删除时的级联是应用层的(索引的 delete_chunks + 仓储的 delete_document/delete_resource),不是 SQL 外键,因为 documents 表由两个面共享。

memory 面的 documents.metadata 经 Pydantic 校验为 {actor, origin_session_id}。按工程惯例,metadata JSON 用 model_dump(mode="json") 构造,使 datetime/AnyUrl 值能序列化进 SQLite。

Store 展示侧表

两张以 store_name 为主键的小侧表保存 memory store 的展示元数据(不属于规范的 documents 基底;二者互为镜像):

sql
CREATE TABLE memory_store_project_roots (
    store_name   TEXT PRIMARY KEY,   -- 例如 'project-<ULID>'
    project_root TEXT NOT NULL       -- provision 时记录的来源 git-root(FR-017a)
);
CREATE TABLE memory_store_labels (
    store_name TEXT PRIMARY KEY,     -- 例如 'project-<ULID>' 或 'global'
    label      TEXT NOT NULL         -- 用户设置的显示名(FR-017c)
);

渲染 store 的可读身份时,label 优先于由 project_root 推导的 basename;清除 label 即删除其行,退回 FR-017a 的推导 / 回退名。两张表都不改变 store 名(project-<ULID>)或 project_id

一个仓库一个 store,跨 git worktree。 项目 ULID = sha256(git-root 路径)。linked worktree 有自己的 .git 文件,故 git_rootinfrastructure/memory/scope_fs.py)会沿该指针的 gitdir/commondir 回溯到仓库 toplevel —— 一个仓库的所有 worktree(含主 checkout)解析为同一个 ULID,即同一个 store。分支解析(git_branch)仍按 worktree 各自计算(handoff 按分支 key)。此前"每个 worktree 各自哈希"造成的碎裂 store,在 daemon 启动时由一次性、幂等、只增不删的合并(application/memory/consolidate.py)修复:重解析每个 project_root,凡 store 名不再等于其根规范 project-<ULID> 者,其 lane 文件并入规范 store(journal 按时间戳去重,其余同名冲突保留为 --from-<ulid> 兄弟文件)后退休(resource + documents + label + root 行)。

落盘规范布局(真相源)

~/.coffer/
└── memory/
    ├── global/                        # project_id = WORKSPACE_GLOBAL_PROJECT_ID (00000000000000000000000000)
    │   ├── knowledge/                 # 语义 lane(recall 在此搜索)
    │   │   ├── inbox/<item>.md        # per-item file = truth(frontmatter + body),新记住的条目
    │   │   ├── <topic>.md             # 经整理的主题文档(由整合 organizer 写入)
    │   │   └── INDEX.md               # 人类审阅入口(由 organizer 重新生成)
    │   ├── consolidation-log.md       # 只追加 changelog(store 根目录;机器本地,在 recall 之外)
    │   ├── superseded/<slug>-<ts>.md  # reorg tombstone(store 根目录;在 recall 之外;可恢复;DO 同步)
    │   └── rules/*.md                 # 过程性 lane:rules.md + 拆分后的 per-topic <slug>.md(store 根目录;在 recall 之外;session-start 注入;DO 同步)
    └── projects/<project-ulid>/       # 每项目一个目录
        ├── knowledge/
        │   ├── inbox/<item>.md
        │   ├── <topic>.md
        │   └── INDEX.md
        ├── consolidation-log.md
        ├── superseded/<slug>-<ts>.md
        ├── rules/*.md                    # rules.md + per-topic <slug>.md(超阈值后自主拆分)
        └── journal/<YYYY-MM-DD>.md       # 情景,追加式,每天一个文件(当天无条目则不建文件);索引进 recall(FR-043)

没有 MEMORY.md —— 此前的派生投影已移除。recall glob knowledge/**/*.md(排除 INDEX.md),所以 organizer 写入主题文档后会被透明拾取,手写的主题文档也会被立即发现。INDEX.md 与 store 根目录的 consolidation-log.md派生/机器本地的:排除在 recall 与同步镜像之外(每台机器从已同步的主题文档重新生成 INDEX.md;日志按机器各自维护)。主题文档本身是真相源,DO 同步。store 根的 superseded/ tombstone 保存 reorg pass(FR-033/034)退役的旧版本:与 handoff/ 一样在 knowledge/ lane 之外,故排除在 recall 之外;但与派生文件不同,它DO 同步 —— 它是可恢复的真相源历史,而非重新生成的派生物。store 根的 rules/过程性 lane(FR-036):organizer 把规则形态的 inbox 条目分类追加进 rules/rules.md(追加,而非主题合并);任一 rules/*.md 超过阈值后,reorg pass 按主题(one-shot LLM)把它拆分为 per-topic rules/<slug>.md(amendment 2026-06-22),读取面拼接全部 rules/*.md。它在 knowledge/ lane 之外,故排除在 recall 之外(rules 由 session-start 注入交付 —— 那是之后的切片 —— 而非 recall),且作为真相源DO 同步(与 handoff/ 一样)。它经 GET /memory_stores/{name}/rules / coffer memory rules 只读暴露。store 根的 journal/情景 lane(FR-040):与 rules//handoff//superseded/ 不同,它参与 recall —— reconciler 把每个 journal/<YYYY-MM-DD>.md(每天一个文件;当天无条目则不建文件)索引为一个记忆文档(像主题文档一样分块),grep 守卫保留 journal/ 命中,使情景事件可被检索(FR-043)。索引是即时的:JournalService.append 写完立即索引该 period 文件(reconcile-on-append),蒸馏出的条目无需等懒式 reconcile-on-read 即可被 recall;启动时的 reindex sweep(run_memory_reindex_sweep)会把此前"写入磁盘却因 store 未被 recall 而未索引"的 journal 补索引。它仍作为真相源历史 DO 同步,且计入 store 的 fact_count(该计数只统计 knowledge/ lane)。

organizerapplication/memory/organizer.py,内部 LLM,仅显式 organize 触发)通过每条目一次 one-shot completion 把 inbox/ 排空进主题文档:取回至多 3 个候选主题文档(不用 LLM)→ 一次 LLM 合并/创建调用 → 写 knowledge/<slug>.md → 删除 inbox 条目(仅在写入成功之后)→ 追加一行 changelog。畸形的 LLM 响应会跳过该条目(留在 inbox,绝不损坏文档)。主题文档 .md 的 frontmatter 是 {title, description, updated_at} + 正文。langchain 的 LLM 调用留在 infrastructure/chat(Contract 9);application/memory 经一个 memory 本地的 LlmCompletionPort 触达它(克隆 distill 切片;Contract 5e 禁止 import application.distill)。

每条事实 .md 的 frontmatter:

markdown
---
kind: knowledge
title: deploy-via-make-release
description: This repo deploys via `make release`, never git push --tags directly.
metadata:
  actor: agent
origin_session_id: 01J...
created_at: 2026-06-09T10:11:12+00:00
updated_at: 2026-06-09T10:11:12+00:00
---

This repo deploys via `make release`. Never run `git push --tags` directly; the
release target tags and pushes atomically.

created_at / updated_at 持久化在 frontmatter 里(文件是真相源);只有解析省略了它们的手写事实文件时,才回退用文件 mtime。

infrastructure/memory/paths.py 是唯一构造这些路径的模块。infrastructure/memory/files.py 是唯一读写每条记忆 .md、扫描 knowledge/ lane 找增量的模块。

级联与完整性规则

动作效果
remember / 用户新增knowledge/inbox/<item-slug>.md → 索引进 documents/chunks/FTS5/(vec)→ 审计。
用户编辑(REST/CLI/外部编辑器)重写 .md → 单一 re-index 例程(sha256 变化 → re-chunk/-embed)→ 审计。(直接的外部编辑器编辑在下一次 lazy reindex-on-read 时生效。)MCP 无编辑工具 —— 仅 REST/CLI。
用户删除(REST/CLI)删除 .md → 移除 documents/chunks/FTS5/vec 行 → 审计。MCP 无删除工具 —— 仅 REST/CLI。
Lane 删除(REST)DELETE /memory_stores/{name}/{journal/<period>,handoff/<branch>,rules,consolidation-log} → 删除 lane 文件 → 丢弃 journal lane 的索引行(FR-043;handoff/rules 在 recall 之外)→ 向 consolidation-log.md 追加一行人类可读记录(删除 changelog 自身时除外)→ 审计 memory_deleted。文件不存在 → 404(与 fact-delete 一致)。
清空一个 scope删除 knowledge/ 下每条记忆条目 → 移除全部索引行 → 审计。store Resource 保留。
整理(显式触发;内部 LLM)逐 inbox 条目:取回 ≤3 个候选主题文档 → 一次 one-shot LLM 合并/创建/分类 → 若 LLM 标记该条目为 rule,追加到 rules/rules.md(过程性 lane,FR-036);否则写 knowledge/<slug>.md → 删除 inbox 条目(仅在写入/追加之后)→ 追加 consolidation-log.md。随后重新生成 INDEX.md、对账索引、把任一超阈值 rules/*.md 经 one-shot LLM 分类拆分为 per-topic rules/<slug>.md(amendment 2026-06-22)、审计 memory_organized(含 rules_appended 计数)。畸形 LLM 输出跳过该条目(留在 inbox);未配置内部模型 → no-op。
重组 reorg(显式触发;内部 agentic LLM)有界的 langgraph create_react_agent 循环,配 list/read/write/supersede 工具作用于主题文档:合并重复 + 拆分过长文档。每次覆盖/supersede 先把旧版本归档到 superseded/<slug>-<ts>.md(绝不硬删除)。随后重新生成 INDEX.md、对账、审计 memory_reorganized。未配置内部模型 → no-op(no_model);无主题文档 → no-op(empty)。
自动整理 auto-organize(静默触发;opt-in,默认关闭)memory 写入通知钩子(重新)武装单个去抖定时器;store 静默达延迟后,对发生变化的 store 作为后台任务运行上面的「整理 Organize」—— 一个 session-end 代理(FR-035)。非阻塞:daemon 关停时取消(未触发的 inbox 原样留给之后的 pass;不丢数据)。失败被吞掉并记日志。无新增 REST/CLI 面。
删除 store Resource移除该 store 的 documents 行、rmtree(store_dir)、审计。
Recall读时惰性 reindex:扫 knowledge/ lane 找增量(按 content_sha256)→ reconcile → 搜索。
修改 embedding 模型允许 → 下次索引时对 store 重新 embedding(文件是真相)。
修改 max_fact_chars允许。

单一 re-index 例程(application/knowledge/reindex.py,与 KB 共享)

compute content_sha256 of the new markdown
 ├ unchanged → skip (no-op)
 └ changed   → delete old chunks/FTS5/vec rows → re-chunk → (vector) re-embed
              → insert new → update documents row → audit *_UPDATED

memory 的所有写路径(remember、update、用户编辑、惰性 reindex 扫描)都汇入这一个例程。

memory 对账器向该例程提供自己的分块器(见 FR-032):共享的 infrastructure/knowledge/chunking.chunk_markdown,绑定固定的 memory 分块 size/overlap 常量(不是 per-store 配置),从而把一份已整理的主题文档切成段落粒度的分块(标题与块结构感知),使 recall 返回其最相关的段落。短的单段落事实仍只切成一块,因此 inbox 与主题文档之分、以及 INDEX.md/handoff/ 的 recall 隔离都不受影响。

当启用 vector 的 store 在 embed 时降级(embedding provider 不可用),该例程只做 keyword 索引并持久化一个空字符串 content_sha256 —— 一个刻意永不匹配的哨兵值,使下一次惰性对账重试 embed,而不是把这条事实当作已是最新。

新增审计事件

何时发出
"memory_added"remember/用户新增成功后
"memory_updated"用户编辑(REST/CLI)成功后
"memory_deleted"用户删除(REST/CLI)成功后
"memory_cleared"清空一个 scope 后

规则注入审计事件(slice 6,FR-049/FR-052)

何时发出
"agent_hook_installed"Coffer 把它的 SessionStart(Claude Code 再加 SessionEnd)hook 条目装入某 agent 的 hooks 配置后
"agent_hook_uninstalled"Coffer 从某 agent 的 hooks 配置移除它的 hook 条目后
"agent_native_memory_disabled"disable_native_memory 被打开、agent 的原生记忆关闭开关被写入后(FR-052)
"agent_native_memory_restored"disable_native_memory 被关闭(或 hook 被卸载)、agent 先前的原生记忆设置被恢复后(FR-052)

这四个事件位于 agent 面(spec 004),由 AgentHookService / 原生记忆开关记录; 它们只携带 agent 名与被改动的配置文件 —— 绝不携带任何规则 bundle 或记忆内容。 SessionStart/SessionEnd hook 的回调本身经 spec 007 既有的记忆路径写入:session-end 蒸馏复用 memory_added / journal_append 事件(与 FR-046 补扫一样,无独立审计事件),session-context 读取不发审计事件(它是读)。

规则运行时注入(slice 6 —— FR-049–FR-052)

slice 6 通过安装 session hook 在运行时把 rules lane(FR-036)交付到每个受管 agent,hook 回调 Coffer 索取一个只作为上下文的 bundle —— 绝不原生写文件(ADR-026)。agent 配置管道 (AgentConfig、hook 安装/卸载、原生记忆开关)位于 spec 004-agent-registry;bundle 组装与 session-end 蒸馏复用 spec 007 的记忆路径。本节记录面向 007 的形状;AgentConfig 字段与 hook-install / 原生记忆 REST 三件套加在 spec 004 的 data-model + 004-agent-registry/contracts/api.openapi.yaml

AgentConfig.disable_native_memory(spec 004 domain)

agent 持久化配置上的新布尔字段,默认 false(ADR-026 姿态 —— Coffer 绝不碰原生记忆)。 在 AgentPatch(可设)与 AgentOut(可读)上呈现。

字段类型说明
disable_native_memorybool默认 false。为 true 时 Coffer 写入 agent 的原生记忆关闭开关,并在关闭/卸载时恢复(FR-052)。迁移校验器宽容(无该键的配置读作 false)。

设为 true 时 Coffer 写入:

  • Claude Code —— ~/.claude/settings.json{"autoMemoryEnabled": false}(JSON、原子、.bak)。恢复 = 删除该键。
  • Codex —— ~/.codex/config.tomlfeatures.memories = false + memories.generate_memories = false(TOML、tomlkit、原子、.bak)。恢复 = 删除这两个键。

已安装 hook 条目形状

Coffer 把它的 hook 装进 agent 顶层 hooks 键(Claude Code settings.json;Codex hooks.json —— 同一套 JSON schema)。命令是 coffer-hook 控制台脚本的绝对路径,并把 agent 名烤进参数 (coffer-hook --agent <name>),因为 hook stdin JSON 不携带 Coffer 的 agent 身份(cwd / session_id / event 来自 stdin)。安装/卸载幂等,且coffer-hook 命令 basename 识别 Coffer 自己的条目 —— 绝不动用户 hook。

jsonc
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|resume|clear|compact",
        "hooks": [
          { "type": "command", "command": "/abs/path/to/coffer-hook --agent claude-code" }
        ]
      }
    ],
    // 仅 Claude Code —— Codex 没有 session-end 事件(FR-051)。
    "SessionEnd": [
      {
        "matcher": "clear|logout|prompt_input_exit|other",
        "hooks": [
          { "type": "command", "command": "/abs/path/to/coffer-hook --agent claude-code" }
        ]
      }
    ]
  }
}

SessionStart 契约(两个 agent):hook 读 stdin {session_id, transcript_path, cwd, hook_event_name, source, …},调 GET …/session-context?cwd=<cwd>,打印 {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "<bundle>"}} 然后退出 0(bundle ≤ 10k 字符)。任何失败(无 daemon、超时、报错)→ 什么都不打印、退出 0 (绝不阻塞 agent)。SessionEnd 契约(仅 Claude Code):hook 读 stdin {session_id, transcript_path, cwd, reason},调 POST …/sessions/{session_id}/end 携带 {cwd},忽略结果、退出 0。

bundle 组装(assemble_session_context(cwd) -> str,FR-049/FR-050)

cwd 解析 recall 作用域 → 对每个作用域读其 rules(拼接全部 rules/*.md,FR-036 读取面)→ 拼接 先项目规则、后全局规则 → 追加两条播种的内置规则(常量,即便 rules lane 为空也始终在场):

  • 播种 resume 规则 —— 当用户想接续此前工作时,调 coffer__resume() 拉取本项目 + 分支保存的工作状态 handoff。
  • 播种软引导规则 —— 优先用 coffer__remember / coffer__recall 而非 agent 的原生记忆;Coffer 是用户各 agent 间的共享 store。

handoff 正文在 bundle 里(经 coffer__resume 按需拉取,FR-050)。返回 markdown;空结果仍携带播种规则。

新增端点(slice 6)

两个 agent 作用域端点支撑这些 hook。它们与其它 /api/v1/agents/{name}/… 路由一起位于 contracts/transcripts.openapi.yaml(面向 agent 的 007 契约);见下文线上契约说明。

方法 + 路径正文 / 查询返回说明
GET /api/v1/agents/{name}/session-context?cwd=cwd 查询{additional_context: string}SessionStart bundle(FR-049/FR-050)。只读;无审计。cwd 不在 git 项目里 → 只含全局 + 播种规则。
POST /api/v1/agents/{name}/sessions/{session_id}/end{cwd}202/200SessionEnd 蒸馏(FR-051)。经 distilled_sessions 账本幂等;已蒸馏 / 无模型 / 非 git 项目时为 no-op。

hook-install 三件套(GET/POST/DELETE /api/v1/agents/{name}/hook-install,克隆 /mcp-install) 与 PATCH /api/v1/agents/{name} 上的 disable_native_memory 字段位于 spec 004-agent-registryAgentOut / AgentPatch / /mcp-install 已在那里),不在本处。

distilled_sessions 幂等账本(与 FR-046 共享)

SessionEnd 蒸馏(FR-051)复用为 FR-046 补扫引入的、按 (agent, session_id, content_sha256) 键控的同一机器本地账本 —— 会话在两条路径间绝不被重复蒸馏(hook 降低延迟;补扫是独立的写入 保证)。slice 6 不引入新的账本表。

对话记录提炼(Spec 007 扩展)

对话记录提炼是 memory 事实的一个生产者 —— 它复用现有的 MemoryFact 底座(不新增表,不新增资源 kind)。

洞察不分类(无 type)

一次 LLM 调用返回一个洞察数组,每条洞察仅携带 name / description / body —— 蒸馏保持“笨”,MUST NOT 为每条洞察分类 type(旧的 InsightType 词汇 decision / gotcha / convention / todo 被废弃 —— FR-045)。每条洞察以 actor="agent" 追加到会话所属项目的 journal 记忆带(情景),而非带 type 的 knowledge/ 事实;把复现的 journal 模式固化进 knowledge/rules 是 organizer 的职责(固化 —— FR-047)。Lane 是唯一分类轴(FR-048);不存在自由格式的 type

出处 —— origin_session_id

每条提炼出的事实在事实 frontmatter 与 documents.metadata 里都携带 origin_session_id(对话记录的 session id)。这使自动化来源可审计:用户可查看是哪个 session 产生了某条事实,必要时可删除或修正它。

提炼洞察的事实 frontmatter 示例:

markdown
---
name: use-make-release-for-tagging
description: Always tag and push via make release; never git push --tags directly.
metadata:
  actor: agent
origin_session_id: 01JXYZ…
created_at: 2026-06-14T08:00:00+00:00
updated_at: 2026-06-14T08:00:00+00:00
---

Always tag and push via `make release`. The Makefile target is atomic — it
tags and pushes in one step. Running `git push --tags` directly bypasses the
release checks and can leave the repo in a half-tagged state.

LLM 调用前必须抹除的不变量

原始记录绝不落盘,也不会出现在事实正文里。LLM 调用前:

  • 所有 tool_use / tool_result 块(Claude/Codex)以及非 text 的 part —— tool、reasoning、file、step(OpenCode)—— 被丢弃。
  • assistant 回复中嵌入的文件内容片段与命令输出被丢弃。
  • 常见 secret 模式(API key、token、PEM block)经正则抹除器删除。
  • 长片段被截断。

只有抹除后的自然语言文本(用户 + 助手的散文部分)发送给 LLM。只有提炼出的洞察文本写入事实 store。原始记录与抹除中间体均不存储于 ~/.coffer/ 的任何位置。

Coffer 读取 ~/.claude/projects/~/.codex/sessions/ 以及 OpenCode 的存储树(~/.local/share/opencode/storage/),但在此流程中绝不写入它们 —— Spec 004 的只读不变量得到完整保留。Cursor / OpenClaw / Hermes 的记录读取器被推迟(见 spec.md 的 US「distill transcript to memory」):它们的存储要么临时、要么无文档、要么不带工作目录无法按项目归类。

审计

提炼复用现有的 memory 写入路径:每条写入的事实都会触发 memory_added 事件,并带上其 origin_session_id。不会发出提炼专属的审计事件。

对话会话摘要(历史列表视图)

对话历史面列出某 agent 的过往会话,供浏览与提炼。摘要是从每个会话 .jsonl 解析出的只读投影 —— 不持久化任何内容(Spec 004 只读不变量):

字段来源
session_idClaude Code 的 sessionId / Codex 的 payload.id;回退到文件路径。
titleClaude Code 的 ai-title(最新者胜),否则第一条真实用户消息(跳过前导/shell/斜杠命令)截断;可为 null。
project_pathClaude Code 顶层 cwd / Codex 的 session_meta.payload.cwd
message_count对话轮次计数 —— Codex 仅计 response_item 消息(绝不计重复的 event_msg 事件)。
started_at第一个事件时间戳。
last_activity_at最后一个事件时间戳。
source_path会话 .jsonl 的绝对路径(经共享 FileActions 驱动在文件管理器中显示)。

列表端点应用服务端搜索(标题或项目路径)、筛选(精确项目;started_at 范围)与排序 (started_at / last_activity_at / message_count,升/降序),以 limit/offset 分页。读取器保留一份进程内、按 mtime 键控的已解析摘要缓存,使含数千会话的 agent 仅重新解析 mtime 变化的文件。

四-lane 读端点(slice 7 —— FR-053/FR-054)

memory store 详情页把 store 呈现为四个 lane 区块(Knowledge / Rules / Journal / Handoff)外加一个整合 changelog 视图(FR-053)。Knowledge 复用既有事实读面 (GET …/facts),也是 recall 操作的对象;Rules 已有 GET …/{name}/rules (FR-036)。slice 7 再加三个只读 lane 端点(FR-054),每个按 store 名寻址 (而非 cwd)—— 它们是对磁盘 lane 文件的薄读投影,不调 LLM,镜像 MemoryService 上的 get_rules/metrics(经解析出的作用域取 store_dir,经 lane 路径助手 + infra 读取器在请求线程外读取)。空 store 返回空列表 / null 加 HTTP 200 —— 绝非 404。

Method + path返回lane 来源
GET /api/v1/memory_stores/{name}/journalJournalOutjournal/<YYYY-MM-DD>.md 文件,最新分片优先(情景;FR-040)。
GET /api/v1/memory_stores/{name}/handoffHandoffOuthandoff/<branch-slug>.md 现场,每分支一个(连续性;FR-023)。
GET /api/v1/memory_stores/{name}/consolidation-logConsolidationLogOutstore 根的 consolidation-log.md;不存在时 text = null(FR-031)。

响应实体(只读投影)

它们是只读 DTO —— 不是新的领域实体或表。每个都携带 lane 文件的磁盘真相(绝对 .md path + 所在 folder_path),使只读 lane 视图能提供 FR-021 的在外部编辑器打开 / 显示 / 复制路径能力,与 FactOut 完全一致。

JournalFileOut —— 一个按时间分片的 journal 文件。

字段类型说明
periodstrjournal/<period>.md 文件的 YYYY-MM-DD 词干(每天一个)。
textstrjournal 文件的 markdown 正文。
pathstrjournal 文件的绝对磁盘 .md 路径。
folder_pathstr所在 journal/ 文件夹的绝对路径。

JournalOut

字段类型说明
filesJournalFileOut[]journal 文件,最新分片优先;空 store 为空列表。

HandoffSceneOut —— 一个按分支的 handoff 现场。

字段类型说明
branchstr现场所属分支(frontmatter branch)。
textstr现场的自由 markdown 正文。
updated_atstr(date-time)现场最后写入时间(frontmatter updated_at)。
pathstrhandoff 现场文件的绝对磁盘 .md 路径。
folder_pathstr所在 handoff/ 文件夹的绝对路径。

HandoffOut

字段类型说明
scenesHandoffSceneOut[]每分支一个现场;无 handoff 的 store 为空列表。

ConsolidationLogOut —— organizer 的追加式固化 changelog。

字段类型说明
textstr | Noneconsolidation-log.md 正文;文件不存在时为 null
pathstrconsolidation-log.md 的绝对磁盘路径(store 根)。
folder_pathstr所在 store 目录的绝对路径。

这些读端点(及其 schema)落在后端拥有的 OpenAPI 契约里;spec/data-model 侧只记录上面的形状。

线上契约(REST)

位于 contracts/api.openapi.yaml。路由在 /api/v1/memory_stores 下(list/get/metrics;事实的 add/list/get/edit/delete/clear;recall;FR-054 的 slice-7 journal/handoff/consolidation-log lane 读取)。写入端点(add/edit/delete/clear)保留 —— 它们是 agent(经 MCP)与 CLI 写入事实的途径;桌面/web UI 是只读视图。读 DTO 携带磁盘真相:FactOut 带事实的绝对 .md path 及其所在文件夹的 folder_pathMemoryStoreOut 带 store 的绝对 store_dir,使只读视图能提供「在外部编辑器打开 / 显示」。kind 无关的 /api/v1/resources/... 对 memory store 继续可用。全应用统一错误包络:{ "error": { "code", "message", "details" } }

slice-6 的 agent 作用域端点(GET …/session-contextPOST …/sessions/{session_id}/end)与其它 /api/v1/agents/{name}/… 路由一起位于 contracts/transcripts.openapi.yaml;hook-install 三件套与 disable_native_memory agent 字段位于 004-agent-registry/contracts/api.openapi.yaml(见上文「规则运行时注入」)。