跳到主要内容

回路:SessionStart 上下文注入与 MCP 三层检索

30 秒导读: 前面几章讲了记忆怎么被抽取、压缩、落库(03)和存在哪(04)。这一章讲闭环的最后一步——记忆怎么回到 Claude 面前。有两条路:一条是被动的,新会话一开场,钩子就把最近的记忆塞进上下文;另一条是主动的,Claude 在干活途中通过 MCP 工具按需去捞。两条路背后是同一个信念:先给便宜的索引,再按需取昂贵的全文——这就是 context engineering(上下文工程)的精髓。


1. 这是什么(零基础也能懂)

一个残酷的事实:每开一个新会话,Claude 就失忆一次。 上一次会话你和它一起改的 bug、定的方案、踩的坑,它全不记得——因为那些都不在新会话的上下文里。

claude-mem 前四章解决了「把记忆记下来」。但记下来不等于用得上。记忆躺在 ~/.claude-mem/claude-mem.db 里,Claude 看不见。这一章解决的是:怎么让躺着的记忆,在恰当的时机,以恰当的量,出现在 Claude 的上下文窗口里。

两条取回路径,各管一段

路径时机谁触发给多少类比
上下文注入会话开场SessionStart 钩子(自动)最近记忆的一个摘要上班前先扫一眼昨天的交接便签
MCP 三层检索会话进行中Claude 自己调工具(主动)按需、精确、分层取干到一半想不起来,去翻档案柜

两条路互补:注入解决「开场就有基本盘」,检索解决「途中要精确回忆某件具体的事」。

一句话直觉

把上下文窗口当工作台,把数据库当仓库。注入 = 上工前先把最常用的几样工具摆上台;检索 = 干活途中缺什么,再去仓库精确取,而且先看货架标签(索引),确认是要的那件,才把整箱(全文)搬过来——不然工作台一下就堆满了。


2. 顶层全景(它大概怎么转)

两条路都以 worker 守护进程(02 章)的 HTTP API 为后端。下面这张图从左到右是「记忆从仓库回到 Claude」的两条流向:

┌─────────────────────────────────────────┐
│ worker 守护进程 (HTTP :port) │
│ SearchManager / TimelineService │
┌────────────────┐ │ ┌──────────┐ ┌──────────────┐ │
│ SQLite │◄──┼───┤ FTS5 关键词│ │ Chroma 向量 │ │
│ memory_items │ │ │ 精确回退 │ │ 语义召回 │ │
│ + FTS5 │ │ └──────────┘ └──────────────┘ │
└────────────────┘ └──────▲───────────────────────▲──────────┘
│ │
路径 A:开场注入(被动) │ 路径 B:途中检索(主动)│
│ │
┌───────────────────────────┴──┐ ┌────────────────┴──────────────┐
│ SessionStart 钩子 │ │ MCP server(4 个检索工具) │
│ context.ts │ │ mcp-server.ts │
│ GET /api/context/inject │ │ ① search → /api/search │
│ → additionalContext │ │ ② timeline → /api/timeline │
└───────────────┬───────────────┘ │ ③ get_observations │
│ │ → /api/observations/batch│
│ └────────────────┬──────────────┘
▼ ▼
┌───────────────────────────────────────────────────────┐
│ Claude 的上下文窗口(新会话) │
│ 开场:一坨注入的摘要 + 途中:Claude 主动捞的记忆 │
└───────────────────────────────────────────────────────┘

部件一句话职责:

部件干什么文件
SessionStart 钩子会话开场调 worker,把最近记忆渲染成 additionalContextsrc/cli/handlers/context.ts
MCP server暴露 4 个检索工具给 Claude 主动调,并强制三层工作流src/servers/mcp-server.ts
SearchManager混合检索的大脑:Chroma 语义 → 90 天过滤 → SQLite hydrate,带 FTS5 回退src/services/worker/SearchManager.ts
TimelineService按「锚点前后 N 条」裁一段时序切片src/services/worker/TimelineService.ts

边界提醒: 记忆一旦注入进上下文,Claude Code 本身怎么消费它(放在系统提示哪个位置、如何影响生成)是宿主平台的事,不属于 claude-mem,本章不展开。本章只讲「记忆怎么被送到上下文窗口门口」。


