forked from yasasbanukaofficial/claude-code
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathautoDream.ts
More file actions
324 lines (300 loc) · 11 KB
/
autoDream.ts
File metadata and controls
324 lines (300 loc) · 11 KB
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
// 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)
}