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/graph.js ADDED
@@ -0,0 +1,619 @@
1
+ export function createGraph() {
2
+ return { files: {}, searches: [], tests: {}, commands: [], steps: 0, sequence: 0, imported: {} };
3
+ }
4
+
5
+ const byString = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
6
+ const isObject = (value) => !!value && typeof value === "object" && !Array.isArray(value);
7
+ const items = (value) => Array.isArray(value) ? value : [];
8
+ const own = (object, key) => Object.hasOwn(object, key) ? object[key] : undefined;
9
+ const put = (object, key, value) => Object.defineProperty(object, key, {
10
+ value, enumerable: true, writable: true, configurable: true,
11
+ });
12
+ const provenance = (via) => typeof via === "string" ? { via } : {};
13
+ const end = (read) => read.to === null ? Infinity : read.to;
14
+
15
+ function trimKeys(object, limit) {
16
+ for (const key of Object.keys(object).slice(0, -limit)) delete object[key];
17
+ }
18
+
19
+ function fileFor(graph, path) {
20
+ let file = own(graph.files, path);
21
+ if (!file) {
22
+ file = { reads: [], edits: [], editSnippets: [] };
23
+ put(graph.files, path, file);
24
+ trimKeys(graph.files, 200);
25
+ }
26
+ return file;
27
+ }
28
+
29
+ function subtract(ranges, coverage) {
30
+ for (const [start, stop] of coverage) {
31
+ ranges = ranges.flatMap(([from, to]) => {
32
+ if (stop < from || start > to) return [[from, to]];
33
+ const rest = [];
34
+ if (from < start) rest.push([from, start - 1]);
35
+ if (to > stop) rest.push([stop + 1, to]);
36
+ return rest;
37
+ });
38
+ }
39
+ return ranges;
40
+ }
41
+
42
+ // Negative bounds describe positions from EOF, so tail never implies whole-file coverage.
43
+ function addRead(graph, path, read) {
44
+ const file = fileFor(graph, path);
45
+ let incoming = [[read.from, end(read)]];
46
+ const retained = [];
47
+ for (const previous of file.reads) {
48
+ if (previous.via !== read.via) {
49
+ retained.push(previous);
50
+ } else if (previous.step > read.step) {
51
+ incoming = subtract(incoming, [[previous.from, end(previous)]]);
52
+ retained.push(previous);
53
+ } else {
54
+ for (const [from, to] of subtract([[previous.from, end(previous)]], [[read.from, end(read)]]))
55
+ retained.push({ ...previous, from, to: to === Infinity ? null : to });
56
+ }
57
+ }
58
+ for (const [from, to] of incoming)
59
+ retained.push({ ...read, from, to: to === Infinity ? null : to });
60
+ retained.sort((a, b) => byString(a.via ?? "", b.via ?? "") || a.from - b.from);
61
+ file.reads = [];
62
+ for (const entry of retained) {
63
+ const last = file.reads.at(-1);
64
+ if (last && last.via === entry.via && last.event === entry.event && entry.from <= end(last) + 1)
65
+ last.to = Math.max(end(last), end(entry)) === Infinity ? null : Math.max(end(last), end(entry));
66
+ else file.reads.push(entry);
67
+ }
68
+ }
69
+
70
+ function addEdit(graph, path, edit, snippet) {
71
+ const file = fileFor(graph, path);
72
+ if (file.edits.some((entry) => entry.via === edit.via && entry.event === edit.event)) return;
73
+ file.edits.push(edit);
74
+ file.editSnippets = typeof snippet === "string" && snippet.trim() ? [snippet.slice(0, 300)] : [];
75
+ }
76
+
77
+ function addSearch(graph, pattern, path, observation) {
78
+ let search = graph.searches.find((entry) => entry.pattern === pattern && entry.path === path);
79
+ if (!search) {
80
+ search = { pattern, path, step: observation.step, observations: [] };
81
+ graph.searches.push(search);
82
+ }
83
+ const previous = search.observations.find((entry) => entry.via === observation.via);
84
+ if (previous && observation.event > previous.event) Object.assign(previous, observation);
85
+ else if (!previous) search.observations.push(observation);
86
+ search.step = Math.max(search.step, observation.step);
87
+ if (graph.searches.length > 50) {
88
+ const oldest = graph.searches.reduce((a, b) => a.step <= b.step ? a : b);
89
+ graph.searches.splice(graph.searches.indexOf(oldest), 1);
90
+ }
91
+ }
92
+
93
+ function addTest(graph, key, run) {
94
+ let test = own(graph.tests, key);
95
+ if (!test) {
96
+ test = { runs: [] };
97
+ put(graph.tests, key, test);
98
+ trimKeys(graph.tests, 30);
99
+ }
100
+ if (test.runs.some((entry) => entry.via === run.via && entry.event === run.event)) return;
101
+ test.runs.push({ ...run, summary: run.summary.slice(0, 120) });
102
+ if (test.runs.length > 10) test.runs.shift();
103
+ }
104
+
105
+ function addCommand(graph, entry) {
106
+ const command = { ...entry, command: entry.command.slice(0, 160) };
107
+ if (graph.commands.some((previous) => previous.via === command.via &&
108
+ previous.event === command.event && previous.command === command.command && previous.kind === command.kind)) return;
109
+ graph.commands.push(command);
110
+ if (graph.commands.length > 8) graph.commands.shift();
111
+ }
112
+
113
+ function parseArgs(raw) {
114
+ if (typeof raw === "string") {
115
+ try { raw = JSON.parse(raw); } catch { return null; }
116
+ }
117
+ return isObject(raw) ? raw : null;
118
+ }
119
+ const argString = (args, key) => typeof args?.[key] === "string" && args[key].trim() ? args[key] : undefined;
120
+
121
+ export function observe(graph, step, call, result) {
122
+ try {
123
+ if (Number.isFinite(step)) graph.steps = step;
124
+ if (!Number.isFinite(step) || typeof call?.name !== "string") return;
125
+ const args = parseArgs(call.arguments);
126
+ if (!args) return;
127
+ const observation = { step, event: ++graph.sequence };
128
+ const failed = !!result?.isError;
129
+ if (call.name === "bash") {
130
+ const command = argString(args, "command");
131
+ if (command) classifyBash(graph, observation, command, result?.text ?? "", failed);
132
+ } else if (!failed && call.name === "read") {
133
+ const path = argString(args, "file_path") ?? argString(args, "path");
134
+ if (!path) return;
135
+ const from = Number.isInteger(args.offset) && args.offset >= 1 ? args.offset : 1;
136
+ const to = Number.isInteger(args.limit) && args.limit > 0 ? from + args.limit - 1 : null;
137
+ addRead(graph, path, { from, to, ...observation });
138
+ } else if (!failed && ["edit", "write", "multi_edit", "apply_patch"].includes(call.name)) {
139
+ const changes = new Map();
140
+ function collect(value) {
141
+ if (Array.isArray(value)) return value.forEach(collect);
142
+ if (!isObject(value)) return;
143
+ const path = argString(value, "file_path") ?? argString(value, "path");
144
+ const snippet = [value.new_string, value.content, value.patch, value.diff].find((v) => typeof v === "string");
145
+ if (path) changes.set(path, snippet);
146
+ for (const nested of Object.values(value)) if (typeof nested === "object") collect(nested);
147
+ }
148
+ collect(args);
149
+ for (const [path, snippet] of changes) {
150
+ addEdit(graph, path, { ...observation }, snippet);
151
+ }
152
+ } else if (!failed && call.name === "grep") {
153
+ if (typeof args.pattern === "string")
154
+ addSearch(graph, args.pattern, argString(args, "path") ?? argString(args, "include") ?? "", { ...observation });
155
+ }
156
+ } catch {
157
+ // Malformed observations must not interrupt the agent.
158
+ }
159
+ }
160
+
161
+ function withoutHeredocs(command) {
162
+ const lines = [], pending = [], heredocs = [];
163
+ let offset = 0;
164
+ for (const line of command.split("\n")) {
165
+ if (pending.length) {
166
+ const { delimiter, tabs, body } = pending[0];
167
+ if ((tabs ? line.replace(/^\t+/, "") : line) === delimiter) pending.shift();
168
+ else body.push(line);
169
+ continue;
170
+ }
171
+ lines.push(line.replace(/'[^']*'|"(?:\\.|[^"\\])*"|\\.|<<(-?)[ \t]*(?:'([^']+)'|"([^"]+)"|([^\s;&|<>]+))/g,
172
+ (match, tabs, single, double, bare, index) => {
173
+ if (tabs === undefined) return match;
174
+ const entry = { at: offset + index, delimiter: single ?? double ?? bare, tabs: !!tabs, body: [] };
175
+ pending.push(entry);
176
+ heredocs.push(entry);
177
+ return " ".repeat(match.length);
178
+ }));
179
+ offset += line.length + 1;
180
+ }
181
+ return { command: lines.join("\n"), heredocs };
182
+ }
183
+
184
+ function segments(command) {
185
+ const parsed = withoutHeredocs(command);
186
+ command = parsed.command;
187
+ const out = [];
188
+ let start = 0, quote = null, piped = false;
189
+ function append(end) {
190
+ const segment = command.slice(start, end).trim();
191
+ if (!piped && segment) out.push({
192
+ command: segment,
193
+ // Only the last input heredoc on this command supplies its script.
194
+ heredoc: parsed.heredocs.filter((entry) => entry.at >= start && entry.at < end)
195
+ .at(-1)?.body.join("\n") ?? "",
196
+ });
197
+ }
198
+ for (let i = 0; i < command.length; i++) {
199
+ const ch = command[i];
200
+ if (ch === "\\" && quote !== "'") { i++; continue; }
201
+ if (quote) {
202
+ if (ch === quote) quote = null;
203
+ } else if (ch === "'" || ch === '"') quote = ch;
204
+ else if (ch === "\n" || ch === ";" || ch === "|" || (ch === "&" && command[i + 1] === "&")) {
205
+ append(i);
206
+ piped = ch === "|" && command[i + 1] !== "|";
207
+ if (command[i + 1] === ch) i++;
208
+ start = i + 1;
209
+ }
210
+ }
211
+ append(command.length);
212
+ return out;
213
+ }
214
+
215
+ function tokenize(segment) {
216
+ const out = [];
217
+ let current = "", quote = null, quoted = false;
218
+ for (let i = 0; i < segment.length; i++) {
219
+ const ch = segment[i];
220
+ if (ch === "\\" && quote !== "'" && i + 1 < segment.length) {
221
+ if (quote === '"' && !['"', "\\", "$", "`", "\n"].includes(segment[i + 1])) current += ch;
222
+ else current += segment[++i];
223
+ }
224
+ else if (quote) {
225
+ if (ch === quote) quote = null;
226
+ else current += ch;
227
+ } else if (ch === '"' || ch === "'") { quote = ch; quoted = true; }
228
+ else if (/\s/.test(ch)) {
229
+ if (current || quoted) out.push(current);
230
+ current = "";
231
+ quoted = false;
232
+ } else current += ch;
233
+ }
234
+ if (current || quoted) out.push(current);
235
+ return out;
236
+ }
237
+ const base = (token) => (token ?? "").replace(/^.*\//, "");
238
+
239
+ function redirects(segment) {
240
+ const paths = [];
241
+ const command = segment.replace(/'[^']*'|"(?:\\.|[^"\\])*"|\\.|(?:\d*|&)(>>?|<)(?:&[\d-]+|[ \t]*((?:"(?:\\.|[^"\\])*"|'[^']*'|\\.|[^\s<>])+))/g,
242
+ (match, operator, target) => {
243
+ if (!operator) return match;
244
+ if (operator.startsWith(">") && target) paths.push(tokenize(target)[0]);
245
+ return "";
246
+ });
247
+ return { command: command.trim(), paths };
248
+ }
249
+
250
+ function writePaths(tokens) {
251
+ const head = base(tokens[0]);
252
+ if (head === "tee") return tokens.slice(1).filter((token) => token && !token.startsWith("-"));
253
+ const perl = head === "perl";
254
+ if (perl ? !tokens.some((token) => /^-[a-zA-Z]*i/.test(token)) :
255
+ head !== "sed" || !tokens.some((token) => /^-i|^--in-place/.test(token))) return [];
256
+ const scriptFlags = perl ? ["-e", "-E"] : ["-e", "-f", "--expression", "--file"];
257
+ const inlineScript = perl ? /^-[eE]./ : /^(?:-[ef].|--(?:expression|file)=)/;
258
+ const paths = [];
259
+ let script = false, options = true;
260
+ for (let i = 1; i < tokens.length; i++) {
261
+ const token = tokens[i];
262
+ if (options && token === "--") { options = false; continue; }
263
+ if (options && scriptFlags.includes(token)) { script = true; i++; continue; }
264
+ if (options && inlineScript.test(token)) { script = true; continue; }
265
+ if (options && token.startsWith("-")) continue;
266
+ if (!script) script = true;
267
+ else if (token) paths.push(token);
268
+ }
269
+ return paths;
270
+ }
271
+
272
+ const PYTHON_WRITE = /\.write_(?:text|bytes)\s*\(|shutil\.(?:copy|copy2|copyfile|move)\s*\(|os\.replace\s*\(/;
273
+ // A python script that writes files names its targets as path-like string literals.
274
+ function pythonWrites(script) {
275
+ if (typeof script !== "string") return [];
276
+ let writes = PYTHON_WRITE.test(script);
277
+ for (const open of script.matchAll(/\bopen\s*\(/g)) {
278
+ if (writes) break;
279
+ const args = script.slice(open.index + open[0].length);
280
+ let depth = 1, argument = 0;
281
+ for (const token of args.matchAll(/'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|[(),]/g)) {
282
+ const value = token[0];
283
+ if (value === "(") depth++;
284
+ else if (value === ")") { if (--depth === 0) break; }
285
+ else if (value === "," && depth === 1) argument++;
286
+ else if (depth === 1 && /^(['"])[wa][btx+]*\1$/.test(value) &&
287
+ (argument === 1 || /\bmode\s*=\s*$/.test(args.slice(0, token.index)))) {
288
+ writes = true;
289
+ break;
290
+ }
291
+ }
292
+ }
293
+ if (!writes) return [];
294
+ return [...script.matchAll(/'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"/g)]
295
+ .map((match) => match[0].slice(1, -1))
296
+ .filter((literal) => literal.includes("/") || /\.[A-Za-z0-9]+$/.test(literal));
297
+ }
298
+
299
+ function pythonScript(tokens, heredoc) {
300
+ if (!/^python[0-9.]*$/.test(base(tokens[0]))) return undefined;
301
+ // -c keeps its program inline; otherwise stdin is the heredoc body.
302
+ const inline = tokens.indexOf("-c");
303
+ return inline >= 0 ? tokens[inline + 1] : heredoc;
304
+ }
305
+
306
+ function patchWritePaths(tokens) {
307
+ const head = base(tokens[0]);
308
+ if (head === "patch" && tokens.some((token) => /^-p\d*$/.test(token))) return ["(patch)"];
309
+ if (head === "git" && tokens[1] === "apply") return ["(patch)"];
310
+ return [];
311
+ }
312
+
313
+ function classifyBash(graph, observation, command, text, failed) {
314
+ const parsed = segments(command).map((entry) => ({ ...entry, ...redirects(entry.command) }));
315
+ const testCount = parsed.filter((entry) => isTestCommand(tokenize(entry.command))).length;
316
+ const summaries = (typeof text === "string" ? text : "").split(/[\r\n]+/)
317
+ .filter((line) => /\b(?:\d+[ \t]+(?:passed|failed|errors?|failures?)|no tests ran|Interrupted)\b/i.test(line));
318
+ let testIndex = 0;
319
+ for (const { command: segment, paths, heredoc } of parsed) {
320
+ const tokens = tokenize(segment);
321
+ const head = base(tokens[0]);
322
+ if (head === "cd") continue;
323
+ const script = pythonScript(tokens, heredoc);
324
+ const writes = [...paths, ...writePaths(tokens), ...patchWritePaths(tokens), ...pythonWrites(script)];
325
+ if (!failed) for (const path of writes) {
326
+ if (!/^\/(?:tmp|dev)(?:\/|$)/.test(path)) addEdit(graph, path, { ...observation });
327
+ }
328
+ if (isTestCommand(tokens)) {
329
+ let summary = summaries[testIndex];
330
+ // On a count mismatch, only the last test segment gets the last summary.
331
+ if (summaries.length !== testCount) {
332
+ summary = testIndex === testCount - 1 ? summaries.at(-1) ?? text : undefined;
333
+ }
334
+ testIndex++;
335
+ addTest(graph, segment, { ...observation, ...testOutcome(summary) });
336
+ continue;
337
+ }
338
+ if (writes.length) continue;
339
+ if (!failed && ["cat", "nl", "head", "tail", "less", "sed"].includes(head)) {
340
+ const reads = parseReads(tokens);
341
+ if (reads.length) {
342
+ for (const { path, from, to } of reads) addRead(graph, path, { from, to, ...observation });
343
+ continue;
344
+ }
345
+ }
346
+ if (["grep", "rg", "find", "ls"].includes(head) || (head === "git" && tokens[1] === "grep")) {
347
+ const search = parseSearch(tokens);
348
+ if (search) {
349
+ addSearch(graph, search.pattern, search.path, { ...observation });
350
+ continue;
351
+ }
352
+ }
353
+ const kind = head === "git" && ["diff", "status", "log"].includes(tokens[1]) ? "inspect" : "command";
354
+ addCommand(graph, { command: segment, ...observation, kind });
355
+ }
356
+ }
357
+
358
+ function isTestCommand(tokens) {
359
+ const head = base(tokens[0]);
360
+ return head === "pytest" || head === "tox" ||
361
+ (/^python[0-9.]*$/.test(head) && tokens[1] === "-m" && ["pytest", "unittest"].includes(tokens[2])) ||
362
+ (head === "npm" && (tokens[1] === "test" || (tokens[1] === "run" && tokens[2] === "test")));
363
+ }
364
+
365
+ function parseReads(tokens) {
366
+ const head = base(tokens[0]);
367
+ let from = 1, to = null, script;
368
+ if (head === "sed") {
369
+ if (!tokens.includes("-n") || tokens.some((token) => /^-i|^--in-place/.test(token))) return [];
370
+ script = tokens.find((token) => /^\d+(?:\s*,\s*\d+)?p$/.test(token));
371
+ if (!script) return [];
372
+ const bounds = script.match(/\d+/g).map(Number);
373
+ [from, to] = [bounds[0], bounds[1] ?? bounds[0]];
374
+ }
375
+ const paths = [];
376
+ let count = "10", options = true;
377
+ const valueFlags = new Set(["-b", "-d", "-f", "-h", "-i", "-l", "-s", "-v", "-w"]);
378
+ for (let i = 1; i < tokens.length; i++) {
379
+ const token = tokens[i];
380
+ if (token === script) continue;
381
+ if (token === "--") { options = false; continue; }
382
+ if (/^\d*[<>]/.test(token)) break;
383
+ if (options && (head === "head" || head === "tail")) {
384
+ if (token === "-n" || token === "--lines") { count = tokens[++i]; continue; }
385
+ const match = /^(?:-n|--lines=)([+-]?\d+)$|^-(\d+)$/.exec(token);
386
+ if (match) { count = match[1] ?? match[2]; continue; }
387
+ if (/^(?:-c|--bytes)/.test(token)) return [];
388
+ }
389
+ if (options && token.startsWith("-")) {
390
+ if (head === "nl" && valueFlags.has(token)) i++;
391
+ continue;
392
+ }
393
+ if (token && token !== "-") paths.push(token);
394
+ }
395
+ if (head === "head" || head === "tail") {
396
+ if (!/^[+-]?\d+$/.test(count) || Number(count) === 0 || (head === "head" && Number(count) < 0)) return [];
397
+ if (head === "head") to = Number(count);
398
+ else if (count.startsWith("+")) from = Number(count);
399
+ else { from = -Math.abs(Number(count)); to = -1; }
400
+ }
401
+ if (from === 0 || (to !== null && to < from)) return [];
402
+ return paths.map((path) => ({ path, from, to }));
403
+ }
404
+
405
+ const GREP_VALUE_FLAGS = new Set([
406
+ "-f", "--file", "-A", "-B", "-C", "-m", "--include", "--exclude", "-g", "--glob", "--type", "-t",
407
+ ]);
408
+ function parseSearch(tokens) {
409
+ const head = base(tokens[0]);
410
+ if (head === "find") {
411
+ const at = tokens.findIndex((token) => ["-name", "-iname", "-path"].includes(token));
412
+ return { pattern: at >= 0 ? tokens[at + 1] ?? "" : "", path: tokens[1] && !tokens[1].startsWith("-") ? tokens[1] : "." };
413
+ }
414
+ if (head === "ls") return { pattern: "", path: tokens.slice(1).find((token) => !token.startsWith("-")) ?? "." };
415
+ let pattern, options = true;
416
+ const paths = [];
417
+ for (let i = head === "git" ? 2 : 1; i < tokens.length; i++) {
418
+ const token = tokens[i];
419
+ if (/^\d*[<>]/.test(token)) break;
420
+ if (options && token === "--") { options = false; continue; }
421
+ if (options && (token === "-e" || token === "--regexp")) { pattern = tokens[++i]; continue; }
422
+ if (options && /^--regexp=|^-e./.test(token)) { pattern = token.replace(/^--regexp=|^-e/, ""); continue; }
423
+ if (options && token.startsWith("-")) {
424
+ if (GREP_VALUE_FLAGS.has(token)) i++;
425
+ continue;
426
+ }
427
+ if (pattern === undefined) pattern = token;
428
+ else paths.push(token);
429
+ }
430
+ return pattern === undefined ? null : { pattern, path: paths.join(" ") };
431
+ }
432
+
433
+ function testOutcome(text) {
434
+ const source = typeof text === "string" ? text : "";
435
+ if (/\bInterrupted\b/i.test(source)) return { outcome: "error", summary: source.trim().slice(0, 120) };
436
+ const status = "(?:passed|failed|errors?|failures?|skipped|xfailed|xpassed)";
437
+ const phrase = `\\d+[ \\t]+${status}\\b`;
438
+ const blocks = [...source.matchAll(new RegExp(`no tests ran|${phrase}(?:[ \\t]*,[ \\t]*${phrase})*`, "gi"))];
439
+ const summary = blocks.at(-1)?.[0];
440
+ if (!summary) return { outcome: "unknown", summary: (source.trim().split(/[\r\n]+/).at(-1) ?? "").slice(0, 120) };
441
+ const matches = [...summary.matchAll(/(\d+)\s+(\w+)/g)];
442
+ const count = (pattern) => matches.some((match) => +match[1] > 0 && pattern.test(match[2]));
443
+ const outcome = count(/^(failed|failures?)$/i) ? "fail" : count(/^errors?$/i) ? "error" : count(/^passed$/i) ? "pass" : "unknown";
444
+ return { outcome, summary: summary.slice(0, 120) };
445
+ }
446
+
447
+ function mergedRanges(reads) {
448
+ const ranges = reads.map((read) => [read.from, end(read)]).sort((a, b) => a[0] - b[0]);
449
+ const merged = [];
450
+ for (const [from, to] of ranges) {
451
+ const last = merged.at(-1);
452
+ if (last && from <= last[1] + 1) last[1] = Math.max(last[1], to);
453
+ else merged.push([from, to]);
454
+ }
455
+ if (merged.some(([from, to]) => from === 1 && to === Infinity)) return [[1, Infinity]];
456
+ return merged;
457
+ }
458
+ function rangeText(ranges) {
459
+ return ranges.map(([from, to], i) => {
460
+ if (from < 0) return to === -1 ? `last ${-from} lines` : `${-to}-${-from} lines from end`;
461
+ if (to === Infinity) return from === 1 ? "whole" : `L${from}-`;
462
+ return `${i ? "" : "L"}${from}${from === to ? "" : "-" + to}`;
463
+ }).join(", ");
464
+ }
465
+ function cut(lines, limit) {
466
+ if (lines.join("\n").length <= limit) return lines.join("\n");
467
+ for (let kept = lines.length - 1; kept >= 0; kept--) {
468
+ const text = [...lines.slice(0, kept), `… ${lines.length - kept} more entries`].join("\n");
469
+ if (text.length <= limit) return text;
470
+ }
471
+ return "";
472
+ }
473
+
474
+ export function digest(graph, maxChars = 2000) {
475
+ const lines = [];
476
+ const paths = Object.keys(graph.files).sort(byString);
477
+ for (const path of paths) {
478
+ const file = graph.files[path];
479
+ if (!file.edits.length) continue;
480
+ const snippet = file.editSnippets.at(-1);
481
+ lines.push(`edit ${path} steps ${file.edits.map((edit) => edit.step).join(",")}${snippet ? " " + snippet.replace(/\s+/g, " ").trim().slice(0, 120) : ""}`);
482
+ }
483
+ const tests = Object.entries(graph.tests).map(([key, test]) => ({ key, run: test.runs.at(-1) }));
484
+ const rank = { fail: 0, error: 1, pass: 2, unknown: 3 };
485
+ tests.sort((a, b) => rank[a.run.outcome] - rank[b.run.outcome] || b.run.step - a.run.step || byString(a.key, b.key));
486
+ for (const { key, run } of tests)
487
+ lines.push(`test ${run.outcome} ${key.slice(0, 160)}${run.summary ? ": " + run.summary.replace(/\s+/g, " ") : ""}`);
488
+ for (const path of paths) {
489
+ const file = graph.files[path];
490
+ const latestEdit = Math.max(-Infinity, ...file.edits.map((edit) => edit.step));
491
+ const fresh = mergedRanges(file.reads.filter((read) => read.step >= latestEdit));
492
+ const wholeFresh = fresh.some(([from, to]) => from === 1 && to === Infinity);
493
+ const stale = wholeFresh ? [] : subtract(mergedRanges(file.reads.filter((read) => read.step < latestEdit)), fresh);
494
+ if (fresh.length) lines.push(`read ${rangeText(fresh)} ${path}`);
495
+ if (stale.length) lines.push(`read ${rangeText(stale)} (stale) ${path}`);
496
+ }
497
+ for (const search of graph.searches.slice().sort((a, b) => byString(a.path, b.path) || byString(a.pattern, b.pattern)))
498
+ lines.push(search.pattern ? `search ${search.pattern}${search.path ? " in " + search.path : ""}` : `list ${search.path || "."}`);
499
+ for (const command of graph.commands)
500
+ lines.push(`${command.kind} ${command.command.replace(/\s+/g, " ")}`);
501
+ return cut(lines, Number.isFinite(maxChars) ? Math.max(0, Math.floor(maxChars)) : 2000);
502
+ }
503
+
504
+ export function progressKey(graph) {
505
+ const files = Object.keys(graph.files).sort(byString).map((path) => {
506
+ const file = graph.files[path];
507
+ return [path, mergedRanges(file.reads).map(([from, to]) => [from, to === Infinity ? null : to]), file.edits.length];
508
+ });
509
+ const tests = Object.keys(graph.tests).sort(byString).map((key) => [key, graph.tests[key].runs.at(-1).outcome]);
510
+ const searches = graph.searches.map((search) => [search.pattern, search.path])
511
+ .sort((a, b) => byString(a[0], b[0]) || byString(a[1], b[1]));
512
+ return JSON.stringify({ files, tests, searches });
513
+ }
514
+
515
+ export function editsAfter(graph, step) {
516
+ return Object.entries(graph.files).flatMap(([path, file]) => file.edits
517
+ .filter((edit) => edit.step > step).map((edit) => ({ path, step: edit.step })))
518
+ .sort((a, b) => a.step - b.step);
519
+ }
520
+
521
+ export function hasRead(graph, path) {
522
+ return !!own(graph.files, path)?.reads.length;
523
+ }
524
+
525
+ // skipReadsOf: paths the target edited after the source started; the source's reads of them are stale.
526
+ export function merge(target, source, label, { skipReadsOf, skipTests = false } = {}) {
527
+ if (target === source) return target;
528
+ // Watermarks survive eviction of bounded histories and prevent replay after restore.
529
+ const seen = {};
530
+ function contribution(entry) {
531
+ const via = entry.via === undefined ? label : `${label}/${entry.via}`;
532
+ if (entry.event <= (own(target.imported, via) ?? -1)) return null;
533
+ put(seen, via, Math.max(own(seen, via) ?? -1, entry.event));
534
+ return { ...entry, via };
535
+ }
536
+ target.steps = Math.max(target.steps, source.steps);
537
+ for (const [path, file] of Object.entries(source.files)) {
538
+ for (const read of file.reads) {
539
+ if (skipReadsOf?.has(path)) continue;
540
+ const entry = contribution(read);
541
+ if (entry) addRead(target, path, entry);
542
+ }
543
+ for (let i = 0; i < file.edits.length; i++) {
544
+ const entry = contribution(file.edits[i]);
545
+ if (entry) addEdit(target, path, entry, i === file.edits.length - 1 ? file.editSnippets.at(-1) : undefined);
546
+ }
547
+ }
548
+ if (!skipTests) for (const [key, test] of Object.entries(source.tests)) for (const run of test.runs) {
549
+ const entry = contribution(run);
550
+ if (entry) addTest(target, key, entry);
551
+ }
552
+ for (const search of source.searches) for (const observation of search.observations) {
553
+ const entry = contribution(observation);
554
+ if (entry) addSearch(target, search.pattern, search.path, entry);
555
+ }
556
+ for (const command of source.commands) {
557
+ const entry = contribution(command);
558
+ if (entry) addCommand(target, entry);
559
+ }
560
+ for (const [via, event] of Object.entries(seen)) put(target.imported, via, event);
561
+ return target;
562
+ }
563
+
564
+ export function fromJSON(value) {
565
+ const raw = parseArgs(value);
566
+ const graph = createGraph();
567
+ if (!raw) return graph;
568
+ if (Number.isFinite(raw.steps)) graph.steps = raw.steps;
569
+ if (Number.isInteger(raw.sequence) && raw.sequence >= 0) graph.sequence = raw.sequence;
570
+ if (isObject(raw.imported)) for (const [via, event] of Object.entries(raw.imported)) {
571
+ if (Number.isInteger(event) && event >= 0) put(graph.imported, via, event);
572
+ }
573
+ function observation(entry, legacyVia) {
574
+ const event = Number.isInteger(entry.event) && entry.event >= 0 ? entry.event : ++graph.sequence;
575
+ const via = provenance(entry.via ?? legacyVia);
576
+ if (via.via === undefined) graph.sequence = Math.max(graph.sequence, event);
577
+ return { step: entry.step, event, ...via };
578
+ }
579
+ if (isObject(raw.files)) for (const [path, stored] of Object.entries(raw.files)) {
580
+ if (!isObject(stored) || !path) continue;
581
+ for (const item of items(stored.reads)) {
582
+ const read = Array.isArray(item) ? { from: item[0], to: item[1], step: item[2] } : item;
583
+ if (!isObject(read) || !Number.isFinite(read.step) || !Number.isInteger(read.from)) continue;
584
+ const valid = read.from > 0 ? read.to === null || (Number.isInteger(read.to) && read.to >= read.from) :
585
+ read.from < 0 && Number.isInteger(read.to) && read.to < 0 && read.to >= read.from;
586
+ if (valid) addRead(graph, path, { from: read.from, to: read.to, ...observation(read, stored.via) });
587
+ }
588
+ const edits = items(stored.edits);
589
+ for (let i = 0; i < edits.length; i++) {
590
+ const edit = typeof edits[i] === "number" ? { step: edits[i] } : edits[i];
591
+ if (!isObject(edit) || !Number.isFinite(edit.step)) continue;
592
+ addEdit(graph, path, observation(edit, stored.via));
593
+ }
594
+ const snippet = items(stored.editSnippets).filter((item) => typeof item === "string").at(-1);
595
+ const file = own(graph.files, path);
596
+ if (file?.edits.length && snippet) file.editSnippets = [snippet.slice(0, 300)];
597
+ }
598
+ for (const search of items(raw.searches)) {
599
+ if (!isObject(search) || typeof search.pattern !== "string") continue;
600
+ for (const entry of Array.isArray(search.observations) ? search.observations : [search]) {
601
+ if (isObject(entry) && Number.isFinite(entry.step))
602
+ addSearch(graph, search.pattern, typeof search.path === "string" ? search.path : "", observation(entry));
603
+ }
604
+ }
605
+ if (isObject(raw.tests)) for (const [key, test] of Object.entries(raw.tests)) {
606
+ if (!key.trim() || !isObject(test)) continue;
607
+ for (const run of items(test.runs)) {
608
+ if (!isObject(run) || !Number.isFinite(run.step) || !["pass", "fail", "error", "unknown"].includes(run.outcome)) continue;
609
+ addTest(graph, key, { ...observation(run), outcome: run.outcome,
610
+ summary: typeof run.summary === "string" ? run.summary : "" });
611
+ }
612
+ }
613
+ for (const command of items(raw.commands)) {
614
+ if (!isObject(command) || !Number.isFinite(command.step) || !argString(command, "command")) continue;
615
+ addCommand(graph, { command: command.command, ...observation(command),
616
+ kind: command.kind === "inspect" ? "inspect" : "command" });
617
+ }
618
+ return graph;
619
+ }