跳转至

Hook 生命周期

Claude-Mem 实现了一套 5 阶段 Hook 系统,用于捕获跨 Claude Code 会话的开发工作。本文档为在其他平台上实现此模式的开发者提供了完整的技术参考。

架构概览

系统架构

这种双进程架构在 Claude Code 和 VS Code 中均可工作:

graph TB
    subgraph EXT["Extension Process (runs in IDE)"]
        direction TB
        ACT[Extension Activation]
        HOOKS[Hook Event Handlers]
        ACT --> HOOKS

        subgraph HOOK_HANDLERS["5 Lifecycle Hooks"]
            H1[SessionStart<br/>activate function]
            H2[UserPromptSubmit<br/>command handler]
            H3[PostToolUse<br/>middleware]
            H4[Stop<br/>idle timeout]
            H5[SessionEnd<br/>deactivate function]
        end

        HOOKS --> HOOK_HANDLERS
    end

    HOOK_HANDLERS -->|"HTTP<br/>(fire-and-forget<br/>2s timeout)"| HTTP[Worker HTTP API<br/>Configured port]

    subgraph WORKER["Worker Process (separate Node.js)"]
        direction TB
        HTTP --> API[Express Server]
        API --> SESS[Session Manager]
        API --> AGENT[SDK Agent]
        API --> DB[Database Manager]

        AGENT -->|Event-Driven| CLAUDE[Claude Agent SDK]
        CLAUDE --> SQLITE[(SQLite + FTS5)]
        CLAUDE --> CHROMA[(Chroma Vectors)]
    end

    style EXT fill:#e1f5ff
    style WORKER fill:#fff4e1
    style HOOK_HANDLERS fill:#f0f0f0

关键原则: - 扩展进程永不阻塞(fire-and-forget HTTP) - Worker 异步处理观察数据 - 会话状态在 IDE 重启后依然持久化

VS Code 扩展 API 集成点

对于移植到 VS Code 的开发者,以下是在 VS Code 扩展 API 中接入 Hook 的位置:

graph LR
    subgraph VSCODE["VS Code Extension API"]
        direction TB
        A["activate(context)"]
        B["commands.registerCommand()"]
        C["chat.createChatParticipant()"]
        D["workspace.onDidSaveTextDocument()"]
        E["window.onDidChangeActiveTextEditor()"]
        F["deactivate()"]
    end

    subgraph HOOKS["Hook Equivalents"]
        direction TB
        G[SessionStart]
        H[UserPromptSubmit]
        I[PostToolUse]
        J[Stop/Summary]
        K[SessionEnd]
    end

    subgraph WORKER_API["Worker HTTP Endpoints"]
        direction TB
        L[GET /api/context/inject]
        M[POST /sessions/init]
        N[POST /sessions/observations]
        O[POST /sessions/summarize]
        P[POST /sessions/complete]
    end

    A --> G
    B --> H
    C --> H
    D --> I
    E --> I
    F --> K

    G --> L
    H --> M
    I --> N
    J --> O
    K --> P

    style VSCODE fill:#007acc,color:#fff
    style HOOKS fill:#f0f0f0
    style WORKER_API fill:#4caf50,color:#fff

实现示例:

const workerPort = process.env.CLAUDE_MEM_WORKER_PORT ?? "<worker-port>"
const workerBaseUrl = `http://127.0.0.1:${workerPort}`

// VS Code Extension - SessionStart Hook
export async function activate(context: vscode.ExtensionContext) {
  const sessionId = generateSessionId()
  const project = workspace.name || 'default'

  // Fetch context from worker
  const response = await fetch(`${workerBaseUrl}/api/context/inject?project=${project}`)
  const context = await response.text()

  // Inject into chat or UI panel
  injectContextToChat(context)
}

// VS Code Extension - UserPromptSubmit Hook
const command = vscode.commands.registerCommand('extension.command', async (prompt) => {
  await fetch(`${workerBaseUrl}/sessions/init`, {
    method: 'POST',
    body: JSON.stringify({ sessionId, project, userPrompt: prompt })
  })
})