3. 路径 A:SessionStart 上下文注入

3.1 它要解决的小问题

新会话一开场,Claude 是一张白纸。我们想在它读用户第一句话之前,先把「你上次在这个项目干了啥」塞进去。Claude Code 的钩子机制给了一个口子:SessionStart 钩子可以返回一段 additionalContext,宿主会把它拼进会话的初始上下文。

所以注入的任务很简单:问 worker 要一段「最近记忆」文本,包装成 additionalContext 返回。 难点全在边角料——多平台差异、失败回退、给人看的彩色 timeline。

3.2 主线走一遍

处理器是 contextHandler.execute(src/cli/handlers/context.ts:55),纯函数,只返回 HookResult,不许碰 stdout/stderr(见文件头 IO 纪律注释)。它拼一个 API 路径,打给 worker,把结果盖成 additionalContext:

输入 HookInput(带 cwd、platform)

├─ 算出项目链 projects ← getProjectContext(cwd).allProjects

├─ 拼路径 /api/context/inject?projects=...&platformSource=... (context.ts:71)
│ claude-code 平台额外加 &colors=true (context.ts:72)

├─ 取上下文正文:
│ platform==='codex' ? 走 MCP session_start_context (context.ts:80)
│ : 走 HTTP executeWithWorkerFallback (context.ts:90)

├─ worker 不可达 → isWorkerFallback → 返回空上下文 emptyResult (context.ts:91)

├─ (可选) 拼一份 stale OAuth 提示插到最前 (context.ts:105)

└─ 返回 { hookSpecificOutput: { additionalContext }, systemMessage? }

一句话:这个处理器是「记忆 → 上下文」的最后一米搬运工,真正的检索逻辑在 worker 里(见 §5)。

3.3 关键细节:平台差异、失败回退、给人看的东西

这个处理器最值得学的是它把面向模型的数据和面向人的提示,严格分成两条通道。文件头注释点破了这条纪律:

  • hookSpecificOutput.additionalContext模型消费(Claude 读)
  • systemMessage用户可见的提示(人读,渲染在终端)

围绕这条纪律,有四个坑它都处理了:

① 两种传输:HTTP vs MCP。 大多数平台走 HTTP(GET /api/context/inject);唯独 codex 平台走 MCP 工具 session_start_context(context.ts:80-85,内部由 fetchSessionStartContextViaMcpcallMcpToolOnce)。为什么分叉?因为 codex 的钩子环境更适合 MCP 调用。MCP 失败会 return null,自动跌回 HTTP 分支(context.ts:87-103),做到「MCP 优先、HTTP 兜底」。

② worker 不可达 → 静默空上下文。 记忆是锦上添花,不是刚需。如果 worker 没起来,executeWithWorkerFallback 返回一个 fallback 标记,isWorkerFallback 命中就直接返回 emptyResult(context.ts:91-93)——空上下文,而不是报错。新会话照常开,只是没记忆。这是「失败要优雅」的教科书写法。

