Claude Code 源码分析Claude Code 源码分析
首页
源码统计
系统架构
UML 图表
工具系统
CodeGraph
首页
源码统计
系统架构
UML 图表
工具系统
CodeGraph
  • 概览

    • Claude Code 源码分析
    • 源码统计
    • CodeGraph 图谱
  • 架构

    • 系统架构
    • UML 图表索引
    • 查询引擎
    • 核心流程
    • 消息系统
    • 状态管理
  • 功能模块

    • 工具系统
    • 斜杠命令
    • 服务层
    • MCP 协议
    • Skills 技能
    • 子代理系统
  • 分层深度

    • 入口层
    • UI / Ink 层
    • utils 基础设施
    • 桥接 / 远程
    • 上下文压缩
  • 原理与安全

    • 底层原理
    • 技术难点
    • 权限与安全
    • 内部机制
    • 遥测与分析
  • 深度专题

    • Hooks 系统
    • 插件系统
    • 记忆系统
    • API 通信层
    • Ink 终端 UI
    • 认证系统
    • 构建与发布
    • 术语表
  • 调用分析

    • 调用链分析
    • 核心文件索引
  • 模块详解

    • utils

      • 模块: utils
      • messages · 消息工厂与规范化
      • session-storage · JSONL 会话持久化
      • permissions · 工具权限决策
      • shell-hooks · 用户 Shell Hook 系统
    • components

      • 模块: components
      • REPL · 主屏编排
      • messages · 消息行渲染
      • PermissionRequest · 权限弹窗
      • PromptInput · 底部输入
    • services

      • 模块: services
      • api-claude · Anthropic API 流式与重试
      • mcp-client · MCP 连接与工具调用
      • compact · 上下文压缩与自动触发
      • analytics · GrowthBook、Datadog 与 1P 事件
    • tools

      • 模块: tools
      • tool-interface · Tool 契约与注册表
      • bash-tool · Shell 执行与权限
      • streaming-executor · 流式工具并发调度
      • agent-tool · 子 Agent 委派
    • commands

      • 模块: commands
      • command-registry · commands.ts 注册与分派
      • model-command · /model 模型选择
      • mcp-commands · /mcp 服务器管理
      • compact-memory-commands · /compact 与 /memory
    • ink

      • 模块: ink
      • Ink 渲染管线 · Screen 与终端输出
      • 终端事件 · resize、paste、stdin
      • Ink Hooks · 输入、搜索、终端状态
      • Ink 组件 · Box、Text、ScrollBox 原语
    • hooks

      • 模块: hooks
      • useCanUseTool · 权限 UI 接缝
      • 输入与快捷键 Hook
      • 合并态 Hook(MCP + 本地)
      • notifs 通知 Hook
    • bridge

      • 模块: bridge
      • repl-bridge · REPL 桥初始化与传输
      • bridge-messaging · 桥消息路由与入站处理
      • remote-bridge-core · env-less 核心与守护主循环
      • bridge-permissions-ui · 权限、API 与 TUI
    • cli

      • 模块: cli
      • Structured IO · NDJSON SDK 协议
      • CLI Transports · Session Ingress 传输层
      • CLI Handlers · 子命令懒加载实现
      • Update & Upload · 自更新与串行上传原语
    • screens

      • 模块: screens
      • REPL 屏 · Screen 类型与顶层路由
      • ResumeConversation · 会话恢复选择器
      • Doctor · 安装诊断全屏
    • entrypoints

      • 模块: entrypoints
      • cli-entrypoint · Bootstrap 与快路径
      • sdk-types · core / control / runtime 类型体系
      • mcp-entrypoint · MCP stdio 服务器
      • sandbox-types · 沙箱配置单一真相源
    • skills

      • 模块: skills
      • skills-loading · 磁盘加载与 bundled 注册表
      • bundled-skills · 内置 skill 与 initBundledSkills
      • mcp-skills · MCP prompt 转 skill
      • skill-tool-integration · SkillTool 与命令注册
    • types

      • 模块: types
      • message-types · Message 联合与 content blocks
      • tool-permission-types · Tool、Permission、Command 类型
      • api-sdk-types · API 与 Hooks 协议类型
      • misc-types · ids、plugin、generated 与其余类型
    • tasks

      • 模块: tasks
      • local-agent-task · 本地 Agent 与主会话后台化
      • remote-agent-task · 远程 CCR 与 In-Process Teammate
      • shell-workflow-tasks · Bash 后台、Workflow 与 stopTask
      • dream-monitor-tasks · Dream、Monitor MCP 与 pill 文案
    • keybindings

      • 模块: keybindings
      • keybinding-registry · 注册、Provider 与 useKeybinding
      • default-bindings · 默认键位表与平台差异
      • command-bindings · command:* 动态斜杠命令绑定
      • vim-bindings · Vim 模式与 keybindings 边界
    • memdir

      • 模块: memdir
      • memdir-core · 路径、加载与 MEMORY.md
      • memory-extraction · extractMemories 与 SessionMemory
      • memdir-commands · /memory、/remember 与命令集成
    • state

      • 模块: state
      • app-state-core · store、AppState 类型与 Provider
      • app-state-selectors · selectors 与 onChangeAppState
      • teammate-state · 队友视图与 swarm 状态
      • state-boundaries · bootstrap、sessionStorage、FileStateCache
    • query

      • 模块: query
      • query config 与 deps · 配置快照与依赖注入
      • query tokenBudget · +500k 自动续跑
      • query transitions · Continue / Terminal 状态机
      • query stopHooks · Stop 事件与 turn 结束编排
  • 模块详解(扩展)

    • messages · 消息工厂与规范化
    • session-storage · JSONL 会话持久化
    • permissions · 工具权限决策
    • shell-hooks · 用户 Shell Hook 系统
    • REPL · 主屏编排
    • messages · 消息行渲染
    • PermissionRequest · 权限弹窗
    • PromptInput · 底部输入
    • api-claude · Anthropic API 流式与重试
    • mcp-client · MCP 连接与工具调用
    • compact · 上下文压缩与自动触发
    • analytics · GrowthBook、Datadog 与 1P 事件
    • tool-interface · Tool 契约与注册表
    • bash-tool · Shell 执行与权限
    • streaming-executor · 流式工具并发调度
    • agent-tool · 子 Agent 委派
    • command-registry · commands.ts 注册与分派
    • model-command · /model 模型选择
    • mcp-commands · /mcp 服务器管理
    • compact-memory-commands · /compact 与 /memory
    • Ink 渲染管线 · Screen 与终端输出
    • 终端事件 · resize、paste、stdin
    • Ink Hooks · 输入、搜索、终端状态
    • Ink 组件 · Box、Text、ScrollBox 原语
    • useCanUseTool · 权限 UI 接缝
    • 输入与快捷键 Hook
    • 合并态 Hook(MCP + 本地)
    • notifs 通知 Hook
    • repl-bridge · REPL 桥初始化与传输
    • bridge-messaging · 桥消息路由与入站处理
    • remote-bridge-core · env-less 核心与守护主循环
    • bridge-permissions-ui · 权限、API 与 TUI
    • Structured IO · NDJSON SDK 协议
    • CLI Transports · Session Ingress 传输层
    • CLI Handlers · 子命令懒加载实现
    • Update & Upload · 自更新与串行上传原语
    • REPL 屏 · Screen 类型与顶层路由
    • ResumeConversation · 会话恢复选择器
    • Doctor · 安装诊断全屏
    • cli-entrypoint · Bootstrap 与快路径
    • sdk-types · core / control / runtime 类型体系
    • mcp-entrypoint · MCP stdio 服务器
    • sandbox-types · 沙箱配置单一真相源
    • skills-loading · 磁盘加载与 bundled 注册表
    • bundled-skills · 内置 skill 与 initBundledSkills
    • mcp-skills · MCP prompt 转 skill
    • skill-tool-integration · SkillTool 与命令注册
    • message-types · Message 联合与 content blocks
    • tool-permission-types · Tool、Permission、Command 类型
    • api-sdk-types · API 与 Hooks 协议类型
    • misc-types · ids、plugin、generated 与其余类型
    • local-agent-task · 本地 Agent 与主会话后台化
    • remote-agent-task · 远程 CCR 与 In-Process Teammate
    • shell-workflow-tasks · Bash 后台、Workflow 与 stopTask
    • dream-monitor-tasks · Dream、Monitor MCP 与 pill 文案
    • keybinding-registry · 注册、Provider 与 useKeybinding
    • default-bindings · 默认键位表与平台差异
    • command-bindings · command:* 动态斜杠命令绑定
    • vim-bindings · Vim 模式与 keybindings 边界
    • memdir-core · 路径、加载与 MEMORY.md
    • memory-extraction · extractMemories 与 SessionMemory
    • memdir-commands · /memory、/remember 与命令集成
    • app-state-core · store、AppState 类型与 Provider
    • app-state-selectors · selectors 与 onChangeAppState
    • teammate-state · 队友视图与 swarm 状态
    • state-boundaries · bootstrap、sessionStorage、FileStateCache
    • query config 与 deps · 配置快照与依赖注入
    • query tokenBudget · +500k 自动续跑
    • query transitions · Continue / Terminal 状态机
    • query stopHooks · Stop 事件与 turn 结束编排
  • 工具详解

    • tool-interface · Tool 契约与注册表
    • tool-permission-types · Tool、Permission、Command 类型
    • 工具: Bash
    • 工具: PowerShell
    • 工具: Agent
    • 工具: LSP
    • 工具: FileEdit
    • 工具: FileRead
    • 工具: Skill
    • 工具: WebFetch
    • 工具: MCP
    • 工具: SendMessage
    • 工具: FileWrite
    • 工具: Config
    • 工具: Grep
    • 工具: Brief
    • 工具: ExitPlanMode
    • 工具: ToolSearch
    • 工具: NotebookEdit
    • 工具: TaskOutput
    • 工具: WebSearch
    • 工具: ScheduleCron

