File size: 11,259 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 | // 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)
}
|