// VS Code Extension - PostToolUse Hook (middleware pattern)
workspace.onDidSaveTextDocument(async (document) => {
  await fetch(`${workerBaseUrl}/api/sessions/observations`, {
    method: 'POST',
    body: JSON.stringify({
      claudeSessionId: sessionId,
      tool_name: 'FileSave',
      tool_input: { path: document.uri.path },
      tool_response: 'File saved successfully'
    })
  })
})

异步处理管道

观察数据如何在不阻塞 IDE 的情况下从扩展流向数据库:

graph TB
    A["Extension: Tool Use Event"] --> B{"Skip List?<br/>(TodoWrite, AskUserQuestion, etc.)"}
    B -->|"Skip"| X["Discard"]
    B -->|"Keep"| C["Strip Privacy Tags<br/>&lt;private&gt;...&lt;/private&gt;"]
    C --> D["HTTP POST to Worker<br/>Configured port"]
    D --> E["2s timeout<br/>fire-and-forget"]
    E --> F["Extension continues<br/>(non-blocking)"]

    D -.Async Path.-> G["Worker: Queue Observation"]
    G --> H["SDK Agent picks up<br/>(event-driven)"]
    H --> I["Call Claude API<br/>(compress observation)"]
    I --> J["Parse XML response"]
    J --> K["Save to SQLite<br/>(sdk_sessions table)"]
    K --> L["Sync to Chroma<br/>(vector embeddings)"]

    style F fill:#90EE90,stroke:#2d6b2d,stroke-width:3px
    style L fill:#87CEEB,stroke:#2d5f8d,stroke-width:3px
    style E fill:#ffeb3b,stroke:#c6a700,stroke-width:2px

关键模式: 扩展的 HTTP 调用具有 2 秒超时,且不会等待 AI 处理。Worker 使用事件驱动的队列异步执行压缩。

5 个生命周期阶段

阶段 Hook 触发时机 目的
1. SessionStart claude-mem hook claude-code context 用户打开 Claude Code 静默注入先前上下文
2. UserPromptSubmit claude-mem hook claude-code session-init 用户提交 Prompt 创建/获取会话、保存 Prompt、初始化 worker
3. PostToolUse claude-mem hook claude-code observation Claude 使用任意工具 将观察数据加入队列以供 AI 压缩
4. Stop claude-mem hook claude-code summarize 用户停止提问 生成会话摘要
5. SessionEnd claude-mem hook claude-code session-end 会话关闭 将会话标记为已完成

PreToolUse(匹配器 Read 以及搜索工具)还会同步运行 claude-mem hook claude-code file-context,以便在 Claude 读取文件之前添加该文件的历史记录,并让 File Read Gate 阻止整文件的 Read 操作。PostToolUseFailure 对于失败的工具调用,会运行与 PostToolUse 相同的 observation Hook。

钩子配置

钩子在 plugin/hooks/hooks.json 中配置(由 scripts/build-hooks.js 生成)。每个运行时钩子都采用 exec 形式:command + args,没有 shell。Claude Code 会在 PATH 中解析 claude-mem 并直接启动它,中间不经过 bash:

