claude-mem 源码分析:Hooks 生命周期

Hooks 生命周期:触发时机、数据流向与 Worker 通信

6 个标准 Hook 实现零侵入信息捕获,bun-runner.js 异步调度 + stdout JSON injection 协议完成双向通信,<private> 标签三道防线保障隐私。

总览

claude-mem 通过 Claude Code 的 6 个生命周期 Hook 实现持久记忆。整条链路分三层:

1
2
3
4
5
6
7
Claude Code (宿主进程)
     stdio 管道(JSON payload
bun-runner.js  ←→  worker-service.cjs (Bun 守护进程)
     HTTP 请求
Worker Express API (127.0.0.1:<port>)
    
SQLite + Chroma

所有 Hook 的定义位于 plugin/hooks/hooks.json,统一调度入口是 plugin/scripts/bun-runner.js

核心架构设计原则

  • Hook 快速返回(≤60-120s 超时),AI 压缩在后台异步执行
  • Worker 是常驻 HTTP 守护进程,避免每次 Hook 都重新初始化 DB/Chroma
  • <private> 标签在 Hook 层和 Worker 层各剥一次,形成双重防御
  • Worker 不可达时优雅降级(WorkerFallback 模式),不阻断 Claude

六个 Hook 一览

配置文件:plugin/hooks/hooks.json

Hook matcher 子命令 timeout 核心职责
Setup * 300s 版本校验(version-check.js,独立脚本)
SessionStart startup|clear|compact startcontext 60s×2 启动 Worker;注入历史记忆到系统提示
UserPromptSubmit 无(全局) session-init 60s 记录用户 Prompt;可选语义检索注入
PreToolUse(Read) Read file-context 60s 读文件前注入该文件的历史修改记录
PostToolUse * observation 120s 记录工具调用结果(observation 创建主路径)
Stop 无(全局) summarize 120s 会话结束时请求 AI 压缩生成摘要

💡 Tip SessionStart 注册了两条命令 hooks.json 中 SessionStart 有两个 hook 对象:先 start(启动/确认 worker 活跃),再 context(注入历史记忆)。顺序串行执行,确保注入时 Worker 一定就绪。

⚠️ Warning PostToolUse 和 Stop 的 timeout 是 120s 其他 hook 是 60s。PostToolUse 需要等 Worker 完成入库(可能有排队),Stop 需要等摘要请求完整发送,因此给了双倍超时。


1. Setup Hook:版本守卫

触发时机:Claude Code 启动最早期,每次进程启动都会执行。

执行逻辑plugin/scripts/version-check.js):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// version-check.js:53-68
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
const markerPath = join(ROOT, '.install-version');
if (!existsSync(markerPath)) {
  emitUpgradeHint('claude-mem: runtime not yet set up - run: npx claude-mem@latest install');
  process.exit(0);
}
const markerVersion = readInstallMarkerVersion(markerPath);
if (markerVersion !== pkg.version) {
  emitUpgradeHint(`claude-mem: upgraded to v${pkg.version} - run: npx claude-mem@latest install`);
}

这是唯一一个不经过 bun-runner.js 的 Hook,直接用 node 运行。它只做一件事:比较 package.json 版本与 .install-version 标记文件,不匹配时提示用户执行安装命令。

version-check.js 如何输出提示

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// version-check.js:22-33
function emitUpgradeHint(message) {
  if (process.env.CLAUDE_MEM_CODEX_HOOK === '1') {
    // Codex 环境:用 hookSpecificOutput JSON 格式注入上下文
    console.log(JSON.stringify({
      hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: message }
    }));
  } else {
    // Claude Code 环境:直接打 stderr
    console.error(message);
  }
}

💡 Tip 设计思路:轻量守卫前置 版本检查必须最轻量(Node 原生,无依赖),因为它在 Bun 运行时还未确认可用的阶段就要执行。如果用 bun-runner.js 来做,会导致鸡生蛋的问题——bun-runner 本身需要 Bun 在 PATH 上,而 Bun 可能因为版本不一致出问题。


2. bun-runner.js:Hook 的统一调度器

