claude-mem 给 Claude Code 加持久记忆:新会话里它还记得上次在这个项目做过什么。

这篇基于 v13.16.1 源码。不按目录结构讲,按这个系统真正值得学的几件事讲。

先给全景,然后依次是三个核心设计、运行时、存储与回注,最后是实测数据和工程细节。

  1. 你的工具调用
  2. PostToolUse hook(异步,不阻塞)
  3. worker HTTP :377xx 内存队列
  4. 长驻 observer SDK 会话(流式喂入)→ XML observation
  5. SQLite + Chroma
  6. 下次 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 条记录的实测:

  1. observation 1895
  2. 不同 (时间戳, 会话) 1760
  3. 一个 turn 出多条的组数 97

94% 的产出 turn 只吐一条。

它不 resume:上下文只活一个进程的命

这点反直觉。ClaudeProvider.ts 每次启动 generator 都会主动清掉 memorySessionId:

  1. if (session.memorySessionId) {
  2. // Observer spawns intentionally opt out of Claude transcript persistence.
  3. // A carried session_id from an earlier no-persist spawn is therefore not
  4. // safe to feed back into `resume` on a later fresh process.
  5. this.dbManager.getSessionStore().updateMemorySessionId(session.sessionDbId, null);
  6. session.memorySessionId = null;
  7. }
  8. const hasRealMemorySessionId = false;
  9. 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:

  1. <observation>
  2. <type>bugfix|discovery|decision|refactor|...</type>
  3. <title/><subtitle/>
  4. <facts><fact/></facts>
  5. <narrative/><concepts/>
  6. <files_read/><files_modified/>
  7. </observation>

记什么

recording_focus 给正向标准:关注 durable technical signal —— 系统现在做得不一样的地方、上线了什么、具体的排查发现。用 implemented / fixed / deployed / discovered / traced 这类动词。

它还专门列反例,防一类很典型的失败:

  1. "Analyzed authentication implementation and stored findings"
  2. "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 推理。能早拦就不往后送。

  1. 0 hook 进程内的守卫
  2. 1 worker 入队前的规则过滤
  3. 2 内容脱敏(切标签)
  4. 3 observer LLM 自己判断值不值得记 主力
  5. 4 输出结构校验
  6. 5 写库前的事实校正

闸 0 / 闸 1:纯规则层

闸 0 在 hook 子进程里,命中就直接返回,HTTP 请求都不发。闸 1 在 worker 里,是权威判定。

防自噬

闸 0 的前两个条件是同一件事的两种写法:

  1. if (process.env.CLAUDE_MEM_INTERNAL === '1') return false; // 环境变量标记
  2. 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() 切掉六种标签:

  1. private, claude-mem-context, system_instruction,
  2. 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:

  1. files_read = 工具证据 模型声称(去重)
  2. files_modified = 工具证据(完全覆盖模型输出)

工具证据从这批消息的原始 tool_input 提取:Read → files_read,Edit/MultiEdit/Write/NotebookEdit → files_modified,apply_patch 单独解析 patch 头。

不对称是有道理的:“读过什么”允许模型补充(它可能从 Grep 结果里知道某文件相关),“改过什么”是硬事实 —— 幻觉一个没动过的路径,会直接毒化后续按文件的检索。


一条消息的一生:12 步

一条工具调用从入队到彻底消失,跨越两个进程边界。

  1. [内存 buffer]
  2. claimNext() claimed=true(不出队)
  3. claimedMessageIds.push / earliestPendingTimestamp / 心跳
  4. buildObservationPrompt(含 16k 截断)
  5. snapshotResponseContext 防竞态
  6. yield SDKUserMessage
  7. ╞═══ 进程边界 ═══════════════════
  8. 模型 turn(后续消息在此排队 涌现批处理)
  9. ╞═══ 进程边界 ═══════════════════
  10. assistant 回包,token 记账
  11. parseAgentXml + 分类 ──┬─ quota/auth 退回重试
  12. ├─ prose/idle 确认丢弃
  13. └─ sessionId 退回
  14. 回读 claimed 原文提文件证据 校正 files_*
  15. 单事务写 SQLitecontent_hash 去重)
  16. confirmClaimedMessages 消息此刻才真正删除
  17. 扇出:Chroma / CloudSync / SSE / CLAUDE.md(全异步)