本章总览

先看职责:memory-extraction 负责在回合结束后做“后台补记忆”,把 transcript 中可长期复用的信息落到 memdir 文件系统。它通过 stop hook 触发、scan 清单预热、forked agent 写入与互斥跳过机制,和主 agent 的即时写入形成互补。

学完本章你应该能

  • 描述 extractMemories 触发时机(stopHooks)
  • 理解 runForkedAgent 与 cache-safe params
  • 说明 hasMemoryWritesSince 跳过逻辑
  • 知道 scanMemoryFiles 如何避免 extract agent ls 浪费 turn
  • 列举 extract 可用 tool 白名单思路
  • 理解 stash/trailing extraction 并发场景

核心概念(先读懂这些)

主 agent 与 extract agent 分工

paths.ts 注释:主 agent prompt 始终含完整 save 指令;主 agent 写了 memory 则 extract 跳过该 uuid 区间;主 agent 没写则 extract 补漏。不是二选一 feature,而是互补双轨。

memoryScan 破 cycle

memoryScan.ts 从 findRelevantMemories 拆出,避免 extractMemories import findRelevantMemories → sideQuery → memdir 循环 (#25372)。scan 仅 fs + frontmatter,无 API client。

Closure-scoped state

initExtractMemories 返回 { executeExtractMemories, drainPendingExtraction, ... },内部 mutable lastExtractUuid、inProgress flag。与 confidenceRating 同模式,避免 module-level 测试污染。

建议学习步骤

  1. 阅读 extractMemories.ts 文件头注释
  2. 跟踪 executeExtractMemories 主流程
  3. 阅读 hasMemoryWritesSince 与 FileWrite 检测
  4. 查看 prompts buildExtractCombinedPrompt
  5. 阅读 stopHooks handleStopHooks 分支
  6. 查看 utils/messages createMemorySavedMessage

常见误区

注意

feature EXTRACT_MEMORIES 必须 call site if feature() 直接判断

注意

isExtractModeActive 不含 feature check

注意

extract subagent 不 record transcript

注意

team memory 路径 feature TEAMMEM require

extractMemories 架构

services/extractMemories/extractMemories.ts:

stopHooks / print exit
  → isAutoMemoryEnabled && feature(EXTRACT_MEMORIES) && isExtractModeActive
  → executeExtractMemories(messages, sinceUuid, ...)
  → hasMemoryWritesSince? skip
  → scanMemoryFiles + formatMemoryManifest 注入 prompt
  → runForkedAgent(extract prompt, limited tools)
  → writtenPaths → createMemorySavedMessage / logEvent

使用 ENTRYPOINT_NAME、getAutoMemPath、isAutoMemPath 来自 memdir/。

源码引用: src/services/extractMemories/extractMemories.ts · 第 1–30 行(共 616 行)

   1| /**
   2|  * Extracts durable memories from the current session transcript
   3|  * and writes them to the auto-memory directory (~/.claude/projects/<path>/memory/).
   4|  *
   5|  * It runs once at the end of each complete query loop (when the model produces
   6|  * a final response with no tool calls) via handleStopHooks in stopHooks.ts.
   7|  *
   8|  * Uses the forked agent pattern (runForkedAgent) — a perfect fork of the main
   9|  * conversation that shares the parent's prompt cache.
  10|  *
  11|  * State is closure-scoped inside initExtractMemories() rather than module-level,
  12|  * following the same pattern as confidenceRating.ts. Tests call
  13|  * initExtractMemories() in beforeEach to get a fresh closure.
  14|  */
  15| 
  16| import { feature } from 'bun:bundle'
  17| import { basename } from 'path'
  18| import { getIsRemoteMode } from '../../bootstrap/state.js'
  19| import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
  20| import { ENTRYPOINT_NAME } from '../../memdir/memdir.js'
  21| import {
  22|   formatMemoryManifest,
  23|   scanMemoryFiles,
  24| } from '../../memdir/memoryScan.js'
  25| import {
  26|   getAutoMemPath,
  27|   isAutoMemoryEnabled,
  28|   isAutoMemPath,
  29| } from '../../memdir/paths.js'
  30| import type { Tool } from '../../Tool.js'

源码引用: src/services/extractMemories/extractMemories.ts · 第 330–400 行(共 616 行)

 330|     context,
 331|     appendSystemMessage,
 332|     isTrailingRun,
 333|   }: {
 334|     context: REPLHookContext
 335|     appendSystemMessage?: AppendSystemMessageFn
 336|     isTrailingRun?: boolean
 337|   }): Promise<void> {
 338|     const { messages } = context
 339|     const memoryDir = getAutoMemPath()
 340|     const newMessageCount = countModelVisibleMessagesSince(
 341|       messages,
 342|       lastMemoryMessageUuid,
 343|     )
 344| 
 345|     // Mutual exclusion: when the main agent wrote memories, skip the
 346|     // forked agent and advance the cursor past this range so the next
 347|     // extraction only considers messages after the main agent's write.
 348|     if (hasMemoryWritesSince(messages, lastMemoryMessageUuid)) {
 349|       logForDebugging(
 350|         '[extractMemories] skipping — conversation already wrote to memory files',
 351|       )
 352|       const lastMessage = messages.at(-1)
 353|       if (lastMessage?.uuid) {
 354|         lastMemoryMessageUuid = lastMessage.uuid
 355|       }
 356|       logEvent('tengu_extract_memories_skipped_direct_write', {
 357|         message_count: newMessageCount,
 358|       })
 359|       return
 360|     }
 361| 
 362|     const teamMemoryEnabled = feature('TEAMMEM')
 363|       ? teamMemPaths!.isTeamMemoryEnabled()
 364|       : false
 365| 
 366|     const skipIndex = getFeatureValue_CACHED_MAY_BE_STALE(
 367|       'tengu_moth_copse',
 368|       false,
 369|     )
 370| 
 371|     const canUseTool = createAutoMemCanUseTool(memoryDir)
 372|     const cacheSafeParams = createCacheSafeParams(context)
 373| 
 374|     // Only run extraction every N eligible turns (tengu_bramble_lintel, default 1).
 375|     // Trailing extractions (from stashed contexts) skip this check since they
 376|     // process already-committed work that should not be throttled.
 377|     if (!isTrailingRun) {
 378|       turnsSinceLastExtraction++
 379|       if (
 380|         turnsSinceLastExtraction <
 381|         (getFeatureValue_CACHED_MAY_BE_STALE('tengu_bramble_lintel', null) ?? 1)
 382|       ) {
 383|         return
 384|       }
 385|     }
 386|     turnsSinceLastExtraction = 0
 387| 
 388|     inProgress = true
 389|     const startTime = Date.now()
 390|     try {
 391|       logForDebugging(
 392|         `[extractMemories] starting — ${newMessageCount} new messages, memoryDir=${memoryDir}`,
 393|       )
 394| 
 395|       // Pre-inject the memory directory manifest so the agent doesn't spend
 396|       // a turn on `ls`. Reuses findRelevantMemories' frontmatter scan.
 397|       // Placed after the throttle gate so skipped turns don't pay the scan cost.
 398|       const existingMemories = formatMemoryManifest(
 399|         await scanMemoryFiles(memoryDir, createAbortController().signal),
 400|       )

stopHooks 触发点

query/stopHooks.ts 在 model 产出 final response(无 tool calls)后运行 stop hooks,并 void executeExtractMemories(...)。

条件链:isExtractModeActive()、feature gate、非 abort。传入 newMessageCount、memoryDir、appendSystemMessage 回调。

与 compact、confidence rating 等 stop hook 并列——extract 是 fire-and-forget async。

源码引用: src/query/stopHooks.ts · 第 1–50 行(共 474 行)

   1| import { feature } from 'bun:bundle'
   2| import { getShortcutDisplay } from '../keybindings/shortcutFormat.js'
   3| import { isExtractModeActive } from '../memdir/paths.js'
   4| import {
   5|   type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
   6|   logEvent,
   7| } from '../services/analytics/index.js'
   8| import type { ToolUseContext } from '../Tool.js'
   9| import type { HookProgress } from '../types/hooks.js'
  10| import type {
  11|   AssistantMessage,
  12|   Message,
  13|   RequestStartEvent,
  14|   StopHookInfo,
  15|   StreamEvent,
  16|   TombstoneMessage,
  17|   ToolUseSummaryMessage,
  18| } from '../types/message.js'
  19| import { createAttachmentMessage } from '../utils/attachments.js'
  20| import { logForDebugging } from '../utils/debug.js'
  21| import { errorMessage } from '../utils/errors.js'
  22| import type { REPLHookContext } from '../utils/hooks/postSamplingHooks.js'
  23| import {
  24|   executeStopHooks,
  25|   executeTaskCompletedHooks,
  26|   executeTeammateIdleHooks,
  27|   getStopHookMessage,
  28|   getTaskCompletedHookMessage,
  29|   getTeammateIdleHookMessage,
  30| } from '../utils/hooks.js'
  31| import {
  32|   createStopHookSummaryMessage,
  33|   createSystemMessage,
  34|   createUserInterruptionMessage,
  35|   createUserMessage,
  36| } from '../utils/messages.js'
  37| import type { SystemPrompt } from '../utils/systemPromptType.js'
  38| import { getTaskListId, listTasks } from '../utils/tasks.js'
  39| import { getAgentName, getTeamName, isTeammate } from '../utils/teammate.js'
  40| 
  41| /* eslint-disable @typescript-eslint/no-require-imports */
  42| const extractMemoriesModule = feature('EXTRACT_MEMORIES')
  43|   ? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js'))
  44|   : null
  45| const jobClassifierModule = feature('TEMPLATES')
  46|   ? (require('../jobs/classifier.js') as typeof import('../jobs/classifier.js'))
  47|   : null
  48| 
  49| /* eslint-enable @typescript-eslint/no-require-imports */
  50| 

源码引用: src/query/stopHooks.ts · 第 140–160 行(共 474 行)

 140|     }
 141|     if (
 142|       feature('EXTRACT_MEMORIES') &&
 143|       !toolUseContext.agentId &&
 144|       isExtractModeActive()
 145|     ) {
 146|       // Fire-and-forget in both interactive and non-interactive. For -p/SDK,
 147|       // print.ts drains the in-flight promise after flushing the response
 148|       // but before gracefulShutdownSync (see drainPendingExtraction).
 149|       void extractMemoriesModule!.executeExtractMemories(
 150|         stopHookContext,
 151|         toolUseContext.appendSystemMessage,
 152|       )
 153|     }
 154|     if (!toolUseContext.agentId) {
 155|       void executeAutoDream(stopHookContext, toolUseContext.appendSystemMessage)
 156|     }
 157|   }
 158| 
 159|   // chicago MCP: auto-unhide + lock release at turn end.
 160|   // Main thread only — the CU lock is a process-wide module-level variable,

