325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
// 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<Partial<AutoDreamConfig> | 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<ToolUseContext['appendSystemMessage']>
|
|
|
|
let runner:
|
|
| ((
|
|
context: REPLHookContext,
|
|
appendSystemMessage?: AppendSystemMessageFn,
|
|
) => Promise<void>)
|
|
| 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<void> {
|
|
await runner?.(context, appendSystemMessage)
|
|
}
|