所有其他 Hook 都经过 plugin/scripts/bun-runner.js。它并不是一个"HTTP 请求发射器",而是一个 Bun 进程启动器,把 Claude Code 给它的 stdin 数据转交给 worker-service.cjs 处理。

工作流程

关键代码片段plugin/scripts/bun-runner.js:96-193):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// bun-runner.js:96-118 — 收集 stdin,最多等 5 秒
async function collectStdin() {
  if (process.stdin.isTTY) { resolve(null); return; }
  const chunks = [];
  process.stdin.on('data', (chunk) => chunks.push(chunk));
  process.stdin.on('end', () => resolve(Buffer.concat(chunks)));
  setTimeout(() => resolve(chunks.length > 0 ? Buffer.concat(chunks) : null), 5000);
}

// bun-runner.js:138-193 — spawn bun 并转发 stdin
const child = spawn(bunPath, args, { stdio: ['pipe', 'inherit', 'inherit'] });
if (stdinData && stdinData.length > 0) {
  child.stdin.write(stdinData);
  child.stdin.end();
} else {
  // Issue #2188: 记录诊断,写 CAPTURE_BROKEN 标记,然后 exit(0)
  appendFileSync(join(logsDir, 'runner-errors.log'), diagnostic);
  writeFileSync(join(dataDir, 'CAPTURE_BROKEN'), diagnostic);
  process.exit(0);  // exit 0 防止 Windows Terminal tab 堆积
}

💡 Tip 为什么用 Node 而不直接用 Bun 运行 bun-runner? Claude Code 的 Hook 系统依赖 node 执行,而 Bun 并不保证在所有系统上都在 PATH 中。bun-runner.js 本身用 Node 执行,负责定位 Bun 并启动它,这样实际的业务逻辑(worker-service.cjs)才能运行在 Bun 的高性能运行时上。分层明确:Node 负责"找到 Bun",Bun 负责"跑业务"。

⚠️ Warning Empty stdin 陷阱(Issue #2188) 在某些 WSL/bash 环境下,hook 进程有时收不到 stdin 数据。旧版用 || '{}' fallback 静默跳过,掩盖了问题。现在的做法是:写 runner-errors.log + CAPTURE_BROKEN 标记文件,然后 exit(0) 避免 Windows Terminal tab 堆积。这个 CAPTURE_BROKEN 文件会在下次 session-start 时被检测到,提示用户诊断。

hook-command.ts:分发执行管道

所有 hook claude-code <event> 命令最终走到 src/cli/hook-command.ts:74-116

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// hook-command.ts:74-116
export async function hookCommand(platform: string, event: string): Promise<number> {
  const adapter = getPlatformAdapter(platform);   // e.g. claudeCodeAdapter
  const handler = getEventHandler(event);         // e.g. observationHandler

  // 1. 读 stdin
  const rawInput = await readJsonFromStdin();
  // 2. 标准化(Claude Code 的 JSON 字段 → NormalizedHookInput)
  const input = adapter.normalizeInput(rawInput);
  input.platform = platform;
  // 3. 执行业务逻辑
  const result = await handler.execute(input);
  // 4. 格式化输出给 Claude Code
  const output = adapter.formatOutput(result);
  console.log(JSON.stringify(output));
  process.exit(result.exitCode ?? HOOK_EXIT_CODES.SUCCESS);
}

Claude Code 适配器的输入标准化src/cli/adapters/claude-code.ts:9-26):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
normalizeInput(raw) {
  return {
    sessionId:      r.session_id,       // Claude 会话 UUID
    cwd:            r.cwd,              // 工作目录(项目识别的关键)
    prompt:         r.prompt,           // UserPromptSubmit 的用户输入
    toolName:       r.tool_name,        // PostToolUse/PreToolUse 的工具名
    toolInput:      r.tool_input,       // 工具输入参数(JSON)
    toolResponse:   r.tool_response,    // 工具返回结果(JSON)
    transcriptPath: r.transcript_path,  // Stop hook 用于读取 JSONL 对话记录
    agentId:        r.agent_id,         // subagent 标识(防重复摘要)
    agentType:      r.agent_type,       // subagent 类型
  };
}

