dave-code 1.1.0 → 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.
- package/README.md +25 -6
- package/bin/aiClient.js +292 -67
- package/bin/check.js +11 -0
- package/bin/cliMenu.js +205 -68
- package/bin/commandRouter.js +20 -0
- package/bin/configManager.js +71 -47
- package/bin/contextManager.js +107 -91
- package/bin/index.js +1413 -243
- package/bin/markdownRenderer.js +264 -0
- package/bin/memoryManager.js +182 -0
- package/bin/planManager.js +61 -9
- package/bin/projectNotebookManager.js +839 -0
- package/bin/runtimeEvents.js +42 -0
- package/bin/scanManager.js +561 -0
- package/bin/sessionManager.js +17 -1
- package/bin/terminalRenderer.js +543 -125
- package/bin/textWidth.js +194 -0
- package/bin/thunderManager.js +302 -0
- package/bin/thunderOrchestrator.js +263 -0
- package/bin/thunderPrompts.js +53 -0
- package/bin/thunderRenderer.js +200 -0
- package/bin/toolRuntime.js +688 -133
- package/package.json +3 -5
package/bin/runtimeEvents.js
CHANGED
|
@@ -18,6 +18,48 @@ export const RUNTIME_EVENT_TYPES = new Set([
|
|
|
18
18
|
'permission.requested',
|
|
19
19
|
'permission.resolved',
|
|
20
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'
|
|
21
63
|
]);
|
|
22
64
|
|
|
23
65
|
export function createRuntimeEvents({ turnId, now = Date.now } = {}) {
|
|
@@ -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
|
+
}
|
package/bin/sessionManager.js
CHANGED
|
@@ -29,6 +29,20 @@ function normalizeSession(session) {
|
|
|
29
29
|
if (!session || typeof session !== 'object' || !session.id || !Array.isArray(session.messages)) return null;
|
|
30
30
|
if (!/^[A-Za-z0-9_-]+$/.test(String(session.id))) return null;
|
|
31
31
|
const messages = session.messages.map(normalizeMessage).filter(Boolean);
|
|
32
|
+
const rawPlan = session.contextPlan && typeof session.contextPlan === 'object' ? session.contextPlan : null;
|
|
33
|
+
const contextPlan = rawPlan && Number.isFinite(rawPlan.budgetTokens) ? {
|
|
34
|
+
version: 1,
|
|
35
|
+
requestFingerprint: String(rawPlan.requestFingerprint || '').slice(0, 100),
|
|
36
|
+
snapshotId: String(rawPlan.snapshotId || '').slice(0, 100),
|
|
37
|
+
contextWindowTokens: Math.max(8192, Number(rawPlan.contextWindowTokens) || 32768),
|
|
38
|
+
usableTokens: Math.max(4096, Number(rawPlan.usableTokens) || Number(rawPlan.budgetTokens)),
|
|
39
|
+
budgetTokens: Math.max(4096, Number(rawPlan.budgetTokens)),
|
|
40
|
+
reserveTokens: Math.max(0, Number(rawPlan.reserveTokens) || 0),
|
|
41
|
+
rationale: String(rawPlan.rationale || '').slice(0, 2000),
|
|
42
|
+
recommendedFiles: Array.isArray(rawPlan.recommendedFiles) ? rawPlan.recommendedFiles.slice(0, 100) : [],
|
|
43
|
+
degraded: rawPlan.degraded === true,
|
|
44
|
+
createdAt: Number(rawPlan.createdAt) || Date.now()
|
|
45
|
+
} : null;
|
|
32
46
|
return {
|
|
33
47
|
...session,
|
|
34
48
|
id: String(session.id),
|
|
@@ -36,7 +50,9 @@ function normalizeSession(session) {
|
|
|
36
50
|
timestamp: Number(session.timestamp) || Date.now(),
|
|
37
51
|
messages,
|
|
38
52
|
workspaceRoot: typeof session.workspaceRoot === 'string' ? session.workspaceRoot : null,
|
|
39
|
-
activeOpenFile: typeof session.activeOpenFile === 'string' ? session.activeOpenFile : null
|
|
53
|
+
activeOpenFile: typeof session.activeOpenFile === 'string' ? session.activeOpenFile : null,
|
|
54
|
+
workMode: session.workMode === 'thunder' ? 'thunder' : 'highway',
|
|
55
|
+
contextPlan
|
|
40
56
|
};
|
|
41
57
|
}
|
|
42
58
|
|