dave-code 1.0.4 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,104 @@
1
+ import { EventEmitter } from 'events';
2
+
3
+ export const RUNTIME_EVENT_TYPES = new Set([
4
+ 'turn.started',
5
+ 'turn.completed',
6
+ 'turn.failed',
7
+ 'turn.cancelled',
8
+ 'model.started',
9
+ 'model.delta',
10
+ 'model.tool_call',
11
+ 'model.completed',
12
+ 'model.retry',
13
+ 'tool.requested',
14
+ 'tool.started',
15
+ 'tool.progress',
16
+ 'tool.completed',
17
+ 'tool.failed',
18
+ 'permission.requested',
19
+ 'permission.resolved',
20
+ 'context.compacted'
21
+ ,'context.planned'
22
+ ,'context.usage'
23
+ ,'read.plan.presented'
24
+ ,'read.plan.resolved'
25
+ ,'budget.warning'
26
+ ,'budget.resolved'
27
+ ,'usage.phase'
28
+ ,'scan.started'
29
+ ,'scan.progress'
30
+ ,'scan.inspect'
31
+ ,'scan.index.completed'
32
+ ,'scan.notebook.checked'
33
+ ,'scan.drift.completed'
34
+ ,'scan.completed'
35
+ ,'scan.degraded'
36
+ ,'scan.note.plan.started'
37
+ ,'scan.note.plan.completed'
38
+ ,'scan.note.plan.degraded'
39
+ ,'phase.changed'
40
+ ,'note.build.started'
41
+ ,'note.build.progress'
42
+ ,'note.build.completed'
43
+ ,'note.loaded'
44
+ ,'note.drift'
45
+ ,'note.update.started'
46
+ ,'note.update.completed'
47
+ ,'note.failed'
48
+ ,'note.synthesis.started'
49
+ ,'note.synthesis.completed'
50
+ ,'read.file.completed'
51
+ ,'memory.recalled'
52
+ ,'memory.saved'
53
+ ,'team.started'
54
+ ,'team.phase'
55
+ ,'team.completed'
56
+ ,'member.added'
57
+ ,'member.updated'
58
+ ,'task.updated'
59
+ ,'message.sent'
60
+ ,'resource.proposed'
61
+ ,'todos.updated'
62
+ ,'resource.approved'
63
+ ]);
64
+
65
+ export function createRuntimeEvents({ turnId, now = Date.now } = {}) {
66
+ const emitter = new EventEmitter();
67
+ const resolvedTurnId = turnId || `turn-${now()}-${Math.random().toString(36).slice(2, 8)}`;
68
+ let seq = 0;
69
+ let closed = false;
70
+
71
+ function emit(type, data = {}) {
72
+ if (closed) return null;
73
+ if (!RUNTIME_EVENT_TYPES.has(type)) {
74
+ throw new Error(`Unknown runtime event type: ${type}`);
75
+ }
76
+ const event = {
77
+ version: 1,
78
+ seq: ++seq,
79
+ type,
80
+ turnId: resolvedTurnId,
81
+ timestamp: now(),
82
+ data: data && typeof data === 'object' ? data : { value: data }
83
+ };
84
+ emitter.emit('event', event);
85
+ return event;
86
+ }
87
+
88
+ function subscribe(listener) {
89
+ emitter.on('event', listener);
90
+ return () => emitter.off('event', listener);
91
+ }
92
+
93
+ function close() {
94
+ closed = true;
95
+ emitter.removeAllListeners();
96
+ }
97
+
98
+ return {
99
+ turnId: resolvedTurnId,
100
+ emit,
101
+ subscribe,
102
+ close
103
+ };
104
+ }
@@ -0,0 +1,561 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { streamAIResponse } from './aiClient.js';
6
+ import { getUsableContextTokens, inferContextWindowTokens } from './configManager.js';
7
+ import { estimateTokens } from './contextManager.js';
8
+ import { isSensitivePath } from './toolRuntime.js';
9
+ import {
10
+ detectNotebookDrift,
11
+ loadProjectNotebook,
12
+ normalizeNotebookBuildPlan
13
+ } from './projectNotebookManager.js';
14
+
15
+ const CACHE_VERSION = 1;
16
+ const BUILTIN_IGNORES = new Set([
17
+ '.git', 'node_modules', '.next', '.nuxt', 'dist', 'build', 'coverage',
18
+ 'vendor', '__pycache__', '.venv', 'venv', 'target', '.cache',
19
+ '.pytest_cache', '.mypy_cache', '.ruff_cache', '.tox', '.nox', 'htmlcov',
20
+ '.turbo', '.parcel-cache', '.vite', 'out', 'artifacts', 'cache',
21
+ 'tmp', 'temp', 'logs'
22
+ ]);
23
+ let cacheBaseDir = path.join(os.homedir(), '.dave-code-scans');
24
+
25
+ export function setScanCacheDirForTesting(directory) {
26
+ cacheBaseDir = directory;
27
+ }
28
+
29
+ function canonicalRoot(root) {
30
+ const resolved = path.resolve(root);
31
+ try { return fs.realpathSync.native(resolved); } catch { return resolved; }
32
+ }
33
+
34
+ function cacheFile(root) {
35
+ const key = crypto.createHash('sha256').update(canonicalRoot(root).toLowerCase()).digest('hex');
36
+ return path.join(cacheBaseDir, `${key}.json`);
37
+ }
38
+
39
+ function atomicWrite(filePath, value) {
40
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
41
+ const temp = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
42
+ fs.writeFileSync(temp, JSON.stringify(value), { encoding: 'utf8', mode: 0o600 });
43
+ try { fs.chmodSync(temp, 0o600); } catch {}
44
+ try {
45
+ fs.renameSync(temp, filePath);
46
+ } catch (error) {
47
+ if (process.platform !== 'win32' || !fs.existsSync(filePath)) throw error;
48
+ fs.unlinkSync(filePath);
49
+ fs.renameSync(temp, filePath);
50
+ } finally {
51
+ if (fs.existsSync(temp)) fs.unlinkSync(temp);
52
+ }
53
+ }
54
+
55
+ function loadCache(root) {
56
+ try {
57
+ const parsed = JSON.parse(fs.readFileSync(cacheFile(root), 'utf8'));
58
+ return parsed?.version === CACHE_VERSION && parsed.files ? parsed : { version: CACHE_VERSION, files: {} };
59
+ } catch {
60
+ return { version: CACHE_VERSION, files: {} };
61
+ }
62
+ }
63
+
64
+ function extensionKind(relPath) {
65
+ const ext = path.extname(relPath).toLowerCase();
66
+ const kinds = {
67
+ '.js': 'JavaScript', '.mjs': 'JavaScript', '.cjs': 'JavaScript', '.ts': 'TypeScript', '.tsx': 'TypeScript UI',
68
+ '.jsx': 'JavaScript UI', '.py': 'Python', '.go': 'Go', '.rs': 'Rust', '.java': 'Java', '.cs': 'C#',
69
+ '.cpp': 'C++', '.c': 'C', '.h': 'C/C++ header', '.json': 'JSON', '.yaml': 'YAML', '.yml': 'YAML',
70
+ '.toml': 'TOML', '.md': 'Documentation', '.html': 'HTML', '.css': 'Stylesheet', '.scss': 'Stylesheet',
71
+ '.sql': 'SQL', '.sh': 'Shell', '.ps1': 'PowerShell', '.bat': 'Batch'
72
+ };
73
+ return kinds[ext] || (ext ? `${ext.slice(1).toUpperCase()} file` : 'File');
74
+ }
75
+
76
+ function purposeHint(relPath) {
77
+ const normalized = relPath.replace(/\\/g, '/').toLowerCase();
78
+ const base = path.basename(normalized);
79
+ if (/^(package|composer|cargo|pyproject|requirements|go\.mod)/.test(base)) return 'Dependency and project manifest';
80
+ if (/^(readme|contributing|changelog|license)/.test(base)) return 'Project documentation';
81
+ if (/(^|\/)(test|tests|spec|__tests__)(\/|$)|\.(test|spec)\./.test(normalized)) return 'Automated tests';
82
+ if (/(^|\/)(config|configs|\.github)(\/|$)|config\./.test(normalized)) return 'Configuration or automation';
83
+ if (/(^|\/)(src|app|lib)(\/|$)/.test(normalized)) return 'Application source';
84
+ if (/(^|\/)(assets|public|static)(\/|$)/.test(normalized)) return 'Static asset';
85
+ if (/(index|main|app|server|cli)\.[^.]+$/.test(base)) return 'Likely entry point';
86
+ return extensionKind(relPath);
87
+ }
88
+
89
+ function gitignorePatterns(root) {
90
+ try {
91
+ return fs.readFileSync(path.join(root, '.gitignore'), 'utf8')
92
+ .split(/\r?\n/).map(line => line.trim()).filter(line => line && !line.startsWith('#'));
93
+ } catch { return []; }
94
+ }
95
+
96
+ function ignoredByPattern(relPath, patterns) {
97
+ const normalized = relPath.replace(/\\/g, '/');
98
+ let ignored = false;
99
+ for (const rawRule of patterns) {
100
+ const negated = rawRule.startsWith('!');
101
+ const raw = negated ? rawRule.slice(1) : rawRule;
102
+ const pattern = raw.replace(/^\//, '').replace(/\/$/, '');
103
+ if (!pattern) continue;
104
+ let matches;
105
+ if (!pattern.includes('*')) matches = normalized === pattern || normalized.startsWith(`${pattern}/`) || normalized.split('/').includes(pattern);
106
+ else {
107
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*').replace(/\?/g, '.');
108
+ matches = new RegExp(`(?:^|/)${escaped}(?:$|/)`).test(normalized);
109
+ }
110
+ if (matches) ignored = !negated;
111
+ }
112
+ return ignored;
113
+ }
114
+
115
+ function requestTerms(request) {
116
+ return [...new Set(String(request || '').toLowerCase().match(/[a-z][a-z0-9_.-]{2,}|[\u3400-\u9fff]{2,}/g) || [])].slice(0, 30);
117
+ }
118
+
119
+ function capEstimatedTokens(value, maxTokens) {
120
+ const text = String(value || '');
121
+ if (estimateTokens(text) <= maxTokens) return text;
122
+ let low = 0;
123
+ let high = text.length;
124
+ while (low < high) {
125
+ const middle = Math.ceil((low + high) / 2);
126
+ if (estimateTokens(text.slice(0, middle)) <= maxTokens) low = middle;
127
+ else high = middle - 1;
128
+ }
129
+ return `${text.slice(0, low)}\n[Token-capped]`;
130
+ }
131
+
132
+ function notebookCatalog(notebook, drift = null) {
133
+ if (!notebook) return null;
134
+ return {
135
+ status: notebook.status,
136
+ maturity: notebook.maturity || 'complete',
137
+ coverage: notebook.coverage,
138
+ headings: Object.keys(notebook.sections || {}).slice(0, 12),
139
+ sectionIndex: Object.fromEntries(Object.entries(notebook.sectionIndex || {}).slice(0, 12).map(([name, entry]) => [
140
+ name,
141
+ {
142
+ files: Array.isArray(entry?.files) ? entry.files.slice(0, 12) : [],
143
+ keywords: Array.isArray(entry?.keywords) ? entry.keywords.slice(0, 20) : []
144
+ }
145
+ ])),
146
+ shortSummary: capEstimatedTokens(notebook.shortSummary || '', 1000),
147
+ drift: (drift?.changes || []).slice(0, 100).map(item => ({
148
+ type: item.type, path: item.path, from: item.from
149
+ }))
150
+ };
151
+ }
152
+
153
+ function isOutlineCandidate(file, terms) {
154
+ const lower = file.path.toLowerCase();
155
+ return file.size <= 512 * 1024 && !file.sensitive && (
156
+ /(^|\/)(package\.json|readme[^/]*|[^/]*(index|main|server|cli)\.[^/]+)$/.test(lower) ||
157
+ terms.some(term => lower.includes(term))
158
+ );
159
+ }
160
+
161
+ async function outlineFile(absPath) {
162
+ const handle = await fs.promises.open(absPath, 'r');
163
+ try {
164
+ const buffer = Buffer.alloc(128 * 1024);
165
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
166
+ const sample = buffer.subarray(0, bytesRead);
167
+ if (sample.includes(0)) return null;
168
+ const text = sample.toString('utf8');
169
+ const lines = text.split(/\r?\n/);
170
+ const symbols = [];
171
+ const imports = [];
172
+ for (let index = 0; index < lines.length && symbols.length < 60; index++) {
173
+ const line = lines[index];
174
+ if (/^\s*(?:import|export\s+.*from|const\s+.*require\s*\()/i.test(line)) imports.push(`${index + 1}: ${line.trim().slice(0, 180)}`);
175
+ if (/^\s*(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|const\s+\w+\s*=\s*(?:async\s*)?\(|def\s+|class\s+)/.test(line)) symbols.push(`${index + 1}: ${line.trim().slice(0, 180)}`);
176
+ }
177
+ return { imports: imports.slice(0, 30), symbols, sampledBytes: bytesRead };
178
+ } finally { await handle.close(); }
179
+ }
180
+
181
+ export async function buildScanSnapshot(workspaceRoot, request = '', { emit = () => {}, signal, includeOutlines = true } = {}) {
182
+ const root = canonicalRoot(workspaceRoot);
183
+ const previous = loadCache(root);
184
+ const patterns = gitignorePatterns(root);
185
+ const terms = requestTerms(request);
186
+ const files = [];
187
+ const excluded = [];
188
+ const languages = {};
189
+ const directories = {};
190
+ let totalBytes = 0;
191
+ let cacheHits = 0;
192
+ const queue = [root];
193
+ emit('scan.started', { root });
194
+ while (queue.length) {
195
+ if (signal?.aborted) throw new Error('Scan cancelled.');
196
+ const directory = queue.shift();
197
+ let entries;
198
+ try { entries = await fs.promises.readdir(directory, { withFileTypes: true }); } catch { continue; }
199
+ for (const entry of entries) {
200
+ const absolute = path.join(directory, entry.name);
201
+ const rel = path.relative(root, absolute).replace(/\\/g, '/');
202
+ if (entry.isSymbolicLink()) { excluded.push({ path: rel, reason: 'symlink' }); continue; }
203
+ if (entry.isDirectory()) {
204
+ if (BUILTIN_IGNORES.has(entry.name) || ignoredByPattern(rel, patterns)) {
205
+ excluded.push({ path: rel, reason: 'ignored-directory' });
206
+ } else queue.push(absolute);
207
+ continue;
208
+ }
209
+ if (!entry.isFile() || ignoredByPattern(rel, patterns)) continue;
210
+ let stat;
211
+ try { stat = await fs.promises.stat(absolute); } catch { continue; }
212
+ const kind = extensionKind(rel);
213
+ const identity = `${stat.size}:${Math.trunc(stat.mtimeMs)}`;
214
+ const cached = previous.files[rel]?.identity === identity ? previous.files[rel] : null;
215
+ if (cached) cacheHits++;
216
+ const file = {
217
+ path: rel, size: stat.size, mtimeMs: stat.mtimeMs, identity, kind,
218
+ purpose: cached?.purpose || purposeHint(rel), sensitive: isSensitivePath(rel),
219
+ outline: cached?.outline || null
220
+ };
221
+ files.push(file);
222
+ totalBytes += stat.size;
223
+ languages[kind] = (languages[kind] || 0) + 1;
224
+ const top = rel.includes('/') ? rel.split('/')[0] : '.';
225
+ directories[top] = (directories[top] || 0) + 1;
226
+ if (files.length % 100 === 0) emit('scan.progress', { files: files.length, bytes: totalBytes, current: rel, cacheHits });
227
+ }
228
+ }
229
+
230
+ if (includeOutlines) {
231
+ const candidates = files.filter(file => isOutlineCandidate(file, terms)).sort((a, b) => a.size - b.size).slice(0, 24);
232
+ for (const file of candidates) {
233
+ if (file.outline) continue;
234
+ try {
235
+ emit('scan.inspect', { path: file.path });
236
+ file.outline = await outlineFile(path.join(root, file.path));
237
+ } catch {}
238
+ }
239
+ }
240
+ const snapshotId = crypto.createHash('sha256')
241
+ .update(files.map(file => `${file.path}:${file.identity}`).join('|')).digest('hex').slice(0, 20);
242
+ const snapshot = {
243
+ version: 1, snapshotId, workspaceRoot: root, createdAt: Date.now(),
244
+ totalFiles: files.length, totalBytes, cacheHits, excluded, languages, directories, files
245
+ };
246
+ atomicWrite(cacheFile(root), {
247
+ version: CACHE_VERSION, snapshotId, updatedAt: Date.now(),
248
+ files: Object.fromEntries(files.map(file => [file.path, {
249
+ identity: file.identity, purpose: file.purpose, outline: file.outline
250
+ }]))
251
+ });
252
+ return snapshot;
253
+ }
254
+
255
+ export function summarizeScanSnapshot(snapshot, request = '') {
256
+ const terms = requestTerms(request);
257
+ const relevant = snapshot.files.map(file => ({
258
+ ...file,
259
+ score: terms.reduce((score, term) => score + (file.path.toLowerCase().includes(term) ? 5 : 0), 0)
260
+ + (/entry point|manifest|configuration/i.test(file.purpose) ? 2 : 0)
261
+ + (file.outline ? 1 : 0)
262
+ })).sort((a, b) => b.score - a.score || a.size - b.size).slice(0, 40);
263
+ const summary = JSON.stringify({
264
+ snapshotId: snapshot.snapshotId,
265
+ totalFiles: snapshot.totalFiles,
266
+ totalBytes: snapshot.totalBytes,
267
+ cacheHitRate: snapshot.totalFiles ? snapshot.cacheHits / snapshot.totalFiles : 0,
268
+ excludedDirectories: snapshot.excluded.slice(0, 40),
269
+ languages: snapshot.languages,
270
+ directories: snapshot.directories,
271
+ candidateFiles: relevant.map(file => ({
272
+ path: file.path, size: file.size, kind: file.kind, purpose: file.purpose,
273
+ ...(file.outline ? { outline: file.outline } : {})
274
+ }))
275
+ }, null, 2);
276
+ return summary.length <= 12000 ? summary : `${summary.slice(0, 12000)}\n[Scan summary truncated; complete metadata remains in the local snapshot cache.]`;
277
+ }
278
+
279
+ function normalizeReadStrategy(value, fallback = 'targeted') {
280
+ const strategy = String(value || '').toLowerCase();
281
+ if (['outline', 'inspect'].includes(strategy)) return 'outline';
282
+ if (['full', 'read'].includes(strategy)) return 'full';
283
+ if (['targeted', 'targeted-read', 'range'].includes(strategy)) return 'targeted';
284
+ return fallback;
285
+ }
286
+
287
+ function normalizeContextPlan(raw, snapshot, profile, request, {
288
+ notebookAvailable = false,
289
+ notebookStatus = 'missing',
290
+ notebookMaturity = 'baseline',
291
+ drift = []
292
+ } = {}) {
293
+ const contextWindowTokens = inferContextWindowTokens(profile);
294
+ const usable = getUsableContextTokens(profile);
295
+ const requestedContext = Number(raw?.contextBudgetTokens ?? raw?.budgetTokens);
296
+ const contextBudgetTokens = Math.max(
297
+ 4096,
298
+ Math.min(usable, Number.isFinite(requestedContext) ? Math.round(requestedContext) : Math.round(usable * 0.45))
299
+ );
300
+ const requestedTurn = Number(raw?.turnBudgetTokens);
301
+ const turnBudgetTokens = Math.max(
302
+ contextBudgetTokens,
303
+ Math.min(contextBudgetTokens * 8, Number.isFinite(requestedTurn) ? Math.round(requestedTurn) : contextBudgetTokens * 3)
304
+ );
305
+ const known = new Set(snapshot.files.map(file => file.path.toLowerCase()));
306
+ const rawFiles = Array.isArray(raw?.files) ? raw.files : (Array.isArray(raw?.recommendedFiles) ? raw.recommendedFiles : []);
307
+ let files = rawFiles
308
+ .map(item => typeof item === 'string' ? { path: item } : item)
309
+ .filter(item => item?.path && known.has(String(item.path).replace(/\\/g, '/').toLowerCase()))
310
+ .slice(0, 100)
311
+ .map(item => {
312
+ const file = snapshot.files.find(candidate => candidate.path.toLowerCase() === String(item.path).replace(/\\/g, '/').toLowerCase());
313
+ const strategy = normalizeReadStrategy(item.strategy);
314
+ const estimatedTokens = Math.max(50, Number(item.estimatedTokens)
315
+ || Math.ceil(Math.min(file?.size || 0, strategy === 'full' ? file?.size || 0 : strategy === 'targeted' ? 24000 : 4000) / 4));
316
+ return {
317
+ path: String(item.path).replace(/\\/g, '/'),
318
+ reason: String(item.reason || ''),
319
+ strategy,
320
+ required: item.required === true,
321
+ estimatedTokens
322
+ };
323
+ });
324
+
325
+ const legacyReuse = raw?.notebookReuse && typeof raw.notebookReuse === 'object' ? raw.notebookReuse : null;
326
+ if (!files.length && legacyReuse?.verifyFiles) {
327
+ files = legacyReuse.verifyFiles
328
+ .map(item => typeof item === 'string' ? { path: item } : item)
329
+ .filter(item => item?.path && known.has(String(item.path).replace(/\\/g, '/').toLowerCase()))
330
+ .slice(0, 40)
331
+ .map(item => ({
332
+ path: String(item.path).replace(/\\/g, '/'),
333
+ reason: String(item.reason || ''),
334
+ strategy: normalizeReadStrategy(item.strategy),
335
+ required: true,
336
+ estimatedTokens: 1000
337
+ }));
338
+ }
339
+
340
+ const rawNotebook = raw?.notebook && typeof raw.notebook === 'object' ? raw.notebook : {};
341
+ const defaultNotebookAction = !notebookAvailable
342
+ ? 'build'
343
+ : (drift.length || ['stale', 'failed', 'partial'].includes(notebookStatus) ? 'update' : 'reuse');
344
+ const requestedNotebookAction = String(rawNotebook.action || '').toLowerCase();
345
+ const notebookAction = ['reuse', 'build', 'update'].includes(requestedNotebookAction)
346
+ ? requestedNotebookAction
347
+ : defaultNotebookAction;
348
+ const notebookBudgetTokens = Math.max(
349
+ 0,
350
+ Math.min(turnBudgetTokens, Math.round(Number(rawNotebook.notebookBudgetTokens ?? raw?.notebookBudgetTokens)
351
+ || (notebookAction === 'reuse' ? 0 : Math.min(2000, Math.round(turnBudgetTokens * 0.15)))))
352
+ );
353
+ const notebook = {
354
+ action: notebookAction,
355
+ status: notebookStatus,
356
+ maturity: notebookMaturity,
357
+ sections: Array.isArray(rawNotebook.sections)
358
+ ? rawNotebook.sections.map(String).slice(0, 12)
359
+ : (Array.isArray(legacyReuse?.trustedSections) ? legacyReuse.trustedSections.map(String).slice(0, 12) : []),
360
+ notebookBudgetTokens
361
+ };
362
+ const recommendedFiles = files.map(item => ({
363
+ path: item.path,
364
+ reason: item.reason,
365
+ strategy: item.strategy
366
+ }));
367
+ const notebookReuse = notebookAvailable ? {
368
+ rationale: String(legacyReuse?.rationale || 'Use the private notebook on demand and verify only task-critical source.'),
369
+ trustedSections: notebook.sections,
370
+ verifyFiles: files.map(item => ({
371
+ path: item.path, reason: item.reason, strategy: item.strategy
372
+ })),
373
+ stalePaths: [...new Set([
374
+ ...(Array.isArray(legacyReuse?.stalePaths) ? legacyReuse.stalePaths.map(String) : []),
375
+ ...drift.flatMap(item => [item.path, item.from].filter(Boolean))
376
+ ])].slice(0, 200)
377
+ } : null;
378
+ return {
379
+ version: 2,
380
+ requestFingerprint: crypto.createHash('sha256').update(String(request)).digest('hex').slice(0, 20),
381
+ snapshotId: snapshot.snapshotId,
382
+ contextWindowTokens,
383
+ usableTokens: usable,
384
+ contextBudgetTokens,
385
+ turnBudgetTokens,
386
+ budgetTokens: contextBudgetTokens,
387
+ reserveTokens: Math.max(0, turnBudgetTokens - contextBudgetTokens),
388
+ rationale: String(raw?.rationale || 'Adaptive fallback based on the model context window.'),
389
+ files,
390
+ notebook,
391
+ recommendedFiles,
392
+ notebookReuse,
393
+ degraded: raw?.degraded === true,
394
+ createdAt: Date.now()
395
+ };
396
+ }
397
+
398
+ export async function planHighwayNotebookBuild({
399
+ snapshot, request = '', emit = () => {}, signal
400
+ }) {
401
+ emit('scan.note.plan.started', { files: snapshot.totalFiles, bytes: snapshot.totalBytes });
402
+ if (signal?.aborted) throw new Error('Scan cancelled.');
403
+ // This decision is intentionally local: a second planning model call costs
404
+ // more time and tokens than it saves on small and medium repositories.
405
+ const plan = normalizeNotebookBuildPlan({
406
+ rationale: 'Fast local plan: outline every effective file and read only entry, manifest, configuration, and request-matching files.',
407
+ estimatedInputTokens: 0, estimatedModelCalls: 1, defaultStrategy: 'outline', groups: [],
408
+ validation: { requiredCoveragePercent: 100, requireEntries: true, requireConfigs: true, requireDependencies: true }
409
+ }, snapshot, request);
410
+ emit('scan.note.plan.completed', { plan });
411
+ return plan;
412
+ }
413
+
414
+ export async function runAdaptiveScan({
415
+ workspaceRoot, request, memories = '', conversationContext = '', profile, emit = () => {}, signal,
416
+ modelRunner = streamAIResponse, notebookPolicy = 'metadata', notebookMode = 'auto', lang = 'cn'
417
+ }) {
418
+ const existingNotebook = notebookPolicy === 'highway' ? loadProjectNotebook(workspaceRoot) : null;
419
+ const snapshot = await buildScanSnapshot(workspaceRoot, request, {
420
+ emit,
421
+ signal,
422
+ // Scan may inspect compact local outlines, but it never builds or refreshes
423
+ // the private notebook. That work begins only after the read plan is approved.
424
+ includeOutlines: true
425
+ });
426
+ emit('scan.index.completed', {
427
+ files: snapshot.totalFiles,
428
+ bytes: snapshot.totalBytes,
429
+ cacheHits: snapshot.cacheHits,
430
+ excluded: snapshot.excluded.length,
431
+ outlines: snapshot.files.filter(file => file.outline).length
432
+ });
433
+ emit('scan.notebook.checked', notebookPolicy === 'highway'
434
+ ? {
435
+ enabled: true,
436
+ found: Boolean(existingNotebook),
437
+ status: existingNotebook?.status || 'missing',
438
+ files: existingNotebook?.coverage?.completed || 0,
439
+ updatedAt: existingNotebook?.updatedAt || null
440
+ }
441
+ : { enabled: false, found: false, status: 'disabled', files: 0, updatedAt: null });
442
+ const metadataSummary = summarizeScanSnapshot(snapshot, request);
443
+ let drift = null;
444
+ if (notebookPolicy === 'highway') {
445
+ try {
446
+ drift = existingNotebook
447
+ ? await detectNotebookDrift(workspaceRoot, snapshot, existingNotebook)
448
+ : { changes: [], semantic: false, shouldUpdate: true, shouldRebuild: true };
449
+ emit('scan.drift.completed', {
450
+ enabled: true,
451
+ action: existingNotebook ? (drift.changes.length ? 'update-planned' : 'loaded') : 'build-planned',
452
+ changes: drift.changes.length,
453
+ semantic: drift.semantic === true,
454
+ notebookStatus: existingNotebook?.status || 'missing'
455
+ });
456
+ } catch (error) {
457
+ emit('scan.drift.completed', { enabled: true, action: 'failed', changes: 0, error: error.message });
458
+ }
459
+ } else {
460
+ emit('scan.drift.completed', { enabled: false, action: 'skipped', changes: 0 });
461
+ }
462
+ const catalog = notebookPolicy === 'highway' ? notebookCatalog(existingNotebook, drift) : null;
463
+ const summary = capEstimatedTokens(JSON.stringify({
464
+ source: catalog ? 'notebook-catalog-and-index' : 'workspace-index',
465
+ snapshotMetadata: capEstimatedTokens(metadataSummary, 3000),
466
+ notebook: catalog,
467
+ requestedNotebookMode: notebookMode
468
+ }, null, 2), 4000);
469
+ emit('scan.progress', { files: snapshot.totalFiles, bytes: snapshot.totalBytes, cacheHits: snapshot.cacheHits, planning: true });
470
+ const prompt = `Create a token-efficient READ plan for the repository task below. Do not solve the task, request source tools, or modify the workspace. Use the compact notebook catalog as orientation only. Plan the post-Scan cumulative token budget and the smallest set of files that formal READ should verify. Submit exactly one COMMIT_CONTEXT_PLAN tool call.
471
+
472
+ The call should prefer this v2 shape:
473
+ {"contextBudgetTokens":number,"turnBudgetTokens":number,"files":[{"path":string,"reason":string,"strategy":"outline|targeted|full","required":boolean,"estimatedTokens":number}],"notebook":{"action":"reuse|build|update","sections":[string],"notebookBudgetTokens":number},"rationale":string}
474
+
475
+ REQUEST:
476
+ ${capEstimatedTokens(request, 800)}
477
+
478
+ RECENT CONVERSATION SUMMARY (untrusted):
479
+ ${capEstimatedTokens(conversationContext || '(none)', 800)}
480
+
481
+ RELEVANT MEMORY (untrusted):
482
+ ${capEstimatedTokens(memories || '(none)', 500)}
483
+
484
+ COMPACT SCAN EVIDENCE:
485
+ ${summary}`;
486
+ let rawPlan = null;
487
+ let legacyReply = '';
488
+ let commitCount = 0;
489
+ const scanUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
490
+ try {
491
+ for await (const event of modelRunner([{ role: 'user', content: prompt }], {
492
+ profile, stream: false, mode: 'scan', workspaceRoot, signal, lang,
493
+ maxOutputTokens: 800, usagePhase: 'scan'
494
+ })) {
495
+ if (event.type === 'model.tool_call' && event.data?.name === 'COMMIT_CONTEXT_PLAN') {
496
+ commitCount++;
497
+ rawPlan = event.data.arguments || {};
498
+ }
499
+ if (event.type === 'model.delta') legacyReply += event.data?.text || '';
500
+ if (event.type === 'model.completed') {
501
+ scanUsage.inputTokens += Number(event.data?.inputTokens) || 0;
502
+ scanUsage.outputTokens += Number(event.data?.outputTokens) || 0;
503
+ scanUsage.totalTokens = scanUsage.inputTokens + scanUsage.outputTokens;
504
+ }
505
+ }
506
+ if (!rawPlan && legacyReply.trim()) {
507
+ const match = legacyReply.trim().match(/^<<COMMIT_CONTEXT_PLAN:\s*(\{[\s\S]*\})>>$/);
508
+ try { rawPlan = JSON.parse(match ? match[1] : legacyReply.trim()); } catch {}
509
+ }
510
+ if (commitCount > 1) throw new Error('Scan model committed more than one context plan.');
511
+ if (!rawPlan) throw new Error('Scan model did not commit a context plan.');
512
+ const contextPlan = normalizeContextPlan(rawPlan, snapshot, profile, request, {
513
+ notebookAvailable: Boolean(existingNotebook?.content),
514
+ notebookStatus: existingNotebook?.status || 'missing',
515
+ notebookMaturity: existingNotebook?.maturity || 'complete',
516
+ drift: drift?.changes || []
517
+ });
518
+ emit('context.planned', { contextPlan, scanUsage });
519
+ emit('scan.completed', { snapshot, contextPlan, notebookAction: contextPlan.notebook.action, scanUsage });
520
+ return {
521
+ snapshot, contextPlan, summary, notebook: existingNotebook,
522
+ notebookAction: contextPlan.notebook.action, notebookDrift: drift, scanUsage
523
+ };
524
+ } catch (error) {
525
+ if (signal?.aborted) throw error;
526
+ const terms = requestTerms(request);
527
+ const fallbackFiles = snapshot.files
528
+ .filter(file => !file.sensitive && (
529
+ /entry point|manifest|configuration/i.test(file.purpose)
530
+ || terms.some(term => file.path.toLowerCase().includes(term))
531
+ ))
532
+ .slice(0, 12)
533
+ .map((file, index) => ({
534
+ path: file.path,
535
+ reason: index === 0 ? 'Likely task entry point.' : 'Metadata-selected task evidence.',
536
+ strategy: file.size > 56000 ? 'outline' : 'targeted',
537
+ required: index < 3,
538
+ estimatedTokens: Math.max(100, Math.ceil(Math.min(file.size, 24000) / 4))
539
+ }));
540
+ const contextPlan = normalizeContextPlan({
541
+ degraded: true,
542
+ rationale: `Scan planning degraded: ${error.message}`,
543
+ files: fallbackFiles,
544
+ notebook: {
545
+ action: !existingNotebook ? 'build' : ((drift?.changes?.length || notebookMode === 'rebuild') ? 'update' : 'reuse'),
546
+ sections: [],
547
+ notebookBudgetTokens: existingNotebook ? 0 : 1600
548
+ }
549
+ }, snapshot, profile, request, {
550
+ notebookAvailable: Boolean(existingNotebook?.content),
551
+ notebookStatus: existingNotebook?.status || 'missing',
552
+ notebookMaturity: existingNotebook?.maturity || 'complete',
553
+ drift: drift?.changes || []
554
+ });
555
+ emit('scan.degraded', { error: error.message, snapshot, contextPlan, scanUsage });
556
+ return {
557
+ snapshot, contextPlan, summary, notebook: existingNotebook,
558
+ notebookAction: contextPlan.notebook.action, notebookDrift: drift, scanUsage
559
+ };
560
+ }
561
+ }