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_modes | list[Literal["grep","keyword","vector","hybrid"]] | 启用的模式。默认 ["grep","keyword"](零配置、离线)。vector 为可选项;hybrid(对 keyword+vector 做 RRF)与 KB 面共享。 |
default_mode | Literal["grep","keyword","vector","hybrid"] | 默认 "keyword"。 |
embedding_provider | str | None | OpenAI 兼容 provider id(如 openai、voyage、local)。vector 必填。 |
embedding_model | str | None | 如 bge-m3(本地)或某云端模型。vector 必填。 |
embedding_base_url | str | None | OpenAI 兼容 provider 的 base URL 覆盖。 |
embedding_credential_ref | str | None | embedding API key 的 keychain ref(绝不明文)。 |
embedding_dimensions | int | 默认 768;范围 1–8192。决定该 store 的 vec_chunks 表宽;随线上契约传输。 |
max_fact_chars | int | 默认 8192;范围 64–32768。可变。 |
merged_identities | list[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 + 正文)的内存视图。
| 字段 | 类型 | 说明 |
|---|---|---|
id | str | 文档 id(ULID);也是 <fact-slug>.md 文件名的基础。 |
title | str | frontmatter title(短标题;旧 name key 仍兼容解析)。 |
description | str | frontmatter description(一行摘要)。 |
body | str | markdown 正文 = 事实文本。 |
actor | Literal["agent","user"] | frontmatter metadata.actor —— 谁写的。 |
origin_session_id | str | None | frontmatter origin_session_id。 |
created_at | datetime | UTC。 |
updated_at | datetime | UTC(编辑前 == created_at)。 |
MemoryScope (domain/memory/scope.py)
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>MemoryHit(domain/knowledge/retrieval.py,共享)
frozen dataclass;recall 结果。
| 字段 | 类型 | 说明 |
|---|---|---|
id | str | 事实(document)id。 |
text | str | 事实正文 / 命中的 passage。 |
score | float | 逐 store 的相关性分数(保留在线上契约里;见下文 RRF)。 |
source | str | 来源事实文件的 <scope>:<fact file path>。 |
time | datetime | 事实的 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 面共享。值对象(StoreRef、Passage、GrepHit、GrepResult、MemoryHit、SearchResult、RetrievalMode)在 domain/knowledge/retrieval.py;协议(KnowledgeIndex、GrepPort、RetrievalPort)在 domain/knowledge/index.py。具体门面是 KnowledgeRetrieval(application/knowledge/retrieval.py):它组合 chunk 索引(infrastructure/knowledge/sqlite_index.py + vec_index.py)、ripgrep 包装器(grep.py)与 embedder 客户端(embeddings.py),并持有 keyword↔vector 的决策(包括带标注的 vector→keyword 回退)—— 两个面都不重复这段逻辑。读时惰性 reindex 的对账由 memory 侧的 MemoryReconciler(application/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—— 对调用方不是错误:vectorrecall 降级为keyword并在结果里设置fallback(绝不抛给用户)。
统一 SQLite schema(Alembic —— 一个重设计 revision)
重设计 revision 删除 memory_records 与所有 chroma/LlamaIndex 目录,然后创建与 KB 共享的、以 documents 为核心的统一 schema。没有数据迁移。
下面的 schema 与 KB 重设计迁移创建的是同一份统一 schema(迁移归 spec 006 所有;这里是它的 memory 视角)。重设计 revision 删除 memory_records 并创建这些表。
-- 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 基底;二者互为镜像):
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_root(infrastructure/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)。
organizer(application/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:
---
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 *_UPDATEDmemory 的所有写路径(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_memory | bool | 默认 false。为 true 时 Coffer 写入 agent 的原生记忆关闭开关,并在关闭/卸载时恢复(FR-052)。迁移校验器宽容(无该键的配置读作 false)。 |
设为 true 时 Coffer 写入:
- Claude Code ——
~/.claude/settings.json:{"autoMemoryEnabled": false}(JSON、原子、.bak)。恢复 = 删除该键。 - Codex ——
~/.codex/config.toml:features.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。
{
"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/200 | SessionEnd 蒸馏(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-registry (AgentOut / 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 示例:
---
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_id | Claude Code 的 sessionId / Codex 的 payload.id;回退到文件路径。 |
title | Claude Code 的 ai-title(最新者胜),否则第一条真实用户消息(跳过前导/shell/斜杠命令)截断;可为 null。 |
project_path | Claude 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}/journal | JournalOut | journal/<YYYY-MM-DD>.md 文件,最新分片优先(情景;FR-040)。 |
GET /api/v1/memory_stores/{name}/handoff | HandoffOut | handoff/<branch-slug>.md 现场,每分支一个(连续性;FR-023)。 |
GET /api/v1/memory_stores/{name}/consolidation-log | ConsolidationLogOut | store 根的 consolidation-log.md;不存在时 text = null(FR-031)。 |
响应实体(只读投影)
它们是只读 DTO —— 不是新的领域实体或表。每个都携带 lane 文件的磁盘真相(绝对 .md path + 所在 folder_path),使只读 lane 视图能提供 FR-021 的在外部编辑器打开 / 显示 / 复制路径能力,与 FactOut 完全一致。
JournalFileOut —— 一个按时间分片的 journal 文件。
| 字段 | 类型 | 说明 |
|---|---|---|
period | str | journal/<period>.md 文件的 YYYY-MM-DD 词干(每天一个)。 |
text | str | journal 文件的 markdown 正文。 |
path | str | journal 文件的绝对磁盘 .md 路径。 |
folder_path | str | 所在 journal/ 文件夹的绝对路径。 |
JournalOut
| 字段 | 类型 | 说明 |
|---|---|---|
files | JournalFileOut[] | journal 文件,最新分片优先;空 store 为空列表。 |
HandoffSceneOut —— 一个按分支的 handoff 现场。
| 字段 | 类型 | 说明 |
|---|---|---|
branch | str | 现场所属分支(frontmatter branch)。 |
text | str | 现场的自由 markdown 正文。 |
updated_at | str(date-time) | 现场最后写入时间(frontmatter updated_at)。 |
path | str | handoff 现场文件的绝对磁盘 .md 路径。 |
folder_path | str | 所在 handoff/ 文件夹的绝对路径。 |
HandoffOut
| 字段 | 类型 | 说明 |
|---|---|---|
scenes | HandoffSceneOut[] | 每分支一个现场;无 handoff 的 store 为空列表。 |
ConsolidationLogOut —— organizer 的追加式固化 changelog。
| 字段 | 类型 | 说明 |
|---|---|---|
text | str | None | consolidation-log.md 正文;文件不存在时为 null。 |
path | str | consolidation-log.md 的绝对磁盘路径(store 根)。 |
folder_path | str | 所在 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_path,MemoryStoreOut 带 store 的绝对 store_dir,使只读视图能提供「在外部编辑器打开 / 显示」。kind 无关的 /api/v1/resources/... 对 memory store 继续可用。全应用统一错误包络:{ "error": { "code", "message", "details" } }。
slice-6 的 agent 作用域端点(GET …/session-context、POST …/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(见上文「规则运行时注入」)。