hasMemoryWritesSince 互斥

检测 transcript 中主 agent 是否已用 Write/Edit 写 isAutoMemPath 下文件。若 true,log skipping 并 return——避免 duplicate 记忆。

prompts.ts 注释:extract 跳过已有 memory writes 的 turn;主 agent prompt 仍鼓励实时 save。

Tool 名常量:FILE_WRITE、FILE_EDIT、BASH 等来自各 Tool prompt.js。

源码引用: src/services/extractMemories/extractMemories.ts · 第 160–220 行(共 616 行)

 160|     behavior: 'deny' as const,
 161|     message: reason,
 162|     decisionReason: { type: 'other' as const, reason },
 163|   }
 164| }
 165| 
 166| /**
 167|  * Creates a canUseTool function that allows Read/Grep/Glob (unrestricted),
 168|  * read-only Bash commands, and Edit/Write only for paths within the
 169|  * auto-memory directory. Shared by extractMemories and autoDream.
 170|  */
 171| export function createAutoMemCanUseTool(memoryDir: string): CanUseToolFn {
 172|   return async (tool: Tool, input: Record<string, unknown>) => {
 173|     // Allow REPL — when REPL mode is enabled (ant-default), primitive tools
 174|     // are hidden from the tool list so the forked agent calls REPL instead.
 175|     // REPL's VM context re-invokes this canUseTool for each inner primitive
 176|     // (toolWrappers.ts createToolWrapper), so the Read/Bash/Edit/Write checks
 177|     // below still gate the actual file and shell operations. Giving the fork a
 178|     // different tool list would break prompt cache sharing (tools are part of
 179|     // the cache key — see CacheSafeParams in forkedAgent.ts).
 180|     if (tool.name === REPL_TOOL_NAME) {
 181|       return { behavior: 'allow' as const, updatedInput: input }
 182|     }
 183| 
 184|     // Allow Read/Grep/Glob unrestricted — all inherently read-only
 185|     if (
 186|       tool.name === FILE_READ_TOOL_NAME ||
 187|       tool.name === GREP_TOOL_NAME ||
 188|       tool.name === GLOB_TOOL_NAME
 189|     ) {
 190|       return { behavior: 'allow' as const, updatedInput: input }
 191|     }
 192| 
 193|     // Allow Bash only for commands that pass BashTool.isReadOnly.
 194|     // `tool` IS BashTool here — no static import needed.
 195|     if (tool.name === BASH_TOOL_NAME) {
 196|       const parsed = tool.inputSchema.safeParse(input)
 197|       if (parsed.success && tool.isReadOnly(parsed.data)) {
 198|         return { behavior: 'allow' as const, updatedInput: input }
 199|       }
 200|       return denyAutoMemTool(
 201|         tool,
 202|         'Only read-only shell commands are permitted in this context (ls, find, grep, cat, stat, wc, head, tail, and similar)',
 203|       )
 204|     }
 205| 
 206|     if (
 207|       (tool.name === FILE_EDIT_TOOL_NAME ||
 208|         tool.name === FILE_WRITE_TOOL_NAME) &&
 209|       'file_path' in input
 210|     ) {
 211|       const filePath = input.file_path
 212|       if (typeof filePath === 'string' && isAutoMemPath(filePath)) {
 213|         return { behavior: 'allow' as const, updatedInput: input }
 214|       }
 215|     }
 216| 
 217|     return denyAutoMemTool(
 218|       tool,
 219|       `only ${FILE_READ_TOOL_NAME}, ${GREP_TOOL_NAME}, ${GLOB_TOOL_NAME}, read-only ${BASH_TOOL_NAME}, and ${FILE_EDIT_TOOL_NAME}/${FILE_WRITE_TOOL_NAME} within ${memoryDir} are allowed`,
 220|     )

源码引用: src/services/extractMemories/prompts.ts · 第 1–30 行(共 155 行)

   1| /**
   2|  * Prompt templates for the background memory extraction agent.
   3|  *
   4|  * The extraction agent runs as a perfect fork of the main conversation — same
   5|  * system prompt, same message prefix. The main agent's system prompt always
   6|  * has full save instructions; when the main agent writes memories itself,
   7|  * extractMemories.ts skips that turn (hasMemoryWritesSince). This prompt
   8|  * fires only when the main agent didn't write, so the save-criteria here
   9|  * overlap the system prompt's harmlessly.
  10|  */
  11| 
  12| import { feature } from 'bun:bundle'
  13| import {
  14|   MEMORY_FRONTMATTER_EXAMPLE,
  15|   TYPES_SECTION_COMBINED,
  16|   TYPES_SECTION_INDIVIDUAL,
  17|   WHAT_NOT_TO_SAVE_SECTION,
  18| } from '../../memdir/memoryTypes.js'
  19| import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
  20| import { FILE_EDIT_TOOL_NAME } from '../../tools/FileEditTool/constants.js'
  21| import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js'
  22| import { FILE_WRITE_TOOL_NAME } from '../../tools/FileWriteTool/prompt.js'
  23| import { GLOB_TOOL_NAME } from '../../tools/GlobTool/prompt.js'
  24| import { GREP_TOOL_NAME } from '../../tools/GrepTool/prompt.js'
  25| 
  26| /**
  27|  * Shared opener for both extract-prompt variants.
  28|  */
  29| function opener(newMessageCount: number, existingMemories: string): string {
  30|   const manifest =

memoryScan 原语

scanMemoryFiles(memoryDir, signal):

  • readdir recursive,filter .md 排除 MEMORY.md
  • readFileInRange 前 30 行 frontmatter
  • parseMemoryType、description
  • sort mtime desc,cap MAX_MEMORY_FILES=200

formatMemoryManifest 一行一文件供 extract prompt 预加载。

extractMemories 与 findRelevantMemories 共用 scan,不共用 sideQuery 选文件逻辑。

源码引用: src/memdir/memoryScan.ts · 第 21–77 行(共 95 行)

  21| const MAX_MEMORY_FILES = 200
  22| const FRONTMATTER_MAX_LINES = 30
  23| 
  24| /**
  25|  * Scan a memory directory for .md files, read their frontmatter, and return
  26|  * a header list sorted newest-first (capped at MAX_MEMORY_FILES). Shared by
  27|  * findRelevantMemories (query-time recall) and extractMemories (pre-injects
  28|  * the listing so the extraction agent doesn't spend a turn on `ls`).
  29|  *
  30|  * Single-pass: readFileInRange stats internally and returns mtimeMs, so we
  31|  * read-then-sort rather than stat-sort-read. For the common case (N ≤ 200)
  32|  * this halves syscalls vs a separate stat round; for large N we read a few
  33|  * extra small files but still avoid the double-stat on the surviving 200.
  34|  */
  35| export async function scanMemoryFiles(
  36|   memoryDir: string,
  37|   signal: AbortSignal,
  38| ): Promise<MemoryHeader[]> {
  39|   try {
  40|     const entries = await readdir(memoryDir, { recursive: true })
  41|     const mdFiles = entries.filter(
  42|       f => f.endsWith('.md') && basename(f) !== 'MEMORY.md',
  43|     )
  44| 
  45|     const headerResults = await Promise.allSettled(
  46|       mdFiles.map(async (relativePath): Promise<MemoryHeader> => {
  47|         const filePath = join(memoryDir, relativePath)
  48|         const { content, mtimeMs } = await readFileInRange(
  49|           filePath,
  50|           0,
  51|           FRONTMATTER_MAX_LINES,
  52|           undefined,
  53|           signal,
  54|         )
  55|         const { frontmatter } = parseFrontmatter(content, filePath)
  56|         return {
  57|           filename: relativePath,
  58|           filePath,
  59|           mtimeMs,
  60|           description: frontmatter.description || null,
  61|           type: parseMemoryType(frontmatter.type),
  62|         }
  63|       }),
  64|     )
  65| 
  66|     return headerResults
  67|       .filter(
  68|         (r): r is PromiseFulfilledResult<MemoryHeader> =>
  69|           r.status === 'fulfilled',
  70|       )
  71|       .map(r => r.value)
  72|       .sort((a, b) => b.mtimeMs - a.mtimeMs)
  73|       .slice(0, MAX_MEMORY_FILES)
  74|   } catch {
  75|     return []
  76|   }
  77| }

源码引用: src/memdir/memoryScan.ts · 第 79–95 行(共 95 行)

  79| /**
  80|  * Format memory headers as a text manifest: one line per file with
  81|  * [type] filename (timestamp): description. Used by both the recall
  82|  * selector prompt and the extraction-agent prompt.
  83|  */
  84| export function formatMemoryManifest(memories: MemoryHeader[]): string {
  85|   return memories
  86|     .map(m => {
  87|       const tag = m.type ? `[${m.type}] ` : ''
  88|       const ts = new Date(m.mtimeMs).toISOString()
  89|       return m.description
  90|         ? `- ${tag}${m.filename} (${ts}): ${m.description}`
  91|         : `- ${tag}${m.filename} (${ts})`
  92|     })
  93|     .join('\n')
  94| }
  95| 

SessionMemory 系统消息

提取或主 agent 写入成功后,createMemorySavedMessage(utils/messages.ts)生成 SystemMemorySavedMessage 进 transcript,UI 显示 MemoryUpdateNotification。

appendSystemMessage 回调由 REPL 传入 extract 闭包,将消息 insert 为 isMeta 或 system subtype memory_saved。

用户可见「记忆已保存」与相对路径 getRelativeMemoryPath。

源码引用: src/utils/messages.ts · 第 200–250 行(共 5513 行)

 200| export function deriveShortMessageId(uuid: string): string {
 201|   // Take first 10 hex chars from the UUID (skipping dashes)
 202|   const hex = uuid.replace(/-/g, '').slice(0, 10)
 203|   // Convert to base36 for shorter representation, take 6 chars
 204|   return parseInt(hex, 16).toString(36).slice(0, 6)
 205| }
 206| 
 207| export const INTERRUPT_MESSAGE = '[Request interrupted by user]'
 208| export const INTERRUPT_MESSAGE_FOR_TOOL_USE =
 209|   '[Request interrupted by user for tool use]'
 210| export const CANCEL_MESSAGE =
 211|   "The user doesn't want to take this action right now. STOP what you are doing and wait for the user to tell you how to proceed."
 212| export const REJECT_MESSAGE =
 213|   "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed."
 214| export const REJECT_MESSAGE_WITH_REASON_PREFIX =
 215|   "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). To tell you how to proceed, the user said:\n"
 216| export const SUBAGENT_REJECT_MESSAGE =
 217|   'Permission for this tool use was denied. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). Try a different approach or report the limitation to complete your task.'
 218| export const SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX =
 219|   'Permission for this tool use was denied. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). The user said:\n'
 220| export const PLAN_REJECTION_PREFIX =
 221|   'The agent proposed a plan that was rejected by the user. The user chose to stay in plan mode rather than proceed with implementation.\n\nRejected plan:\n'
 222| 
 223| /**
 224|  * Shared guidance for permission denials, instructing the model on appropriate workarounds.
 225|  */
 226| export const DENIAL_WORKAROUND_GUIDANCE =
 227|   `IMPORTANT: You *may* attempt to accomplish this action using other tools that might naturally be used to accomplish this goal, ` +
 228|   `e.g. using head instead of cat. But you *should not* attempt to work around this denial in malicious ways, ` +
 229|   `e.g. do not use your ability to run tests to execute non-test actions. ` +
 230|   `You should only try to work around this restriction in reasonable ways that do not attempt to bypass the intent behind this denial. ` +
 231|   `If you believe this capability is essential to complete the user's request, STOP and explain to the user ` +
 232|   `what you were trying to do and why you need this permission. Let the user decide how to proceed.`
 233| 
 234| export function AUTO_REJECT_MESSAGE(toolName: string): string {
 235|   return `Permission to use ${toolName} has been denied. ${DENIAL_WORKAROUND_GUIDANCE}`
 236| }
 237| export function DONT_ASK_REJECT_MESSAGE(toolName: string): string {
 238|   return `Permission to use ${toolName} has been denied because Claude Code is running in don't ask mode. ${DENIAL_WORKAROUND_GUIDANCE}`
 239| }
 240| export const NO_RESPONSE_REQUESTED = 'No response requested.'
 241| 
 242| // Synthetic tool_result content inserted by ensureToolResultPairing when a
 243| // tool_use block has no matching tool_result. Exported so HFI submission can
 244| // reject any payload containing it — placeholder satisfies pairing structurally
 245| // but the content is fake, which poisons training data if submitted.
 246| export const SYNTHETIC_TOOL_RESULT_PLACEHOLDER =
 247|   '[Tool result missing due to internal error]'
 248| 
 249| // Prefix used by UI to detect classifier denials and render them concisely
 250| const AUTO_MODE_REJECTION_PREFIX =

源码引用: src/types/message.ts · 第 61–62 行(共 135 行)

  61| export type SystemMemorySavedMessage = SystemMessage
  62| export type SystemStopHookSummaryMessage = SystemMessage

源码引用: src/services/extractMemories/extractMemories.ts · 第 450–500 行(共 616 行)

 450|           : '0.0'
 451|       logForDebugging(
 452|         `[extractMemories] finished — ${writtenPaths.length} files written, cache: read=${result.totalUsage.cache_read_input_tokens} create=${result.totalUsage.cache_creation_input_tokens} input=${result.totalUsage.input_tokens} (${hitPct}% hit)`,
 453|       )
 454| 
 455|       if (writtenPaths.length > 0) {
 456|         logForDebugging(
 457|           `[extractMemories] memories saved: ${writtenPaths.join(', ')}`,
 458|         )
 459|       } else {
 460|         logForDebugging('[extractMemories] no memories saved this run')
 461|       }
 462| 
 463|       // Index file updates are mechanical — the agent touches MEMORY.md to add
 464|       // a topic link, but the user-visible "memory" is the topic file itself.
 465|       const memoryPaths = writtenPaths.filter(
 466|         p => basename(p) !== ENTRYPOINT_NAME,
 467|       )
 468|       const teamCount = feature('TEAMMEM')
 469|         ? count(memoryPaths, teamMemPaths!.isTeamMemPath)
 470|         : 0
 471| 
 472|       // Log extraction event with usage from the forked agent
 473|       logEvent('tengu_extract_memories_extraction', {
 474|         input_tokens: result.totalUsage.input_tokens,
 475|         output_tokens: result.totalUsage.output_tokens,
 476|         cache_read_input_tokens: result.totalUsage.cache_read_input_tokens,
 477|         cache_creation_input_tokens:
 478|           result.totalUsage.cache_creation_input_tokens,
 479|         message_count: newMessageCount,
 480|         turn_count: turnCount,
 481|         files_written: writtenPaths.length,
 482|         memories_saved: memoryPaths.length,
 483|         team_memories_saved: teamCount,
 484|         duration_ms: Date.now() - startTime,
 485|       })
 486| 
 487|       logForDebugging(
 488|         `[extractMemories] writtenPaths=${writtenPaths.length} memoryPaths=${memoryPaths.length} appendSystemMessage defined=${appendSystemMessage != null}`,
 489|       )
 490|       if (memoryPaths.length > 0) {
 491|         const msg = createMemorySavedMessage(memoryPaths)
 492|         if (feature('TEAMMEM')) {
 493|           msg.teamCount = teamCount
 494|         }
 495|         appendSystemMessage?.(msg)
 496|       }
 497|     } catch (error) {
 498|       // Extraction is best-effort — log but don't notify on error
 499|       logForDebugging(`[extractMemories] error: ${error}`)
 500|       logEvent('tengu_extract_memories_error', {

并发 stash 与 drain

extract 进行中又来 stop hook → stash context for trailing run(log extraction in progress — stashing)。

drainPendingExtraction 在 print.ts CLI exit 调用,确保 trailing extraction 完成。

initExtractMemories closure 维护 inProgress、stashedMessages 状态机。

源码引用: src/services/extractMemories/extractMemories.ts · 第 510–570 行(共 616 行)

 510|       const trailing = pendingContext
 511|       pendingContext = undefined
 512|       if (trailing) {
 513|         logForDebugging(
 514|           '[extractMemories] running trailing extraction for stashed context',
 515|         )
 516|         await runExtraction({
 517|           context: trailing.context,
 518|           appendSystemMessage: trailing.appendSystemMessage,
 519|           isTrailingRun: true,
 520|         })
 521|       }
 522|     }
 523|   }
 524| 
 525|   // --- Public entry point (captured by extractor) ---
 526| 
 527|   async function executeExtractMemoriesImpl(
 528|     context: REPLHookContext,
 529|     appendSystemMessage?: AppendSystemMessageFn,
 530|   ): Promise<void> {
 531|     // Only run for the main agent, not subagents
 532|     if (context.toolUseContext.agentId) {
 533|       return
 534|     }
 535| 
 536|     if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_passport_quail', false)) {
 537|       if (process.env.USER_TYPE === 'ant' && !hasLoggedGateFailure) {
 538|         hasLoggedGateFailure = true
 539|         logEvent('tengu_extract_memories_gate_disabled', {})
 540|       }
 541|       return
 542|     }
 543| 
 544|     // Check auto-memory is enabled
 545|     if (!isAutoMemoryEnabled()) {
 546|       return
 547|     }
 548| 
 549|     // Skip in remote mode
 550|     if (getIsRemoteMode()) {
 551|       return
 552|     }
 553| 
 554|     // If an extraction is already in progress, stash this context for a
 555|     // trailing run (overwrites any previously stashed context — only the
 556|     // latest matters since it has the most messages).
 557|     if (inProgress) {
 558|       logForDebugging(
 559|         '[extractMemories] extraction in progress — stashing for trailing run',
 560|       )
 561|       logEvent('tengu_extract_memories_coalesced', {})
 562|       pendingContext = { context, appendSystemMessage }
 563|       return
 564|     }
 565| 
 566|     await runExtraction({ context, appendSystemMessage })
 567|   }
 568| 
 569|   extractor = async (context, appendSystemMessage) => {
 570|     const p = executeExtractMemoriesImpl(context, appendSystemMessage)

源码引用: src/cli/print.ts · 第 354–375 行(共 5595 行)

 354| import { isExtractModeActive } from '../memdir/paths.js'
 355| 
 356| // Dead code elimination: conditional imports
 357| /* eslint-disable @typescript-eslint/no-require-imports */
 358| const coordinatorModeModule = feature('COORDINATOR_MODE')
 359|   ? (require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js'))
 360|   : null
 361| const proactiveModule =
 362|   feature('PROACTIVE') || feature('KAIROS')
 363|     ? (require('../proactive/index.js') as typeof import('../proactive/index.js'))
 364|     : null
 365| const cronSchedulerModule = feature('AGENT_TRIGGERS')
 366|   ? (require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js'))
 367|   : null
 368| const cronJitterConfigModule = feature('AGENT_TRIGGERS')
 369|   ? (require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js'))
 370|   : null
 371| const cronGate = feature('AGENT_TRIGGERS')
 372|   ? (require('../tools/ScheduleCronTool/prompt.js') as typeof import('../tools/ScheduleCronTool/prompt.js'))
 373|   : null
 374| const extractMemoriesModule = feature('EXTRACT_MEMORIES')
 375|   ? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js'))

源码引用: src/cli/print.ts · 第 960–975 行(共 5595 行)

 960|   logHeadlessProfilerTurn()
 961| 
 962|   // Drain any in-flight memory extraction before shutdown. The response is
 963|   // already flushed above, so this adds no user-visible latency — it just
 964|   // delays process exit so gracefulShutdownSync's 5s failsafe doesn't kill
 965|   // the forked agent mid-flight. Gated by isExtractModeActive so the
 966|   // tengu_slate_thimble flag controls non-interactive extraction end-to-end.
 967|   if (feature('EXTRACT_MEMORIES') && isExtractModeActive()) {
 968|     await extractMemoriesModule!.drainPendingExtraction()
 969|   }
 970| 
 971|   gracefulShutdownSync(
 972|     lastMessage?.type === 'result' && lastMessage?.is_error ? 1 : 0,
 973|   )
 974| }
 975| 

findRelevantMemories 对比

query-time 召回(findRelevantMemories.ts):用户发消息时 sideQuery Sonnet 从 scan 列表选 ≤5 文件,exclude alreadySurfaced,thread mtimeMs。

与 extract 写入 正交:recall 读,extract 写。MEMORY_SHAPE_TELEMETRY feature 记录 recall 形状。

memdir 模块 9 文件中 findRelevantMemories + memoryScan 服务两条读路径。

源码引用: src/memdir/findRelevantMemories.ts · 第 18–45 行(共 142 行)

  18| const SELECT_MEMORIES_SYSTEM_PROMPT = `You are selecting memories that will be useful to Claude Code as it processes a user's query. You will be given the user's query and a list of available memory files with their filenames and descriptions.
  19| 
  20| Return a list of filenames for the memories that will clearly be useful to Claude Code as it processes the user's query (up to 5). Only include memories that you are certain will be helpful based on their name and description.
  21| - If you are unsure if a memory will be useful in processing the user's query, then do not include it in your list. Be selective and discerning.
  22| - If there are no memories in the list that would clearly be useful, feel free to return an empty list.
  23| - If a list of recently-used tools is provided, do not select memories that are usage reference or API documentation for those tools (Claude Code is already exercising them). DO still select memories containing warnings, gotchas, or known issues about those tools — active use is exactly when those matter.
  24| `
  25| 
  26| /**
  27|  * Find memory files relevant to a query by scanning memory file headers
  28|  * and asking Sonnet to select the most relevant ones.
  29|  *
  30|  * Returns absolute file paths + mtime of the most relevant memories
  31|  * (up to 5). Excludes MEMORY.md (already loaded in system prompt).
  32|  * mtime is threaded through so callers can surface freshness to the
  33|  * main model without a second stat.
  34|  *
  35|  * `alreadySurfaced` filters paths shown in prior turns before the
  36|  * Sonnet call, so the selector spends its 5-slot budget on fresh
  37|  * candidates instead of re-picking files the caller will discard.
  38|  */
  39| export async function findRelevantMemories(
  40|   query: string,
  41|   memoryDir: string,
  42|   signal: AbortSignal,
  43|   recentTools: readonly string[] = [],
  44|   alreadySurfaced: ReadonlySet<string> = new Set(),
  45| ): Promise<RelevantMemory[]> {

源码引用: src/memdir/memoryShapeTelemetry.ts · 第 1–2 行(共 2 行)

   1| export function recordMemoryShapeTelemetry(): void {}
   2| 

与 SessionMemory 的边界

extractMemories 与 services/SessionMemory 都是后台提取,但目标完全不同。extractMemories 写 auto-memory topic 文件,面向跨会话长期知识,依赖 getAutoMemPath、memoryScan、buildExtractAutoOnlyPrompt/CombinedPrompt,并在写入后用 createMemorySavedMessage 生成 memory_saved 系统消息。SessionMemory 写当前会话的摘要 markdown,面向 compact、awaySummary、skillify 等“本会话上下文压缩”场景,阈值来自 SessionMemoryConfig,状态如 lastSummarizedMessageId、extractionStartedAt、tokensAtLastExtraction 存在 SessionMemory/sessionMemoryUtils 中。

边界上有两个常见误区。第一,主 agent 直接 Write/Edit 到 auto-memory 时,extractMemories 会通过 hasMemoryWritesSince 跳过该区间,避免重复保存;SessionMemory 不参与这条互斥。第二,extractMemories 的 forked agent skipTranscript=true,因为它只是维护磁盘记忆,不应把后台分析过程插回主对话;SessionMemory 则会在 compact 前等待提取完成,以便 compact 可以用最新摘要替换旧消息。理解这两个差异后,看到 stopHooks、print drain、compact sessionMemoryCompact、/remember skill 时,就能判断它们是在维护长期 memdir,还是在维护当前会话摘要。

并发处理也体现了后台任务的产品取舍。extractMemories 正在运行时,新一轮 stop hook 不会启动第二个 forked agent,而是覆盖 pendingContext,只保留最新消息视图;当前 run 完成后再做 trailing extraction。这避免多个后台 agent 同时写 MEMORY.md 或同一 topic 文件,也避免频繁 stop hook 把 token 花在重复扫描上。print.ts 在 CLI 退出前 drain pending extraction,是为了让非交互模式的最后一轮也有机会落盘。

源码引用: src/services/extractMemories/extractMemories.ts · 第 345–360 行(共 616 行)

 345|     // Mutual exclusion: when the main agent wrote memories, skip the
 346|     // forked agent and advance the cursor past this range so the next
 347|     // extraction only considers messages after the main agent's write.
 348|     if (hasMemoryWritesSince(messages, lastMemoryMessageUuid)) {
 349|       logForDebugging(
 350|         '[extractMemories] skipping — conversation already wrote to memory files',
 351|       )
 352|       const lastMessage = messages.at(-1)
 353|       if (lastMessage?.uuid) {
 354|         lastMemoryMessageUuid = lastMessage.uuid
 355|       }
 356|       logEvent('tengu_extract_memories_skipped_direct_write', {
 357|         message_count: newMessageCount,
 358|       })
 359|       return
 360|     }

源码引用: src/services/extractMemories/extractMemories.ts · 第 415–427 行(共 616 行)

 415|       const result = await runForkedAgent({
 416|         promptMessages: [createUserMessage({ content: userPrompt })],
 417|         cacheSafeParams,
 418|         canUseTool,
 419|         querySource: 'extract_memories',
 420|         forkLabel: 'extract_memories',
 421|         // The extractMemories subagent does not need to record to transcript.
 422|         // Doing so can create race conditions with the main thread.
 423|         skipTranscript: true,
 424|         // Well-behaved extractions complete in 2-4 turns (read → write).
 425|         // A hard cap prevents verification rabbit-holes from burning turns.
 426|         maxTurns: 5,
 427|       })

源码引用: src/services/SessionMemory/sessionMemoryUtils.ts · 第 18–53 行(共 208 行)

  18| export type SessionMemoryConfig = {
  19|   /** Minimum context window tokens before initializing session memory.
  20|    * Uses the same token counting as autocompact (input + output + cache tokens)
  21|    * to ensure consistent behavior between the two features. */
  22|   minimumMessageTokensToInit: number
  23|   /** Minimum context window growth (in tokens) between session memory updates.
  24|    * Uses the same token counting as autocompact (tokenCountWithEstimation)
  25|    * to measure actual context growth, not cumulative API usage. */
  26|   minimumTokensBetweenUpdate: number
  27|   /** Number of tool calls between session memory updates */
  28|   toolCallsBetweenUpdates: number
  29| }
  30| 
  31| // Default configuration values
  32| export const DEFAULT_SESSION_MEMORY_CONFIG: SessionMemoryConfig = {
  33|   minimumMessageTokensToInit: 10000,
  34|   minimumTokensBetweenUpdate: 5000,
  35|   toolCallsBetweenUpdates: 3,
  36| }
  37| 
  38| // Current session memory configuration
  39| let sessionMemoryConfig: SessionMemoryConfig = {
  40|   ...DEFAULT_SESSION_MEMORY_CONFIG,
  41| }
  42| 
  43| // Track the last summarized message ID (shared state)
  44| let lastSummarizedMessageId: string | undefined
  45| 
  46| // Track extraction state with timestamp (set by sessionMemory.ts)
  47| let extractionStartedAt: number | undefined
  48| 
  49| // Track context size at last memory extraction (for minimumTokensBetweenUpdate)
  50| let tokensAtLastExtraction = 0
  51| 
  52| // Track whether session memory has been initialized (met minimumMessageTokensToInit)
  53| let sessionMemoryInitialized = false

源码引用: src/services/compact/sessionMemoryCompact.ts · 第 526–531 行(共 631 行)

 526|   // Wait for any in-progress session memory extraction to complete (with timeout)
 527|   await waitForSessionMemoryExtraction()
 528| 
 529|   const lastSummarizedMessageId = getLastSummarizedMessageId()
 530|   const sessionMemory = await getSessionMemoryContent()
 531| 

失败与降级策略(避免主流程受阻)

extractMemories 的实现强调“后台尽力而为,不阻塞主对话”。触发侧通常使用 void executeExtractMemories,说明即使提取失败,主 query loop 也不会回滚;执行侧在关键节点记录日志与 telemetry,而不是把异常上抛给用户输入路径。再加上 inProgress + pendingContext 机制,系统会优先完成当前提取,再处理最新一轮上下文,避免并发 fork 污染同一记忆目录。

这种降级策略与 memdir 的产品定位一致:记忆提取是增强能力,不应成为可用性单点。排障时应先确认触发条件与门控,再看互斥跳过和后台执行日志,最后才追查写盘细节。

源码引用: src/query/stopHooks.ts · 第 140–160 行(共 474 行)

 140|     }
 141|     if (
 142|       feature('EXTRACT_MEMORIES') &&
 143|       !toolUseContext.agentId &&
 144|       isExtractModeActive()
 145|     ) {
 146|       // Fire-and-forget in both interactive and non-interactive. For -p/SDK,
 147|       // print.ts drains the in-flight promise after flushing the response
 148|       // but before gracefulShutdownSync (see drainPendingExtraction).
 149|       void extractMemoriesModule!.executeExtractMemories(
 150|         stopHookContext,
 151|         toolUseContext.appendSystemMessage,
 152|       )
 153|     }
 154|     if (!toolUseContext.agentId) {
 155|       void executeAutoDream(stopHookContext, toolUseContext.appendSystemMessage)
 156|     }
 157|   }
 158| 
 159|   // chicago MCP: auto-unhide + lock release at turn end.
 160|   // Main thread only — the CU lock is a process-wide module-level variable,

源码引用: src/services/extractMemories/extractMemories.ts · 第 510–570 行(共 616 行)

 510|       const trailing = pendingContext
 511|       pendingContext = undefined
 512|       if (trailing) {
 513|         logForDebugging(
 514|           '[extractMemories] running trailing extraction for stashed context',
 515|         )
 516|         await runExtraction({
 517|           context: trailing.context,
 518|           appendSystemMessage: trailing.appendSystemMessage,
 519|           isTrailingRun: true,
 520|         })
 521|       }
 522|     }
 523|   }
 524| 
 525|   // --- Public entry point (captured by extractor) ---
 526| 
 527|   async function executeExtractMemoriesImpl(
 528|     context: REPLHookContext,
 529|     appendSystemMessage?: AppendSystemMessageFn,
 530|   ): Promise<void> {
 531|     // Only run for the main agent, not subagents
 532|     if (context.toolUseContext.agentId) {
 533|       return
 534|     }
 535| 
 536|     if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_passport_quail', false)) {
 537|       if (process.env.USER_TYPE === 'ant' && !hasLoggedGateFailure) {
 538|         hasLoggedGateFailure = true
 539|         logEvent('tengu_extract_memories_gate_disabled', {})
 540|       }
 541|       return
 542|     }
 543| 
 544|     // Check auto-memory is enabled
 545|     if (!isAutoMemoryEnabled()) {
 546|       return
 547|     }
 548| 
 549|     // Skip in remote mode
 550|     if (getIsRemoteMode()) {
 551|       return
 552|     }
 553| 
 554|     // If an extraction is already in progress, stash this context for a
 555|     // trailing run (overwrites any previously stashed context — only the
 556|     // latest matters since it has the most messages).
 557|     if (inProgress) {
 558|       logForDebugging(
 559|         '[extractMemories] extraction in progress — stashing for trailing run',
 560|       )
 561|       logEvent('tengu_extract_memories_coalesced', {})
 562|       pendingContext = { context, appendSystemMessage }
 563|       return
 564|     }
 565| 
 566|     await runExtraction({ context, appendSystemMessage })
 567|   }
 568| 
 569|   extractor = async (context, appendSystemMessage) => {
 570|     const p = executeExtractMemoriesImpl(context, appendSystemMessage)

源码引用: src/cli/print.ts · 第 960–975 行(共 5595 行)

 960|   logHeadlessProfilerTurn()
 961| 
 962|   // Drain any in-flight memory extraction before shutdown. The response is
 963|   // already flushed above, so this adds no user-visible latency — it just
 964|   // delays process exit so gracefulShutdownSync's 5s failsafe doesn't kill
 965|   // the forked agent mid-flight. Gated by isExtractModeActive so the
 966|   // tengu_slate_thimble flag controls non-interactive extraction end-to-end.
 967|   if (feature('EXTRACT_MEMORIES') && isExtractModeActive()) {
 968|     await extractMemoriesModule!.drainPendingExtraction()
 969|   }
 970| 
 971|   gracefulShutdownSync(
 972|     lastMessage?.type === 'result' && lastMessage?.is_error ? 1 : 0,
 973|   )
 974| }
 975| 

提取提示为何要先注入 memory 清单

extract agent 并不是直接拿整段 transcript 自由发挥,它在提示里先看到当前 memory 文件清单与摘要信息。这样做的目的有两个:第一,减少 agent 为了“看看已有记忆”而额外调用读取工具的回合浪费;第二,降低重复写入概率,让模型在决定新增/更新时有明确参照。memoryScan 提供 frontmatter 级元信息,formatMemoryManifest 再转为紧凑文本片段,正好兼顾成本与可读性。

这一步也体现了 extraction 与 recall 的分工:recall 关心给当前回答找最相关的少量文件,提取关心维护全局记忆目录的一致性。二者都基于 scanMemoryFiles,但提示目标和后续动作不同。

源码引用: src/memdir/memoryScan.ts · 第 21–95 行(共 95 行)

  21| const MAX_MEMORY_FILES = 200
  22| const FRONTMATTER_MAX_LINES = 30
  23| 
  24| /**
  25|  * Scan a memory directory for .md files, read their frontmatter, and return
  26|  * a header list sorted newest-first (capped at MAX_MEMORY_FILES). Shared by
  27|  * findRelevantMemories (query-time recall) and extractMemories (pre-injects
  28|  * the listing so the extraction agent doesn't spend a turn on `ls`).
  29|  *
  30|  * Single-pass: readFileInRange stats internally and returns mtimeMs, so we
  31|  * read-then-sort rather than stat-sort-read. For the common case (N ≤ 200)
  32|  * this halves syscalls vs a separate stat round; for large N we read a few
  33|  * extra small files but still avoid the double-stat on the surviving 200.
  34|  */
  35| export async function scanMemoryFiles(
  36|   memoryDir: string,
  37|   signal: AbortSignal,
  38| ): Promise<MemoryHeader[]> {
  39|   try {
  40|     const entries = await readdir(memoryDir, { recursive: true })
  41|     const mdFiles = entries.filter(
  42|       f => f.endsWith('.md') && basename(f) !== 'MEMORY.md',
  43|     )
  44| 
  45|     const headerResults = await Promise.allSettled(
  46|       mdFiles.map(async (relativePath): Promise<MemoryHeader> => {
  47|         const filePath = join(memoryDir, relativePath)
  48|         const { content, mtimeMs } = await readFileInRange(
  49|           filePath,
  50|           0,
  51|           FRONTMATTER_MAX_LINES,
  52|           undefined,
  53|           signal,
  54|         )
  55|         const { frontmatter } = parseFrontmatter(content, filePath)
  56|         return {
  57|           filename: relativePath,
  58|           filePath,
  59|           mtimeMs,
  60|           description: frontmatter.description || null,
  61|           type: parseMemoryType(frontmatter.type),
  62|         }
  63|       }),
  64|     )
  65| 
  66|     return headerResults
  67|       .filter(
  68|         (r): r is PromiseFulfilledResult<MemoryHeader> =>
  69|           r.status === 'fulfilled',
  70|       )
  71|       .map(r => r.value)
  72|       .sort((a, b) => b.mtimeMs - a.mtimeMs)
  73|       .slice(0, MAX_MEMORY_FILES)
  74|   } catch {
  75|     return []
  76|   }
  77| }
  78| 
  79| /**
  80|  * Format memory headers as a text manifest: one line per file with
  81|  * [type] filename (timestamp): description. Used by both the recall
  82|  * selector prompt and the extraction-agent prompt.
  83|  */
  84| export function formatMemoryManifest(memories: MemoryHeader[]): string {
  85|   return memories
  86|     .map(m => {
  87|       const tag = m.type ? `[${m.type}] ` : ''
  88|       const ts = new Date(m.mtimeMs).toISOString()
  89|       return m.description
  90|         ? `- ${tag}${m.filename} (${ts}): ${m.description}`
  91|         : `- ${tag}${m.filename} (${ts})`
  92|     })
  93|     .join('\n')
  94| }
  95| 

源码引用: src/services/extractMemories/extractMemories.ts · 第 367–410 行(共 616 行)

 367|       'tengu_moth_copse',
 368|       false,
 369|     )
 370| 
 371|     const canUseTool = createAutoMemCanUseTool(memoryDir)
 372|     const cacheSafeParams = createCacheSafeParams(context)
 373| 
 374|     // Only run extraction every N eligible turns (tengu_bramble_lintel, default 1).
 375|     // Trailing extractions (from stashed contexts) skip this check since they
 376|     // process already-committed work that should not be throttled.
 377|     if (!isTrailingRun) {
 378|       turnsSinceLastExtraction++
 379|       if (
 380|         turnsSinceLastExtraction <
 381|         (getFeatureValue_CACHED_MAY_BE_STALE('tengu_bramble_lintel', null) ?? 1)
 382|       ) {
 383|         return
 384|       }
 385|     }
 386|     turnsSinceLastExtraction = 0
 387| 
 388|     inProgress = true
 389|     const startTime = Date.now()
 390|     try {
 391|       logForDebugging(
 392|         `[extractMemories] starting — ${newMessageCount} new messages, memoryDir=${memoryDir}`,
 393|       )
 394| 
 395|       // Pre-inject the memory directory manifest so the agent doesn't spend
 396|       // a turn on `ls`. Reuses findRelevantMemories' frontmatter scan.
 397|       // Placed after the throttle gate so skipped turns don't pay the scan cost.
 398|       const existingMemories = formatMemoryManifest(
 399|         await scanMemoryFiles(memoryDir, createAbortController().signal),
 400|       )
 401| 
 402|       const userPrompt =
 403|         feature('TEAMMEM') && teamMemoryEnabled
 404|           ? buildExtractCombinedPrompt(
 405|               newMessageCount,
 406|               existingMemories,
 407|               skipIndex,
 408|             )
 409|           : buildExtractAutoOnlyPrompt(
 410|               newMessageCount,

源码引用: src/services/extractMemories/prompts.ts · 第 31–90 行(共 155 行)

  31|     existingMemories.length > 0
  32|       ? `\n\n## Existing memory files\n\n${existingMemories}\n\nCheck this list before writing — update an existing file rather than creating a duplicate.`
  33|       : ''
  34|   return [
  35|     `You are now acting as the memory extraction subagent. Analyze the most recent ~${newMessageCount} messages above and use them to update your persistent memory systems.`,
  36|     '',
  37|     `Available tools: ${FILE_READ_TOOL_NAME}, ${GREP_TOOL_NAME}, ${GLOB_TOOL_NAME}, read-only ${BASH_TOOL_NAME} (ls/find/cat/stat/wc/head/tail and similar), and ${FILE_EDIT_TOOL_NAME}/${FILE_WRITE_TOOL_NAME} for paths inside the memory directory only. ${BASH_TOOL_NAME} rm is not permitted. All other tools — MCP, Agent, write-capable ${BASH_TOOL_NAME}, etc — will be denied.`,
  38|     '',
  39|     `You have a limited turn budget. ${FILE_EDIT_TOOL_NAME} requires a prior ${FILE_READ_TOOL_NAME} of the same file, so the efficient strategy is: turn 1 — issue all ${FILE_READ_TOOL_NAME} calls in parallel for every file you might update; turn 2 — issue all ${FILE_WRITE_TOOL_NAME}/${FILE_EDIT_TOOL_NAME} calls in parallel. Do not interleave reads and writes across multiple turns.`,
  40|     '',
  41|     `You MUST only use content from the last ~${newMessageCount} messages to update your persistent memories. Do not waste any turns attempting to investigate or verify that content further — no grepping source files, no reading code to confirm a pattern exists, no git commands.` +
  42|       manifest,
  43|   ].join('\n')
  44| }
  45| 
  46| /**
  47|  * Build the extraction prompt for auto-only memory (no team memory).
  48|  * Four-type taxonomy, no scope guidance (single directory).
  49|  */
  50| export function buildExtractAutoOnlyPrompt(
  51|   newMessageCount: number,
  52|   existingMemories: string,
  53|   skipIndex = false,
  54| ): string {
  55|   const howToSave = skipIndex
  56|     ? [
  57|         '## How to save memories',
  58|         '',
  59|         'Write each memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format:',
  60|         '',
  61|         ...MEMORY_FRONTMATTER_EXAMPLE,
  62|         '',
  63|         '- Organize memory semantically by topic, not chronologically',
  64|         '- Update or remove memories that turn out to be wrong or outdated',
  65|         '- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.',
  66|       ]
  67|     : [
  68|         '## How to save memories',
  69|         '',
  70|         'Saving a memory is a two-step process:',
  71|         '',
  72|         '**Step 1** — write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format:',
  73|         '',
  74|         ...MEMORY_FRONTMATTER_EXAMPLE,
  75|         '',
  76|         '**Step 2** — add a pointer to that file in `MEMORY.md`. `MEMORY.md` is an index, not a memory — each entry should be one line, under ~150 characters: `- [Title](file.md) — one-line hook`. It has no frontmatter. Never write memory content directly into `MEMORY.md`.',
  77|         '',
  78|         '- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep the index concise',
  79|         '- Organize memory semantically by topic, not chronologically',
  80|         '- Update or remove memories that turn out to be wrong or outdated',
  81|         '- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.',
  82|       ]
  83| 
  84|   return [
  85|     opener(newMessageCount, existingMemories),
  86|     '',
  87|     'If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry.',
  88|     '',
  89|     ...TYPES_SECTION_INDIVIDUAL,
  90|     ...WHAT_NOT_TO_SAVE_SECTION,

本章小结与延伸

memory-extraction = 回合末 fork agent 写盘。下一章 memdir-commands 读 /memory 与 /remember UI。 继续学习:

  • memdir-commands
  • memdir-core
Prev
memdir-core · 路径、加载与 MEMORY.md
Next
memdir-commands · /memory、/remember 与命令集成