前言
基于泄露的Claude Code最新版本:v2.1.88 ,使用GPT 5.4进行分析。不绕弯子,直接复制原始回答。
Claude Code CLI 记忆流转方案研究
这份文档专门回答一个问题:这个仓库里的“记忆”到底是怎么流动的。结论先说在前面,它实现的不是一个简单的 MEMORY.md 文件,也不是一个“检索几条历史摘要”的轻量功能,而是一整套以文件系统为中心、以 prompt 和受限子代理为控制面的持久化体系。作者真正设计的重点,不是“模型记住了什么”,而是“哪些信息应该沉淀、以什么粒度沉淀、谁可以写、何时读入、读多少、如何避免污染上下文,以及如何在团队范围内安全同步”。
从源码结构看,记忆系统横跨多处实现:
- 目录与总开关:
src/memdir/paths.ts - 记忆提示构造:
src/memdir/memdir.ts - team/private 双目录提示:
src/memdir/teamMemPrompts.ts - 规则类记忆与
CLAUDE.md发现:src/utils/claudemd.ts - 运行时 attachment 注入:
src/utils/attachments.ts - 查询时的相关记忆选择:
src/memdir/findRelevantMemories.ts - 记忆目录扫描:
src/memdir/memoryScan.ts - Agent 专属记忆:
src/tools/AgentTool/agentMemory.ts - session memory:
src/services/SessionMemory/sessionMemory.ts与sessionMemoryUtils.ts - 基于 forked agent 的长期记忆抽取:
src/services/extractMemories/extractMemories.ts - team memory 同步与防泄漏:
src/memdir/teamMemPaths.ts、src/services/teamMemorySync/index.ts、teamMemSecretGuard.ts
如果把整套流转压缩成一句话,可以概括为:启动时先挂上记忆相关后台服务;system prompt 里注入“如何维护记忆”的制度文本;查询时按文件路径、用户输入和会话状态动态决定哪些记忆真正进入当前上下文;回合结束后再由主 agent 或受限 forked agent 把值得长期保留的内容写回对应目录;其中 session memory 负责当前会话摘要,team memory 负责团队共享,并额外叠加路径校验、冲突处理和 secret guard。
一、记忆分层:它不是一种记忆,而是五层
从职责上看,这套系统至少包含五层记忆。
第一层是规则型记忆,也就是 CLAUDE.md、CLAUDE.local.md、.claude/rules/*.md 这一类文件。它们不是“知识库”,而是指令和约束,更像仓库级、路径级、用户级的行为规范。src/utils/claudemd.ts 里甚至把它们的加载优先级、@include、路径过滤、frontmatter 解析、缓存失效都做成了一整套独立机制,说明作者认为它们本质上是“上下文治理规则”,不是普通附件。
第二层是 auto memory,也就是项目长期记忆。它对应 src/memdir/paths.ts 和 src/memdir/memdir.ts。默认路径是 <memoryBase>/projects/<sanitized-git-root>/memory/,目录里不是一个单独的大文件,而是 MEMORY.md 作为入口索引,再配合若干主题 markdown 文件存储实际内容。作者在 prompt 里明确强调:MEMORY.md 是索引,不是内容本体;详细内容应该写在单独文件里,索引中只保留单行入口。
第三层是 Agent memory,对应 src/tools/AgentTool/agentMemory.ts。它不是项目全局记忆,而是 agent 类型级别的持久偏好和角色知识,按 user、project、local 三种 scope 拆开。这个设计很重要,因为它把“某个 agent 长期习惯如何工作”从“这个项目本身的长期知识”中分离了出来。
第四层是 session memory,对应 src/services/SessionMemory/sessionMemory.ts。它不是未来会话可复用的项目知识,而是当前会话的滚动摘要,主要用于 compact 和上下文收缩。这一层的目标不是记住“长期有用的事情”,而是保证当前长会话不会因为上下文过大而丢失关键进展。
第五层是 team memory,对应 src/memdir/teamMemPaths.ts 和 src/services/teamMemorySync/index.ts。它是 auto memory 的一个共享子层,路径通常在 <autoMemPath>/team/。这层的语义不是“我和模型的私有记忆”,而是“团队协作中值得共享的非代码知识”。正因为它要同步给协作者,所以其安全策略比 auto memory 重得多。
这五层分别回答不同问题:
- 规则型记忆:这里应该怎么协作、怎么写、有什么局部约束
- auto memory:这个项目过去积累了哪些长期有价值的信息
- Agent memory:某类 agent 长期偏好什么工作方式
- session memory:当前会话到现在为止发生了什么
- team memory:哪些信息应该跨成员共享
很多对这个仓库的误读,正是因为把这五层混成了“一份记忆”。
二、路径解析与开关:记忆并不是总是启用
auto memory 的总开关在 src/memdir/paths.ts 的 isAutoMemoryEnabled()。它的判定顺序很细:
- 如果
CLAUDE_CODE_DISABLE_AUTO_MEMORY为真,直接关闭。 - 如果
CLAUDE_CODE_DISABLE_AUTO_MEMORY被显式设为假,则允许开启。 - 如果
CLAUDE_CODE_SIMPLE为真,则关闭。 - 如果是 remote 模式但没有
CLAUDE_CODE_REMOTE_MEMORY_DIR,则关闭。 - 如果 settings 里有
autoMemoryEnabled,尊重配置。 - 否则默认开启。
这意味着 auto memory 不是单纯的 feature flag,而是环境变量、运行模式、持久化能力和用户设置共同决定的行为。
路径解析同样不是简单 join()。getAutoMemPath() 的优先级是:
CLAUDE_COWORK_MEMORY_PATH_OVERRIDE- 可信设置源中的
autoMemoryDirectory <memoryBase>/projects/<sanitized-git-root>/memory/
而且路径会经过 validateMemoryPath() 的清洗和约束:拒绝相对路径、近根路径、Windows drive root、UNC 路径、空字节,以及某些 ~ 展开后会落到整个 home 目录或其上级的危险情况。更关键的是,项目自己的 projectSettings 不允许指定 autoMemoryDirectory;只有 policy/local/user 这类受信任来源可以。源码注释明确写出这样做的原因:防止恶意仓库通过设置文件,把 memory 重定向到 ~/.ssh 之类的位置,从而借助工具权限绕开用户预期。
这套路径策略说明,作者不是把记忆目录当普通缓存,而是当成一个有安全边界的写入面来设计。
三、目录形态:为什么是 MEMORY.md + 主题文件
src/memdir/memdir.ts 里定义了 ENTRYPOINT_NAME = 'MEMORY.md',还给它设了双重上限:
MAX_ENTRYPOINT_LINES = 200MAX_ENTRYPOINT_BYTES = 25_000
truncateEntrypointContent() 会先按行截断,再按字节截断,并在必要时追加 warning,提醒模型索引已经过长,应该把细节挪到 topic files。这个设计透露出一个非常明确的意图:索引永远应该是短的、稳定的、适合高频注入的,而详细知识应该拆到独立文件里,按需读取。
buildMemoryLines() 和 buildMemoryPrompt() 进一步把这个原则写进制度文本。prompt 明确告诉模型:
- 记忆应当写入各自独立的文件
MEMORY.md只是索引,不是记忆正文- 索引每项应为一行,保持极短
- 不要写重复记忆
- 优先语义组织,不要按时间流水堆积
- 如果已有错误或过时的记忆,要更新或删除
这里还有一个很值得注意的约束:prompt 中专门区分 memory、plan、tasks 三种持久化机制。源码提示里明确告诉模型,非 trivial implementation task 的协商方案应该写 plan,不应该保存成 memory;当前会话中的待办拆解应该用 tasks,而不是写入长期记忆。也就是说,作者不是把“所有持久化信息都叫记忆”,而是很强地维持不同持久化载体的边界。
记忆类型本身也不是开放式自由标签。注释里反复强调 typed-memory 的封闭四类型分类:user / feedback / project / reference。同时又明确排除“可以从当前代码、架构、git 历史里直接推导出来的信息”。这说明设计目标并不是备份项目状态,而是沉淀那些无法从代码再推回去、但未来又值得复用的信息。
四、system prompt 与用户上下文:机制提示和内容注入是分开的
这一点非常关键,也是最容易被误读的地方。
loadMemoryPrompt() 在 src/memdir/memdir.ts 中负责生成的是“记忆机制提示”。它告诉主 agent:有哪些目录、目录已经存在、记忆该怎么组织、什么时候应该读记忆、什么时候不该把信息写成记忆、是否有 team/private 双目录、以及在 KAIROS 模式下是否应该写每日 append-only 日志。
但这并不等于所有 MEMORY.md 内容都直接塞进 system prompt。真实内容的注入,还要看 src/context.ts 和 src/utils/claudemd.ts。
context.ts 在构造 user context 时,会调用 getClaudeMds(filterInjectedMemoryFiles(await getMemoryFiles()))。这里的关键是 filterInjectedMemoryFiles():当 tengu_moth_copse 打开时,auto/team 的 MEMORY.md 索引会从常规注入路径中过滤掉,因为同一套实验下,系统改为依赖 findRelevantMemories 预取和按需 attachment,而不是持续把整个 memory index 放进上下文。
因此,源码里的分工大致是:
loadMemoryPrompt():告诉模型“记忆机制是什么”getMemoryFiles()/getClaudeMds():把规则类记忆和某些入口文件注入用户上下文relevant_memoriesattachment:把与当前问题相关的长期记忆按需注入
这三件事不是一回事,作者刻意把“制度文本”和“实际内容”拆开了。
五、规则型记忆:CLAUDE.md 体系如何进入上下文
src/utils/claudemd.ts 是规则型记忆的大本营。文件开头就写清楚了加载顺序:
- managed memory,例如
/etc/claude-code/CLAUDE.md - user memory,例如
~/.claude/CLAUDE.md - project memory,例如项目内
CLAUDE.md、.claude/CLAUDE.md、.claude/rules/*.md - local memory,例如
CLAUDE.local.md
它们按照“越靠近当前工作目录、优先级越高”的方式组合,同时支持 @include 指令,把外部文本纳入同一规则树中。作者甚至专门维护了一个允许被 include 的文本扩展名白名单,防止二进制文件或不合适的内容被纳入规则上下文。
更细的路径级动态注入发生在 src/utils/attachments.ts 的 getNestedMemoryAttachmentsForFile()。这个函数围绕某个目标文件做目录遍历,处理顺序非常讲究:
- 先加载 managed/user conditional rules
- 再处理从 CWD 到目标路径的嵌套目录,其中每层都可贡献
CLAUDE.md、无条件规则、条件规则 - 最后再处理 root 到 CWD 的条件规则
返回的 attachment 类型是 nested_memory。构造过程中,memoryFilesToAttachments() 会同时写入 loadedNestedMemoryPaths 和 readFileState,原因在注释里说得很直白:单靠 LRU 读缓存不够,缓存被顶掉后同一份 CLAUDE.md 还会被反复注入,所以必须额外用一个不会淘汰的集合来做去重。
这里能看出作者对上下文污染是有明确认知的。规则型记忆很重要,但如果同一份规则在长会话中不断重复进 prompt,收益会迅速下降,副作用会上升。因此这条链路有相当细致的 dedupe 设计。
六、相关记忆召回:auto/team/agent memory 并不全量注入
真正与“当前问题相关”的长期记忆,是通过 src/memdir/findRelevantMemories.ts 和 src/utils/attachments.ts 动态选出来的。
这条链路的起点是 startRelevantMemoryPrefetch()。在 query 迭代开始时,如果满足以下条件,它会启动一次异步记忆预取:
- auto memory 已开启
tengu_moth_copsegate 为真- 最近一条真实用户消息存在
- 用户输入不是单词级极短文本
- 本会话累计 surfaced memories 尚未超过字节预算
它会创建一个子 AbortController,把预取任务绑到本轮 query 的 abort 生命周期上,这样用户中途取消时,side query 也会立刻终止,而不是拖到 queryLoop 结束再清理。
真正的筛选由 getRelevantMemoryAttachments() 完成。它先判断检索范围:
- 如果用户在输入中
@mention了某个 agent,就只查该 agent 的 memory dir - 否则默认查 auto memory dir
接着它调用 findRelevantMemories()。后者先用 scanMemoryFiles() 扫描 memory 目录,读取每个 markdown 文件前 30 行 frontmatter,提取:
- 文件名
- 绝对路径
mtimeMsdescriptiontype
扫描结果会按修改时间倒序排序,并最多保留 200 个文件。也就是说,这里不是全文向量检索,而是先做一个轻量级文件头扫描。
随后 selectRelevantMemories() 会构造 manifest,把每个候选记忆格式化成一行,再交给一个 side query 模型选择最多 5 条。这里有一个很细的提示工程:如果最近已经成功使用过某些工具,那么与这些工具相关的“普通 API 文档或使用参考”不应该再被选进来,因为当前对话已经在实际使用这些工具;但如果记忆里保存的是 warning、gotcha、known issue,仍然应该允许选中。这个细节说明相关记忆召回不是纯关键词匹配,而是带有“当前上下文已知什么”的偏置。
选出的记忆还会再经过两轮过滤:
- 已在
readFileState中存在的文件跳过 - 已在历史
relevant_memoriesattachment 中 surfaced 过的文件跳过
最后由 readMemoriesForSurfacing() 真正读盘,并加上 freshness header。单文件会受行数和字节数双重上限约束;如果被截断,不是直接丢弃,而是返回前缀并附上“完整内容请用 FileReadTool 读取”说明。作者的判断很明确:既然已经被挑成 top relevant 了,哪怕只给出前半段,也比完全丢掉更有价值。
因此,长期记忆真正进入当前 prompt 的方式,不是“永远加载整份 memory 目录”,而是“目录中只保留可写的长期知识,查询时再做轻量扫描、side query 选择、预算控制、历史去重后,以 attachment 形式注入最相关的小部分”。
七、Agent memory:为什么要单独做一层
src/tools/AgentTool/agentMemory.ts 说明,这个仓库不把所有长期知识都塞进项目级 auto memory。某些 agent 明显需要自己的持久记忆,例如某个 reviewer agent 可能长期记住“用户偏好简洁 review 还是详细 review”,某个特定工具型 agent 可能长期记住“在用户环境中优先使用 bun 而不是 npm”。这些知识既不完全属于项目,也不完全属于当前会话,所以作者又单独做了 agent memory。
路径策略如下:
userscope:<memoryBase>/agent-memory/<agentType>/projectscope:<cwd>/.claude/agent-memory/<agentType>/localscope:<cwd>/.claude/agent-memory-local/<agentType>/
如果在 remote memory mount 环境下,local scope 也会被 project namespacing 到远端挂载位置。
loadAgentMemoryPrompt() 会在 agent spawn 时为对应 agent 生成专属的 memory prompt,同时 fire-and-forget 地确保目录存在。这里有一个非常能体现作者取舍的点:这个函数明知自己运行在同步 prompt 构造路径里,不能 await mkdir,但依然异步发起目录创建,因为真正写入发生在一次 API 往返之后,到那时目录通常已经准备好;即便还没准备好,FileWriteTool 自己也会处理父目录创建。这种实现说明作者更在意调用链可用性和 prompt 构造时延,而不是把一切初始化都做成强同步。
八、session memory:它是当前会话的摘要层,不是长期知识库
session memory 是这套系统中最容易与 auto memory 混淆的一层,但二者职责完全不同。
src/setup.ts 在非 bare 模式下会同步调用 initSessionMemory(),它本身不立刻执行摘要,而只是注册 post-sampling hook。真正的 gate 在 hook 运行时再懒检查:
tengu_session_memory是否开启- auto compact 是否开启
- 是否为主 REPL 线程
也就是说,session memory 从一开始就被设计成“服务于长会话和上下文管理”的机制,而不是普遍存在的持久化系统。
src/services/SessionMemory/sessionMemoryUtils.ts 给出默认阈值:
- 初始化阈值:
minimumMessageTokensToInit = 10000 - 两次更新之间的最小增长:
minimumTokensBetweenUpdate = 5000 - 工具调用阈值:
toolCallsBetweenUpdates = 3
shouldExtractMemory() 的逻辑也很精细:
- 先看是否达到初始化阈值;未达到前根本不启动 session memory。
- 达到后,每次检查距离上次抽取是否又增长了足够多 token。
- 同时统计自上次更新以来的工具调用数。
- 再检查最近一个 assistant turn 是否还含有 tool calls。
最终触发条件是:
- token 增量阈值满足,并且工具调用阈值满足;或
- token 增量阈值满足,并且最近一个 assistant turn 没有工具调用
源码里特别强调:token 阈值永远是硬条件,不能因为工具调用次数够了就频繁抽取。这说明作者担心的是“过度频繁地维护摘要本身会吞噬上下文预算和模型预算”。
真正执行更新时,系统会:
- 用
createSubagentContext()创建隔离上下文 - 通过
setupSessionMemoryFile()创建或读取摘要文件 - 如果文件刚创建,则加载模板
- 主动清掉
readFileState中该文件的缓存,避免FileReadTool返回file_unchangedstub - 构造“基于当前摘要更新会话记忆”的提示
- 调用
runForkedAgent()启动querySource: 'session_memory'的 fork
这时最核心的安全边界来自 createMemoryFileCanUseTool(memoryPath)。它只允许对唯一目标 session memory 文件执行 FileEdit,其余工具全部拒绝。换句话说,session memory 更新器并不是“一个带记忆意识的子 agent”,而是“一个只能改某一份摘要文件的受限编辑器”。
还有两个细节很能体现这套设计的成熟度。
第一,attachments.ts 在 querySource === 'session_memory' 时跳过 teammate mailbox。源码注释直接写明原因:这个 fork 与 leader 共享 teamContext,如果不跳过,它可能把本该发给 leader 的消息当作自己的附件读走。作者不只限制了它能写什么,还限制了它能看到什么。
第二,sessionMemoryUtils.ts 维护了 extractionStartedAt,waitForSessionMemoryExtraction() 会在 compact 前最多等待 15 秒;如果抽取超过 1 分钟则视为 stale,不再等待。这保证了 compact 不会和正在更新的 session memory 互相踩踏。
九、session memory 与 compact:它不是旁路功能,而是上下文压缩的一部分
src/services/compact/sessionMemoryCompact.ts 表明,session memory 不是一个可有可无的附加摘要,而是 compact 流程的一条备选主路径。
shouldUseSessionMemoryCompaction() 由两个 gate 共同决定:tengu_session_memory 和 tengu_sm_compact。如果开启,compact 会优先尝试使用 session memory:
- 等待任何在途的 session memory 抽取完成
- 读取 session memory 文件
- 如果文件不存在,回退到传统 compact
- 如果文件仍只是模板、没有有效内容,也回退
- 否则根据
lastSummarizedMessageId计算哪些旧消息已被摘要覆盖
这里甚至考虑了 resumed session 的场景:如果 session memory 文件有内容,但当前进程并不知道 lastSummarizedMessageId,系统仍然会把 session memory 当成有效摘要使用,只是保守地调整保留消息边界。
最终 createCompactionResultFromSessionMemory() 会把 session memory 转成 compact summary message,并在内容过长时再次做截断,防止摘要自己反过来吞掉 post-compact token 预算。也就是说,这层摘要并不是“越长越好”,而是仍然服从上下文预算治理。
从架构上看,session memory 的位置很清晰:它既是会话滚动 checkpoint,也是自动 compact 的高质量输入。
十、长期记忆抽取:后台 forked agent 才是 durable memory 的真正维护者
项目长期记忆的自动维护,不是由 session memory 负责,而是由 src/services/extractMemories/extractMemories.ts 负责。
它的初始化发生在 src/utils/backgroundHousekeeping.ts。startBackgroundHousekeeping() 在非 bare 的交互式主流程中启动一系列后台服务,其中在 feature('EXTRACT_MEMORIES') 打开时会调用 initExtractMemories()。src/main.tsx 又表明这个 background housekeeping 是 headless/interactive 主流程的一部分,因此 durable memory 抽取不是 setup 期就强同步执行,而是作为后台 housekeeping 挂上去。
源码注释写得非常清楚:它在每个完整 query loop 结束时、也就是模型给出最终响应且没有后续工具调用时,通过 stop hooks 运行一次。它不是每个 token 流式阶段都插手,而是在一个回合真正落定后再看是否有值得沉淀的长期信息。
initExtractMemories() 里维护了一套闭包级状态:
lastMemoryMessageUuid:抽取游标inProgress:是否已有抽取在运行turnsSinceLastExtraction:节流计数pendingContext:若当前有抽取在跑,后续请求会被折叠到这里inFlightExtractions:用于 shutdown 前 drain
真正执行时,executeExtractMemoriesImpl() 会先做一串前置 gate:
- 只对主 agent 运行,子 agent 直接跳过
tengu_passport_quailgate 必须开启- auto memory 必须开启
- remote mode 直接跳过
- 如果已经有抽取在跑,则当前上下文不会并发再起一个,而是覆盖性地 stash 到
pendingContext
这说明作者非常在意 durable extraction 的串行化和 coalescing,不希望多个后台记忆代理同时盯着同一段对话乱写。
runExtraction() 内部还有一个重要分支:hasMemoryWritesSince()。如果主 agent 在最近区间里已经直接对 auto memory 文件做过 Write/Edit,那么后台抽取器会认为这段内容已经被主代理显式处理,直接跳过并推进游标。也就是说,“主 agent 主动写记忆”和“后台自动补记”在同一段消息区间内是互斥的,避免了双重写入和冲突。
真正的抽取代理通过 runForkedAgent() 启动,权限由 createAutoMemCanUseTool(memoryDir) 严格限制:
- 允许
REPL,但 REPL 内的原语调用仍会再次经过权限检查 - 允许
Read / Grep / Glob - 允许只读 Bash
- 允许
FileEdit / FileWrite,但仅限 auto memory 目录 - 其余工具全部拒绝
再配上 maxTurns: 5、skipTranscript: true,这个子代理更像“后台受限整理工”,而不是“第二个主助手”。
抽取开始前,系统会用 scanMemoryFiles() 先扫描当前 memory 目录,把已有文件头和描述格式化成 manifest,直接拼进 prompt。这样抽取代理不需要先自己花一轮去 ls 或扫目录。这是一个很典型的 agent 工程优化:把可以在宿主侧预先提供的 cheap context 全都提前塞好,把昂贵的工具回合留给真正必要的读写判断。
抽取成功后,系统会解析子代理输出中的 tool_use block,找出所有写过的路径,再统计其中真正的 memory 文件数、team memory 文件数,并通过 appendSystemMessage 生成用户可见的 memory saved 系统消息。失败则只是 debug/logEvent,不打断主流程。说明作者将 durable memory extraction 定位为“重要但 best-effort 的后台整理”。
如果抽取过程中有新的请求到来,pendingContext 会在 finally 里触发一个 trailing run。这个 trailing run 会基于刚刚推进过的游标重新计算新消息区间,因此不会重复处理整个历史。这种“单实例执行 + 尾随补跑”的模式非常适合长会话。
十一、team memory:共享层为什么要比 auto memory 更重
当 TEAMMEM 开启时,loadMemoryPrompt() 不再只生成单目录的 auto memory 提示,而是调用 buildCombinedMemoryPrompt()。它在 prompt 里明确告诉模型存在两个目录:
- private directory:auto memory 根目录
- shared team directory:
<autoMemPath>/team/
同时还会多出一节 “Memory scope”,明确区分 private 与 team 两类记忆,并额外强调“不得把敏感信息保存到 shared team memories 中”。换句话说,team memory 不是简单地多一个目录,而是 prompt 层面的协作语义升级。
从路径实现看,src/memdir/teamMemPaths.ts 对 team memory 的保护远强于普通 auto memory。它做了:
- null byte 检测
- URL 编码穿越检测
- Unicode 归一化后的穿越检测
- 反斜杠拒绝
- 绝对路径拒绝
realpathDeepestExisting()驱动的 symlink 逃逸检测- symlink loop 检测
- containment check
这里的设计假设很清楚:team memory 既是共享入口,又可能被外部同步内容反向写回本地,因此必须把路径安全防护做得像处理半不可信输入一样严格。
十二、team memory 同步:pull-first、delta push、server-wins、删不下去
src/services/teamMemorySync/index.ts 的 syncTeamMemory() 很直白:
- 先 pull remote -> local
- 再 push local -> remote
pull 阶段支持 ETag。若服务器未变化,可直接 notModified 返回;若有变化,则把远端条目写入本地 team 目录。写入时 writeRemoteEntriesToLocal() 会:
- 先对每个相对路径做
validateTeamMemKey() - 拒绝 oversized entry
- 读取本地同名文件并比较内容,相同则不改写,避免 mtime 抖动与 watcher 噪音
- 仅对真正变化的文件执行 mkdir + write
pull 还会刷新 serverChecksums。如果服务器回包里没有 per-key checksum,下一次 push 会退化成 full push,但之后会自愈。
push 阶段更有意思。它首先一次性读取本地 team memory,但不是无脑读盘:readLocalTeamMemory() 会递归遍历目录,跳过超过大小阈值的文件,并在把内容纳入上传集合前先跑 scanForSecrets()。一旦发现疑似密钥,该文件直接跳过,不会上送,而且只记录规则标签,不记录 secret 值本身。若本地文件数超过服务器已知 cap,还会按排序后的键做稳定截断,确保 delta 计算可重复,而不是每次随机掉不同的文件。
真正上传时,pushTeamMemory() 不是全量覆盖,而是用 serverChecksums 与本地 hash 计算 delta,只上传本地与服务器内容 hash 不同的键。若遇到 412 Precondition Failed,会走 conflict resolution:刷新服务器 hashes,重算 delta,再重试。源码里明确说明它采用的是 local-wins-on-conflict 策略。原因也写得很实在:push 是由本地用户刚刚发生的编辑触发的,如果因为队友先推了一版就把本地编辑静默丢掉,用户无法接受;相反,覆盖远端同 key 的内容虽然也会丢掉队友编辑,但至少本地用户此时还在工作链路上,更有机会重新合并。
更保守的一点是:删除不会传播到服务器。源码注释明确指出,下一次 pull 甚至可能把本地删掉的东西带回来。这个策略看起来“笨”,但非常符合 team memory 的产品定位:宁可降低删除一致性,也要减少误删和同步放大风险。
十三、secret guard:team memory 被视为高风险泄漏面
普通 auto memory 没有这么重的写前检查,但 team memory 有。
src/services/teamMemorySync/teamMemSecretGuard.ts 暴露了 checkTeamMemSecrets(filePath, content),由 FileWriteTool 和 FileEditTool 在输入校验阶段调用。如果目标路径属于 team memory,内容里又匹配到疑似 secret,就直接拒绝这次写入,并明确提示“team memory 会同步给所有仓库协作者”。
同步器上传前还会再次用 scanForSecrets() 扫描本地文件。也就是说,team memory 有两层防线:
- 写工具入口阻止模型把 secret 写进去
- 上传通道再次防止含 secret 的文件离开本机
这说明作者对“共享记忆很容易变成隐形泄漏面”这件事是有充分警觉的。
十四、搜索过往上下文:记忆不只是被动注入,也允许主动回查
buildSearchingPastContextSection() 会在特定 gate 下,把“如何搜索 memory 目录与 transcript 目录”也写进 memory prompt。这里甚至区分了:
- embedded search tools / REPL 模式:给 shell 形式的
grep - 非 embedded 场景:给
GrepTool调用格式
提示里明确建议模型先搜 memory directory,再把 session transcript logs 当最后手段,并使用窄搜索词。说明作者不只关心“自动召回哪些记忆”,也关心“当自动召回不够时,模型如何自己以最小代价回查过去”。
这使得记忆系统从单纯的“自动注入若干条记忆”升级成了“结构化存储 + 自动召回 + 必要时主动检索”的完整闭环。
十五、启动时序:这些机制在生命周期中何时挂上去
从启动路径看,记忆系统并不是一次性全初始化,而是分层挂载:
src/setup.ts在非 bare 模式下同步调用initSessionMemory(),只负责注册 post-sampling hook- 同一个
setup.ts里,在背景任务阶段启动 team memory watcher src/main.tsx在 headless/interactive 主流程中启动backgroundHousekeepingsrc/utils/backgroundHousekeeping.ts中在EXTRACT_MEMORIES开启时调用initExtractMemories()
也就是说:
- session memory 由 setup 早期同步挂 hook
- team memory watcher 在后台启动
- durable memory extraction 由 background housekeeping 启动
这种分层挂载说明作者对启动性能和功能时效做过权衡:真正影响首轮 query 的只做必要注册;需要后台运行的功能尽量延后、异步、housekeeping 化。
十六、一个完整的记忆流转例子
为了把前面的机制串起来,可以看一个典型场景。
用户进入一个仓库,对某个文件发起修改请求。系统启动后,setup.ts 已经注册好 session memory hook,team watcher 也可能已在后台运行,backgroundHousekeeping 也已挂上 durable extraction。
用户开始提问时,context.ts 会构造 user context,注入 CLAUDE.md 类规则文本;loadMemoryPrompt() 也已经把“记忆系统该如何工作”的制度文本放进 system prompt。若当前 gate 配置允许,startRelevantMemoryPrefetch() 会异步扫描 auto memory 或某个被点名 agent 的 memory 目录,挑最多 5 条相关主题记忆,准备在后续 query iteration 中作为 relevant_memories attachment 注入。
如果用户当前操作的文件路径命中了局部规则,那么 getNestedMemoryAttachmentsForFile() 会进一步把与此路径相关的 nested_memory 注入。此时主 agent 同时拿到了:
- 全局/局部规则
- 记忆机制说明
- 少量当前问题最相关的长期记忆
而不是一整个巨大 memory 目录。
对话持续一段时间后,如果 token 增长和工具调用达到阈值,session memory hook 会触发,启动一个只能改 session summary 文件的受限 forked agent,把本次长会话的进展写成滚动摘要。若随后上下文超限,compact 可以优先使用这份摘要。
当一个完整回合结束时,extractMemories 又会检查这段消息是否值得沉淀长期记忆。如果主 agent 已经自己改过 memory 文件,后台抽取跳过;否则它启动受限 forked agent,在 auto memory 或 team memory 中写入新的主题文件,并必要时更新索引。
如果其中有 team memory 写入,watcher / sync 流程会在后台把这些共享知识拉齐到远端,但会经过路径校验、冲突处理和 secret guard。最终,当前会话中的临时上下文就被拆成了:
- 局部规则,仍属于
CLAUDE.md体系 - 会话摘要,属于 session memory
- 长期项目知识,属于 auto memory
- 团队共享知识,属于 team memory
这就是这套系统“把不同寿命的信息拆层沉淀”的具体体现。
十七、总体评价:为什么这套实现很工程化
这套设计最大的优点,是边界清楚而且可审计。
它没有把“记忆”当神秘黑箱,而是把不同信息放进不同目录和不同生命周期中;没有让任何子 agent 拿到无限写权限,而是通过 canUseTool 把每类写入严格圈在最小范围内;没有把所有历史无脑放进 prompt,而是通过规则发现、路径级注入、相关召回、session 摘要、主动搜索几层机制共同协作。
它也不是没有代价。理解成本很高,因为同一个“记忆行为”往往横跨 paths.ts、memdir.ts、claudemd.ts、attachments.ts、sessionMemory.ts、extractMemories.ts、teamMemorySync/index.ts。同时,它大量依赖文件系统语义、缓存和 gate,意味着要正确修改它并不轻松。
但从工程目标看,这些复杂度是有意义的。作者显然不满足于“模型有一点长期上下文”这种模糊能力,而是想把记忆做成一套真正可落地、可限制、可调试、可同步的基础设施。也因此,这个仓库的记忆系统更像一个多层持久化子系统,而不是一个 prompt 小技巧。
结论
这个仓库实现的具体记忆流转方案,可以总结成下面这条链:
- 启动时挂上 session memory、team watcher、durable extraction 等后台能力。
- system prompt 先声明记忆目录、分类、写入协议和使用边界。
- 用户上下文中注入
CLAUDE.md类规则文本。 - 查询时再按路径和当前输入,动态注入局部规则与相关长期记忆。
- 会话增长到一定规模后,用受限 forked agent 维护 session summary。
- 回合结束后,再用另一个受限 forked agent 把值得长期保存的信息写回 auto/team memory。
- team memory 额外经过路径校验、delta 同步、冲突处理和 secret guard。
它不是“让模型什么都记住”,而是把不同寿命、不同作用域、不同风险等级的信息,拆分后以文件系统为中心做有控制的沉淀、召回和同步。这正是这套实现最值得研究的地方。
Comments NOTHING