dsh-autoctx 0.0.0-stage → 1.2.4

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/briefs.js ADDED
@@ -0,0 +1,369 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { lstat, readFile, readlink } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+
5
+ const chars = value => typeof value === 'string' ? value : '';
6
+ const budget = (value, fallback) => Number.isFinite(value) ? Math.max(0, Math.floor(value)) : fallback;
7
+ const errorText = error => error instanceof Error ? error.message : String(error);
8
+
9
+ function git(cwd, args, timeoutMs) {
10
+ return new Promise((accept, reject) => {
11
+ const child = execFile('git', ['-c', 'core.quotePath=false', '-c', 'safe.directory=*', ...args], {
12
+ cwd, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, encoding: 'utf8',
13
+ }, (error, stdout) => error ? reject(error) : accept(stdout));
14
+ child.stdin?.end();
15
+ });
16
+ }
17
+
18
+ function diffUnits(diff) {
19
+ const units = [];
20
+ for (const file of diff.split(/(?=^diff --git )/m).filter(Boolean)) {
21
+ const parts = file.split(/(?=^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@)/m);
22
+ if (parts.length === 1) units.push(file);
23
+ else {
24
+ units.push(parts[0] + parts[1]);
25
+ units.push(...parts.slice(2));
26
+ }
27
+ }
28
+ return units;
29
+ }
30
+
31
+ function boundedDiff(diff, maxChars) {
32
+ if (diff.length <= maxChars) return { diff, truncated: false };
33
+ const marker = '\n[… diff truncated …]\n';
34
+ let kept = '';
35
+ for (const unit of diffUnits(diff)) {
36
+ if (kept.length + unit.length + marker.length > maxChars) break;
37
+ kept += unit;
38
+ }
39
+ return { diff: kept + (maxChars >= marker.length ? marker : ''), truncated: true };
40
+ }
41
+
42
+ export async function workspaceDiff(cwd, { maxChars = 12000, timeoutMs = 5000 } = {}) {
43
+ // Without a workspace, git would diff whatever directory the host process runs in.
44
+ if (!cwd) return { diff: '', files: [], untracked: [], truncated: false, error: 'no workspace' };
45
+ try {
46
+ let base;
47
+ try {
48
+ base = (await git(cwd, ['rev-parse', '--verify', '--quiet', 'HEAD'], timeoutMs)).trim();
49
+ } catch (error) {
50
+ if (error.code !== 1) throw error;
51
+ // An unborn repository still needs a single diff against its current files.
52
+ base = (await git(cwd, ['hash-object', '-t', 'tree', '--stdin'], timeoutMs)).trim();
53
+ }
54
+ const diff = await git(cwd, ['diff', '--no-color', '--no-ext-diff', '-U3', base, '--'], timeoutMs);
55
+ const names = await git(cwd, ['diff', '--no-ext-diff', '--name-only', '-z', base, '--'], timeoutMs);
56
+ const status = await git(cwd, ['status', '--porcelain', '-z', '--untracked-files=all'], timeoutMs);
57
+ const files = names.split('\0').filter(Boolean), untracked = [];
58
+ const entries = status.split('\0');
59
+ for (let i = 0; i < entries.length; i++) {
60
+ const entry = entries[i];
61
+ if (entry.startsWith('?? ')) untracked.push(entry.slice(3));
62
+ if (/^[RC]|^.[RC]/.test(entry)) i++;
63
+ }
64
+ let combined = diff, omitted = false;
65
+ // Reading every new file of a large untracked tree would stall the turn; the first 20 are enough.
66
+ for (const path of untracked.slice(0, 20)) {
67
+ if (!files.includes(path)) files.push(path);
68
+ const absolute = resolve(cwd, path), info = await lstat(absolute);
69
+ const header = `diff --git ${JSON.stringify('a/' + path)} ${JSON.stringify('b/' + path)}\n`;
70
+ if (!info.isSymbolicLink() && !info.isFile()) throw new Error(`Unsupported untracked file: ${path}`);
71
+ if (!info.isSymbolicLink() && info.size >= 16000) {
72
+ combined += header + '[Untracked file content omitted: at least 4000 characters]\n';
73
+ omitted = true;
74
+ continue;
75
+ }
76
+ const content = info.isSymbolicLink() ? await readlink(absolute) : await readFile(absolute, 'utf8');
77
+ if (content.length >= 4000) {
78
+ combined += header + `[Untracked file content omitted: ${content.length} characters]\n`;
79
+ omitted = true;
80
+ } else if (content.includes('\0')) {
81
+ combined += header + `Binary files /dev/null and ${JSON.stringify('b/' + path)} differ\n`;
82
+ } else {
83
+ const lines = content ? content.split('\n') : [];
84
+ if (content.endsWith('\n')) lines.pop();
85
+ combined += header + `new file mode ${info.isSymbolicLink() ? '120000' : info.mode & 0o111 ? '100755' : '100644'}\n` +
86
+ `--- /dev/null\n+++ ${JSON.stringify('b/' + path)}\n@@ -0,0 +${lines.length ? 1 : 0},${lines.length} @@\n` +
87
+ lines.map(line => '+' + line + '\n').join('') +
88
+ (content && !content.endsWith('\n') ? '\\n' : '');
89
+ }
90
+ }
91
+ const bounded = boundedDiff(combined, budget(maxChars, 12000));
92
+ return { ...bounded, files, untracked, truncated: bounded.truncated || omitted };
93
+ } catch (error) {
94
+ return { diff: '', files: [], untracked: [], truncated: false, error: errorText(error) };
95
+ }
96
+ }
97
+
98
+ function diffPath(header) {
99
+ let path = header;
100
+ if (path.startsWith('"')) {
101
+ path = /^"(?:\\.|[^"\\])*"/.exec(path)?.[0] ?? path;
102
+ try { path = JSON.parse(path); }
103
+ catch {
104
+ const escapes = { a: 7, b: 8, f: 12, n: 10, r: 13, t: 9, v: 11 };
105
+ const bytes = [];
106
+ for (const token of path.slice(1, -1).matchAll(/\\([0-7]{1,3}|.)|[^\\]+/g)) {
107
+ if (token[1] === undefined) bytes.push(Buffer.from(token[0]));
108
+ else if (/^[0-7]/.test(token[1])) bytes.push(Buffer.from([parseInt(token[1], 8)]));
109
+ else bytes.push(Buffer.from([escapes[token[1]] ?? token[1].charCodeAt(0)]));
110
+ }
111
+ path = Buffer.concat(bytes).toString('utf8');
112
+ }
113
+ } else path = path.split('\t')[0];
114
+ return path === '/dev/null' ? null : path.replace(/^[ab]\//, '');
115
+ }
116
+
117
+ function hunksOf(diff) {
118
+ const hunks = [];
119
+ let path, oldPath, removed = false, oldLeft = 0, newLeft = 0;
120
+ for (const line of chars(diff).split('\n')) {
121
+ if (line.startsWith('diff --git ')) {
122
+ path = oldPath = undefined;
123
+ oldLeft = newLeft = 0;
124
+ } else if (/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/.test(line)) {
125
+ const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
126
+ oldLeft = Number(match[2] ?? 1);
127
+ newLeft = Number(match[4] ?? 1);
128
+ if (path) hunks.push({ path, start: Number(match[3]), count: newLeft, removed });
129
+ } else if (oldLeft > 0 || newLeft > 0) {
130
+ if (line.startsWith(' ') || line.startsWith('-')) oldLeft--;
131
+ if (line.startsWith(' ') || line.startsWith('+')) newLeft--;
132
+ } else if (line.startsWith('--- ')) oldPath = diffPath(line.slice(4));
133
+ else if (line.startsWith('+++ ')) {
134
+ const current = diffPath(line.slice(4));
135
+ path = current ?? oldPath;
136
+ removed = current === null;
137
+ }
138
+ }
139
+ return hunks;
140
+ }
141
+
142
+ const definition = /^\s*(async\s+)?(def|class|function)\s|^\s*(export\s+)?(async\s+)?function\s|=>\s*\{\s*$/;
143
+
144
+ export async function hunkContext(cwd, diff, { radius = 20, maxChars = 8000 } = {}) {
145
+ try {
146
+ const hunks = hunksOf(diff), grouped = new Map(), windows = [];
147
+ radius = budget(radius, 20);
148
+ maxChars = budget(maxChars, 8000);
149
+ for (const hunk of hunks) {
150
+ if (!grouped.has(hunk.path)) grouped.set(hunk.path, []);
151
+ grouped.get(hunk.path).push(hunk);
152
+ }
153
+ for (const [path, changes] of grouped) {
154
+ let lines;
155
+ try {
156
+ if (changes[0].removed) {
157
+ windows.push({ text: `### ${path}\n[Deleted file; removed code is in the diff.]`, hunks: changes.length });
158
+ continue;
159
+ }
160
+ lines = (await readFile(resolve(cwd, path), 'utf8')).split('\n');
161
+ if (lines.at(-1) === '' && lines.length > 1) lines.pop();
162
+ } catch (error) {
163
+ windows.push({ text: `### ${path}\n[Unable to read current file: ${errorText(error)}]`, hunks: changes.length });
164
+ continue;
165
+ }
166
+ const ranges = [];
167
+ for (const hunk of changes.sort((a, b) => a.start - b.start)) {
168
+ const start = Math.max(1, Math.min(lines.length, hunk.start));
169
+ let from = Math.max(1, start - radius);
170
+ const to = Math.min(lines.length, start + Math.max(1, hunk.count) - 1 + radius);
171
+ for (let line = start; line >= Math.max(1, start - 120); line--) {
172
+ if (definition.test(lines[line - 1])) { from = Math.min(from, line); break; }
173
+ }
174
+ const previous = ranges.at(-1);
175
+ if (previous && from <= previous.to + 1) {
176
+ previous.to = Math.max(previous.to, to);
177
+ previous.hunks++;
178
+ } else ranges.push({ from, to, hunks: 1 });
179
+ }
180
+ for (const range of ranges) windows.push({
181
+ text: `### ${path} (lines ${range.from}-${range.to})\n` +
182
+ lines.slice(range.from - 1, range.to).map((line, i) => `${String(range.from + i).padStart(5)} | ${line}`).join('\n'),
183
+ hunks: range.hunks,
184
+ });
185
+ }
186
+ let text = '', included = 0;
187
+ for (const window of windows) {
188
+ const remaining = hunks.length - included - window.hunks;
189
+ const next = text + (text ? '\n\n' : '') + window.text;
190
+ const marker = remaining ? `\n\n[… ${remaining} more hunks omitted …]` : '';
191
+ if (next.length + marker.length > maxChars) break;
192
+ text = next;
193
+ included += window.hunks;
194
+ }
195
+ if (included < hunks.length) {
196
+ const marker = `[… ${hunks.length - included} more hunks omitted …]`;
197
+ if (text.length + (text ? 2 : 0) + marker.length <= maxChars) text += (text ? '\n\n' : '') + marker;
198
+ }
199
+ return text;
200
+ } catch (error) {
201
+ const message = `[Unable to collect hunk context: ${errorText(error)}]`;
202
+ return message.slice(0, budget(maxChars, 8000));
203
+ }
204
+ }
205
+
206
+ function headTail(text, maxChars) {
207
+ if (!maxChars) return '';
208
+ if (text.length <= maxChars) return text;
209
+ const reserve = `\n[… ${text.split('\n').length} lines …]\n`;
210
+ if (maxChars <= reserve.length) return text.slice(-maxChars);
211
+ const room = maxChars - reserve.length, head = Math.floor(room / 3), tail = room - head;
212
+ const count = text.slice(head, text.length - tail).split('\n').length;
213
+ return text.slice(0, head) + `\n[… ${count} lines …]\n` + text.slice(-tail);
214
+ }
215
+
216
+ export function failureExcerpt(text, { maxChars = 3000, focus = [] } = {}) {
217
+ text = chars(text);
218
+ maxChars = budget(maxChars, 3000);
219
+ if (!maxChars) return '';
220
+ if (text.length <= maxChars) return text;
221
+ const lines = text.split('\n'), scores = new Map();
222
+ const mark = (i, score) => {
223
+ if (i >= 0 && i < lines.length) scores.set(i, Math.max(scores.get(i) ?? 0, score));
224
+ };
225
+ const terms = (Array.isArray(focus) ? focus : [focus]).filter(term => typeof term === 'string' && term.length);
226
+ let summary = false;
227
+ for (let i = 0; i < lines.length; i++) {
228
+ const line = lines[i];
229
+ if (/^_+\s+.+\s+_+\s*$/.test(line)) mark(i, 6);
230
+ if (/Traceback/.test(line)) {
231
+ mark(i, 6);
232
+ const frames = [];
233
+ for (let j = i + 1; j < lines.length; j++) {
234
+ if (/Traceback|^_+\s|^=+\s/.test(lines[j])) break;
235
+ if (/^\s*File ".+", line \d+|^\s*\S+\.py:\d+:/.test(lines[j])) frames.push(j);
236
+ }
237
+ for (const frame of frames.slice(-2)) {
238
+ mark(frame, 7);
239
+ for (let j = frame + 1; j < lines.length && /^\s+\S/.test(lines[j]) && !/^\s*File "/.test(lines[j]); j++) mark(j, 6);
240
+ }
241
+ }
242
+ if (/^E(?:\s|$)/.test(line)) {
243
+ mark(i, 8);
244
+ for (let j = i + 1; j < lines.length && /^\s+\S/.test(lines[j]); j++) mark(j, 7);
245
+ }
246
+ if (/\bassert\b|\b\w*(?:Error|Exception)\b/.test(line)) mark(i, 8);
247
+ if (/short test summary info/.test(line)) summary = true;
248
+ if (summary && line.trim()) mark(i, 5);
249
+ if (summary && /^=+/.test(line) && !/short test summary info/.test(line)) summary = false;
250
+ if (/^(?:=+\s*)?.*\b\d+\s+(?:passed|failed|errors?|failures?|skipped)\b.*(?:=+)?\s*$/.test(line)) mark(i, 9);
251
+ if (terms.some(term => line.includes(term))) {
252
+ mark(i, 8);
253
+ for (let j = i - 2; j <= i + 2; j++) if (j !== i) mark(j, 2);
254
+ }
255
+ }
256
+ if (!scores.size) return headTail(text, maxChars);
257
+ const selected = new Map();
258
+ const render = () => {
259
+ const output = [];
260
+ let skipped = 0;
261
+ for (let i = 0; i < lines.length; i++) {
262
+ if (!selected.has(i)) { skipped++; continue; }
263
+ if (skipped) output.push(`[… ${skipped} lines …]`);
264
+ skipped = 0;
265
+ output.push(selected.get(i));
266
+ }
267
+ if (skipped) output.push(`[… ${skipped} lines …]`);
268
+ return output.join('\n');
269
+ };
270
+ let used = 0;
271
+ for (const [i] of [...scores].sort((a, b) => b[1] - a[1] || b[0] - a[0])) {
272
+ const value = headTail(lines[i], Math.floor(maxChars / 2));
273
+ const cost = value.length + 1;
274
+ if (used + cost + 60 > maxChars) continue;
275
+ selected.set(i, value);
276
+ used += cost;
277
+ }
278
+ if (!selected.size) {
279
+ const best = [...scores].sort((a, b) => b[1] - a[1] || b[0] - a[0])[0][0];
280
+ selected.set(best, headTail(lines[best], Math.max(0, maxChars - 60)));
281
+ }
282
+ let result = render();
283
+ for (const [i] of [...scores].sort((a, b) => a[1] - b[1] || a[0] - b[0])) {
284
+ if (result.length <= maxChars) break;
285
+ if (selected.size <= 1) break;
286
+ selected.delete(i);
287
+ result = render();
288
+ }
289
+ return result.length <= maxChars ? result : headTail(result, maxChars);
290
+ }
291
+
292
+ export function adviceQuestion(trigger, { test, attempts } = {}) {
293
+ trigger = chars(trigger);
294
+ if (trigger === 'stall') return 'No new evidence for several steps. Given the current change and the code below, what is the most likely remaining defect, and which one check would confirm it?';
295
+ if (trigger.startsWith('repeated failure: ')) {
296
+ const command = chars(test) || trigger.slice('repeated failure: '.length);
297
+ const count = Number.isInteger(attempts) && attempts >= 0 ? attempts : 'several';
298
+ return `\`${command}\` still fails after ${count} edit-and-rerun attempts. Using the current change, the code around it and the failure output below: what in the changed code or its callers causes this failure, and what single change would fix it?`;
299
+ }
300
+ return `Given the current change, the code around it and the observed failure below${trigger ? ' (trigger: ' + trigger + ')' : ''}, what is the most likely defect, and what single change would fix it?`;
301
+ }
302
+
303
+ const trimMarker = '\n[… trimmed …]';
304
+
305
+ function trimText(text, maxChars) {
306
+ if (text.length <= maxChars) return text;
307
+ return maxChars >= trimMarker.length ? text.slice(0, maxChars - trimMarker.length) + trimMarker : text.slice(0, maxChars);
308
+ }
309
+
310
+ function brief(prompt, sections, priorities, maxChars) {
311
+ const render = () => chars(prompt) + sections.map(section => `\n\n## ${section.title}\n${section.text}`).join('');
312
+ let result = render();
313
+ for (const [title, minimum] of priorities) {
314
+ if (result.length <= maxChars) break;
315
+ const section = sections.find(entry => entry.title === title);
316
+ const floor = Math.min(section.text.length, minimum ? minimum + trimMarker.length : 0);
317
+ const keep = Math.max(floor, section.text.length - (result.length - maxChars));
318
+ section.text = trimText(section.text, keep);
319
+ result = render();
320
+ }
321
+ // Role instructions, failure evidence and minimum code survive even an impossible budget.
322
+ return result;
323
+ }
324
+
325
+ export function advisorBrief({ prompt, question, failure, diff, context, attempts, task, ledger, maxChars = 16000 } = {}) {
326
+ return brief(prompt, [
327
+ { title: 'Question', text: chars(question) },
328
+ { title: 'Observed failure', text: chars(failure) },
329
+ { title: 'Current change (diff)', text: chars(diff) },
330
+ { title: 'Relevant code', text: chars(context) },
331
+ { title: 'Previous attempts', text: chars(attempts) },
332
+ { title: 'Task', text: chars(task) },
333
+ { title: 'Evidence ledger', text: chars(ledger) },
334
+ ], [['Evidence ledger', 0], ['Task', 1500], ['Relevant code', 0], ['Current change (diff)', 2000]], budget(maxChars, 16000));
335
+ }
336
+
337
+ export function reviewBrief({ prompt, task, diff, context, ledger, maxChars = 16000 } = {}) {
338
+ return brief(prompt, [
339
+ { title: 'Scope', text: 'Review exactly this change; the surrounding code is included so you need not rediscover it.' },
340
+ { title: 'Change', text: chars(diff) },
341
+ { title: 'Surrounding code', text: chars(context) },
342
+ { title: 'Original issue', text: chars(task) },
343
+ { title: 'Evidence ledger', text: chars(ledger) },
344
+ ], [['Evidence ledger', 0], ['Original issue', 1500], ['Surrounding code', 0], ['Change', 2000]], budget(maxChars, 16000));
345
+ }
346
+
347
+ export function attemptsFrom(graph, testKey) {
348
+ const test = graph?.tests && Object.hasOwn(graph.tests, testKey) ? graph.tests[testKey] : undefined;
349
+ const runs = (Array.isArray(test?.runs) ? test.runs : [])
350
+ .filter(run => run.via === undefined).slice().sort((a, b) => a.event - b.event);
351
+ const edits = Object.entries(graph?.files ?? {}).flatMap(([path, file]) =>
352
+ (Array.isArray(file.edits) ? file.edits : []).filter(edit => edit.via === undefined)
353
+ .map(edit => ({ path, event: edit.event }))).sort((a, b) => a.event - b.event);
354
+ const lines = [];
355
+ for (let i = Math.max(0, runs.length - 5); i < runs.length; i++) {
356
+ const run = runs[i], previous = runs[i - 1];
357
+ const paths = [...new Set(edits.filter(edit => (!previous || edit.event > previous.event) && edit.event <= run.event)
358
+ .map(edit => edit.path))];
359
+ if (paths.length) lines.push(`Edited ${previous ? 'since previous run' : 'before first run'}: ${paths.join(', ')}`);
360
+ const summary = chars(run.summary).replace(/\s+/g, ' ').trim();
361
+ lines.push(`Run ${i + 1} (event ${run.event}): ${run.outcome}${summary ? ' — ' + summary : ''}`);
362
+ }
363
+ const last = runs.at(-1);
364
+ if (last) {
365
+ const paths = [...new Set(edits.filter(edit => edit.event > last.event).map(edit => edit.path))];
366
+ if (paths.length) lines.push(`Edited since last run: ${paths.join(', ')}`);
367
+ }
368
+ return lines.join('\n');
369
+ }