下面三条约束是读这段代码的钥匙。

claim 不出队,confirm 才出队

claimNext() 只翻一个标志位,消息还留在数组里

  1. const next = list.find(m => !m.claimed);
  2. next.claimed = true;
  3. return next;

两个原因:第 ⑨ 步要回头读原始 tool_input 提文件证据;第 ⑧ 步的岔路里有三条要退回重来。

顺序不能反 —— 必须先写库、先提完证据,再删消息

快照而非引用

  1. 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 是一个常驻守护进程

不是库,不是每次拉起的脚本,是一个跑在你机器上的后台进程:

  1. PID 19075 PPID 1 已运行 1 10 小时 内存 69 MB
  2. bun .../plugin/scripts/worker-service.cjs --daemon
  3. 监听 127.0.0.1:37701 (LISTEN)

PPID 1 说明它和启动它的终端脱钩了。端口是算出来的:37700 + (uid % 100),不同用户不打架又不需要配置。

身份证明在 ~/.claude-mem/worker.pid

  1. { "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

这是多会话最会咬人的地方:

  1. const maxConcurrent = parseInt(settings.CLAUDE_MEM_MAX_CONCURRENT_AGENTS, 10) || 2;
  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

  1. { "CLAUDE_MEM_MAX_CONCURRENT_AGENTS": "4" }

判断是不是卡在 slot 上,看日志里这行:

  1. 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,就是本文这次分析产生的:

  1. type discovery
  2. title User Inquiring About Claude-Mem Memory Trigger Conditions
  3. subtitle User asked what situations trigger memory recording and what
  4. content actually gets persisted to memory.
  5. facts ["User explicitly asked: ...", "The question implies ...", ...]
  6. narrative The user in the primary session asked a follow-up question about
  7. how the claude-mem memory system decides what to record... (约 700 字符)
  8. concepts ["how-it-works","why-it-exists","gotcha"]
  9. files_read []
  10. files_modified []
  11. prompt_number 4
  12. discovery_tokens 3708
  13. text 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 会把这个文件的历史记忆插进去。

有个前置门槛:

  1. const FILE_READ_GATE_MIN_BYTES = 1_500;

小于 1.5KB 的文件不触发 —— 小文件模型直接读完就懂了,塞历史反而是噪音。

UserPromptSubmit:语义注入,默认关闭

  1. CLAUDE_MEM_SEMANTIC_INJECT: 'false', // experimental, disabled by default

开了之后,每次提问且 prompt ≥ 20 字符时,拿你的原话做语义检索取 top 5。这是三个触发点里唯一真正用”相关性”的。默认关掉,因为每轮都查向量库有延迟成本。

选取逻辑:时间倒序 + 两道白名单

最反直觉的发现 —— SessionStart 的注入没有相关性排序queryObservationsMulti() 的核心:

  1. WHERE (o.project IN (...) OR o.merged_into_project IN (...))
  2. AND (? IS NULL OR s.platform_source = ?)
  3. AND type IN (...) -- 白名单 1
  4. AND EXISTS ( -- 白名单 2
  5. SELECT 1 FROM json_each(o.concepts)
  6. WHERE value IN (...)
  7. )
  8. ORDER BY o.created_at_epoch DESC
  9. LIMIT ? -- 默认 50

没有 query 参数,没有打分,没有向量。 就是”这个项目最近的 50 条”。

两道白名单都来自当前 mode 的定义:

  1. type {bugfix, feature, refactor, change, discovery, decision,
  2. security_alert, security_note, sensitive}
  3. concepts {how-it-works, why-it-exists, what-changed,
  4. problem-solution, gotcha, pattern, trade-off}

白名单的代价:野生 type 永远注不进来

这把两个发现连起来了。parser 对非法 type 是”记 error 但保留”,后果不只是检索会漏 —— 那些 milestone / verification / progress / environment 永远通不过 type IN (...)

它们存在数据库里占着空间,但对自动注入等于不存在。只有显式调 get_observations 或 mem-search 才捞得到。

渲染:一行一条,让模型自己拉

关键默认值:

  1. CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50'
  2. CLAUDE_MEM_CONTEXT_SESSION_COUNT: '10'
  3. CLAUDE_MEM_CONTEXT_FULL_COUNT: '0'

默认一条都不展开成正文。 每条 observation 就是一行:

  1. return `${obs.id} ${time} ${icon} ${title}`;

真正的渐进披露不是”推”,是”拉” —— 注入内容的头尾都写着:

  1. Fetch details: get_observations([IDs]) | Search: mem-search skill

注入的是一张目录。 模型看标题觉得相关,自己调 MCP 工具展开。相关性判断被推迟到模型手上,而不是在 SQL 里猜。

实测:一次真实注入

直接打这个端点,5825 字节、73 行:

  1. # [claude-mem] recent context, 2026-08-28 11:56pm GMT+8
  2. Mode: Code Development (code)
  3. Legend: 🎯session bugfix feature refactor change discovery decision security_alert security_note sensitive
  4. Format: ID TIME TYPE TITLE
  5. Stats: 50 obs (17,432t read) | 246,519t work | 93% savings
  6. ### Aug 27, 2026
  7. S167 Publish chinese blog post about claude-mem architecture to Laumonkey CMS (Aug 27 at 2:01 PM)
  8. 1880 3:17p User Requested New Post Creation in "Monkey"
  9. 1881 3:18p monkey-save Skill: Laumonkey CMS Integration via Python CLI
  10. 1883 " ⊘ Laumonkey API Running on Private Network IP 100.86.29.101
  11. ...
  12. 1929 11:56p ○ 用户要求澄清"回注"机制的触发时机与内容选择逻辑

正好 50 条 observation + 10 条 summary,和默认值对得上。

1883 被标成 ⊘ sensitive,因为内容含内网 IP —— 这是 sensitive 这个 type 的实际用法。" 是时间省略符:与上一条同分钟就不重复打时间。

file-context:唯一带打分的那个

按文件查是有排序逻辑的,deduplicateObservations()

  1. 1. 40 条候选
  2. 2. memory_session_id 去重 —— 每个会话只留最新一条
  3. 3. 特异性打分:
  4. 目标文件在 files_modified +2
  5. 这条记忆总共只涉及 3 个文件 +2
  6. 8 个文件 +1
  7. 4. 取前 15

第 3 条的逻辑很聪明:一条记忆涉及的文件越少,它对其中每个文件的信息量越大。”改了这一个文件”比”扫过 20 个文件”更有说服力。

三个触发点对比

SessionStart PreToolUse(Read) UserPromptSubmit
触发频率 每会话一次 每次读大文件 每次提问
选取依据 时间倒序 特异性打分 语义相似度
白名单过滤 type + concepts
数量 50 obs + 10 summary 15 5
展开程度 仅标题 仅标题 标题 + narrative
默认

写入侧不判断”以后用不用得上”

六道闸管的是写入。observation 一旦落库,注入和检索是另一套完全独立的逻辑。

准确说:写入侧只判断”这是不是持久信号”,不判断”以后用不用得上”。

这个切分很关键 —— 写入时无法预知未来的查询,硬要在那时做相关性判断只会误删。而注入侧同样不猜:它给目录,让模型自己决定展开哪条。


实测数据与已知问题

1893 条记录、51 个会话的 type 分布:

  1. discovery 943 feature 384 change 223 bugfix 120 refactor 69 decision 30
  2. ──────────────────────────────────────────────────────────────────────────────
  3. milestone 23 verification 18 code 13 progress 10 environment 7 status 6
  4. infrastructure 5 build 4 pattern 3 test_result 3 ... 还有 20 种各 1-2

非法 type 被保留,检索会漏

第二行那一大堆 mode 里根本没定义。原因在 parser.ts

  1. if (!validTypes.includes(type)) {
  2. logger.error('PARSER', `Invalid observation type: ${type}, preserving emitted type`);
  3. }

记 error,但保留模型吐出的值。 设计上是”宁可脏也不丢”,代价是 type 维度的检索和统计会漏 —— 按 bugfix 筛选,就漏掉了被标成 lint_fixsolutionerror 的那些。

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 注入:

  1. #!/usr/bin/env bun
  2. var __filename = __filename || require("node:path").resolve(process.argv[1] || "");
  3. var __dirname = __dirname || require("node:path").dirname(__filename);
  4. 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 宁可覆盖模型输出也不接受幻觉路径;散文输出宁可丢弃也不重试。写入侧只管”是不是持久信号”,相关性判断留给检索侧。