{
  "hooks": {
    "Setup": [{
      "matcher": "*",
      "hooks": [{
        "type": "command",
        "shell": "bash",
        "command": "… resolve the plugin root …; node \"$_P/scripts/version-check.js\"",
        "timeout": 300
      }]
    }],
    "SessionStart": [{
      "matcher": "startup|resume|clear|compact",
      "hooks": [
        { "type": "command", "command": "claude-mem", "args": ["start"], "timeout": 60, "async": true }
      ]
    }, {
      "matcher": "startup|clear|compact",
      "hooks": [
        { "type": "command", "command": "claude-mem", "args": ["hook", "claude-code", "context"], "timeout": 60 }
      ]
    }, {
      "matcher": "startup",
      "hooks": [
        { "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/ensure-launcher.cjs"], "timeout": 180 }
      ]
    }],
    "UserPromptSubmit": [{
      "hooks": [{ "type": "command", "command": "claude-mem", "args": ["hook", "claude-code", "session-init"], "timeout": 15 }]
    }],
    "PreToolUse": [{
      "matcher": "Read|^Bash$|^Grep$|^Glob$|^memory_search$|^memory$|^mcp__.+__memory_search$",
      "hooks": [{ "type": "command", "command": "claude-mem", "args": ["hook", "claude-code", "file-context"], "timeout": 15 }]
    }],
    "PostToolUse": [{
      "matcher": "*",
      "hooks": [{ "type": "command", "command": "claude-mem", "args": ["hook", "claude-code", "observation"], "timeout": 120, "async": true }]
    }],
    "PostToolUseFailure": [{
      "matcher": "*",
      "hooks": [{ "type": "command", "command": "claude-mem", "args": ["hook", "claude-code", "observation"], "timeout": 120, "async": true }]
    }],
    "Stop": [{
      "hooks": [{ "type": "command", "command": "claude-mem", "args": ["hook", "claude-code", "summarize"], "timeout": 120, "async": true }]
    }],
    "SessionEnd": [{
      "hooks": [{ "type": "command", "command": "claude-mem", "args": ["hook", "claude-code", "session-end"], "async": true }]
    }]
  }
}
  • claude-mem 是一个版本稳定的编译启动器,不是 npm CLI。npx claude-mem install / update 会将其放置在 ~/.local/bin(POSIX)或 %LOCALAPPDATA%\claude-mem\bin(Windows),并将该目录添加到 PATH。它会找到插件根目录,在 Bun 下使用相同的参数运行 worker-service.cjs,并在任何失败时以 0 退出。
  • ensure-launcher.cjs(仅启动时执行)是针对仅通过插件市场安装的自我修复机制:当启动器缺失或过期时,它会放置或刷新启动器。
  • Setup 是唯一仍然使用 bash 的入口。Claude Code 仅在 claude --init、--init-only 和 -p --maintenance 时触发它,而从不在插件安装、更新或正常会话启动时触发。

有关启动器的行为以及启动器缺失时你会看到的错误,请参阅 钩子架构。


阶段 1:SessionStart

时机:当用户打开 Claude Code 或恢复会话时

触发的钩子(按顺序): 1. claude-mem start - 启动 worker service(异步) 2. claude-mem hook claude-code context - 获取并静默注入先前会话的上下文 3. node ${CLAUDE_PLUGIN_ROOT}/scripts/ensure-launcher.cjs - 仅启动时执行,同步:将 claude-mem 启动器保留在 PATH 中,并在记忆钩子未激活时显示 systemMessage

(运行时设置由 npx claude-mem install / npx claude-mem repair 处理。Setup 钩子仅在 claude --init / --maintenance 时运行 version-check.js:当插件的依赖缺失时安装它们,安装完成后记录 .install-version 标记,并且仅在依赖仍然缺失时要求你运行 npx claude-mem@latest install。)

Note

从 Claude Code 2.1.0(ultrathink 更新)开始,SessionStart 钩子不再显示用户可见的消息。上下文会通过 hookSpecificOutput.additionalContext 静默注入。

序列图

sequenceDiagram
    participant User
    participant IDE as IDE/Extension
    participant ContextHook as context-hook.js
    participant Worker as Worker Service
    participant DB as SQLite Database

    User->>IDE: Opens workspace / resumes session
    IDE->>ContextHook: Trigger SessionStart hook
    ContextHook->>ContextHook: Generate/reuse session_id
    ContextHook->>Worker: Health check (max 10s retry)

    alt Worker Ready
        ContextHook->>Worker: GET /api/context/inject?project=X
        Worker->>DB: SELECT * FROM observations<br/>WHERE project=X<br/>ORDER BY created_at DESC<br/>LIMIT 50
        DB-->>Worker: Last 50 observations
        Worker-->>ContextHook: Context markdown
        ContextHook-->>IDE: hookSpecificOutput.additionalContext
        IDE->>IDE: Inject context to Claude's prompt
        IDE-->>User: Session ready with context
    else Worker Not Ready
        ContextHook-->>IDE: Empty context (graceful degradation)
        IDE-->>User: Session ready without context
    end

    Note over User,DB: Total time: <300ms (with health check)

