source dump of claude code
at main 324 lines 11 kB view raw
1// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered 2// Background memory consolidation. Fires the /dream prompt as a forked 3// subagent when time-gate passes AND enough sessions have accumulated. 4// 5// Gate order (cheapest first): 6// 1. Time: hours since lastConsolidatedAt >= minHours (one stat) 7// 2. Sessions: transcript count with mtime > lastConsolidatedAt >= minSessions 8// 3. Lock: no other process mid-consolidation 9// 10// State is closure-scoped inside initAutoDream() rather than module-level 11// (tests call initAutoDream() in beforeEach for a fresh closure). 12 13import type { REPLHookContext } from '../../utils/hooks/postSamplingHooks.js' 14import { 15 createCacheSafeParams, 16 runForkedAgent, 17} from '../../utils/forkedAgent.js' 18import { 19 createUserMessage, 20 createMemorySavedMessage, 21} from '../../utils/messages.js' 22import type { Message } from '../../types/message.js' 23import { logForDebugging } from '../../utils/debug.js' 24import type { ToolUseContext } from '../../Tool.js' 25import { logEvent } from '../analytics/index.js' 26import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' 27import { isAutoMemoryEnabled, getAutoMemPath } from '../../memdir/paths.js' 28import { isAutoDreamEnabled } from './config.js' 29import { getProjectDir } from '../../utils/sessionStorage.js' 30import { 31 getOriginalCwd, 32 getKairosActive, 33 getIsRemoteMode, 34 getSessionId, 35} from '../../bootstrap/state.js' 36import { createAutoMemCanUseTool } from '../extractMemories/extractMemories.js' 37import { buildConsolidationPrompt } from './consolidationPrompt.js' 38import { 39 readLastConsolidatedAt, 40 listSessionsTouchedSince, 41 tryAcquireConsolidationLock, 42 rollbackConsolidationLock, 43} from './consolidationLock.js' 44import { 45 registerDreamTask, 46 addDreamTurn, 47 completeDreamTask, 48 failDreamTask, 49 isDreamTask, 50} from '../../tasks/DreamTask/DreamTask.js' 51import { FILE_EDIT_TOOL_NAME } from '../../tools/FileEditTool/constants.js' 52import { FILE_WRITE_TOOL_NAME } from '../../tools/FileWriteTool/prompt.js' 53 54// Scan throttle: when time-gate passes but session-gate doesn't, the lock 55// mtime doesn't advance, so the time-gate keeps passing every turn. 56const SESSION_SCAN_INTERVAL_MS = 10 * 60 * 1000 57 58type AutoDreamConfig = { 59 minHours: number 60 minSessions: number 61} 62 63const DEFAULTS: AutoDreamConfig = { 64 minHours: 24, 65 minSessions: 5, 66} 67 68/** 69 * Thresholds from tengu_onyx_plover. The enabled gate lives in config.ts 70 * (isAutoDreamEnabled); this returns only the scheduling knobs. Defensive 71 * per-field validation since GB cache can return stale wrong-type values. 72 */ 73function getConfig(): AutoDreamConfig { 74 const raw = 75 getFeatureValue_CACHED_MAY_BE_STALE<Partial<AutoDreamConfig> | null>( 76 'tengu_onyx_plover', 77 null, 78 ) 79 return { 80 minHours: 81 typeof raw?.minHours === 'number' && 82 Number.isFinite(raw.minHours) && 83 raw.minHours > 0 84 ? raw.minHours 85 : DEFAULTS.minHours, 86 minSessions: 87 typeof raw?.minSessions === 'number' && 88 Number.isFinite(raw.minSessions) && 89 raw.minSessions > 0 90 ? raw.minSessions 91 : DEFAULTS.minSessions, 92 } 93} 94 95function isGateOpen(): boolean { 96 if (getKairosActive()) return false // KAIROS mode uses disk-skill dream 97 if (getIsRemoteMode()) return false 98 if (!isAutoMemoryEnabled()) return false 99 return isAutoDreamEnabled() 100} 101 102// Ant-build-only test override. Bypasses enabled/time/session gates but NOT 103// the lock (so repeated turns don't pile up dreams) or the memory-dir 104// precondition. Still scans sessions so the prompt's session-hint is populated. 105function isForced(): boolean { 106 return false 107} 108 109type AppendSystemMessageFn = NonNullable<ToolUseContext['appendSystemMessage']> 110 111let runner: 112 | (( 113 context: REPLHookContext, 114 appendSystemMessage?: AppendSystemMessageFn, 115 ) => Promise<void>) 116 | null = null 117 118/** 119 * Call once at startup (from backgroundHousekeeping alongside 120 * initExtractMemories), or per-test in beforeEach for a fresh closure. 121 */ 122export function initAutoDream(): void { 123 let lastSessionScanAt = 0 124 125 runner = async function runAutoDream(context, appendSystemMessage) { 126 const cfg = getConfig() 127 const force = isForced() 128 if (!force && !isGateOpen()) return 129 130 // --- Time gate --- 131 let lastAt: number 132 try { 133 lastAt = await readLastConsolidatedAt() 134 } catch (e: unknown) { 135 logForDebugging( 136 `[autoDream] readLastConsolidatedAt failed: ${(e as Error).message}`, 137 ) 138 return 139 } 140 const hoursSince = (Date.now() - lastAt) / 3_600_000 141 if (!force && hoursSince < cfg.minHours) return 142 143 // --- Scan throttle --- 144 const sinceScanMs = Date.now() - lastSessionScanAt 145 if (!force && sinceScanMs < SESSION_SCAN_INTERVAL_MS) { 146 logForDebugging( 147 `[autoDream] scan throttle — time-gate passed but last scan was ${Math.round(sinceScanMs / 1000)}s ago`, 148 ) 149 return 150 } 151 lastSessionScanAt = Date.now() 152 153 // --- Session gate --- 154 let sessionIds: string[] 155 try { 156 sessionIds = await listSessionsTouchedSince(lastAt) 157 } catch (e: unknown) { 158 logForDebugging( 159 `[autoDream] listSessionsTouchedSince failed: ${(e as Error).message}`, 160 ) 161 return 162 } 163 // Exclude the current session (its mtime is always recent). 164 const currentSession = getSessionId() 165 sessionIds = sessionIds.filter(id => id !== currentSession) 166 if (!force && sessionIds.length < cfg.minSessions) { 167 logForDebugging( 168 `[autoDream] skip — ${sessionIds.length} sessions since last consolidation, need ${cfg.minSessions}`, 169 ) 170 return 171 } 172 173 // --- Lock --- 174 // Under force, skip acquire entirely — use the existing mtime so 175 // kill's rollback is a no-op (rewinds to where it already is). 176 // The lock file stays untouched; next non-force turn sees it as-is. 177 let priorMtime: number | null 178 if (force) { 179 priorMtime = lastAt 180 } else { 181 try { 182 priorMtime = await tryAcquireConsolidationLock() 183 } catch (e: unknown) { 184 logForDebugging( 185 `[autoDream] lock acquire failed: ${(e as Error).message}`, 186 ) 187 return 188 } 189 if (priorMtime === null) return 190 } 191 192 logForDebugging( 193 `[autoDream] firing — ${hoursSince.toFixed(1)}h since last, ${sessionIds.length} sessions to review`, 194 ) 195 logEvent('tengu_auto_dream_fired', { 196 hours_since: Math.round(hoursSince), 197 sessions_since: sessionIds.length, 198 }) 199 200 const setAppState = 201 context.toolUseContext.setAppStateForTasks ?? 202 context.toolUseContext.setAppState 203 const abortController = new AbortController() 204 const taskId = registerDreamTask(setAppState, { 205 sessionsReviewing: sessionIds.length, 206 priorMtime, 207 abortController, 208 }) 209 210 try { 211 const memoryRoot = getAutoMemPath() 212 const transcriptDir = getProjectDir(getOriginalCwd()) 213 // Tool constraints note goes in `extra`, not the shared prompt body — 214 // manual /dream runs in the main loop with normal permissions and this 215 // would be misleading there. 216 const extra = ` 217 218**Tool constraints for this run:** Bash is restricted to read-only commands (\`ls\`, \`find\`, \`grep\`, \`cat\`, \`stat\`, \`wc\`, \`head\`, \`tail\`, and similar). Anything that writes, redirects to a file, or modifies state will be denied. Plan your exploration with this in mind — no need to probe. 219 220Sessions since last consolidation (${sessionIds.length}): 221${sessionIds.map(id => `- ${id}`).join('\n')}` 222 const prompt = buildConsolidationPrompt(memoryRoot, transcriptDir, extra) 223 224 const result = await runForkedAgent({ 225 promptMessages: [createUserMessage({ content: prompt })], 226 cacheSafeParams: createCacheSafeParams(context), 227 canUseTool: createAutoMemCanUseTool(memoryRoot), 228 querySource: 'auto_dream', 229 forkLabel: 'auto_dream', 230 skipTranscript: true, 231 overrides: { abortController }, 232 onMessage: makeDreamProgressWatcher(taskId, setAppState), 233 }) 234 235 completeDreamTask(taskId, setAppState) 236 // Inline completion summary in the main transcript (same surface as 237 // extractMemories's "Saved N memories" message). 238 const dreamState = context.toolUseContext.getAppState().tasks?.[taskId] 239 if ( 240 appendSystemMessage && 241 isDreamTask(dreamState) && 242 dreamState.filesTouched.length > 0 243 ) { 244 appendSystemMessage({ 245 ...createMemorySavedMessage(dreamState.filesTouched), 246 verb: 'Improved', 247 }) 248 } 249 logForDebugging( 250 `[autoDream] completed — cache: read=${result.totalUsage.cache_read_input_tokens} created=${result.totalUsage.cache_creation_input_tokens}`, 251 ) 252 logEvent('tengu_auto_dream_completed', { 253 cache_read: result.totalUsage.cache_read_input_tokens, 254 cache_created: result.totalUsage.cache_creation_input_tokens, 255 output: result.totalUsage.output_tokens, 256 sessions_reviewed: sessionIds.length, 257 }) 258 } catch (e: unknown) { 259 // If the user killed from the bg-tasks dialog, DreamTask.kill already 260 // aborted, rolled back the lock, and set status=killed. Don't overwrite 261 // or double-rollback. 262 if (abortController.signal.aborted) { 263 logForDebugging('[autoDream] aborted by user') 264 return 265 } 266 logForDebugging(`[autoDream] fork failed: ${(e as Error).message}`) 267 logEvent('tengu_auto_dream_failed', {}) 268 failDreamTask(taskId, setAppState) 269 // Rewind mtime so time-gate passes again. Scan throttle is the backoff. 270 await rollbackConsolidationLock(priorMtime) 271 } 272 } 273} 274 275/** 276 * Watch the forked agent's messages. For each assistant turn, extracts any 277 * text blocks (the agent's reasoning/summary — what the user wants to see) 278 * and collapses tool_use blocks to a count. Edit/Write file_paths are 279 * collected for phase-flip + the inline completion message. 280 */ 281function makeDreamProgressWatcher( 282 taskId: string, 283 setAppState: import('../../Task.js').SetAppState, 284): (msg: Message) => void { 285 return msg => { 286 if (msg.type !== 'assistant') return 287 let text = '' 288 let toolUseCount = 0 289 const touchedPaths: string[] = [] 290 for (const block of msg.message.content) { 291 if (block.type === 'text') { 292 text += block.text 293 } else if (block.type === 'tool_use') { 294 toolUseCount++ 295 if ( 296 block.name === FILE_EDIT_TOOL_NAME || 297 block.name === FILE_WRITE_TOOL_NAME 298 ) { 299 const input = block.input as { file_path?: unknown } 300 if (typeof input.file_path === 'string') { 301 touchedPaths.push(input.file_path) 302 } 303 } 304 } 305 } 306 addDreamTurn( 307 taskId, 308 { text: text.trim(), toolUseCount }, 309 touchedPaths, 310 setAppState, 311 ) 312 } 313} 314 315/** 316 * Entry point from stopHooks. No-op until initAutoDream() has been called. 317 * Per-turn cost when enabled: one GB cache read + one stat. 318 */ 319export async function executeAutoDream( 320 context: REPLHookContext, 321 appendSystemMessage?: AppendSystemMessageFn, 322): Promise<void> { 323 await runner?.(context, appendSystemMessage) 324}