Claude Code 适配器的输出格式化src/cli/adapters/claude-code.ts:27-42):

1
2
3
4
5
6
formatOutput(result) {
  if (r.hookSpecificOutput) {
    return { hookSpecificOutput: result.hookSpecificOutput, systemMessage: r.systemMessage };
  }
  return { systemMessage: r.systemMessage };  // 普通输出只带 systemMessage
}

💡 Tip NormalizedHookInput 是平台适配层的核心 src/cli/types.ts 定义的 NormalizedHookInput 把 Claude Code、Cursor、Codex、Gemini CLI 等不同平台的 JSON 结构统一映射到同一接口。Handler 只需要处理这个标准化的接口,完全不感知平台差异。这是一个经典的适配器模式应用。


3. SessionStart Hook:双步启动

SessionStart 在 matcher 为 startup|clear|compact 时触发,执行两个串行命令

Step 1: 启动 Worker(start 子命令)

1
node bun-runner.js worker-service.cjs start

worker-service.ts:776-783case 'start'

1
2
3
4
5
case 'start': {
  const result = await ensureWorkerStarted(port);  // 检查是否已在运行,否则 spawnDaemon
  exitWithStatus(result === 'dead' ? 'error' : 'ready', ...);
  break;
}

输出 {"continue":true,"suppressOutput":true} 告知 Claude Code 继续。

Step 2: 注入历史记忆(context 子命令)

1
node bun-runner.js worker-service.cjs hook claude-code context