上下文钩子(claude-mem hook claude-code context){#context-hook-claude-mem-hook-claude-code-context}

用途:将先前会话的上下文注入 Claude 的初始上下文。

输入(通过 stdin):

{
  "session_id": "claude-session-123",
  "cwd": "/path/to/project",
  "source": "startup"
}

处理过程: 1. 等待 worker 可用(健康检查,最多 10 秒) 2. 调用:GET http://127.0.0.1:<worker-port>/api/context/inject?project={project} 3. 将格式化上下文作为 hookSpecificOutput 中的 additionalContext 返回

输出(通过 stdout):

{
  "hookSpecificOutput": {
    "hookEventName": "SessionStart",
    "additionalContext": "<<formatted context markdown>>"
  }
}

实现:src/cli/handlers/context.ts

阶段 2:UserPromptSubmit

时机:当用户在会话中提交任何提示时

挂钩:claude-mem hook claude-code session-init

序列图

sequenceDiagram
    participant User
    participant IDE as IDE/Extension
    participant NewHook as new-hook.js
    participant DB as Direct SQLite Access
    participant Worker as Worker Service

    User->>IDE: Submits prompt: "Add login feature"
    IDE->>NewHook: Trigger UserPromptSubmit<br/>{ session_id, cwd, prompt }

    NewHook->>NewHook: Extract project = basename(cwd)
    NewHook->>NewHook: Strip privacy tags<br/>&lt;private&gt;...&lt;/private&gt;

    alt Prompt fully private (empty after stripping)
        NewHook-->>IDE: Skip (don't save)
    else Prompt has content
        NewHook->>DB: INSERT OR IGNORE INTO sdk_sessions<br/>(claude_session_id, project, first_user_prompt)
        DB-->>NewHook: sessionDbId (new or existing)

        NewHook->>DB: UPDATE sdk_sessions<br/>SET prompt_counter = prompt_counter + 1<br/>WHERE id = sessionDbId
        DB-->>NewHook: promptNumber (e.g., 1 for first, 2 for continuation)

        NewHook->>DB: INSERT INTO user_prompts<br/>(session_id, prompt_number, prompt)

        NewHook->>Worker: POST /sessions/{sessionDbId}/init<br/>{ project, userPrompt, promptNumber }<br/>(fire-and-forget, 2s timeout)
        Worker-->>NewHook: 200 OK (or timeout)

        NewHook-->>IDE: { continue: true, suppressOutput: true }
        IDE-->>User: Prompt accepted
    end

    Note over NewHook,DB: Idempotent: Same session_id → same sessionDbId

关键模式: INSERT OR IGNORE 确保相同的 session_id 始终映射到相同的 sessionDbId,从而实现对话的连续。

输入(通过 stdin):

{
  "session_id": "claude-session-123",
  "cwd": "/path/to/project",
  "prompt": "User's actual prompt text"
}

处理步骤:

// 1. Extract project name from working directory
project = path.basename(cwd)

// 2. Create or get database session (IDEMPOTENT)
sessionDbId = db.createSDKSession(session_id, project, prompt)
// INSERT OR IGNORE: Creates new row if first prompt, returns existing if continuation

// 3. Increment prompt counter
promptNumber = db.incrementPromptCounter(sessionDbId)
// Returns 1 for first prompt, 2 for continuation, etc.

// 4. Strip privacy tags
cleanedPrompt = stripMemoryTags(prompt)
// Removes <private>...</private> and <claude-mem-context>...</claude-mem-context>

// 5. Skip if fully private
if (!cleanedPrompt || cleanedPrompt.trim() === '') {
  return  // Don't save, don't call worker
}

// 6. Save user prompt to database
db.saveUserPrompt(session_id, promptNumber, cleanedPrompt)

// 7. Initialize session via worker HTTP
POST http://127.0.0.1:<worker-port>/sessions/{sessionDbId}/init
Body: { project, userPrompt, promptNumber }

输出:

{ "continue": true, "suppressOutput": true }

实现:src/cli/handlers/session-init.ts

Note

相同的 session_id 会在对话中流经所有钩子。createSDKSession 调用是幂等的——它会返回用于后续提示的现有会话。


阶段 3:PostToolUse

时机:当 Claude 使用任何工具(Read、Bash、Grep、Write 等)之后

挂钩:claude-mem hook claude-code observation

序列图

sequenceDiagram
    participant Claude as Claude AI
    participant IDE as IDE/Extension
    participant SaveHook as save-hook.js
    participant Worker as Worker Service
    participant Agent as SDK Agent
    participant DB as SQLite + Chroma

    Claude->>IDE: Uses tool: Read("/src/auth.ts")
    IDE->>SaveHook: PostToolUse hook triggered<br/>{ session_id, tool_name, tool_input, tool_response }

    SaveHook->>SaveHook: Check skip list<br/>(TodoWrite, AskUserQuestion, etc.)

    alt Tool in skip list
        SaveHook-->>IDE: Discard (low-value tool)
    else Tool allowed
        SaveHook->>SaveHook: Strip privacy tags from input/response

        SaveHook->>SaveHook: Ensure worker running<br/>(health check)

        SaveHook->>Worker: POST /api/sessions/observations<br/>{ claudeSessionId, tool_name, tool_input, tool_response, cwd }<br/>(fire-and-forget, 2s timeout)

        SaveHook-->>IDE: { continue: true, suppressOutput: true }
        IDE-->>Claude: Tool execution complete

        Note over Worker,DB: Async path (doesn't block IDE)

        Worker->>Worker: createSDKSession(claudeSessionId)<br/>→ returns sessionDbId
        Worker->>Worker: Check if prompt was private<br/>(skip if fully private)
        Worker->>Agent: Queue observation for processing
        Agent->>Agent: Call Claude SDK to compress<br/>observation into structured format
        Agent->>DB: Save compressed observation<br/>to sdk_sessions table
        Agent->>DB: Sync to Chroma vector DB
    end

    Note over SaveHook,DB: Total sync time: ~2ms<br/>AI processing: 1-3s (async)

关键模式: 钩子在 HTTP POST 后立即返回。AI 压缩在 Worker 中异步完成,不会阻塞 Claude 的工具执行。

输入(通过 stdin):

{
  "session_id": "claude-session-123",
  "cwd": "/path/to/project",
  "tool_name": "Read",
  "tool_input": { "file_path": "/src/index.ts" },
  "tool_response": "file contents..."
}

处理步骤:

// 1. Check blocklist - skip low-value tools
const SKIP_TOOLS = {
  'ListMcpResourcesTool',  // MCP infrastructure noise
  'SlashCommand',          // Command invocation
  'Skill',                 // Skill invocation
  'TodoWrite',             // Task management meta-tool
  'AskUserQuestion'        // User interaction
}

if (SKIP_TOOLS[tool_name]) return

// 2. Ensure worker is running
await ensureWorkerRunning()

// 3. Send to worker (fire-and-forget HTTP)
POST http://127.0.0.1:<worker-port>/api/sessions/observations
Body: {
  claudeSessionId: session_id,
  tool_name,
  tool_input,
  tool_response,
  cwd
}
Timeout: 2000ms

Worker 处理过程: 1. 查找或创建会话:createSDKSession(claudeSessionId, '', '') 2. 获取提示计数器 3. 检查隐私(如果用户提示完全私有则跳过) 4. 从 tool_input 和 tool_response 中剥离记忆标签 5. 将观察结果加入队列,交给 SDK Agent 处理 6. SDK Agent 调用 Claude 将观察结果压缩为结构化格式 7. 将压缩后的观察结果存储到数据库中,并同步到 Chroma

输出:

{ "continue": true, "suppressOutput": true }

实现:src/cli/handlers/observation.ts


阶段 4:停止

时机:当用户停止或暂停提问时

Hook:claude-mem hook claude-code summarize

序列图

sequenceDiagram
    participant User
    participant IDE as IDE/Extension
    participant SummaryHook as summary-hook.js
    participant Worker as Worker Service
    participant Agent as SDK Agent
    participant DB as SQLite Database

    User->>IDE: Stops asking questions<br/>(pause, idle, or explicit stop)
    IDE->>SummaryHook: Stop hook triggered<br/>{ session_id, cwd, transcript_path }

    SummaryHook->>SummaryHook: Read transcript JSONL file
    SummaryHook->>SummaryHook: Extract last user message<br/>(type: "user")
    SummaryHook->>SummaryHook: Extract last assistant message<br/>(type: "assistant", filter &lt;system-reminder&gt;)

    SummaryHook->>Worker: POST /api/sessions/summarize<br/>{ claudeSessionId, last_user_message, last_assistant_message }<br/>(fire-and-forget, 2s timeout)

    SummaryHook-->>IDE: { continue: true, suppressOutput: true }
    IDE-->>User: Session paused/stopped

    Note over Worker,DB: Async path

    Worker->>Worker: Lookup sessionDbId from claudeSessionId
    Worker->>Agent: Queue summarization request
    Agent->>Agent: Call Claude SDK with prompt:<br/>"Summarize: request, investigated, learned, completed, next_steps"
    Agent->>Agent: Parse XML response
    Agent->>DB: INSERT INTO session_summaries<br/>{ session_id, request, investigated, learned, completed, next_steps }
    Agent->>DB: Sync to Chroma (for semantic search)

    Note over SummaryHook,DB: Total sync time: ~2ms<br/>AI summarization: 2-5s (async)

关键模式: 摘要为异步生成,不会阻塞用户恢复工作或关闭会话。

输入(通过 stdin):

{
  "session_id": "claude-session-123",
  "cwd": "/path/to/project",
  "transcript_path": "/path/to/transcript.jsonl"
}

处理步骤:

// 1. Extract last messages from transcript JSONL
const lines = fs.readFileSync(transcript_path, 'utf-8').split('\n')
// Find last user message (type: "user")
// Find last assistant message (type: "assistant", filter <system-reminder> tags)

// 2. Ensure worker is running
await ensureWorkerRunning()

// 3. Send summarization request (fire-and-forget HTTP)
POST http://127.0.0.1:<worker-port>/api/sessions/summarize
Body: {
  claudeSessionId: session_id,
  last_user_message: string,
  last_assistant_message: string
}
Timeout: 2000ms

Note

summary 钩子不会调用 POST /api/processing,该路由并不是加载动画开关。它是仅限 localhost 的运维重试机制。{ "isProcessing": false } 会为每个持有缓冲工作且没有正在运行的生成器的活动会话重启 observer。其中包括自动恢复扫描不会处理的情况,例如凭据被拒绝或传输失败。响应中的 scheduledSessions 字段报告重试了多少个会话。{ "isProcessing": true } 会被接受,但不会执行任何操作。

Worker 处理流程: 1. 将摘要任务加入 SDK Agent 的队列 2. Agent 调用 Claude 生成结构化摘要 3. 摘要存储到数据库中,字段包括:request、investigated、learned、completed、next_steps

输出:

{ "continue": true, "suppressOutput": true }

实现:src/cli/handlers/summarize.ts


阶段 5:SessionEnd

时机:当 Claude Code 会话关闭时(exit、clear、logout 等)

Hook:claude-mem hook claude-code session-end

序列图

sequenceDiagram
    participant User
    participant IDE as IDE/Extension
    participant CleanupHook as cleanup-hook.js
    participant Worker as Worker Service
    participant DB as SQLite Database
    participant SSE as SSE Clients (Viewer UI)

    User->>IDE: Closes session<br/>(exit, clear, logout)
    IDE->>CleanupHook: SessionEnd hook triggered<br/>{ session_id, cwd, transcript_path, reason }

    CleanupHook->>Worker: POST /api/sessions/complete<br/>{ claudeSessionId, reason }<br/>(fire-and-forget, 2s timeout)

    CleanupHook-->>IDE: { continue: true, suppressOutput: true }
    IDE-->>User: Session closed

    Note over Worker,SSE: Async path

    Worker->>Worker: Lookup sessionDbId from claudeSessionId
    Worker->>DB: UPDATE sdk_sessions<br/>SET status = 'completed', completed_at = NOW()<br/>WHERE claude_session_id = claudeSessionId
    Worker->>SSE: Broadcast session completion event<br/>(for live viewer UI updates)

    SSE-->>SSE: Update UI to show session as completed

    Note over CleanupHook,SSE: Total sync time: ~2ms

关键模式: 会话完成状态会被跟踪以用于分析和 UI 更新,但不会阻止用户关闭 IDE。

输入(通过 stdin):

{
  "session_id": "claude-session-123",
  "cwd": "/path/to/project",
  "transcript_path": "/path/to/transcript.jsonl",
  "reason": "exit"
}

处理步骤:

// Send session complete (fire-and-forget HTTP)
POST http://127.0.0.1:<worker-port>/api/sessions/complete
Body: {
  claudeSessionId: session_id,
  reason: string  // 'exit' | 'clear' | 'logout' | 'prompt_input_exit' | 'other'
}
Timeout: 2000ms

Worker 处理流程: 1. 根据 claudeSessionId 查找会话 2. 在数据库中将会话标记为 'completed' 3. 向 SSE 客户端广播会话完成事件

输出:

{ "continue": true, "suppressOutput": true }

实现:src/cli/handlers/session-end.ts


会话状态机

理解会话生命周期和状态转换:

stateDiagram-v2
    [*] --> Initialized: SessionStart hook<br/>(generate session_id)

    Initialized --> Active: UserPromptSubmit<br/>(first prompt)

    Active --> Active: UserPromptSubmit<br/>(continuation prompts)<br/>promptNumber++

    Active --> ObservationQueued: PostToolUse hook<br/>(tool execution captured)

    ObservationQueued --> Active: Observation processed<br/>(async, non-blocking)

    Active --> Summarizing: Stop hook<br/>(user pauses/stops)

    Summarizing --> Active: User resumes<br/>(new prompt submitted)

    Summarizing --> Completed: SessionEnd hook<br/>(session closes)

    Active --> Completed: SessionEnd hook<br/>(session closes)

    Completed --> [*]

    note right of Active
        session_id: constant (e.g., "claude-session-abc123")
        sessionDbId: constant (e.g., 42)
        promptNumber: increments (1, 2, 3, ...)
        All operations use same sessionDbId
    end note

    note right of ObservationQueued
        Fire-and-forget HTTP
        AI compression happens async
        IDE never blocks
    end note

关键要点: - session_id 在对话过程中始终保持不变 - sessionDbId 是该会话的数据库主键 - promptNumber 随每次用户提示递增 - 状态转换为非阻塞式(即发即忘模式)


数据库架构

以会话为中心的数据模型,可实现跨会话记忆:

erDiagram
    SDK_SESSIONS ||--o{ USER_PROMPTS : "has many"
    SDK_SESSIONS ||--o{ OBSERVATIONS : "has many"
    SDK_SESSIONS ||--o{ SESSION_SUMMARIES : "has many"

    SDK_SESSIONS {
        integer id PK "Auto-increment primary key"
        text claude_session_id UK "From IDE (e.g., 'claude-session-123')"
        text project "Project name from cwd basename"
        text first_user_prompt "Initial prompt that started session"
        integer prompt_counter "Increments with each UserPromptSubmit"
        text status "initialized | active | completed"
        datetime created_at
        datetime completed_at
    }

    USER_PROMPTS {
        integer id PK
        integer session_id FK "References SDK_SESSIONS.id"
        integer prompt_number "1, 2, 3, ... matches prompt_counter"
        text prompt "User's actual prompt (tags stripped)"
        datetime created_at
    }

    OBSERVATIONS {
        integer id PK
        integer session_id FK "References SDK_SESSIONS.id"
        integer prompt_number "Which prompt this observation belongs to"
        text tool_name "Read, Bash, Grep, Write, etc."
        text tool_input_json "Stripped of privacy tags"
        text tool_response_text "Stripped of privacy tags"
        text compressed_observation "AI-generated structured observation"
        datetime created_at
    }

    SESSION_SUMMARIES {
        integer id PK
        integer session_id FK "References SDK_SESSIONS.id"
        text request "What user requested"
        text investigated "What was explored"
        text learned "What was discovered"
        text completed "What was accomplished"
        text next_steps "What remains to be done"
        datetime created_at
    }

幂等性模式:

-- This ensures same session_id always maps to same sessionDbId
INSERT OR IGNORE INTO sdk_sessions (claude_session_id, project, first_user_prompt)
VALUES (?, ?, ?)
RETURNING id;

-- If already exists, returns existing row
-- If new, creates and returns new row

外键级联:

所有子表(user_prompts、observations、session_summaries)都使用 session_id 外键引用 SDK_SESSIONS.id。这可以确保: - 一个会话的所有数据均可通过 sessionDbId 查询 - 会话删除会级联到子表 - 为上下文注入提供高效的连接操作

Warning

绝不要自行生成会话 ID。务必使用 IDE 提供的 session_id——这是将所有数据关联在一起的唯一事实来源。


隐私与标签剥离

双标签系统

// User-Level Privacy Control (manual)
<private>sensitive data</private>

// System-Level Recursion Prevention (auto-injected)
<claude-mem-context>...</claude-mem-context>

处理流水线

位置:src/utils/tag-stripping.ts

// Called by: new-hook.js (user prompts) and save-hook.js (tool_input, tool_response)
stripMemoryTags(content: string): string

执行顺序(边缘处理): 1. new-hook.js 在保存前从用户提示中剥离标签 2. save-hook.js 在发送给 worker 前从工具数据中剥离标签 3. worker 在存储前再次剥离标签(纵深防御)


SDK Agent 处理

查询循环(事件驱动)

位置:src/services/worker/SDKAgent.ts

async startSession(session: ActiveSession, worker?: any) {
  // 1. Create event-driven message generator
  const messageGenerator = this.createMessageGenerator(session)

  // 2. Run Agent SDK query loop
  const queryResult = query({
    prompt: messageGenerator,
    options: {
      model: 'claude-sonnet-5',
      disallowedTools: ['Bash', 'Read', 'Write', ...],  // Observer-only
      abortController: session.abortController
    }
  })

  // 3. Process responses
  for await (const message of queryResult) {
    if (message.type === 'assistant') {
      await this.processSDKResponse(session, text, worker)
    }
  }
}

消息类型

消息生成器会产生三种类型的提示:

  1. 初始提示(第 1 次提示):启动观察的完整指令
  2. 继续提示(第 2 次及之后):仅包含继续工作的上下文
  3. 观察提示:用于压缩为观察结果的工具使用数据
  4. 摘要提示:需要汇总的会话数据

实现清单

对于在其他平台上实现此模式的开发者:

Hook 注册

  • [ ] 在平台配置中定义 Hook 入口点
  • [ ] 5 种 Hook 类型:SessionStart(2 个 Hook)、UserPromptSubmit、PostToolUse、Stop、SessionEnd
  • [ ] 当宿主支持时,优先使用 exec 形式的 Hook 条目(command + args,不使用 shell)
  • [ ] 传入 session_id、cwd 及上下文相关的数据

数据库架构

  • [ ] 启用 WAL 模式的 SQLite
  • [ ] 4 个主表:sdk_sessions、user_prompts、observations、session_summaries
  • [ ] 为常见查询建立索引

Worker Service

  • [ ] 在配置的 worker 端口上运行 HTTP 服务器
  • [ ] 使用 Bun 运行时进行进程管理
  • [ ] 3 个核心服务:SessionManager、SDKAgent、DatabaseManager

Hook 实现

  • [ ] context-hook:GET /api/context/inject(带健康检查)
  • [ ] new-hook:createSDKSession、saveUserPrompt、POST /sessions/{id}/init
  • [ ] save-hook:跳过低价值工具,POST /api/sessions/observations
  • [ ] summary-hook:解析转录文本,POST /api/sessions/summarize
  • [ ] cleanup-hook:POST /api/sessions/complete

隐私与标签

  • [ ] 实现 stripMemoryTags()
  • [ ] 在 Hook 层处理标签(边缘处理)
  • [ ] 最大标签数为 100(ReDoS 防护)

SDK 集成

  • [ ] 调用 Claude Agent SDK 处理观察结果/摘要
  • [ ] 解析 XML 响应以获取结构化数据
  • [ ] 存储到数据库 + 同步到向量数据库

核心设计原则

  1. Session ID 是唯一真相来源:绝不自行生成 Session ID
  2. 幂等数据库操作:使用 INSERT OR IGNORE 创建会话
  3. 边缘处理保障隐私:在 Hook 层、数据到达 Worker 之前移除标签
  4. Fire-and-Forget 非阻塞模式:HTTP 超时防止阻塞 IDE
  5. 事件驱动而非轮询:向 SDK Agent 发送零延迟队列通知
  6. 始终保存一切:不存在“孤儿”会话

常见陷阱

问题 根本原因 解决方案
Session ID 不匹配 不同 Hook 中使用了不同的 session_id 始终使用来自 Hook 输入的 ID
重复会话 创建新会话而不是使用现有会话 使用 INSERT OR IGNORE,以 session_id 作为键
阻塞 IDE 等待完整响应 使用短超时的 Fire-and-Forget
数据库中存在 Memory 标签 在错误的层移除标签 在 Hook 层、发送 HTTP 之前移除标签
找不到 Worker 健康检查过快 添加带指数退避的重试循环

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 cl/claude-mem