π File detail
services/extractMemories/extractMemories.ts
π― Use case
This file lives under βservices/β, which covers long-lived services (LSP, MCP, OAuth, tool execution, memory, compaction, voice, settings sync, β¦). On the API surface it exposes createAutoMemCanUseTool, initExtractMemories, executeExtractMemories, and drainPendingExtraction β mainly functions, hooks, or classes. Dependencies touch bun:bundle and Node path helpers. It composes internal code from bootstrap, hooks, memdir, Tool, and tools (relative imports). What the file header says: Extracts durable memories from the current session transcript and writes them to the auto-memory directory (~/.claude/projects/<path>/memory/). It runs once at the end of each complete query loop (when the model produces a final response with no tool calls) via handleStopHooks in.
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
Extracts durable memories from the current session transcript and writes them to the auto-memory directory (~/.claude/projects/<path>/memory/). It runs once at the end of each complete query loop (when the model produces a final response with no tool calls) via handleStopHooks in stopHooks.ts. Uses the forked agent pattern (runForkedAgent) β a perfect fork of the main conversation that shares the parent's prompt cache. State is closure-scoped inside initExtractMemories() rather than module-level, following the same pattern as confidenceRating.ts. Tests call initExtractMemories() in beforeEach to get a fresh closure.
π€ Exports (heuristic)
createAutoMemCanUseToolinitExtractMemoriesexecuteExtractMemoriesdrainPendingExtraction
π External import roots
Package roots from from "β¦" (relative paths omitted).
bun:bundlepath
π₯οΈ Source preview
/**
* Extracts durable memories from the current session transcript
* and writes them to the auto-memory directory (~/.claude/projects/<path>/memory/).
*
* It runs once at the end of each complete query loop (when the model produces
* a final response with no tool calls) via handleStopHooks in stopHooks.ts.
*
* Uses the forked agent pattern (runForkedAgent) β a perfect fork of the main
* conversation that shares the parent's prompt cache.
*
* State is closure-scoped inside initExtractMemories() rather than module-level,
* following the same pattern as confidenceRating.ts. Tests call
* initExtractMemories() in beforeEach to get a fresh closure.
*/
import { feature } from 'bun:bundle'
import { basename } from 'path'
import { getIsRemoteMode } from '../../bootstrap/state.js'
import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
import { ENTRYPOINT_NAME } from '../../memdir/memdir.js'
import {
formatMemoryManifest,
scanMemoryFiles,
} from '../../memdir/memoryScan.js'
import {
getAutoMemPath,
isAutoMemoryEnabled,
isAutoMemPath,
} from '../../memdir/paths.js'
import type { Tool } from '../../Tool.js'
import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
import { FILE_EDIT_TOOL_NAME } from '../../tools/FileEditTool/constants.js'
import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js'
import { FILE_WRITE_TOOL_NAME } from '../../tools/FileWriteTool/prompt.js'
import { GLOB_TOOL_NAME } from '../../tools/GlobTool/prompt.js'
import { GREP_TOOL_NAME } from '../../tools/GrepTool/prompt.js'
import { REPL_TOOL_NAME } from '../../tools/REPLTool/constants.js'
import type {
AssistantMessage,
Message,
SystemLocalCommandMessage,
SystemMessage,
} from '../../types/message.js'
import { createAbortController } from '../../utils/abortController.js'
import { count, uniq } from '../../utils/array.js'
import { logForDebugging } from '../../utils/debug.js'
import {
createCacheSafeParams,
runForkedAgent,
} from '../../utils/forkedAgent.js'
import type { REPLHookContext } from '../../utils/hooks/postSamplingHooks.js'
import {
createMemorySavedMessage,
createUserMessage,
} from '../../utils/messages.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js'
import { logEvent } from '../analytics/index.js'
import { sanitizeToolNameForAnalytics } from '../analytics/metadata.js'
import {
buildExtractAutoOnlyPrompt,
buildExtractCombinedPrompt,
} from './prompts.js'
/* eslint-disable @typescript-eslint/no-require-imports */
const teamMemPaths = feature('TEAMMEM')
? (require('../../memdir/teamMemPaths.js') as typeof import('../../memdir/teamMemPaths.js'))
: null
/* eslint-enable @typescript-eslint/no-require-imports */
// ============================================================================
// Helpers
// ============================================================================
/**
* Returns true if a message is visible to the model (sent in API calls).
* Excludes progress, system, and attachment messages.
*/
function isModelVisibleMessage(message: Message): boolean {
return message.type === 'user' || message.type === 'assistant'
}
function countModelVisibleMessagesSince(
messages: Message[],
sinceUuid: string | undefined,
): number {
if (sinceUuid === null || sinceUuid === undefined) {
return count(messages, isModelVisibleMessage)
}
let foundStart = false
let n = 0
for (const message of messages) {
if (!foundStart) {
if (message.uuid === sinceUuid) {
foundStart = true
}
continue
}
if (isModelVisibleMessage(message)) {
n++
}
}
// If sinceUuid was not found (e.g., removed by context compaction),
// fall back to counting all model-visible messages rather than returning 0
// which would permanently disable extraction for the rest of the session.
if (!foundStart) {
return count(messages, isModelVisibleMessage)
}
return n
}
/**
* Returns true if any assistant message after the cursor UUID contains a
* Write/Edit tool_use block targeting an auto-memory path.
*
* The main agent's prompt has full save instructions β when it writes
* memories, the forked extraction is redundant. runExtraction skips the
* agent and advances the cursor past this range, making the main agent
* and the background agent mutually exclusive per turn.
*/
function hasMemoryWritesSince(
messages: Message[],
sinceUuid: string | undefined,
): boolean {
let foundStart = sinceUuid === undefined
for (const message of messages) {
if (!foundStart) {
if (message.uuid === sinceUuid) {
foundStart = true
}
continue
}
if (message.type !== 'assistant') {
continue
}
const content = (message as AssistantMessage).message.content
if (!Array.isArray(content)) {
continue
}
for (const block of content) {
const filePath = getWrittenFilePath(block)
if (filePath !== undefined && isAutoMemPath(filePath)) {
return true
}
}
}
return false
}
// ============================================================================
// Tool Permissions
// ============================================================================
function denyAutoMemTool(tool: Tool, reason: string) {
logForDebugging(`[autoMem] denied ${tool.name}: ${reason}`)
logEvent('tengu_auto_mem_tool_denied', {
tool_name: sanitizeToolNameForAnalytics(tool.name),
})
return {
behavior: 'deny' as const,
message: reason,
decisionReason: { type: 'other' as const, reason },
}
}
/**
* Creates a canUseTool function that allows Read/Grep/Glob (unrestricted),
* read-only Bash commands, and Edit/Write only for paths within the
* auto-memory directory. Shared by extractMemories and autoDream.
*/
export function createAutoMemCanUseTool(memoryDir: string): CanUseToolFn {
return async (tool: Tool, input: Record<string, unknown>) => {
// Allow REPL β when REPL mode is enabled (ant-default), primitive tools
// are hidden from the tool list so the forked agent calls REPL instead.
// REPL's VM context re-invokes this canUseTool for each inner primitive
// (toolWrappers.ts createToolWrapper), so the Read/Bash/Edit/Write checks
// below still gate the actual file and shell operations. Giving the fork a
// different tool list would break prompt cache sharing (tools are part of
// the cache key β see CacheSafeParams in forkedAgent.ts).
if (tool.name === REPL_TOOL_NAME) {
return { behavior: 'allow' as const, updatedInput: input }
}
// Allow Read/Grep/Glob unrestricted β all inherently read-only
if (
tool.name === FILE_READ_TOOL_NAME ||
tool.name === GREP_TOOL_NAME ||
tool.name === GLOB_TOOL_NAME
) {
return { behavior: 'allow' as const, updatedInput: input }
}
// Allow Bash only for commands that pass BashTool.isReadOnly.
// `tool` IS BashTool here β no static import needed.
if (tool.name === BASH_TOOL_NAME) {
const parsed = tool.inputSchema.safeParse(input)
if (parsed.success && tool.isReadOnly(parsed.data)) {
return { behavior: 'allow' as const, updatedInput: input }
}
return denyAutoMemTool(
tool,
'Only read-only shell commands are permitted in this context (ls, find, grep, cat, stat, wc, head, tail, and similar)',
)
}
if (
(tool.name === FILE_EDIT_TOOL_NAME ||
tool.name === FILE_WRITE_TOOL_NAME) &&
'file_path' in input
) {
const filePath = input.file_path
if (typeof filePath === 'string' && isAutoMemPath(filePath)) {
return { behavior: 'allow' as const, updatedInput: input }
}
}
return denyAutoMemTool(
tool,
`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`,
)
}
}
// ============================================================================
// Extract file paths from agent output
// ============================================================================
/**
* Extract file_path from a tool_use block's input, if present.
* Returns undefined when the block is not an Edit/Write tool use or has no file_path.
*/
function getWrittenFilePath(block: {
type: string
name?: string
input?: unknown
}): string | undefined {
if (
block.type !== 'tool_use' ||
(block.name !== FILE_EDIT_TOOL_NAME && block.name !== FILE_WRITE_TOOL_NAME)
) {
return undefined
}
const input = block.input
if (typeof input === 'object' && input !== null && 'file_path' in input) {
const fp = (input as { file_path: unknown }).file_path
return typeof fp === 'string' ? fp : undefined
}
return undefined
}
function extractWrittenPaths(agentMessages: Message[]): string[] {
const paths: string[] = []
for (const message of agentMessages) {
if (message.type !== 'assistant') {
continue
}
const content = (message as AssistantMessage).message.content
if (!Array.isArray(content)) {
continue
}
for (const block of content) {
const filePath = getWrittenFilePath(block)
if (filePath !== undefined) {
paths.push(filePath)
}
}
}
return uniq(paths)
}
// ============================================================================
// Initialization & Closure-scoped State
// ============================================================================
type AppendSystemMessageFn = (
msg: Exclude<SystemMessage, SystemLocalCommandMessage>,
) => void
/** The active extractor function, set by initExtractMemories(). */
let extractor:
| ((
context: REPLHookContext,
appendSystemMessage?: AppendSystemMessageFn,
) => Promise<void>)
| null = null
/** The active drain function, set by initExtractMemories(). No-op until init. */
let drainer: (timeoutMs?: number) => Promise<void> = async () => {}
/**
* Initialize the memory extraction system.
* Creates a fresh closure that captures all mutable state (cursor position,
* overlap guard, pending context). Call once at startup alongside
* initConfidenceRating/initPromptCoaching, or per-test in beforeEach.
*/
export function initExtractMemories(): void {
// --- Closure-scoped mutable state ---
/** Every promise handed out by the extractor that hasn't settled yet.
* Coalesced calls that stash-and-return add fast-resolving promises
* (harmless); the call that starts real work adds a promise covering the
* full trailing-run chain via runExtraction's recursive finally. */
const inFlightExtractions = new Set<Promise<void>>()
/** UUID of the last message processed β cursor so each run only
* considers messages added since the previous extraction. */
let lastMemoryMessageUuid: string | undefined
/** One-shot flag: once we log that the gate is disabled, don't repeat. */
let hasLoggedGateFailure = false
/** True while runExtraction is executing β prevents overlapping runs. */
let inProgress = false
/** Counts eligible turns since the last extraction run. Resets to 0 after each run. */
let turnsSinceLastExtraction = 0
/** When a call arrives during an in-progress run, we stash the context here
* and run one trailing extraction after the current one finishes. */
let pendingContext:
| {
context: REPLHookContext
appendSystemMessage?: AppendSystemMessageFn
}
| undefined
// --- Inner extraction logic ---
async function runExtraction({
context,
appendSystemMessage,
isTrailingRun,
}: {
context: REPLHookContext
appendSystemMessage?: AppendSystemMessageFn
isTrailingRun?: boolean
}): Promise<void> {
const { messages } = context
const memoryDir = getAutoMemPath()
const newMessageCount = countModelVisibleMessagesSince(
messages,
lastMemoryMessageUuid,
)
// Mutual exclusion: when the main agent wrote memories, skip the
// forked agent and advance the cursor past this range so the next
// extraction only considers messages after the main agent's write.
if (hasMemoryWritesSince(messages, lastMemoryMessageUuid)) {
logForDebugging(
'[extractMemories] skipping β conversation already wrote to memory files',
)
const lastMessage = messages.at(-1)
if (lastMessage?.uuid) {
lastMemoryMessageUuid = lastMessage.uuid
}
logEvent('tengu_extract_memories_skipped_direct_write', {
message_count: newMessageCount,
})
return
}
const teamMemoryEnabled = feature('TEAMMEM')
? teamMemPaths!.isTeamMemoryEnabled()
: false
const skipIndex = getFeatureValue_CACHED_MAY_BE_STALE(
'tengu_moth_copse',
false,
)
const canUseTool = createAutoMemCanUseTool(memoryDir)
const cacheSafeParams = createCacheSafeParams(context)
// Only run extraction every N eligible turns (tengu_bramble_lintel, default 1).
// Trailing extractions (from stashed contexts) skip this check since they
// process already-committed work that should not be throttled.
if (!isTrailingRun) {
turnsSinceLastExtraction++
if (
turnsSinceLastExtraction <
(getFeatureValue_CACHED_MAY_BE_STALE('tengu_bramble_lintel', null) ?? 1)
) {
return
}
}
turnsSinceLastExtraction = 0
inProgress = true
const startTime = Date.now()
try {
logForDebugging(
`[extractMemories] starting β ${newMessageCount} new messages, memoryDir=${memoryDir}`,
)
// Pre-inject the memory directory manifest so the agent doesn't spend
// a turn on `ls`. Reuses findRelevantMemories' frontmatter scan.
// Placed after the throttle gate so skipped turns don't pay the scan cost.
const existingMemories = formatMemoryManifest(
await scanMemoryFiles(memoryDir, createAbortController().signal),
)
const userPrompt =
feature('TEAMMEM') && teamMemoryEnabled
? buildExtractCombinedPrompt(
newMessageCount,
existingMemories,
skipIndex,
)
: buildExtractAutoOnlyPrompt(
newMessageCount,
existingMemories,
skipIndex,
)
const result = await runForkedAgent({
promptMessages: [createUserMessage({ content: userPrompt })],
cacheSafeParams,
canUseTool,
querySource: 'extract_memories',
forkLabel: 'extract_memories',
// The extractMemories subagent does not need to record to transcript.
// Doing so can create race conditions with the main thread.
skipTranscript: true,
// Well-behaved extractions complete in 2-4 turns (read β write).
// A hard cap prevents verification rabbit-holes from burning turns.
maxTurns: 5,
})
// Advance the cursor only after a successful run. If the agent errors
// out (caught below), the cursor stays put so those messages are
// reconsidered on the next extraction.
const lastMessage = messages.at(-1)
if (lastMessage?.uuid) {
lastMemoryMessageUuid = lastMessage.uuid
}
const writtenPaths = extractWrittenPaths(result.messages)
const turnCount = count(result.messages, m => m.type === 'assistant')
const totalInput =
result.totalUsage.input_tokens +
result.totalUsage.cache_creation_input_tokens +
result.totalUsage.cache_read_input_tokens
const hitPct =
totalInput > 0
? (
(result.totalUsage.cache_read_input_tokens / totalInput) *
100
).toFixed(1)
: '0.0'
logForDebugging(
`[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)`,
)
if (writtenPaths.length > 0) {
logForDebugging(
`[extractMemories] memories saved: ${writtenPaths.join(', ')}`,
)
} else {
logForDebugging('[extractMemories] no memories saved this run')
}
// Index file updates are mechanical β the agent touches MEMORY.md to add
// a topic link, but the user-visible "memory" is the topic file itself.
const memoryPaths = writtenPaths.filter(
p => basename(p) !== ENTRYPOINT_NAME,
)
const teamCount = feature('TEAMMEM')
? count(memoryPaths, teamMemPaths!.isTeamMemPath)
: 0
// Log extraction event with usage from the forked agent
logEvent('tengu_extract_memories_extraction', {
input_tokens: result.totalUsage.input_tokens,
output_tokens: result.totalUsage.output_tokens,
cache_read_input_tokens: result.totalUsage.cache_read_input_tokens,
cache_creation_input_tokens:
result.totalUsage.cache_creation_input_tokens,
message_count: newMessageCount,
turn_count: turnCount,
files_written: writtenPaths.length,
memories_saved: memoryPaths.length,
team_memories_saved: teamCount,
duration_ms: Date.now() - startTime,
})
logForDebugging(
`[extractMemories] writtenPaths=${writtenPaths.length} memoryPaths=${memoryPaths.length} appendSystemMessage defined=${appendSystemMessage != null}`,
)
if (memoryPaths.length > 0) {
const msg = createMemorySavedMessage(memoryPaths)
if (feature('TEAMMEM')) {
msg.teamCount = teamCount
}
appendSystemMessage?.(msg)
}
} catch (error) {
// Extraction is best-effort β log but don't notify on error
logForDebugging(`[extractMemories] error: ${error}`)
logEvent('tengu_extract_memories_error', {
duration_ms: Date.now() - startTime,
})
} finally {
inProgress = false
// If a call arrived while we were running, run a trailing extraction
// with the latest stashed context. The trailing run will compute its
// newMessageCount relative to the cursor we just advanced β so it only
// picks up messages added between the two calls, not the full history.
const trailing = pendingContext
pendingContext = undefined
if (trailing) {
logForDebugging(
'[extractMemories] running trailing extraction for stashed context',
)
await runExtraction({
context: trailing.context,
appendSystemMessage: trailing.appendSystemMessage,
isTrailingRun: true,
})
}
}
}
// --- Public entry point (captured by extractor) ---
async function executeExtractMemoriesImpl(
context: REPLHookContext,
appendSystemMessage?: AppendSystemMessageFn,
): Promise<void> {
// Only run for the main agent, not subagents
if (context.toolUseContext.agentId) {
return
}
if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_passport_quail', false)) {
if (process.env.USER_TYPE === 'ant' && !hasLoggedGateFailure) {
hasLoggedGateFailure = true
logEvent('tengu_extract_memories_gate_disabled', {})
}
return
}
// Check auto-memory is enabled
if (!isAutoMemoryEnabled()) {
return
}
// Skip in remote mode
if (getIsRemoteMode()) {
return
}
// If an extraction is already in progress, stash this context for a
// trailing run (overwrites any previously stashed context β only the
// latest matters since it has the most messages).
if (inProgress) {
logForDebugging(
'[extractMemories] extraction in progress β stashing for trailing run',
)
logEvent('tengu_extract_memories_coalesced', {})
pendingContext = { context, appendSystemMessage }
return
}
await runExtraction({ context, appendSystemMessage })
}
extractor = async (context, appendSystemMessage) => {
const p = executeExtractMemoriesImpl(context, appendSystemMessage)
inFlightExtractions.add(p)
try {
await p
} finally {
inFlightExtractions.delete(p)
}
}
drainer = async (timeoutMs = 60_000) => {
if (inFlightExtractions.size === 0) return
await Promise.race([
Promise.all(inFlightExtractions).catch(() => {}),
// eslint-disable-next-line no-restricted-syntax -- sleep() has no .unref(); timer must not block exit
new Promise<void>(r => setTimeout(r, timeoutMs).unref()),
])
}
}
// ============================================================================
// Public API
// ============================================================================
/**
* Run memory extraction at the end of a query loop.
* Called fire-and-forget from handleStopHooks, alongside prompt suggestion/coaching.
* No-ops until initExtractMemories() has been called.
*/
export async function executeExtractMemories(
context: REPLHookContext,
appendSystemMessage?: AppendSystemMessageFn,
): Promise<void> {
await extractor?.(context, appendSystemMessage)
}
/**
* Awaits all in-flight extractions (including trailing stashed runs) with a
* soft timeout. Called by print.ts after the response is flushed but before
* gracefulShutdownSync, so the forked agent completes before the 5s shutdown
* failsafe kills it. No-op until initExtractMemories() has been called.
*/
export async function drainPendingExtraction(
timeoutMs?: number,
): Promise<void> {
await drainer(timeoutMs)
}