handler 链路src/cli/handlers/context.ts):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// context.ts:15-81
export const contextHandler: EventHandler = {
  async execute(input: NormalizedHookInput): Promise<HookResult> {
    const context = getProjectContext(cwd);           // 从 cwd 推断项目名
    const projectsParam = context.allProjects.join(',');
    const apiPath = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}`;

    // GET http://localhost:37700/api/context/inject?projects=xxx
    const contextResult = await executeWithWorkerFallback<string>(apiPath, 'GET');
    if (isWorkerFallback(contextResult)) {
      return emptyResult;  // Worker 不可达时优雅降级,返回空上下文
    }

    // 可选:检测 stale OAuth token 并追加提示(Issue #2215)
    const staleReason = readStaleMarker();
    if (staleReason) {
      additionalContext = `[claude-mem] Claude Desktop OAuth token is stale: ${staleReason}\n...`;
    }

    return {
      hookSpecificOutput: {
        hookEventName: 'SessionStart',
        additionalContext   // 注入到 Claude 的系统上下文
      }
    };
  }
};

Worker 端处理SearchRoutes.ts:337-387):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
GET /api/context/inject?projects=my-project
         ▼ handleContextInject()
1. 判断是否有 observations(若无则返回欢迎提示)
2. 调用 generateContext({ projects, cwd, full: false })
3. 从 SQLite 拉取该项目历史 observations/session summaries
4. 格式化为带时间线的文本
         ▼ res.setHeader('Content-Type', 'text/plain')
         ▼ res.send(contextText)

返回给 Claude Code 的 JSON 结构context.ts:74-80):

1
2
3
4
5
6
{
  "hookSpecificOutput": {
    "hookEventName": "SessionStart",
    "additionalContext": "## Recent Activity\n...[历史记忆文本,包含 observation 摘要和 session 摘要]..."
  }
}

Claude Code 收到后会把 additionalContext 注入到当前会话的系统提示中,使 Claude 在会话开始时就"记住"之前做过的事。


4. UserPromptSubmit Hook:session-init

触发时机:用户每次按下发送,在 Claude 处理前触发(全局,无 matcher 过滤)。

子命令hook claude-code session-init

完整 handler 流程

Handler 关键代码src/cli/handlers/session-init.ts:40-119):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 1. 多 Agent 协议消息检测(<task-notification> 标签)
if (rawPrompt && isInternalProtocolPayload(rawPrompt)) {
  return { continue: true, suppressOutput: true };
}

// 2. 空 prompt 处理(语音/图片输入无文本)
const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt;

// 3. 调用 Worker
const initResult = await executeWithWorkerFallback<SessionInitResponse>(
  '/api/sessions/init', 'POST',
  { contentSessionId: sessionId, project, prompt, platformSource },
);

// 4. 可选语义检索注入(实验性功能)
const semanticInject = String(settings.CLAUDE_MEM_SEMANTIC_INJECT).toLowerCase() === 'true';
if (semanticInject && prompt.length >= 20 && prompt !== '[media prompt]') {
  const semanticResult = await executeWithWorkerFallback<SemanticContextResponse>(
    '/api/context/semantic', 'POST',
    { q: prompt, project, limit: settings.CLAUDE_MEM_SEMANTIC_INJECT_LIMIT || '5' },
  );
  if (!isWorkerFallback(semanticResult) && semanticResult?.context) {
    additionalContext = semanticResult.context;  // 相关历史 observations 文本
  }
}

Worker 侧 tag strippingSessionRoutes.ts:389):

1
2
3
4
5
6
const cleanedPrompt = stripMemoryTagsFromPrompt(prompt);
if (!cleanedPrompt || cleanedPrompt.trim() === '') {
  res.json({ sessionDbId, promptNumber, skipped: true, reason: 'private' });
  return;  // 整个 prompt 都是私有的,不保存
}
store.saveUserPrompt(contentSessionId, promptNumber, cleanedPrompt);

💡 Tip <private> 的生效时机在这里 如果用户输入 <private>不要记录这段</private>,到达 Worker 后 stripMemoryTagsFromPrompt 会把它剔除。如果剔除后为空,则整个 prompt 被标记为 skipped: reason: 'private',session-init handler 收到后也直接返回 continue: true 不做语义注入。后续同一 prompt 下触发的 observation 也会经过 PrivacyCheckValidator 检测而跳过。

💡 Tip isInternalProtocolPayload() 防止多 Agent 协议消息被记录 当 orchestrator 给 subagent 发送 <task-notification> 消息时,这些 payload 不应该被当作用户 prompt 记录到数据库。isInternalProtocolPayload() 检测整条消息是否完全由协议 tag 构成(使用 ^ $ 锚定),如果是则直接跳过,不发送给 Worker。


5. PreToolUse(Read) Hook:file-context

触发时机:Claude 调用 Read 工具之前(不影响工具执行,只注入上下文)。

子命令hook claude-code file-context

完整处理流程

关键常量file-context.ts:11-16):

1
2
3
4
const FILE_READ_GATE_MIN_BYTES = 1_500;   // < 1.5KB 的文件不注入(太小没历史价值)
const FETCH_LOOKAHEAD_LIMIT = 40;          // 向 Worker 最多拉取 40 条原始记录
const DISPLAY_LIMIT = 15;                  // 去重+评分后最多展示 15 条
const MAX_FILE_CONTEXT_PATHS = 10;         // 批量 Read 时最多处理 10 个路径

mtime 时间戳门控file-context.ts:229-239)——注意:这在查询 Worker 之后执行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// file-context.ts:229-239
if (fileMtimeMs > 0) {
  const newestObservationMs = Math.max(...data.observations.map(o => o.created_at_epoch));
  if (fileMtimeMs >= newestObservationMs) {
    logger.debug('HOOK', 'File modified since last observation, skipping context injection', {
      filePath: relativePath, fileMtimeMs, newestObservationMs,
    });
    return null;  // 文件比最新 observation 还新 → 历史可能失效 → 跳过
  }
}

去重与评分算法file-context.ts:51-84):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Step 1: 每个 session 只保留一条(按时间最早的)
const seenSessions = new Set<string>();
const dedupedBySession = observations.filter(obs => {
  const key = obs.memory_session_id;
  if (!seenSessions.has(key)) { seenSessions.add(key); return true; }
  return false;
});

// Step 2: 按相关性评分排序
const scored = dedupedBySession.map(obs => {
  const inModified = filesModified.includes(targetPath);  // 修改过比仅读过更重要
  let score = 0;
  if (inModified) score += 2;
  if (totalFiles <= 3) score += 2;   // 只涉及少数文件的 obs 更精准
  else if (totalFiles <= 8) score += 1;
  return { obs, score };
});
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, DISPLAY_LIMIT).map(s => s.obs);

输出格式formatFileTimelinefile-context.ts:86-134):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Current: 2026-05-08 10:30am PDT
This file has prior observations — supplementary context follows.
- **Need details?** get_observations([IDs])
- **Need structural map?** smart_outline("src/foo.ts")

### May 7, 2026
42 10:30a ⚖️ Refactored auth module to use JWT
58 2:15p 🔴 Fix null pointer in token validation
### May 8, 2026
71 9:00a ✅ Add refresh token expiry logic

最终返回给 Claude Code (file-context.ts:174-181):

1
2
3
4
5
6
7
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "additionalContext": "Current: ...\n### May 8, 2026\n...",
    "permissionDecision": "allow"
  }
}

permissionDecision: "allow" 明确告知 Claude Code:不需要再次向用户确认这个 Read 调用,直接放行。

💡 Tip 正确的执行顺序:先查询,后做时间戳判断 mtime 检查发生在拿到 observations 之后:没有历史记录时直接返回 null(不浪费时间做 stat),有记录时才检查文件是否已被修改。顺序依赖性:stat → 检查文件大小 → 查 Worker → 检查 mtime vs 最新 obs

💡 Tip 评分机制的设计逻辑 “修改过该文件"比"仅读过"得分高 (+2),因为 Edit/Write 类 observation 含有更有价值的上下文(改了什么)。“涉及文件数少"得分高,因为专门针对这个文件的 observation 精准度更高,而涉及十几个文件的 observation 可能只是顺带读了一下。

⚠️ Warning PreToolUse 不会阻断工具执行 即使 file-context 返回空(Worker 不可达、文件太小、无历史记录),Read 工具仍然正常执行。只有当 permissionDecision: "block" 时才会阻止工具执行,file-context 始终返回 "allow"


6. PostToolUse Hook:observation 创建主路径

触发时机:Claude 每次调用任何工具完成后触发(matcher *,无工具过滤)。

子命令hook claude-code observation

完整创建链路

Handler 代码src/cli/handlers/observation.ts:31-43):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const result = await executeWithWorkerFallback<{ status?: string }>(
  '/api/sessions/observations',
  'POST',
  {
    contentSessionId: sessionId,
    platformSource,
    tool_name: toolName,
    tool_input: toolInput,    // 原始工具输入(未序列化,在 Worker 侧做)
    tool_response: toolResponse,
    cwd,
    agentId: input.agentId,   // 用于识别 subagent 的 observations
    agentType: input.agentType,
  },
);

Worker 侧 tag strippingsrc/services/worker/http/shared.ts:154-158):

1
2
3
4
5
6
const cleanedToolInput = payload.toolInput !== undefined
  ? stripMemoryTagsFromJson(JSON.stringify(payload.toolInput))
  : '{}';
const cleanedToolResponse = payload.toolResponse !== undefined
  ? stripMemoryTagsFromJson(JSON.stringify(payload.toolResponse))
  : '{}';

Worker 侧跳过条件shared.ts:106-124):

  • isProjectExcluded(cwd) → 项目在排除列表
  • CLAUDE_MEM_SKIP_TOOLS 设置包含该工具名
  • 工具路径包含 session-memory(防止元数据自我记录)
  • PrivacyCheckValidator 检测到当前 prompt 已被标记为私有

⚠️ Warning observation 的 timeout 是 120 秒 hooks.json 中 PostToolUse 的 timeout 设置为 120s,比其他 hook 的 60s 要长。这是因为 observation 要等 Worker 完成入库操作,而 Worker 可能同时在处理上一个 AI 压缩任务,存在排队等待。

💡 Tip AI 压缩是异步的 —— hook 不等待 queueObservation() 只是把数据放入队列,ensureGeneratorRunning() 触发后台 Generator 协程。PostToolUse hook 本身在 Worker 返回 {ok: true} 时就退出,不等待 AI 压缩完成。这保证了 hook 在超时前快速返回,AI 处理在 Worker 进程内后台进行。


7. Stop Hook:触发摘要

触发时机:Claude 完成回答,会话停止时(全局,无 matcher 过滤)。

子命令hook claude-code summarize

handler 完整流程

Handler 源码src/cli/handlers/summarize.ts:17-87):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 跳过条件 1: Codex Stop hook 重入
if (input.stopHookActive === true) {
  return { continue: true, suppressOutput: true };
}

// 跳过条件 2: subagent(只有主 agent 才生成摘要)
if (input.agentId) {
  return { continue: true, suppressOutput: true };
}

// 提取最后一条 assistant 消息
let lastAssistantMessage = '';
if (input.lastAssistantMessage !== undefined) {
  lastAssistantMessage = stripMemoryTagsFromPrompt(input.lastAssistantMessage);
} else {
  // fallback: 从 JSONL transcript 文件中解析
  lastAssistantMessage = extractLastMessage(transcriptPath, 'assistant', true);
  lastAssistantMessage = stripMemoryTagsFromPrompt(lastAssistantMessage);
}

if (!lastAssistantMessage || !lastAssistantMessage.trim()) {
  return { continue: true, suppressOutput: true };  // 没有可摘要的内容
}

// 发送给 Worker 异步处理(不等待 AI 生成完成)
const queueResult = await executeWithWorkerFallback<{ status?: string }>(
  '/api/sessions/summarize', 'POST',
  { contentSessionId: sessionId, last_assistant_message: lastAssistantMessage, platformSource },
);

跳过条件总结

条件 原因
shouldTrackProject(cwd) === false 项目被排除在外
input.stopHookActive === true Codex 平台 Stop hook 重入保护
input.agentId 存在 subagent 不独立生成摘要,只有 orchestrator 做
sessionId 缺失 无法关联到 session
transcriptPath 缺失(且无直接传入) 无法获取 assistant 消息
提取 message 后为空 没有可摘要的内容

💡 Tip Stop hook 里的 tag stripping 是第一层防御 summarize.ts:43,52 在发送给 Worker 之前就对 lastAssistantMessage 做了 strip。即使 Claude 在回复中引用了 <private> 内容,也会在这里被清除,不会进入数据库。Worker 侧 SessionRoutes.ts:291 会再 strip 一次(双重防御)。

⚠️ Warning extractLastMessage 读取 JSONL transcript 文件 Stop hook 提供 transcript_path(Claude Code 对话记录的 JSONL 文件路径)。extractLastMessage 倒序读取这个文件,找最后一条 role=assistant 的消息。如果文件读取失败(竞态、权限等),则整个 Stop hook 跳过,不报错——宁可漏掉一次摘要,也不阻断 Claude 退出。


tag-stripping.ts 详解

src/utils/tag-stripping.ts 是隐私处理的核心工具,被多个层调用。

支持的 tag 列表

1
2
3
4
5
6
7
8
9
// tag-stripping.ts:4-11
const TAG_NAMES = [
  'private',            // 用户手写的隐私标记 —— 防止内容被记录
  'claude-mem-context', // 系统注入的历史上下文 —— 防止递归存储(存储后再次被存储)
  'system_instruction', // 系统指令(下划线版)
  'system-instruction', // 系统指令(连字符版)
  'persisted-output',   // 已持久化的输出
  'system-reminder',    // Claude Code 的 system-reminder 标签
] as const;

正则实现

1
2
3
4
5
// tag-stripping.ts:14-17
const STRIP_REGEX = new RegExp(
  `<(${TAG_NAMES.join('|')})\\b[^>]*>[\\s\\S]*?</\\1>`,
  'g'
);

正则解析:

  • \b[^>]*> —— 支持 tag 携带属性,如 <private id="1"><system-reminder type="warning">
  • [\s\S]*? —— 非贪婪匹配,支持多行内容,且多个嵌套 tag 各自被剥除
  • \1 反向引用 —— 确保开闭 tag 名称一致,<private>...</private> 而非跨 tag 乱配

两个主要调用方

1
2
3
4
5
6
7
8
// tag-stripping.ts:48-54
export function stripMemoryTagsFromJson(content: string): string {
  return stripTags(content).stripped;  // 用于 tool_input/tool_response(JSON 字符串)
}

export function stripMemoryTagsFromPrompt(content: string): string {
  return stripTags(content).stripped;  // 用于 prompt/assistant message(自然语言)
}

两者目前等价,但分开命名便于未来针对不同内容类型定制处理逻辑。

双重防御:调用层级

调用阶段 文件 行号 处理对象 防御层
Hook 层(Stop) summarize.ts 43, 52 lastAssistantMessage 第 1 层:发送前
Worker API 入口(Prompt) SessionRoutes.ts ~389 用户 prompt 第 1 层:入库前
Worker API 入口(Summary) SessionRoutes.ts ~291 last_assistant_message 第 2 层:兜底
Observation 入库 shared.ts 154-158 tool_input/tool_response 第 1 层:入库前

💡 Tip 为什么需要两层? Hook 层(edge processing)能快速过滤大部分敏感内容,减少通过网络传输的数据量。Worker 层是"最终守门员”,防止 edge 层逻辑有漏洞(如未来新增平台适配器忘记调用 strip)时数据进库。纵深防御不是重复代码,而是容错。

MAX_TAG_COUNT = 100 的安全限制

1
2
3
4
5
6
// tag-stripping.ts:37-43
if (total > MAX_TAG_COUNT) {
  logger.warn('SYSTEM', 'tag count exceeds limit', undefined, {
    tagCount: total, maxAllowed: MAX_TAG_COUNT
  });
}

超过 100 个 tag 时仍然全部剥离,但记录 warn 日志。防止 ReDoS 攻击构造大量嵌套 tag 导致 regex 回溯失控(非贪婪匹配已大幅降低风险,但 100 限制是额外安全边界)。

isInternalProtocolPayload() —— 协议消息检测

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// tag-stripping.ts:56-68
const PROTOCOL_ONLY_TAGS = ['task-notification'] as const;
const PROTOCOL_ONLY_REGEX = new RegExp(
  `^\\s*<(${PROTOCOL_ONLY_TAGS.join('|')})\\b[^>]*>(?:(?!<\\1\\b|</\\1\\b)[\\s\\S])*</\\1>\\s*$`,
);
const MAX_PROTOCOL_PAYLOAD_BYTES = 256 * 1024;  // 256KB 上限

export function isInternalProtocolPayload(text: string): boolean {
  if (!text) return false;
  if (text.length > MAX_PROTOCOL_PAYLOAD_BYTES) return false;
  return PROTOCOL_ONLY_REGEX.test(text);
}

^ $ 锚定确保整条消息就是协议 tag,而不是包含协议 tag 的普通消息。256KB 上限防止超大消息导致 regex 匹配超时。


executeWithWorkerFallback:容错通信机制

所有 handler 调用 Worker API 都通过 executeWithWorkerFallback()src/shared/worker-utils.ts:443-492)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
export async function executeWithWorkerFallback<T = unknown>(
  url: string,
  method: 'GET' | 'POST' | 'PUT' | 'DELETE',
  body?: unknown,
  options: WorkerFallbackOptions = {},
): Promise<WorkerCallResult<T>> {
  // 1. 懒启动检查(每个 hook 进程只检查一次,有缓存)
  const alive = await ensureWorkerAliveOnce();
  if (!alive) {
    recordWorkerUnreachable();  // 持久化失败计数器
    return { continue: true, reason: 'worker_unreachable', [WORKER_FALLBACK_BRAND]: true };
  }

  // 2. 发送 HTTP 请求
  const response = await workerHttpRequest(url, init);

  // 3. 处理非 2xx 响应
  if (!response.ok) {
    resetWorkerFailureCounter();  // 注意:非 2xx 也重置计数器(worker 在线,只是业务错误)
    if (response.status === 429 || response.status >= 500) {
      return { continue: true, reason: `worker_api_${response.status}`, [WORKER_FALLBACK_BRAND]: true };
    }
    return parsed as T;  // 4xx 等返回原始响应(业务层判断)
  }

  resetWorkerFailureCounter();  // 成功后重置连续失败计数
  return JSON.parse(await response.text()) as T;
}

懒启动机制(ensureWorkerRunning()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// worker-utils.ts:277-329
async function ensureWorkerRunning(): Promise<boolean> {
  if (await isWorkerPortAlive()) {  // 先检查健康和 PID 文件
    await checkWorkerVersion();     // 版本不匹配时记录 debug log(不自动重启)
    const ready = await waitForWorkerReadiness();  // 等待 DB/Chroma 初始化
    return ready;
  }

  // Worker 不在 → 找 Bun 运行时和 worker-service.cjs
  const proc = spawnHidden(runtimePath, [scriptPath, '--daemon'], { detached: true });
  proc.unref();  // 父进程(hook)退出后子进程继续运行

  // 轮询 /api/health,指数退避:250ms → 500ms → 1000ms
  const alive = await waitForWorkerPort({ attempts: 3, backoffMs: 250 });
  const ready = await waitForWorkerReadiness();  // 再等 DB/Chroma ready
  return ready;
}

// 每个 hook 进程缓存结果,避免重复探活
let aliveCache: boolean | null = null;
export async function ensureWorkerAliveOnce(): Promise<boolean> {
  if (aliveCache !== null) return aliveCache;
  aliveCache = await ensureWorkerRunning();
  return aliveCache;
}

WorkerFallback 品牌类型(Brand Type)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// worker-utils.ts:425-437
const WORKER_FALLBACK_BRAND: unique symbol = Symbol.for('claude-mem/worker-fallback');

export type WorkerFallback =
  | { continue: true; [WORKER_FALLBACK_BRAND]: true }
  | { continue: true; reason: string; [WORKER_FALLBACK_BRAND]: true };

export function isWorkerFallback<T>(result: WorkerCallResult<T>): result is WorkerFallback {
  return typeof result === 'object'
    && result !== null
    && (result as any)[WORKER_FALLBACK_BRAND] === true;
}

💡 Tip 为什么用 Symbol 而不是字段名判断? Symbol.for('claude-mem/worker-fallback') 是全局唯一 key。普通的 API 响应中即使碰巧包含 { continue: true }(如 session-init 的正常响应),也不会误判为 WorkerFallback,因为它没有这个 Symbol 属性。这是类型级别的不可伪造标记(brand type pattern)。

连续失败升级机制

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// worker-utils.ts:401-416
function recordWorkerUnreachable(): number {
  const state = readHookFailureState();  // 从 ~/.claude-mem/state/hook-failures.json 读取
  const next = { consecutiveFailures: state.consecutiveFailures + 1, lastFailureAt: Date.now() };
  writeHookFailureStateAtomic(next);  // 原子写(tmp → rename)

  const threshold = getFailLoudThreshold();  // 默认 3,可配置 CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD
  if (next.consecutiveFailures >= threshold) {
    process.stderr.write(`claude-mem worker unreachable for ${next.consecutiveFailures} consecutive hooks.\n`);
    process.exit(HOOK_EXIT_CODES.BLOCKING_ERROR);  // exit(2):Claude Code 把 stderr 喂给 Claude
  }
  return next.consecutiveFailures;
}

⚠️ Warning exit(2) 会让 Claude 知道插件出问题了 连续 3 次(默认)Worker 不可达后,hook 以 exit code 2 退出。Claude Code 遇到 exit 2 会把 stderr 作为系统消息注入给 Claude,使 Claude 能够告知用户 claude-mem 插件需要重启。失败计数持久化到文件,跨 hook 进程累计,成功一次则重置为 0。


完整数据流时序图


关键设计决策总结

设计 原因
Hook → bun-runner(node) → worker-service(bun) 的两层调用 Claude Code 只能调用 node;业务代码需要 Bun 高性能运行时
Worker 是常驻 HTTP 服务,而不是每次 hook 都重启 避免 SQLite/Chroma 重复初始化,hook 超时预算只有 60-120s
tag stripping 在 hook 层和 Worker 层双重执行 防御纵深:edge 层快速过滤,Worker 层作为最终守门员
Stop hook 跳过 subagent(agentId 存在时) 防止每个子 agent 都触发摘要,只有顶层会话生成摘要
PostToolUse matcher *(所有工具) 不限制工具类型,Read/Edit/Bash 等都会产生 observation
PreToolUse 只匹配 Read 文件上下文注入只对读文件有意义;Edit/Bash 等不需要提前注入历史