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 |
start → context |
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 处理。
工作流程
sequenceDiagram
participant CC as Claude Code
participant BR as bun-runner.js (node)
participant WS as worker-service.cjs (bun)
CC->>BR: 启动进程 + stdio JSON payload
BR->>BR: isPluginDisabledInClaudeSettings()?
Note right of BR: 读 ~/.claude/settings.json
enabledPlugins['claude-mem@thedotmack'] === false → exit(0)
BR->>BR: collectStdin() 收集 JSON(最多等 5 秒)
BR->>WS: spawn(bunPath, ['worker-service.cjs', 'hook', 'claude-code', event])
BR->>WS: 将 stdinData pipe 进子进程 stdin
WS->>WS: main() → case 'hook' → hookCommand()
WS-->>BR: stdout + 退出码
BR-->>CC: 透传退出码 + stdout关键代码片段(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-783 的 case '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 流程
sequenceDiagram
participant CC as Claude Code
participant SI as session-init handler
participant W as Worker API
participant DB as SQLite
CC->>SI: UserPromptSubmit {session_id, prompt, cwd}
SI->>SI: shouldTrackProject(cwd)? 否→ 静默跳过
SI->>SI: isInternalProtocolPayload(prompt)? 是→ 跳过(多 Agent 协议消息)
SI->>SI: prompt 为空? → 替换为 '[media prompt]'
SI->>W: POST /api/sessions/init {contentSessionId, project, prompt, platformSource}
W->>W: createSDKSession() 获取/创建 sessionDbId
W->>W: stripMemoryTagsFromPrompt(prompt)
W->>DB: saveUserPrompt(sessionId, promptNumber, cleanedPrompt)
W-->>SI: {sessionDbId, promptNumber, skipped?, reason?}
SI->>SI: skipped && reason==='private'? → 返回不注入
SI->>SI: CLAUDE_MEM_SEMANTIC_INJECT=true? 且 prompt >= 20 chars?
SI->>W: POST /api/context/semantic {q: prompt, project, limit}
W-->>SI: {context: "...", count: N}
SI-->>CC: {continue:true} 或 hookSpecificOutput {additionalContext}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 stripping(SessionRoutes.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
完整处理流程
sequenceDiagram
participant CC as Claude Code
participant FC as fileContextHandler
participant FS as 文件系统
participant W as Worker API
CC->>FC: PreToolUse {tool_input: {file_path, filePaths?}}
FC->>FC: shouldTrackProject(cwd)? 否则跳过
FC->>FS: statSync(filePath)
Note right of FS: size < 1500 bytes → null
ENOENT → null
FS-->>FC: {size, mtimeMs}
FC->>W: GET /api/observations/by-file?path=&limit=40
W-->>FC: {observations: ObservationRow[], count: number}
FC->>FC: observations.length == 0? → null
FC->>FC: fileMtimeMs >= newestObservationMs? → null
FC->>FC: deduplicateObservations(data, relativePath, displayLimit=15)
FC->>FC: formatFileTimeline(dedupedObs, filePath)
FC-->>CC: hookSpecificOutput {additionalContext, permissionDecision: "allow"}关键常量(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);
|
输出格式(formatFileTimeline,file-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
完整创建链路
sequenceDiagram
participant CC as Claude Code
participant BR as bun-runner.js
participant OB as observationHandler
participant W as Worker (/api/sessions/observations)
participant PCV as PrivacyCheckValidator
participant SM as SessionManager
participant DB as SQLite
participant GEN as ObservationGenerator (AI)
CC->>BR: stdio JSON {session_id, tool_name, tool_input, tool_response, cwd}
BR->>OB: collectStdin → spawn bun worker-service.cjs hook
OB->>OB: toolName 缺失? → exit 0
OB->>OB: shouldTrackProject(cwd)? 否→ 静默跳过
OB->>W: POST /api/sessions/observations
{contentSessionId, tool_name, tool_input, tool_response, cwd, agentId?}
W->>W: isProjectExcluded(cwd)? → skipped
W->>W: skipTools.has(toolName)? → skipped
W->>W: session_memory 路径检测 → skipped
W->>W: createSDKSession() 获取/创建 sessionDbId
W->>PCV: checkUserPromptPrivacy(sessionId, promptNumber)
PCV-->>W: null (私有) → skipped; string → 继续
W->>W: stripMemoryTagsFromJson(tool_input)
W->>W: stripMemoryTagsFromJson(tool_response)
W->>SM: queueObservation(sessionDbId, {tool_name, tool_input, ...})
W->>GEN: ensureGeneratorRunning(sessionDbId, 'observation') [异步]
W-->>OB: {ok: true, sessionDbId}
OB-->>CC: {continue: true, suppressOutput: true}
GEN->>GEN: AI SDK 压缩 → 提取 title/type/files_read/files_modified
GEN->>DB: INSERT INTO observationsHandler 代码(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 stripping(src/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 完整流程
sequenceDiagram
participant CC as Claude Code
participant SZ as summarizeHandler
participant TR as transcript-parser
participant W as Worker API
CC->>SZ: Stop {session_id, transcript_path?, lastAssistantMessage?}
SZ->>SZ: shouldTrackProject(cwd)? 否→ 跳过
SZ->>SZ: input.stopHookActive === true? → 跳过(Codex 重入检测)
SZ->>SZ: input.agentId 存在? → 跳过(subagent 不做摘要)
SZ->>SZ: input.sessionId 缺失? → 跳过
alt lastAssistantMessage 直接传入
SZ->>SZ: stripMemoryTagsFromPrompt(lastAssistantMessage)
else 需要从 transcript 解析
SZ->>TR: extractLastMessage(transcriptPath, 'assistant', true)
TR->>TR: 读取 JSONL 文件,倒序找最后一条 assistant 消息
TR-->>SZ: lastAssistantMessage 文本
SZ->>SZ: stripMemoryTagsFromPrompt(lastAssistantMessage)
end
SZ->>SZ: lastAssistantMessage 为空? → 跳过
SZ->>W: POST /api/sessions/summarize
{contentSessionId, last_assistant_message, platformSource}
W->>W: queueSummarize(sessionDbId, lastAssistantMessage)
W->>W: ensureGeneratorRunning(sessionDbId, 'summarize') [异步]
W-->>SZ: {status: 'queued'}
SZ-->>CC: {continue: true, suppressOutput: true}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。
完整数据流时序图
sequenceDiagram
participant U as User
participant CC as Claude Code
participant BR as bun-runner.js
participant W as Worker (Bun HTTP)
participant DB as SQLite
CC->>BR: [SessionStart] stdio JSON
BR->>W: bun worker-service.cjs start
W->>W: spawnDaemon if not running
BR->>W: bun worker-service.cjs hook context
W->>DB: SELECT recent observations/summaries
W-->>CC: hookSpecificOutput.additionalContext (历史记忆)
U->>CC: 输入 prompt
CC->>BR: [UserPromptSubmit] stdio JSON {session_id, prompt}
BR->>W: POST /api/sessions/init
W->>W: stripMemoryTagsFromPrompt(prompt)
W->>DB: saveUserPrompt(sessionId, promptNumber, cleanedPrompt)
W-->>CC: {sessionDbId, promptNumber}
CC->>BR: [PreToolUse:Read] stdio JSON {tool_input: {file_path}}
BR->>W: GET /api/observations/by-file?path=...
W->>DB: SELECT observations WHERE files_read/modified LIKE '%file%'
W-->>CC: hookSpecificOutput.additionalContext (文件历史)
CC->>CC: 执行 Read 工具
CC->>BR: [PostToolUse] stdio JSON {tool_name, tool_input, tool_response}
BR->>W: POST /api/sessions/observations
W->>W: stripMemoryTagsFromJson(toolInput + toolResponse)
W->>DB: INSERT observation
W->>W: ensureGeneratorRunning() (AI 压缩异步启动)
W-->>CC: {ok: true}
CC->>BR: [Stop] stdio JSON {transcript_path, lastAssistantMessage}
BR->>W: POST /api/sessions/summarize
W->>W: stripMemoryTagsFromPrompt(lastAssistantMessage)
W->>W: queueSummarize() → AI SDK 生成摘要
W->>DB: INSERT summary
W-->>CC: {status: 'queued'}
关键设计决策总结
| 设计 |
原因 |
| 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 等不需要提前注入历史 |