③ stale OAuth 提示(issue #2215)。 worker 生成记忆要用 Claude Desktop 的 OAuth token 调 Agent SDK。如果之前某次 worker 启动发现 keychain 里的 token 过期了,会写一个「stale marker」。这里 readStaleMarker() 读到它,就把一句人类可读的提醒插到 additionalContext 最前面(context.ts:105-114):

// context.ts:108 — 真实源码
const staleReason = readStaleMarker();
if (staleReason) {
const hint = `[claude-mem] Claude Desktop OAuth token is stale: ${staleReason}\n...`;
additionalContext = additionalContext ? `${hint}\n\n${additionalContext}` : hint;
}

重点看:提醒混进了给模型的通道,这样即使用户没看终端,Claude 也知道「记忆可能不新鲜」这件事。

④ 彩色 timeline(给人看的)。CLAUDE_MEM_CONTEXT_SHOW_TERMINAL_OUTPUT=true,处理器再打一次 worker(带 colors=true),拿一份 ANSI 彩色的 timeline(context.ts:116-133),拼进 systemMessage 并附上 View Observations Live @ http://localhost:{port}(context.ts:139-141)。注意这份彩色版只进 systemMessage(人的通道),不进模型通道——模型不需要 ANSI 转义码。gemini-cli 平台是特例:没有彩色版时,把纯文本 additionalContext 也回显给人看(context.ts:137)。


4. 路径 B:MCP 三层检索(主动、渐进)

4.1 它要解决的小问题

注入解决了「开场基本盘」,但会话干到一半,Claude 常需要精确回忆某件具体的事:「上次那个鉴权 bug 我们最后怎么修的?」这时它要能主动查

朴素做法:一个 search(query) 工具,把命中的记忆全文一股脑返回。问题是——全文很贵。一条记忆的完整叙述可能 500–1000 token,返回 20 条就是上万 token,一次搜索就能把上下文窗口吃掉一大块。而这 20 条里,Claude 真正想细看的可能只有 2 条。

4.2 思路:渐进式披露(progressive disclosure)

claude-mem 的答案是把「搜索」拆成三层,一层比一层贵,只在必要时下钻:

① search(query) 便宜 ~50-100 token/条 → 一张带 ID 的紧凑索引(标题/日期/文件)
│ Claude 扫一眼索引,挑出感兴趣的 ID

② timeline(anchor=ID) 中等 → 该锚点前后的时序切片(当时在干啥)
│ Claude 确认「就是这段」,锁定要看全文的 ID

③ get_observations([ID]) 昂贵 ~500-1000 token/条 → 只对筛出的少数 ID 拉全文

核心哲学:先给索引再按需取全文。 就像你查论文——先看标题摘要列表(便宜),锁定几篇,再去下载全文 PDF(贵),而不是把整个数据库的 PDF 全下下来翻。

4.3 强制工作流:__IMPORTANT 工具

这套哲学不是写在文档里指望 Claude 自觉,而是编码进了一个特殊的 MCP 工具mcp-server.ts 里工具列表的第一个就叫 __IMPORTANT(src/servers/mcp-server.ts:439),它的 description 和返回内容都在反复强调三层工作流:

// mcp-server.ts:440 — 工具 description(Claude 列工具时就会读到)
3-LAYER WORKFLOW (ALWAYS FOLLOW):
1. search(query) → Get index with IDs (~50-100 tokens/result)
2. timeline(anchor=ID) → Get context around interesting results
3. get_observations([IDs]) → Fetch full details ONLY for filtered IDs
NEVER fetch full details without filtering first. 10x token savings.

这是一个巧妙的「用工具名和描述做提示工程」的手法:__IMPORTANT 前缀让它排在最前、名字自带强调,Claude 一读工具清单就被灌输了「先索引后全文」的纪律。它自己没有真实功能(handler 只返回那段工作流说明),纯粹是行为约束的载体

4.4 四个检索工具

worker 模式下,MCP server 暴露这 4 个工具(全部是 callWorker 到 worker HTTP 的薄封装):

工具关键参数打到 worker 的 endpoint定义
search① 索引query, limit, project, platformSource, type, dateStart/End, orderBy/api/searchmcp-server.ts:473
timeline② 时序anchor(ID) query(自动找锚点)、depth_before/after/api/timelinemcp-server.ts:496
get_observations③ 全文ids(必填,ID 数组)/api/observations/batchmcp-server.ts:514
session_start_contextproject/projects, platformSource, full, colors/api/context/injectmcp-server.ts:533

前三个正是三层;第四个 session_start_context(handleSessionStartContext,mcp-server.ts:372)是把路径 A 的注入文本也做成一个工具——它调的正是路径 A 那个 /api/context/inject,返回「钩子在开场会注入的同一段文本」。这让 codex 平台的钩子(§3.3 ①)和调试都能复用同一条渲染逻辑。

另一组工具(server 运行时,非本章重点): observation_add / observation_record_event / observation_search / observation_context(mcp-server.ts:556 起)是 CLAUDE_MEM_RUNTIME=server 时才可用的一组,直连服务端 REST 核心(/v1/*),requireServerForObservationTool 会在 worker 模式下用一句友好报错挡住它们(mcp-server.ts:202)。本章聚焦默认的 worker 模式三层检索,这组只需知道存在。

4.5 get_observations 为什么强制 ids 必填

看它的 inputSchema:required: ['ids'](mcp-server.ts:526)。这是把「先过滤再取全文」的纪律编进了 schema——你无法在不给具体 ID 的情况下调 get_observations。想拿全文?先经过 search / timeline 拿到 ID。协议层面堵死了「无脑拉全文」的路。


5. 深入实现:混合检索管线(SearchManager)

上面 search / timeline 工具只是把请求转给 worker。真正的检索大脑是 SearchManager(src/services/worker/SearchManager.ts)。这一节讲它怎么把 Chroma 的语义召回和 SQLite 的精确数据缝合起来。

5.1 核心管线:语义召回 → 近期过滤 → SQLite hydrate

被检索的双索引(SQLite 存全文与结构、Chroma 存向量镜像)来自 04 章。这里讲怎么用这两个索引。最能代表设计的是 hybridSemanticHydrate(SearchManager.ts:103),三步:

① Chroma 语义召回
queryChroma(query, 100, whereFilter) (SearchManager.ts:111)
→ 拿回最多 100 个语义最近的 doc,每个带 metadata(含 created_at_epoch)

② 90 天近期过滤
只留 created_at_epoch > (now - RECENCY_WINDOW_MS) (SearchManager.ts:115-119)
RECENCY_WINDOW_MS = 90 天 (search/types.ts:7)

③ SQLite hydrate(补全文)
hydrate(recentIds) → getObservationsByIds(...) (SearchManager.ts:123)
→ 只对过滤后的 ID,去 SQLite 拉标题/叙述/文件等真实字段

为什么要这么缝? 因为两个索引各有所长(见 04 章):

  • Chroma 懂语义(「鉴权 bug」能召回「JWT 校验失败」),但它的 payload 是精简的向量镜像;
  • SQLite 有全部真实字段,但只会关键词匹配(FTS5)。

所以:用 Chroma 找"哪些"(语义打分,给 ID),用 SQLite 取"是什么"(全文,按 ID)。 这就是 hybrid 的含义。

为什么中间夹一道 90 天过滤? 语义相似不代表还相关——半年前的旧记忆语义上可能很像,但对「现在」多半没用。近期窗口把语义召回再往「新鲜」收一刀。(注:当调用带了显式 dateRange 时,performChromaSemanticSearch 用用户给的区间替代这个默认 90 天,见 SearchManager.ts:344-357。)

5.2 双项目 scoping:一条记忆的两个户口

Chroma 的 where 过滤由 buildDocTypeWhereFilter(SearchManager.ts:80)拼。除了按 doc_typeplatform_source 过滤,它对项目做了一个双户口 $or:

// SearchManager.ts:83 — 真实源码(简化)
const projectFilter = {
$or: [
{ project }, // 记忆当初属于哪个项目
{ merged_into_project: project } // 或者被合并进了这个项目
]
};

一句话:项目改名 / 合并后,老记忆还能被新项目名搜到——户口迁移不丢档案

5.3 三条路径与降级:chroma / fts / filter_only

search()(SearchManager.ts:426)不是只有一条路。它按「有没有 query、Chroma 在不在」分三条路,并在每条路上都留了回退。这也正好对应遥测里记的 search_strategy(SearchManager.ts:31):

有 query?
┌────┴─────┐
没有 有
│ │
PATH 1: filter_only │ Chroma 初始化了吗?
纯 SQLite 结构过滤 ┌──┴───┐
(SM.ts:440) 是 否
│ │
PATH 2: chroma PATH 3: fts
语义管线(§5.1) 直接 FTS5 关键词
(SM.ts:454) (SM.ts:511)

Chroma 抛错?──是──► 跌回 FTS5
│ chromaFailed=true
否 (SM.ts:490-508)

命中 0 且带 platformSource?
│──是──► scoped FTS5 回退
│ platformScopedChromaZeroFallback=true
│ (SM.ts:405-417)

值得单独点出的两个降级细节:

① Chroma 抛错 → 整体跌回 FTS5。 performChromaSemanticSearch 抛异常(如 Chroma 连接断)被 search() 的 catch 接住,改用 sessionSearch.searchObservations 走 SQLite FTS5(SearchManager.ts:490-508)。遥测记 fallback_reason: 'chroma_connection'(连接错)或 'chroma_error'(SearchManager.ts:546-547)。

② 平台 scoped 但 Chroma 命中 0 → scoped FTS5 回退。 这是个精妙的边角:早期写进 Chroma 的向量没带 platform_source 元数据,于是一个带 platformSource 过滤的语义查询可能在 Chroma 里命中 0(不是没相关记忆,是老向量没这个字段)。此时不能就此认输,而是跌回带同样过滤的 FTS5(SearchManager.ts:405-417),让 SQLite 侧的 platform_source 列去兜底。

遥测的隐私纪律(值得学): SearchTelemetryEnvelope(SearchManager.ts:28)只记 result_countsearch_strategychroma_availablefallback_reason 这类计数、布尔、闭合枚举——注释明确写「绝不记 query 文本、结果内容或错误消息」(SearchManager.ts:24-26)。可观测但不泄密。

5.4 Chroma 的 ID 怎么变回 SQLite 的 ID

一个易漏但关键的接缝:Chroma 里 doc 的 ID 是字符串(如 obs_42_...summary_7_...prompt_9),而 SQLite 用数字主键。转换发生在 ChromaSync.deduplicateQueryResults(src/services/sync/ChromaSync.ts:868),用正则从字符串 ID 里抠出数字并标出实体类型(ChromaSync.ts:880-882):obs_(\d+)_ → observation、summary_(\d+)_ → session、prompt_(\d+) → prompt。所以 queryChroma 返回的 ids 已经是能直接喂给 SQLite 的数字 ID——§5.1 的 hydrate 才接得上。


6. 深入实现:timeline 的时序切片

timeline 工具回答的是「围绕这条记忆,前后当时都在干啥」。它比 search 多一步:先定锚点,再切一段前后窗口。

6.1 两种入口:anchor 或 query

SearchManager.timeline()(SearchManager.ts:691)接受二选一(给了两个会报错,SearchManager.ts:709):

  • query 模式:先用 §5.1 的混合管线找到最相关的那一条当锚点(searchChromaForTimeline → 失败再 FTS,SearchManager.ts:723-745),取其 idcreated_at_epoch
  • anchor 模式:直接给锚点。锚点可以是三种——observation ID(数字)、session ID(S123)、或 ISO 时间戳(SearchManager.ts:763-808),分别走 getTimelineAroundObservation / getTimelineAroundTimestamp

6.2 按深度裁窗口:filterByDepth

worker 会把锚点附近一批 item(观察 + 会话 + 用户提问)按时间排好,交给 TimelineService.filterByDepth(src/services/worker/TimelineService.ts:11)裁成「锚点前 N、后 M」。逻辑很短但很讲究——先在有序列表里定位锚点下标,再按下标切片:

// TimelineService.ts:20-35 — 真实源码(简化)
let anchorIndex = -1;
if (typeof anchorId === 'number') { // observation:按 id 精确定位
anchorIndex = items.findIndex(i => i.type === 'observation' && i.data.id === anchorId);
} else if (typeof anchorId === 'string' && anchorId.startsWith('S')) { // session:S123
anchorIndex = items.findIndex(i => i.type === 'session' && i.data.id === sessionNum);
} else { // 时间戳:第一个 epoch ≥ 锚点的位置
anchorIndex = items.findIndex(i => i.epoch >= anchorEpoch);
}
const startIndex = Math.max(0, anchorIndex - depth_before);
const endIndex = Math.min(items.length, anchorIndex + depth_after + 1);
return items.slice(startIndex, endIndex);

重点看三种锚点各有定位法:数字/会话 ID 是精确等值匹配,时间戳则是**「第一个不早于它」**的近似定位(TimelineService.ts:27)。切片用 Math.max/min 夹边界,锚点在两端也不会越界。默认前后各 10 条(SearchManager.ts:694-695)。

6.3 渲染成分组表格

裁完的 item 交给 renderTimeline(SearchManager.ts:143)渲染成按天分组、天内按文件分组的 markdown 表格,观察行里插会话摘要行和用户提问行,锚点那行标 <- **ANCHOR**(SearchManager.ts:236)。这样 Claude(和人)一眼能看出「这条记忆处在一串事件的什么位置」。


7. token 经济学:为什么这套设计值

把两条路径合起来看,claude-mem 的整个检索面都在为一件事服务:在有限的上下文窗口里,花最少的 token 换最多的相关记忆。

三个省 token 的杠杆

杠杆怎么省在哪
分层披露索引 ~50-100 token/条,全文 ~500-1000 token/条;只对筛出的少数 ID 取全文 → 约 10x__IMPORTANT 三层工作流 mcp-server.ts:439
近期过滤语义召回后砍掉 90 天前的,不把陈旧记忆搬进窗口hybridSemanticHydrate SearchManager.ts:115
schema 强制get_observationsids 必填,协议层堵死「无脑拉全文」mcp-server.ts:526

一句话总结这套哲学

上下文窗口是稀缺资源,记忆是海量资源。 好的记忆系统不是「把能想起来的全倒给模型」,而是给模型一套「先看目录、再按需翻页」的取回工具,让它自己决定花多少 token 换多少上下文。claude-mem 把这套纪律编码进工具名(__IMPORTANT)、schema(ids 必填)和管线(近期过滤),而不是指望模型自觉——这是本项目最值得带走的 context engineering 经验。


8. 边界与局限(诚实)

  • 注入只在开场发生一次。 SessionStart 钩子跑完就结束;会话中新增的记忆不会自动再注入,得靠 Claude 主动 search
  • worker 不在 = 没记忆,不报错。 路径 A 会静默返回空上下文(context.ts:91),路径 B 的工具会报「worker 不可达」类错误(ensureWorkerConnection 自动拉起 worker 失败时,mcp-server.ts:407)。记忆是增强,不是刚需——这是刻意的取舍。
  • 语义质量受 Chroma 可用性摆布。 Chroma 挂了就退化成 FTS5 关键词匹配(SearchManager.ts:490),「鉴权 bug ↔ JWT 校验」这类同义召回就没了,只能靠字面命中。
  • 旧向量缺 platform_source 平台 scoped 查询在 Chroma 上可能命中 0,靠 scoped FTS5 兜底(SearchManager.ts:405);这是历史数据迁移的疤,不是 bug。
  • 记忆注入后如何被消费,claude-mem 管不到。 那是 Claude Code / codex / gemini 等宿主平台的行为,本项目只负责送到门口。

9. 代码地图(导航索引)

主题文件路径符号名
SessionStart 注入处理器(纯函数)src/cli/handlers/context.tscontextHandler.execute
注入路径拼装 / colors 差异src/cli/handlers/context.ts:71apiPath / colorApiPath
codex 走 MCP 取上下文src/cli/handlers/context.ts:40fetchSessionStartContextViaMcp
worker 不可达 → 空上下文src/cli/handlers/context.ts:91isWorkerFallback / emptyResult
stale OAuth 提示(#2215)src/cli/handlers/context.ts:108readStaleMarker
MCP 三层工作流约束src/servers/mcp-server.ts:439__IMPORTANT 工具
MCP 检索工具定义src/servers/mcp-server.ts:473search / timeline / get_observations
注入文本也做成工具src/servers/mcp-server.ts:372handleSessionStartContext
server 运行时 observation_* 工具src/servers/mcp-server.ts:202requireServerForObservationTool
worker 转发薄封装src/servers/mcp-server.ts:71callWorker
混合检索:语义→近期→hydratesrc/services/worker/SearchManager.ts:103hybridSemanticHydrate
三路径主搜索src/services/worker/SearchManager.ts:426search
Chroma 语义 + scoped FTS 回退src/services/worker/SearchManager.ts:312performChromaSemanticSearch
双项目 $or scopingsrc/services/worker/SearchManager.ts:80buildDocTypeWhereFilter
遥测信封(隐私纪律)src/services/worker/SearchManager.ts:28SearchTelemetryEnvelope
时序:定锚 + 切前后窗src/services/worker/SearchManager.ts:691timeline
按深度裁时序切片src/services/worker/TimelineService.ts:11filterByDepth
90 天近期窗口常量src/services/worker/search/types.ts:7SEARCH_CONSTANTS.RECENCY_WINDOW_MS
Chroma 字符串 ID → SQLite 数字 IDsrc/services/sync/ChromaSync.ts:868deduplicateQueryResults

同组其它章: index · 01 接入层 · 02 worker 与观察者 · 03 压缩管线 · 04 存储层