claude-mem 给 Claude Code 加持久记忆:新会话里它还记得上次在这个项目做过什么。
这篇基于 v13.16.1 源码。不按目录结构讲,按这个系统真正值得学的几件事讲。
先给全景,然后依次是三个核心设计、运行时、存储与回注,最后是实测数据和工程细节。
你的工具调用→ PostToolUse hook(异步,不阻塞)→ worker HTTP :377xx → 内存队列→ 长驻 observer SDK 会话(流式喂入)→ XML observation→ SQLite + Chroma→ 下次 SessionStart:时间线压缩 → 注入上下文
核心设计:长驻观察员
这是整个项目最值得抄的一个想法。
为什么不是”每次调用总结一次”
朴素做法是:每次工具调用完,拿 input/output 调一次 LLM 生成摘要。这样做的产出是流水账 —— 模型看不到”读了 A、改了 B、跑测试失败、再改 B”是一次完整的调试过程,只能记成四条互不相干的记录。
claude-mem 的做法是让一个带上下文的会话持续旁观。
流式输入:一个 generator 喂一个进程
ClaudeProvider.ts 用 Agent SDK 的 query({ prompt: asyncGenerator }) 起流式输入会话。createMessageGenerator() 先 yield 一条 init prompt(”你是旁观者,把观察到的工具调用记成 observation”),然后进入无限循环:从队列取消息,包成 <observed_from_primary_session> 继续 yield 进同一个会话。
好处是双份的:模型带着上下文判断,产出质量高;prompt 前缀命中缓存,token 便宜。
批处理是涌现的,不是配置的
队列一次只 claim 一条,每条 yield 都触发一个模型 turn。但如果工具调用来得比模型生成快,后续消息会在 SDK 侧排队,下一个 turn 一起进来。
代码里没有任何地方配置批大小。 N 条工具调用压成 M 条 observation,完全是速度差涌现出来的。
我本机 1893 条记录的实测:
总 observation 数 1895不同 (时间戳, 会话) 组 1760一个 turn 出多条的组数 97
94% 的产出 turn 只吐一条。
它不 resume:上下文只活一个进程的命
这点反直觉。ClaudeProvider.ts 每次启动 generator 都会主动清掉 memorySessionId:
if (session.memorySessionId) {// Observer spawns intentionally opt out of Claude transcript persistence.// A carried session_id from an earlier no-persist spawn is therefore not// safe to feed back into `resume` on a later fresh process.this.dbManager.getSessionStore().updateMemorySessionId(session.sessionDbId, null);session.memorySessionId = null;}const hasRealMemorySessionId = false;const shouldResume = false; // ← 硬编码
因为 observer 的 spawn 带了 --no-session-persistence(commit 641ff245,#2909),不落 transcript 的 session_id 没法拿来 resume。
所以 memorySessionId 现在的角色不是恢复上下文,而是当外键 —— observations.memory_session_id 指向它,标识”哪个 observer 进程生命周期产出的这条记忆”。
推论:进程一死(空闲 / abort / 崩溃),观察上下文就没了,下次全新 init prompt 重来。
提示词里的三组指令
输出骨架由 mode 配置驱动(plugin/modes/,/mode-creator 就是改这个)。模型必须返回 XML:
<observation><type>bugfix|discovery|decision|refactor|...</type><title/><subtitle/><facts><fact/>…</facts><narrative/><concepts/><files_read/><files_modified/></observation>
记什么
recording_focus 给正向标准:关注 durable technical signal —— 系统现在做得不一样的地方、上线了什么、具体的排查发现。用 implemented / fixed / deployed / discovered / traced 这类动词。
它还专门列反例,防一类很典型的失败:
❌ "Analyzed authentication implementation and stored findings"❌ "Tracked deployment steps and logged outcomes"
也就是记”我这个观察员做了什么”而不是”被观察的会话做了什么”。让 LLM 当观察员时极容易犯这个错。
跳什么
skip_guidance 明确要跳过:空的 status 检查、装包、单纯目录列举、重复操作、查了但没查到的研究、照现有模式的直白实现、重命名/提取这类小重构、自解释的改动、常规配置更新。
code--chill 这个 mode 末尾一句是整个项目对”什么值得记”的最终定义:
When in doubt, skip it. Less is more. Only record what you’d be frustrated to figure out again.
跳过的协议是:返回空响应,不要用散文解释为什么跳过。原因见下一节的闸 4。
怎么分类
9 种 type:bugfix / feature / refactor / change / discovery / decision / security_alert / security_note / sensitive。
最后一个的定义值得抄:
算不上私密、但你不希望在错误的上下文里泄漏到后续内容创作中的信息(内部 URL、未发布的计划、个人信息、业务指标、客户或合作方名称)
承认”隐私”不是布尔量,<private> 之外还需要一档”记下来但要小心用”。
什么进得来:成本递增的六道闸
从工具调用到落库要过六关,编号规则是成本递增 —— 闸 0 是几个字符串比较,闸 3 之后要烧一次 LLM 推理。能早拦就不往后送。
闸 0 hook 进程内的守卫闸 1 worker 入队前的规则过滤闸 2 内容脱敏(切标签)闸 3 observer LLM 自己判断值不值得记 ← 主力闸 4 输出结构校验闸 5 写库前的事实校正
闸 0 / 闸 1:纯规则层
闸 0 在 hook 子进程里,命中就直接返回,HTTP 请求都不发。闸 1 在 worker 里,是权威判定。
防自噬
闸 0 的前两个条件是同一件事的两种写法:
if (process.env.CLAUDE_MEM_INTERNAL === '1') return false; // 环境变量标记if (isWithin(cwd, OBSERVER_SESSIONS_DIR)) return false; // 工作目录兜底
observer 本身也是 Claude 进程,也会触发 PostToolUse。不拦就是无限递归 —— 记录”我记录了一条记录”,再记录”我记录了’我记录了一条记录’”。
项目排除为什么查两遍
闸 0 只保护标准 hook 路径。但 /api/sessions/observations 还有别的入口:OpenCode 插件直接 POST、Cursor / Windsurf / Antigravity 集成、transcript 重放、兼容层 adapter。
闸 0 是性能优化,闸 1 是正确性保证,两边共用 isProjectExcluded() 保证语义一致。
工具黑名单的选择标准
默认值是 ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion。
共同点:它们的内容不是”你做了什么”,而是”框架在运转”。TodoWrite 是待办快照、AskUserQuestion 是交互协议、Skill 是调度动作。反过来 Bash / Read / Edit / Grep / WebFetch 全部默认入队。
隐私判定的粒度是”一轮对话”
PrivacyCheckValidator 判断的不是这次工具调用,而是它所属的那轮 user prompt:
- 行存在但为空 → 整轮被
<private>包裹,这轮所有 observation 和 summary 全丢 - 行不存在(
null)→ 明确放行并打 warn
第二条是修过的 bug(#2794/#2795):UserPromptSubmit hook 可能跑在 worker 启动完成前,prompt 没落库。老代码把 null 和 '' 都当”私密”,整个会话的记忆被静默冻结。缺失是故障,空才是意图。
闸 2:切标签,防回音
过闸的内容经 stripMemoryTags() 切掉六种标签:
private, claude-mem-context, system_instruction,system-instruction, persisted-output, system-reminder
<private> 是给你用的,其余四种是防回音:claude-mem-context 是上次注入的记忆,system-reminder 是框架注入的提示。不切,系统就会开始记录自己注入的记忆,几轮之后数据库里全是自我引用的复读。
闸 3:LLM 判断 —— 真正的主力
前三层都是规则,只能排除”结构上不该记的”。真正决定”这次有没有价值”的是 observer 自己,靠上一节那三组指令。
判断频率 = 通过前两闸的工具调用数,一条不落。 但”判断”和”落库”是两件事:绝大多数判断的产出是空字符串,直接被消费掉。
闸 4:输出结构校验
observer 说了不算。classifyObserverOutput() 分三类:
| 类别 | 含义 | 动作 |
|---|---|---|
xml |
含 <observation> / <summary> / <skip_summary/> |
进入解析 |
idle |
空白 | 约定的跳过信号,确认消费,不重试 |
prose |
其他自然语言 | 记 warn + preview,丢弃且不重试 |
散文为什么不重试
注释写得很直白:
Re-queueing them creates an observer loop where the same low-signal batch is retried.
一段”我觉得这批没什么好记的”的散文,和一段真正的失败输出,在解析器眼里长得一样。重试它只会无限循环。这就是提示词里反复强调”跳过就返回空、别解释”的原因。
两个例外
isQuotaLimitedObserverOutput() 和 isAuthFailureObserverOutput() 用正则识别”配额用完了”和”401/403,请 /login”两种散文,此时保留这批消息、退回队列、abort 会话,等你恢复后重来。
区分”模型认为不值得记”(丢掉)和”模型没能记”(退回),是这个分类器唯一的目的。
闸 5:写库前的事实校正
即使 XML 合法,文件列表也不完全信 LLM:
files_read = 工具证据 ∪ 模型声称(去重)files_modified = 工具证据(完全覆盖模型输出)
工具证据从这批消息的原始 tool_input 提取:Read → files_read,Edit/MultiEdit/Write/NotebookEdit → files_modified,apply_patch 单独解析 patch 头。
不对称是有道理的:“读过什么”允许模型补充(它可能从 Grep 结果里知道某文件相关),“改过什么”是硬事实 —— 幻觉一个没动过的路径,会直接毒化后续按文件的检索。
一条消息的一生:12 步
一条工具调用从入队到彻底消失,跨越两个进程边界。
[内存 buffer]│ ① claimNext() → claimed=true(不出队)│ ② claimedMessageIds.push / earliestPendingTimestamp / 心跳│ ③ buildObservationPrompt(含 16k 截断)│ ④ snapshotResponseContext ← 防竞态│ ⑤ yield SDKUserMessage╞═══ 进程边界 ═══════════════════│ ⑥ 模型 turn(后续消息在此排队 → 涌现批处理)╞═══ 进程边界 ═══════════════════│ ⑦ assistant 回包,token 记账│ ⑧ parseAgentXml + 分类 ──┬─ quota/auth → 退回重试│ ├─ prose/idle → 确认丢弃│ └─ 无 sessionId → 退回│ ⑨ 回读 claimed 原文提文件证据 → 校正 files_*│ ⑩ 单事务写 SQLite(content_hash 去重)│ ⑪ confirmClaimedMessages ← 消息此刻才真正删除└ ⑫ 扇出:Chroma / CloudSync / SSE / CLAUDE.md(全异步)
下面三条约束是读这段代码的钥匙。
claim 不出队,confirm 才出队
claimNext() 只翻一个标志位,消息还留在数组里:
const next = list.find(m => !m.claimed);next.claimed = true;return next;
两个原因:第 ⑨ 步要回头读原始 tool_input 提文件证据;第 ⑧ 步的岔路里有三条要退回重来。
顺序不能反 —— 必须先写库、先提完证据,再删消息。
快照而非引用
activeResponseContext.current = snapshotResponseContext(session);
冻结 project / promptNumber / pendingAgentId / pendingAgentType。
从 yield 到模型返回是几秒到几十秒的 await。这期间你可能已提交新 prompt、切了 worktree、起了子 agent。第 ⑩ 步写库时若直接读 session.xxx,这条记忆会被打上错误的归属标签。
时间戳用入队时刻
yield 出去的消息带 _originalTimestamp = enqueuedAt,它最终成为 observation 的 created_at_epoch。
于是记忆的时间反映的是”你什么时候做的这件事”,而不是”observer 什么时候想明白的”。这两者可能差几十秒。
扇出:全部不阻塞
写库成功后扇出四处:Chroma 向量化、CloudSync 通知、SSE 推给 viewer、文件夹 CLAUDE.md 更新。
全是 .then().catch() 或 void。Chroma 挂了、Telegram 超时、CLAUDE.md 写失败,一律只记日志。SQLite 写成功就算这条消息处理完了,向量检索退化成纯 FTS 也不影响主流程。
这里有个 #2240 的补丁:先 [...new Set(observationIds)] 去重再扇出。因为 content_hash 可能把多条解析结果折叠到同一行,1:1 同步会让 Chroma 反复报 “IDs already exist”。
运行时:worker 与多会话
worker 是一个常驻守护进程
不是库,不是每次拉起的脚本,是一个跑在你机器上的后台进程:
PID 19075 PPID 1 已运行 1 天 10 小时 内存 69 MBbun .../plugin/scripts/worker-service.cjs --daemon监听 127.0.0.1:37701 (LISTEN)
PPID 1 说明它和启动它的终端脱钩了。端口是算出来的:37700 + (uid % 100),不同用户不打架又不需要配置。
身份证明在 ~/.claude-mem/worker.pid:
{ "pid": 19075, "port": 37701, "startedAt": "...", "startToken": "..." }
一个文件,两种身份
最容易绕晕的地方 —— worker-service.cjs 这同一个文件用在两个完全不同的场景:
| 身份 | 启动方式 | 生命周期 |
|---|---|---|
| 守护进程 | worker-service.cjs --daemon |
常驻几天 |
| 一次性 CLI | worker-service.cjs hook claude-code observation |
几十毫秒 |
你每次调工具,Claude Code 拉起的是第二种:读 stdin、发一个 HTTP 请求给第一种、退出。两者靠 127.0.0.1:37701 通信。
单例保证靠 pid 文件(含 startToken 防 PID 复用误判)+ 端口检测。开一百个会话,worker 也只有一个 —— 每用户单例,不是每会话、不是每项目。
它同时是五个东西
| 角色 | 端点 |
|---|---|
| 采集 API | POST /api/sessions/observations |
| observer 宿主 | 内部:SessionManager + SDK 子进程池 |
| 检索 API | /api/search /api/timeline /api/context/inject |
| Web UI | GET / + GET /stream (SSE) |
| MCP 后端 | mcp-server.cjs 转发过来 |
多会话:隔离彻底,并发受限
会话身份
(platform_source, content_session_id) 联合判定。两个终端开两个会话 → 两个 sessionDbId,即使同一个项目目录也完全独立。
每会话一整套状态
sessionDbId 是贯穿所有数据结构的分区键:
| 结构 | 隔离粒度 |
|---|---|
sessions: Map<number, ActiveSession> |
每会话一个 |
buffers: Map<number, BufferedMessage[]> |
每会话一条队列 |
events: Map<number, EventEmitter> |
每会话一个唤醒信号 |
seenToolUseIds: Map<number, Set> |
去重集合不跨会话 |
abortController |
中止一个不影响其他 |
generatorPromise |
每会话最多一个 generator |
最后一条是关键保护:有 generator 在跑就什么都不做,新消息只是入队 + 唤醒。一个会话任意时刻最多一个 observer 进程。
slot 信号量:默认只有 2
这是多会话最会咬人的地方:
const maxConcurrent = parseInt(settings.CLAUDE_MEM_MAX_CONCURRENT_AGENTS, 10) || 2;const slotReservation = await waitForSlot(maxConcurrent, session.abortController.signal);
同时开 5 个会话,最多 2 个 observer 在跑,其余卡在 waitForSlot。硬上限 TOTAL_PROCESS_HARD_CAP = 10,超了直接抛错拒绝 spawn。
reservedSlots 计数器是修 #3287 加的,注释很有意思:
Registration only happens after spawn() returns a PID, and the caller has a wide await gap (OAuth refresh) between the grant and the spawn. Without a reservation, every concurrent caller observes the same stale count and all of them spawn (#3287: 9 agents against a max of 2).
授予 slot 和进程出现在注册表之间有个 OAuth 刷新窗口,之前所有并发调用者读到同一个陈旧计数,于是全都 spawn 了。
排队时消息不丢,但批次会变粗
卡住的是 generator 启动,不是消息入队。 ingestObservation() 同步入 buffer 立刻返回,跟有没有 slot 无关。
代价是攒了几十条才开始判断,模型一个 turn 看到一大坨,产出的粒度和顺序都和实时处理不一样。这不是 bug,但是并发高时记忆质量下降的真实原因。另一个代价是 buffer 没有容量上限。
跨会话共享的东西
| 全局资源 | 影响 |
|---|---|
globalRateLimitStore |
任一会话收到限流事件,所有会话共用这份配额快照 |
| 进程注册表 / slot 池 | 上面的信号量 |
| Chroma collection | 按 project 分(cm__<project>),不按会话 |
~/.claude-mem/.env 凭证 |
所有 observer 共用同一份 OAuth token |
第一条是对的 —— 配额是账号级的,否则 5 个会话会一起把配额撞穿。
调参
多 worktree 并行开发时默认的 2 明显跟不上。改 ~/.claude-mem/settings.json:
{ "CLAUDE_MEM_MAX_CONCURRENT_AGENTS": "4" }
判断是不是卡在 slot 上,看日志里这行:
PROCESS Pool limit reached (2/2), waiting for slot...
队列为什么是内存的
SessionMessageBuffer 的注释解释了一个重要的历史决策 —— 它替换掉了原来的 SQLite pending_messages 表:
一条 buffered message 是喂给有状态、非确定性 reducer 的一个片段。老的持久化队列存下了片段却丢掉了 reducer 的状态,所以崩溃后”重放” pending 行会产生不同的/重复的 observation,或者无限循环 —— 那就是当时的 retry storm。Claude Code 的 transcript JSONL 才是真正的持久事实源,transcript 重放才是恢复路径。
所以设计上是故意的:worker 挂了,没来得及判断的东西就是丢了。 因此没有 processing 状态、没有启动扫描、没有 pending 触发的重启。
存储:存的不是原文,是重述
原始 tool_input / tool_output 从来没有落过库。 它们只活在内存 buffer 里,喂给 observer 之后就没了。
observations 表里没有任何一列存原始数据。存进去的 100% 是模型重写的产物。这是”压缩系统”这个名字的真正含义 —— 不是压缩存储,是用 LLM 做有损语义重述。
真实的一行长什么样
id=1893,就是本文这次分析产生的:
type discoverytitle User Inquiring About Claude-Mem Memory Trigger Conditionssubtitle User asked what situations trigger memory recording and whatcontent actually gets persisted to memory.facts ["User explicitly asked: ...", "The question implies ...", ...]narrative The user in the primary session asked a follow-up question abouthow the claude-mem memory system decides what to record... (约 700 字符)concepts ["how-it-works","why-it-exists","gotcha"]files_read []files_modified []prompt_number 4discovery_tokens 3708text NULL ← 遗留列,现在永不写入
字段分工
| 列 | 用途 |
|---|---|
title |
注入时只显示这个 |
subtitle |
展开第一层 |
facts |
可独立引用的原子事实(JSON 数组) |
narrative |
完整展开时的正文 |
concepts |
注入白名单 + 精确匹配检索 |
files_read / files_modified |
按文件检索 |
content_hash |
hash(session, title, narrative),去重 |
两个细节:
concepts会在第一个:处截断(#3379)——"gotcha: WASM quirk"存成"gotcha"。因为注入 SQL 是精确匹配,不截断这条记忆就永远注不进来- FTS 索引只覆盖
title/subtitle/narrative/text/facts/concepts,文件列表不在全文索引里,按文件检索走另一条 SQL
summary 是另一张表,五段式
Stop hook 触发,产出 request / investigated / learned / completed / next_steps / notes。
有意思的是 next_steps —— 它在猜你下一步要问什么。这正是下次会话注入时最有价值的那一段。
回注:什么时候,注什么
三个触发点
| 触发点 | Hook | 默认 | 注什么 |
|---|---|---|---|
| 会话开始 | SessionStart |
开 | 项目的近期时间线 |
| 读文件前 | PreToolUse(Read) |
开 | 这个文件的历史 |
| 每次提问 | UserPromptSubmit |
关 | 与 prompt 语义相关的记忆 |
SessionStart:主力
context handler 请求 GET /api/context/inject?projects=...,结果塞进 additionalContext。
projects 是复数 —— 取 getProjectContext(cwd).allProjects,worktree 场景下多个目录共享记忆。SQL 里还有 merged_into_project 兜底已合并的分支。
PreToolUse(Read):按文件补充
你每次 Read 一个文件之前,file-context handler 会把这个文件的历史记忆插进去。
有个前置门槛:
const FILE_READ_GATE_MIN_BYTES = 1_500;
小于 1.5KB 的文件不触发 —— 小文件模型直接读完就懂了,塞历史反而是噪音。
UserPromptSubmit:语义注入,默认关闭
CLAUDE_MEM_SEMANTIC_INJECT: 'false', // experimental, disabled by default
开了之后,每次提问且 prompt ≥ 20 字符时,拿你的原话做语义检索取 top 5。这是三个触发点里唯一真正用”相关性”的。默认关掉,因为每轮都查向量库有延迟成本。
选取逻辑:时间倒序 + 两道白名单
最反直觉的发现 —— SessionStart 的注入没有相关性排序。queryObservationsMulti() 的核心:
WHERE (o.project IN (...) OR o.merged_into_project IN (...))AND (? IS NULL OR s.platform_source = ?)AND type IN (...) -- 白名单 1AND EXISTS ( -- 白名单 2SELECT 1 FROM json_each(o.concepts)WHERE value IN (...))ORDER BY o.created_at_epoch DESCLIMIT ? -- 默认 50
没有 query 参数,没有打分,没有向量。 就是”这个项目最近的 50 条”。
两道白名单都来自当前 mode 的定义:
type ∈ {bugfix, feature, refactor, change, discovery, decision,security_alert, security_note, sensitive}concepts ∩ {how-it-works, why-it-exists, what-changed,problem-solution, gotcha, pattern, trade-off} ≠ ∅
白名单的代价:野生 type 永远注不进来
这把两个发现连起来了。parser 对非法 type 是”记 error 但保留”,后果不只是检索会漏 —— 那些 milestone / verification / progress / environment 永远通不过 type IN (...)。
它们存在数据库里占着空间,但对自动注入等于不存在。只有显式调 get_observations 或 mem-search 才捞得到。
渲染:一行一条,让模型自己拉
关键默认值:
CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50'CLAUDE_MEM_CONTEXT_SESSION_COUNT: '10'CLAUDE_MEM_CONTEXT_FULL_COUNT: '0' ← 零
默认一条都不展开成正文。 每条 observation 就是一行:
return `${obs.id} ${time} ${icon} ${title}`;
真正的渐进披露不是”推”,是”拉” —— 注入内容的头尾都写着:
Fetch details: get_observations([IDs]) | Search: mem-search skill
注入的是一张目录。 模型看标题觉得相关,自己调 MCP 工具展开。相关性判断被推迟到模型手上,而不是在 SQL 里猜。
实测:一次真实注入
直接打这个端点,5825 字节、73 行:
# [claude-mem] recent context, 2026-08-28 11:56pm GMT+8Mode: Code Development (code)Legend: 🎯session ●bugfix ◆feature ↻refactor ✓change ○discovery ⚖decision ⚠security_alert ⚷security_note ⊘sensitiveFormat: ID TIME TYPE TITLEStats: 50 obs (17,432t read) | 246,519t work | 93% savings### Aug 27, 2026S167 Publish chinese blog post about claude-mem architecture to Laumonkey CMS (Aug 27 at 2:01 PM)1880 3:17p ✓ User Requested New Post Creation in "Monkey"1881 3:18p ○ monkey-save Skill: Laumonkey CMS Integration via Python CLI1883 " ⊘ Laumonkey API Running on Private Network IP 100.86.29.101...1929 11:56p ○ 用户要求澄清"回注"机制的触发时机与内容选择逻辑
正好 50 条 observation + 10 条 summary,和默认值对得上。
1883 被标成 ⊘ sensitive,因为内容含内网 IP —— 这是 sensitive 这个 type 的实际用法。" 是时间省略符:与上一条同分钟就不重复打时间。
file-context:唯一带打分的那个
按文件查是有排序逻辑的,deduplicateObservations():
1. 取 40 条候选2. 按 memory_session_id 去重 —— 每个会话只留最新一条3. 特异性打分:目标文件在 files_modified 里 +2这条记忆总共只涉及 ≤3 个文件 +2≤8 个文件 +14. 取前 15 条
第 3 条的逻辑很聪明:一条记忆涉及的文件越少,它对其中每个文件的信息量越大。”改了这一个文件”比”扫过 20 个文件”更有说服力。
三个触发点对比
| SessionStart | PreToolUse(Read) | UserPromptSubmit | |
|---|---|---|---|
| 触发频率 | 每会话一次 | 每次读大文件 | 每次提问 |
| 选取依据 | 时间倒序 | 特异性打分 | 语义相似度 |
| 白名单过滤 | type + concepts | 无 | 无 |
| 数量 | 50 obs + 10 summary | 15 | 5 |
| 展开程度 | 仅标题 | 仅标题 | 标题 + narrative |
| 默认 | 开 | 开 | 关 |
写入侧不判断”以后用不用得上”
六道闸管的是写入。observation 一旦落库,注入和检索是另一套完全独立的逻辑。
准确说:写入侧只判断”这是不是持久信号”,不判断”以后用不用得上”。
这个切分很关键 —— 写入时无法预知未来的查询,硬要在那时做相关性判断只会误删。而注入侧同样不猜:它给目录,让模型自己决定展开哪条。
实测数据与已知问题
1893 条记录、51 个会话的 type 分布:
discovery 943 │ feature 384 │ change 223 │ bugfix 120 │ refactor 69 │ decision 30──────────────────────────────────────────────────────────────────────────────milestone 23 │ verification 18 │ code 13 │ progress 10 │ environment 7 │ status 6infrastructure 5 │ build 4 │ pattern 3 │ test_result 3 │ ... 还有 20 种各 1-2 条
非法 type 被保留,检索会漏
第二行那一大堆 mode 里根本没定义。原因在 parser.ts:
if (!validTypes.includes(type)) {logger.error('PARSER', `Invalid observation type: ${type}, preserving emitted type`);}
记 error,但保留模型吐出的值。 设计上是”宁可脏也不丢”,代价是 type 维度的检索和统计会漏 —— 按 bugfix 筛选,就漏掉了被标成 lint_fix、solution、error 的那些。
discovery 占一半说明什么
它和 skip_guidance 是自洽的:直白的代码改动被有意跳过了,所以实际使用中 claude-mem 主要在记”我搞明白了什么”,而不是”我改了什么”。
这也提示了它的定位 —— 它是调查笔记,不是变更日志。
工程细节备忘
.cjs:一个强制声明
两个 package.json 都是 "type": "module",所以 .js 按 ESM 解析。但构建产物明确指定 format: 'cjs' + .cjs 扩展名把它顶回去。
| 扩展名 | 解析方式 | 受 type 影响 |
|---|---|---|
.js |
看 package.json | 是 |
.cjs |
永远 CommonJS | 否 |
.mjs |
永远 ESM | 否 |
选 CJS 是因为这些文件的运行方式很杂:hook 用 node 拉起、daemon 用 bun、MCP server 被 Claude Desktop 以未知方式加载。对一个要跨 macOS/Linux/Windows、跨 Node/Bun 的分发产物,CJS 阻力最小。
打成单文件(worker 2.9MB)也是为此 —— hook 进程被高频拉起,自包含 bundle 省掉模块解析开销。
import.meta.url 补丁
依赖里有 ESM 代码用 import.meta.url,CJS 里没这东西。解法是 define 替换 + banner 注入:
#!/usr/bin/env bunvar __filename = __filename || require("node:path").resolve(process.argv[1] || "");var __dirname = __dirname || require("node:path").dirname(__filename);var __IMPORT_META_URL__ = require("node:url").pathToFileURL(__filename).href;
注释点出关键:映射成 file:// URL 而不是裸路径,因为 new URL(rel, base) 要求 base 是合法 URL。
16k 字段截断
单个字段超 16k 字符会被截成头 60% + 尾 30%,中间插 <elided chars="..." />。
保留头部(文件路径、命令、错误信息通常在这)和尾部(最终错误往往在这),并且显式告诉模型这里被截断了,不要脑补。防止一次大文件 Read 撑爆 observer 的上下文窗口(#2468 报告过 13 万字符的文件)。
其他防御
- 隔离凭证:observer 用
~/.claude-mem/.env的独立凭证,不继承process.env - 空闲自杀:3 分钟没新消息就 abort 杀掉 SDK 子进程,顺带让出 slot
- 重复进程防护:spawn 前先 SIGTERM 掉同
sessionDbId的存活旧进程 - hook 缓冲 stderr:避免第三方库输出泄漏进模型上下文
小结
三句话概括这个系统的设计品味:
一、永不阻塞主会话。 重活全在独立 worker 里异步做,hook 侧任何失败都静默降级成 no-op,扇出全部 fire-and-forget。这种”记忆系统是纯增益,坏了也不影响你干活”的定位,是它敢挂在每一次 PostToolUse 上的前提。
二、把判断交给带上下文的观察员。 相比逐条无状态总结,长驻流式 observer 记的是”你在做什么”,而不是”你调了什么 API”。批处理是速度差涌现的,不需要配置。
三、成本递增地拦,宁可漏记不可污染。 六道闸从字符串比较排到 LLM 推理;files_modified 宁可覆盖模型输出也不接受幻觉路径;散文输出宁可丢弃也不重试。写入侧只管”是不是持久信号”,相关性判断留给检索侧。