// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered // Background memory consolidation. Fires the /dream prompt as a forked // subagent when time-gate passes AND enough sessions have accumulated. // // Gate order (cheapest first): // 1. Time: hours since lastConsolidatedAt >= minHours (one stat) // 2. Sessions: transcript count with mtime > lastConsolidatedAt >= minSessions // 3. Lock: no other process mid-consolidation // // State is closure-scoped inside initAutoDream() rather than module-level // (tests call initAutoDream() in beforeEach for a fresh closure). import type { REPLHookContext } from '../../utils/hooks/postSamplingHooks.js' import { createCacheSafeParams, runForkedAgent, } from '../../utils/forkedAgent.js' import { createUserMessage, createMemorySavedMessage, } from '../../utils/messages.js' import type { Message } from '../../types/message.js' import { logForDebugging } from '../../utils/debug.js' import type { ToolUseContext } from '../../Tool.js' import { logEvent } from '../analytics/index.js' import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' import { isAutoMemoryEnabled, getAutoMemPath } from '../../memdir/paths.js' import { isAutoDreamEnabled } from './config.js' import { getProjectDir } from '../../utils/sessionStorage.js' import { getOriginalCwd, getKairosActive, getIsRemoteMode, getSessionId, } from '../../bootstrap/state.js' import { createAutoMemCanUseTool } from '../extractMemories/extractMemories.js' import { buildConsolidationPrompt } from './consolidationPrompt.js' import { readLastConsolidatedAt, listSessionsTouchedSince, tryAcquireConsolidationLock, rollbackConsolidationLock, } from './consolidationLock.js' import { registerDreamTask, addDreamTurn, completeDreamTask, failDreamTask, isDreamTask, } from '../../tasks/DreamTask/DreamTask.js' import { FILE_EDIT_TOOL_NAME } from '../../tools/FileEditTool/constants.js' import { FILE_WRITE_TOOL_NAME } from '../../tools/FileWriteTool/prompt.js' // Scan throttle: when time-gate passes but session-gate doesn't, the lock // mtime doesn't advance, so the time-gate keeps passing every turn. const SESSION_SCAN_INTERVAL_MS = 10 * 60 * 1000 type AutoDreamConfig = { minHours: number minSessions: number } const DEFAULTS: AutoDreamConfig = { minHours: 24, minSessions: 5, } /** * Thresholds from tengu_onyx_plover. The enabled gate lives in config.ts * (isAutoDreamEnabled); this returns only the scheduling knobs. Defensive * per-field validation since GB cache can return stale wrong-type values. */ function getConfig(): AutoDreamConfig { const raw = getFeatureValue_CACHED_MAY_BE_STALE | null>( 'tengu_onyx_plover', null, ) return { minHours: typeof raw?.minHours === 'number' && Number.isFinite(raw.minHours) && raw.minHours > 0 ? raw.minHours : DEFAULTS.minHours, minSessions: typeof raw?.minSessions === 'number' && Number.isFinite(raw.minSessions) && raw.minSessions > 0 ? raw.minSessions : DEFAULTS.minSessions, } } function isGateOpen(): boolean { if (getKairosActive()) return false // KAIROS mode uses disk-skill dream if (getIsRemoteMode()) return false if (!isAutoMemoryEnabled()) return false return isAutoDreamEnabled() } // Ant-build-only test override. Bypasses enabled/time/session gates but NOT // the lock (so repeated turns don't pile up dreams) or the memory-dir // precondition. Still scans sessions so the prompt's session-hint is populated. function isForced(): boolean { return false } type AppendSystemMessageFn = NonNullable let runner: | (( context: REPLHookContext, appendSystemMessage?: AppendSystemMessageFn, ) => Promise) | null = null /** * Call once at startup (from backgroundHousekeeping alongside * initExtractMemories), or per-test in beforeEach for a fresh closure. */ export function initAutoDream(): void { let lastSessionScanAt = 0 runner = async function runAutoDream(context, appendSystemMessage) { const cfg = getConfig() const force = isForced() if (!force && !isGateOpen()) return // --- Time gate --- let lastAt: number try { lastAt = await readLastConsolidatedAt() } catch (e: unknown) { logForDebugging( `[autoDream] readLastConsolidatedAt failed: ${(e as Error).message}`, ) return } const hoursSince = (Date.now() - lastAt) / 3_600_000 if (!force && hoursSince < cfg.minHours) return // --- Scan throttle --- const sinceScanMs = Date.now() - lastSessionScanAt if (!force && sinceScanMs < SESSION_SCAN_INTERVAL_MS) { logForDebugging( `[autoDream] scan throttle — time-gate passed but last scan was ${Math.round(sinceScanMs / 1000)}s ago`, ) return } lastSessionScanAt = Date.now() // --- Session gate --- let sessionIds: string[] try { sessionIds = await listSessionsTouchedSince(lastAt) } catch (e: unknown) { logForDebugging( `[autoDream] listSessionsTouchedSince failed: ${(e as Error).message}`, ) return } // Exclude the current session (its mtime is always recent). const currentSession = getSessionId() sessionIds = sessionIds.filter(id => id !== currentSession) if (!force && sessionIds.length < cfg.minSessions) { logForDebugging( `[autoDream] skip — ${sessionIds.length} sessions since last consolidation, need ${cfg.minSessions}`, ) return } // --- Lock --- // Under force, skip acquire entirely — use the existing mtime so // kill's rollback is a no-op (rewinds to where it already is). // The lock file stays untouched; next non-force turn sees it as-is. let priorMtime: number | null if (force) { priorMtime = lastAt } else { try { priorMtime = await tryAcquireConsolidationLock() } catch (e: unknown) { logForDebugging( `[autoDream] lock acquire failed: ${(e as Error).message}`, ) return } if (priorMtime === null) return } logForDebugging( `[autoDream] firing — ${hoursSince.toFixed(1)}h since last, ${sessionIds.length} sessions to review`, ) logEvent('tengu_auto_dream_fired', { hours_since: Math.round(hoursSince), sessions_since: sessionIds.length, }) const setAppState = context.toolUseContext.setAppStateForTasks ?? context.toolUseContext.setAppState const abortController = new AbortController() const taskId = registerDreamTask(setAppState, { sessionsReviewing: sessionIds.length, priorMtime, abortController, }) try { const memoryRoot = getAutoMemPath() const transcriptDir = getProjectDir(getOriginalCwd()) // Tool constraints note goes in `extra`, not the shared prompt body — // manual /dream runs in the main loop with normal permissions and this // would be misleading there. const extra = ` **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. Sessions since last consolidation (${sessionIds.length}): ${sessionIds.map(id => `- ${id}`).join('\n')}` const prompt = buildConsolidationPrompt(memoryRoot, transcriptDir, extra) const result = await runForkedAgent({ promptMessages: [createUserMessage({ content: prompt })], cacheSafeParams: createCacheSafeParams(context), canUseTool: createAutoMemCanUseTool(memoryRoot), querySource: 'auto_dream', forkLabel: 'auto_dream', skipTranscript: true, overrides: { abortController }, onMessage: makeDreamProgressWatcher(taskId, setAppState), }) completeDreamTask(taskId, setAppState) // Inline completion summary in the main transcript (same surface as // extractMemories's "Saved N memories" message). const dreamState = context.toolUseContext.getAppState().tasks?.[taskId] if ( appendSystemMessage && isDreamTask(dreamState) && dreamState.filesTouched.length > 0 ) { appendSystemMessage({ ...createMemorySavedMessage(dreamState.filesTouched), verb: 'Improved', }) } logForDebugging( `[autoDream] completed — cache: read=${result.totalUsage.cache_read_input_tokens} created=${result.totalUsage.cache_creation_input_tokens}`, ) logEvent('tengu_auto_dream_completed', { cache_read: result.totalUsage.cache_read_input_tokens, cache_created: result.totalUsage.cache_creation_input_tokens, output: result.totalUsage.output_tokens, sessions_reviewed: sessionIds.length, }) } catch (e: unknown) { // If the user killed from the bg-tasks dialog, DreamTask.kill already // aborted, rolled back the lock, and set status=killed. Don't overwrite // or double-rollback. if (abortController.signal.aborted) { logForDebugging('[autoDream] aborted by user') return } logForDebugging(`[autoDream] fork failed: ${(e as Error).message}`) logEvent('tengu_auto_dream_failed', {}) failDreamTask(taskId, setAppState) // Rewind mtime so time-gate passes again. Scan throttle is the backoff. await rollbackConsolidationLock(priorMtime) } } } /** * Watch the forked agent's messages. For each assistant turn, extracts any * text blocks (the agent's reasoning/summary — what the user wants to see) * and collapses tool_use blocks to a count. Edit/Write file_paths are * collected for phase-flip + the inline completion message. */ function makeDreamProgressWatcher( taskId: string, setAppState: import('../../Task.js').SetAppState, ): (msg: Message) => void { return msg => { if (msg.type !== 'assistant') return let text = '' let toolUseCount = 0 const touchedPaths: string[] = [] for (const block of msg.message.content) { if (block.type === 'text') { text += block.text } else if (block.type === 'tool_use') { toolUseCount++ if ( block.name === FILE_EDIT_TOOL_NAME || block.name === FILE_WRITE_TOOL_NAME ) { const input = block.input as { file_path?: unknown } if (typeof input.file_path === 'string') { touchedPaths.push(input.file_path) } } } } addDreamTurn( taskId, { text: text.trim(), toolUseCount }, touchedPaths, setAppState, ) } } /** * Entry point from stopHooks. No-op until initAutoDream() has been called. * Per-turn cost when enabled: one GB cache read + one stat. */ export async function executeAutoDream( context: REPLHookContext, appendSystemMessage?: AppendSystemMessageFn, ): Promise { await runner?.(context, appendSystemMessage) }