| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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' |
|
|
| |
| const teamMemPaths = feature('TEAMMEM') |
| ? (require('../../memdir/teamMemPaths.js') as typeof import('../../memdir/teamMemPaths.js')) |
| : null |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| 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 (!foundStart) { |
| return count(messages, isModelVisibleMessage) |
| } |
| return n |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 |
| } |
|
|
| |
| |
| |
|
|
| 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 }, |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| export function createAutoMemCanUseTool(memoryDir: string): CanUseToolFn { |
| return async (tool: Tool, input: Record<string, unknown>) => { |
| |
| |
| |
| |
| |
| |
| |
| if (tool.name === REPL_TOOL_NAME) { |
| return { behavior: 'allow' as const, updatedInput: input } |
| } |
|
|
| |
| if ( |
| tool.name === FILE_READ_TOOL_NAME || |
| tool.name === GREP_TOOL_NAME || |
| tool.name === GLOB_TOOL_NAME |
| ) { |
| return { behavior: 'allow' as const, updatedInput: input } |
| } |
|
|
| |
| |
| 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`, |
| ) |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| 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) |
| } |
|
|
| |
| |
| |
|
|
| type AppendSystemMessageFn = ( |
| msg: Exclude<SystemMessage, SystemLocalCommandMessage>, |
| ) => void |
|
|
| |
| let extractor: |
| | (( |
| context: REPLHookContext, |
| appendSystemMessage?: AppendSystemMessageFn, |
| ) => Promise<void>) |
| | null = null |
|
|
| |
| let drainer: (timeoutMs?: number) => Promise<void> = async () => {} |
|
|
| |
| |
| |
| |
| |
| |
| export function initExtractMemories(): void { |
| |
|
|
| |
| |
| |
| |
| const inFlightExtractions = new Set<Promise<void>>() |
|
|
| |
| |
| let lastMemoryMessageUuid: string | undefined |
|
|
| |
| let hasLoggedGateFailure = false |
|
|
| |
| let inProgress = false |
|
|
| |
| let turnsSinceLastExtraction = 0 |
|
|
| |
| |
| let pendingContext: |
| | { |
| context: REPLHookContext |
| appendSystemMessage?: AppendSystemMessageFn |
| } |
| | undefined |
|
|
| |
|
|
| 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, |
| ) |
|
|
| |
| |
| |
| 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) |
|
|
| |
| |
| |
| 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}`, |
| ) |
|
|
| |
| |
| |
| 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', |
| |
| |
| skipTranscript: true, |
| |
| |
| maxTurns: 5, |
| }) |
|
|
| |
| |
| |
| 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') |
| } |
|
|
| |
| |
| const memoryPaths = writtenPaths.filter( |
| p => basename(p) !== ENTRYPOINT_NAME, |
| ) |
| const teamCount = feature('TEAMMEM') |
| ? count(memoryPaths, teamMemPaths!.isTeamMemPath) |
| : 0 |
|
|
| |
| 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) { |
| |
| logForDebugging(`[extractMemories] error: ${error}`) |
| logEvent('tengu_extract_memories_error', { |
| duration_ms: Date.now() - startTime, |
| }) |
| } finally { |
| inProgress = false |
|
|
| |
| |
| |
| |
| 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, |
| }) |
| } |
| } |
| } |
|
|
| |
|
|
| async function executeExtractMemoriesImpl( |
| context: REPLHookContext, |
| appendSystemMessage?: AppendSystemMessageFn, |
| ): Promise<void> { |
| |
| 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 |
| } |
|
|
| |
| if (!isAutoMemoryEnabled()) { |
| return |
| } |
|
|
| |
| if (getIsRemoteMode()) { |
| return |
| } |
|
|
| |
| |
| |
| 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(() => {}), |
| |
| new Promise<void>(r => setTimeout(r, timeoutMs).unref()), |
| ]) |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| export async function executeExtractMemories( |
| context: REPLHookContext, |
| appendSystemMessage?: AppendSystemMessageFn, |
| ): Promise<void> { |
| await extractor?.(context, appendSystemMessage) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export async function drainPendingExtraction( |
| timeoutMs?: number, |
| ): Promise<void> { |
| await drainer(timeoutMs) |
| } |
|
|