pi-supernova 0.6.0 → 0.7.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.
Files changed (49) hide show
  1. package/README.md +27 -3
  2. package/docs/CHANGELOG.md +104 -0
  3. package/docs/TOKEN_COSTS.md +13 -5
  4. package/index.js +120 -79
  5. package/package.json +1 -1
  6. package/src/adapters/bash.js +73 -0
  7. package/src/adapters/edit.js +249 -0
  8. package/src/adapters/errors.js +31 -0
  9. package/src/adapters/index.js +31 -0
  10. package/src/adapters/list.js +102 -0
  11. package/src/adapters/read.js +805 -0
  12. package/src/adapters/refs.js +41 -0
  13. package/src/adapters/write.js +96 -0
  14. package/src/bridge/catalog.js +28 -222
  15. package/src/bridge/host-bridge.js +113 -1668
  16. package/src/bridge/invoke.js +35 -0
  17. package/src/bridge/native-tools.js +1 -198
  18. package/src/context/evidence.js +140 -76
  19. package/src/context/fuzzy.js +42 -24
  20. package/src/context/ledger.js +43 -24
  21. package/src/context/outline.js +23 -18
  22. package/src/context/repo-index.js +206 -170
  23. package/src/context/search.js +157 -77
  24. package/src/context/snap.js +240 -136
  25. package/src/context/spans.js +2 -1
  26. package/src/context/surface.js +14 -5
  27. package/src/contract/bash.js +31 -0
  28. package/src/contract/edit.js +95 -0
  29. package/src/contract/read.js +220 -0
  30. package/src/fs/check.js +12 -8
  31. package/src/fs/diff.js +18 -7
  32. package/src/fs/json-read.js +66 -35
  33. package/src/fs/patch.js +94 -50
  34. package/src/fs/source-window.js +82 -0
  35. package/src/fs/text-ops.js +512 -0
  36. package/src/fs/vfs.js +205 -175
  37. package/src/fs/workspace.js +119 -108
  38. package/src/output/bottleneck.js +195 -116
  39. package/src/output/format.js +101 -67
  40. package/src/runtime/guest-deny-imports.js +34 -0
  41. package/src/runtime/guest-worker.js +289 -292
  42. package/src/runtime/parallel.js +97 -64
  43. package/src/runtime/program-batch.js +178 -69
  44. package/src/runtime/reference.js +15 -14
  45. package/src/runtime/runtime.js +327 -187
  46. package/src/shared/decode.js +58 -36
  47. package/src/ui/omp-frame.js +59 -42
  48. package/src/ui/render-measure.js +51 -29
  49. package/src/ui/render.js +241 -145
@@ -0,0 +1,82 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { relativeSlash } from "./workspace.js";
4
+
5
+ export const SOURCE_REF = /((?:\/|[A-Za-z]:[\\/])?(?:[\w.@-]+[\\/])*[\w.@-]+\.(?:m?[jt]sx?|c[jt]s|py|rs|go|java|kt|rb|php|c|cc|cpp|h|hpp|cs|swift|json|ya?ml|toml))(?::|\()(\d+)/g;
6
+
7
+ function prefix(p) {
8
+ return p.endsWith(path.sep) ? p : p + path.sep;
9
+ }
10
+
11
+ async function readBoundedFile(real, rootPrefix, signal) {
12
+ const handle = await fs.open(real, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
13
+
14
+ try {
15
+ const stat = await handle.stat();
16
+
17
+ if (!real.startsWith(rootPrefix) || !stat.isFile() || stat.size > 1024 * 1024) return null;
18
+ const buffer = Buffer.alloc(Math.min(stat.size, 1024 * 1024));
19
+ let offset = 0;
20
+
21
+ while (offset < buffer.length) {
22
+ const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
23
+
24
+ if (bytesRead <= 0) break;
25
+ offset += bytesRead;
26
+ signal?.throwIfAborted();
27
+ }
28
+
29
+ return buffer.subarray(0, offset).toString("utf8");
30
+ } finally { await handle.close(); }
31
+ }
32
+
33
+ function windowRows(text, lineNo, ledger, rel) {
34
+ const raw = text.split("\n");
35
+
36
+ if (lineNo < 1 || lineNo > raw.length) return null;
37
+ const start = Math.max(1, lineNo - 2);
38
+ const rows = [];
39
+
40
+ for (let l = start; l <= Math.min(raw.length, lineNo + 2); l++) rows.push((l === lineNo ? "►" : " ") + String(l).padStart(4) + " " + raw[l - 1]);
41
+ ledger.recordOrigin(rel, start, rows);
42
+
43
+ return rel + ":" + lineNo + "\n" + rows.join("\n");
44
+ }
45
+
46
+ /** Fresh bounded source window for a diagnostic; no index warmup or stale cached bodies. */
47
+ export async function sourceWindow(cwd, commandCwd, file, lineNo, signal, ledger) {
48
+ const candidate = path.resolve(commandCwd, file);
49
+
50
+ try {
51
+ const root = await fs.realpath(cwd);
52
+ const cwdPrefix = prefix(path.resolve(cwd));
53
+ const rootPrefix = prefix(root);
54
+
55
+ if (!candidate.startsWith(cwdPrefix) && !candidate.startsWith(rootPrefix)) return null;
56
+ const real = await fs.realpath(candidate);
57
+ const text = await readBoundedFile(real, rootPrefix, signal);
58
+
59
+ if (text === null) return null;
60
+
61
+ return windowRows(text, lineNo, ledger, relativeSlash(root, real));
62
+ } catch { return null; }
63
+ }
64
+
65
+ /** A failing command names path:line; the model wants those lines next. Attach them (≤4 sites). */
66
+ export async function sourceForReferences(cwd, commandCwd, output, signal, ledger) {
67
+ const seen = new Set();
68
+ const blocks = [];
69
+
70
+ for (const m of output.matchAll(SOURCE_REF)) {
71
+ const key = m[1] + ":" + m[2];
72
+
73
+ if (seen.has(key)) continue;
74
+ if (seen.size >= 4) break;
75
+ seen.add(key);
76
+ const block = await sourceWindow(cwd, commandCwd, m[1], Number(m[2]), signal, ledger);
77
+
78
+ if (block) blocks.push(block);
79
+ }
80
+
81
+ return blocks.length ? "\n--- source\n" + blocks.join("\n") : "";
82
+ }
@@ -0,0 +1,512 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { isString, isNumber, isObject } from "../shared/decode.js";
5
+ import { assertFilesystemPath } from "./workspace.js";
6
+ import { MAX_DIFF_MATCHES } from "./diff.js";
7
+
8
+ const JSON_TWO_BYTE = new Set([0x22, 0x5c, 8, 9, 10, 12, 13]);
9
+
10
+ function jsonAsciiWidth(c) {
11
+ if (JSON_TWO_BYTE.has(c)) return 2;
12
+
13
+ if (c < 32) return 6;
14
+
15
+ return 1;
16
+ }
17
+
18
+ function jsonUnitWidth(s, i) {
19
+ const c = s.charCodeAt(i);
20
+
21
+ if (c >= 0xD800 && c <= 0xDBFF && i + 1 < s.length) {
22
+ const d = s.charCodeAt(i + 1);
23
+
24
+ if (d >= 0xDC00 && d <= 0xDFFF) return { add: 2, skip: 2 };
25
+
26
+ return { add: 6, skip: 1 };
27
+ }
28
+
29
+ if (c >= 0xD800 && c <= 0xDFFF) return { add: 6, skip: 1 };
30
+
31
+ return { add: jsonAsciiWidth(c), skip: 1 };
32
+ }
33
+
34
+ /** UTF-16 length of JSON.stringify(s) for a string, without allocating the JSON. */
35
+ export function jsonStringLength(s) {
36
+ let n = 2;
37
+
38
+ for (let i = 0; i < s.length; ) {
39
+ const unit = jsonUnitWidth(s, i);
40
+ n += unit.add;
41
+ i += unit.skip;
42
+ }
43
+
44
+ return n;
45
+ }
46
+
47
+ /** Largest prefix whose JSON.stringify length is <= limit. */
48
+ export function maxJsonStringPrefix(s, limit) {
49
+ let used = 2;
50
+ let i = 0;
51
+
52
+ while (i < s.length) {
53
+ const unit = jsonUnitWidth(s, i);
54
+
55
+ if (used + unit.add > limit) break;
56
+ used += unit.add;
57
+ i += unit.skip;
58
+ }
59
+
60
+ return i;
61
+ }
62
+
63
+ export function textResult(text, details) {
64
+ return {
65
+ content: [{ type: "text", text: String(text ?? "") }],
66
+ details: details || {},
67
+ };
68
+ }
69
+
70
+ export function resultDiff(response) {
71
+ let details = response?.details;
72
+
73
+ if (isString(details)) {
74
+ try {
75
+ details = JSON.parse(details);
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ }
80
+
81
+ return isObject(details) ? details.diff : undefined;
82
+ }
83
+
84
+ /** Unwrap a single matching quote pair around the whole string (`'git status'`). */
85
+ export function unwrapIfFullyQuoted(s) {
86
+ if (s.length < 2) return s;
87
+ const q = s[0];
88
+
89
+ if (q !== "'" && q !== '"') return s;
90
+
91
+ if (s[s.length - 1] !== q) return s;
92
+ const inner = s.slice(1, -1);
93
+
94
+ if (inner.includes(q)) return s;
95
+
96
+ return inner;
97
+ }
98
+
99
+ function totalContentLines(text) {
100
+ if (text === "") return 1;
101
+
102
+ return contentLineInfo(text).count + (text.endsWith("\n") ? 1 : 0);
103
+ }
104
+
105
+ function emptySliceInfo(text, totalLines) {
106
+ return { text: "", end: totalLines, total: totalLines, count: 0, eof: true, whole: totalLines === 1 && text === "" };
107
+ }
108
+
109
+ function sliceWindow(text, startIndex, count, totalLines) {
110
+ const endExclusive = Math.min(totalLines, startIndex + count);
111
+ const start = lineStartIndex(text, startIndex + 1);
112
+ const end = lineEndIndex(text, start, endExclusive - startIndex);
113
+ let selected = text.slice(start, end);
114
+ const eof = endExclusive >= totalLines || (endExclusive === totalLines - 1 && text.endsWith("\n"));
115
+
116
+ if (endExclusive < totalLines && !selected.endsWith("\n")) selected += "\n";
117
+
118
+ return { text: selected, end: endExclusive, total: totalLines, count: endExclusive - startIndex, eof, whole: startIndex === 0 && eof };
119
+ }
120
+
121
+ export function sliceLinesRawInfo(text, offset, limit) {
122
+ const totalLines = totalContentLines(text);
123
+
124
+ if (!isNumber(offset) && !isNumber(limit)) {
125
+ return { text, end: totalLines, total: totalLines, count: totalLines, eof: true, whole: true };
126
+ }
127
+
128
+ const startIndex = (isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1) - 1;
129
+ const count = isNumber(limit) ? Math.max(0, Math.floor(limit)) : totalLines;
130
+
131
+ if (count === 0 || startIndex >= totalLines) return emptySliceInfo(text, totalLines);
132
+
133
+ return sliceWindow(text, startIndex, count, totalLines);
134
+ }
135
+
136
+ /** Read-window slicing preserves the selected lines' own line ending. */
137
+ export function sliceLinesRaw(text, offset, limit) {
138
+ return sliceLinesRawInfo(text, offset, limit).text;
139
+ }
140
+
141
+ export function readLineParam(value, name) {
142
+ if (value === undefined) return undefined;
143
+ const number = isNumber(value) ? value : isString(value) && value.trim() !== "" ? Number(value) : NaN;
144
+
145
+ if (!Number.isFinite(number)) throw new Error("read " + name + " must be a finite number");
146
+
147
+ return Math.floor(number);
148
+ }
149
+
150
+ export function normalizeReadWindow(params) {
151
+ if (!isObject(params)) return params;
152
+ const normalized = { ...params };
153
+ const offset = readLineParam(params.offset, "offset");
154
+ const limit = readLineParam(params.limit, "limit");
155
+
156
+ if (offset !== undefined) normalized.offset = Math.max(1, offset);
157
+ if (limit !== undefined) normalized.limit = Math.max(0, limit);
158
+
159
+ return normalized;
160
+ }
161
+
162
+ export function resolveReadPath(cwd, target) {
163
+ if (!isString(target) || !target.trim()) throw new Error("read requires path");
164
+ const input = assertFilesystemPath(target, "read");
165
+
166
+ return path.resolve(cwd, input === "~" ? homedir() : input.startsWith("~/") ? path.join(homedir(), input.slice(2)) : input);
167
+ }
168
+
169
+ export async function probeExistingPath(cwd, targetParam, vfs) {
170
+ const targetPath = resolveReadPath(cwd, targetParam);
171
+
172
+ if (vfs.getOverlay(targetPath) !== undefined) {
173
+ const overlay = vfs.getOverlay(targetPath);
174
+
175
+ return { path: targetPath, directory: false, size: Buffer.byteLength(overlay, "utf8"), overlay };
176
+ }
177
+
178
+ try {
179
+ const st = await fs.stat(targetPath);
180
+
181
+ return { path: targetPath, directory: st.isDirectory(), size: st.size };
182
+ } catch (err) {
183
+ if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
184
+
185
+ if (vfs.getOverlayPaths().some(file => file.startsWith(targetPath + path.sep))) return { path: targetPath, directory: true };
186
+
187
+ return null;
188
+ }
189
+ }
190
+
191
+ export const EDIT_PREVIEW_LINES = 16;
192
+
193
+ export const MAX_DIRECTORY_ENTRIES = 10000;
194
+
195
+ export function sourceLines(content) {
196
+ const raw = content.split("\n");
197
+
198
+ if (raw.at(-1) === "") raw.pop();
199
+
200
+ return raw;
201
+ }
202
+
203
+ export function lineNumberAt(content, index) {
204
+ let line = 1;
205
+
206
+ for (let i = 0; i < index; i++) if (content.charCodeAt(i) === 10) line++;
207
+
208
+ return line;
209
+ }
210
+
211
+ export function formatNumberedLine(n, text) {
212
+ return String(n).padStart(5) + " " + text;
213
+ }
214
+
215
+ export function numberedPreview(content, cap = EDIT_PREVIEW_LINES) {
216
+ const { count, preview } = contentLineInfo(content, cap);
217
+
218
+ if (count === 0) return "0 lines";
219
+ const body = preview.map((line, i) => formatNumberedLine(i + 1, line)).join("\n");
220
+ const suffix = count + " lines total";
221
+
222
+ return body + "\n" + suffix;
223
+ }
224
+
225
+ function lineAt(content, n) {
226
+ const range = lineTextRange(content, n);
227
+
228
+ return content.slice(range.start, range.end).replace(/\r?\n$/, "");
229
+ }
230
+
231
+ function duplicateEditError(target, content, index, second) {
232
+ const a = lineNumberAt(content, index);
233
+ const b = lineNumberAt(content, second);
234
+
235
+ return new Error("edit target is not unique in " + target + ": lines " + a + " and " + b + "; include more surrounding lines in oldText, or pass edits:[{oldText,newText},…]\n" + formatNumberedLine(a, lineAt(content, a)) + "\n" + formatNumberedLine(b, lineAt(content, b)));
236
+ }
237
+
238
+ function matchReplacement(target, content, replacement) {
239
+ if (!isString(replacement?.oldText) || replacement.oldText.length === 0) {
240
+ throw new Error("edit requires non-empty oldText");
241
+ }
242
+
243
+ if (!isString(replacement?.newText)) throw new Error("edit requires newText");
244
+ const oldText = String(replacement.oldText);
245
+ const newText = String(replacement.newText);
246
+ const index = content.indexOf(oldText);
247
+
248
+ if (index < 0) {
249
+ throw new Error("edit target not found in " + target + ": oldText must match the file byte-for-byte\n" + numberedPreview(content));
250
+ }
251
+ const second = content.indexOf(oldText, index + 1);
252
+
253
+ if (second >= 0) throw duplicateEditError(target, content, index, second);
254
+
255
+ return { ...replacement, oldText, newText, index, end: index + oldText.length };
256
+ }
257
+
258
+ function assertNoOverlap(target, matches) {
259
+ for (let i = 1; i < matches.length; i++) {
260
+ if (matches[i].index < matches[i - 1].end) throw new Error(`edit targets overlap in ${target}`);
261
+ }
262
+ }
263
+
264
+ export function applyReplacements(target, content, requestedEdits) {
265
+ if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
266
+ const matches = requestedEdits.map((replacement) => matchReplacement(target, content, replacement));
267
+ matches.sort((a, b) => a.index - b.index);
268
+ assertNoOverlap(target, matches);
269
+ let updated = content;
270
+
271
+ for (let i = matches.length - 1; i >= 0; i--) {
272
+ const match = matches[i];
273
+ updated = updated.slice(0, match.index) + match.newText + updated.slice(match.end);
274
+ }
275
+
276
+ return { updated, matches };
277
+ }
278
+
279
+ export function lineStartIndex(content, line) {
280
+ let index = 0;
281
+
282
+ for (let current = 1; current < line; current++) {
283
+ const next = content.indexOf("\n", index);
284
+
285
+ if (next < 0) return content.length;
286
+ index = next + 1;
287
+ }
288
+
289
+ return Math.min(index, content.length);
290
+ }
291
+
292
+ export function lineEndIndex(content, startIndex, lineCount) {
293
+ let index = startIndex;
294
+
295
+ for (let i = 0; i < lineCount; i++) {
296
+ const next = content.indexOf("\n", index);
297
+
298
+ if (next < 0) return content.length;
299
+ index = next + 1;
300
+ }
301
+
302
+ return index;
303
+ }
304
+
305
+ export function lineTextRange(content, line) {
306
+ const start = lineStartIndex(content, line);
307
+
308
+ return { start, end: lineEndIndex(content, start, 1) };
309
+ }
310
+
311
+ export function shiftDiffLines(diff, delta) {
312
+ if (!diff || delta === 0) return diff;
313
+
314
+ return {
315
+ ...diff,
316
+ lines: diff.lines.map(line => ({
317
+ ...line,
318
+ lineNum: line.lineNum + delta,
319
+ newLineNum: line.newLineNum === undefined ? undefined : line.newLineNum + delta,
320
+ })),
321
+ };
322
+ }
323
+
324
+ function suffixSeparator(content, endIndex) {
325
+ return content.slice(Math.max(0, endIndex - 2), endIndex) === "\r\n" ? "\r\n" : "\n";
326
+ }
327
+
328
+ function withLineEnding(insert, ending) {
329
+ if (insert !== "" && !insert.endsWith("\n")) return insert + ending;
330
+
331
+ return insert;
332
+ }
333
+
334
+ function viewInsertText(content, startIndex, endIndex, newText) {
335
+ const hasSuffix = endIndex < content.length;
336
+ // A view replaces whole source lines. Preserve the separator before following
337
+ // lines, but let an explicit trailing newline change a no-trailing-newline EOF.
338
+ if (hasSuffix) return content.slice(0, startIndex) + withLineEnding(newText, suffixSeparator(content, endIndex)) + content.slice(endIndex);
339
+ const insert = content.endsWith("\n") ? withLineEnding(newText, content.endsWith("\r\n") ? "\r\n" : "\n") : newText;
340
+
341
+ return content.slice(0, startIndex) + insert + content.slice(endIndex);
342
+ }
343
+
344
+ function assertViewRange(target, start, end) {
345
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1 || end < start) {
346
+ throw new Error("edit requires a valid view range in " + target);
347
+ }
348
+ }
349
+
350
+ export function applyViewReplace(target, content, start, end, oldText, newText) {
351
+ assertViewRange(target, start, end);
352
+ const current = sliceLinesRaw(content, start, end - start + 1);
353
+
354
+ if (current !== oldText) {
355
+ const shown = current.length ? current : content;
356
+
357
+ throw new Error("edit view is stale in " + target + ": lines " + start + "-" + end + " changed\n" + numberedPreview(shown));
358
+ }
359
+
360
+ const startIndex = lineStartIndex(content, start);
361
+ const endIndex = lineEndIndex(content, startIndex, Math.max(0, end - start + 1));
362
+
363
+ return { updated: viewInsertText(content, startIndex, endIndex, newText), oldText, newText };
364
+ }
365
+
366
+ export const WRITE_DIFF_MAX_READ_BYTES = 512 * 1024;
367
+ export const WRITE_APPEND_MAX_READ_BYTES = 64 * 1024 * 1024;
368
+ export const QUICK_CHECK_MAX_CHARS = 2 * 1024 * 1024;
369
+
370
+ async function snapshotLargeFile(vfs, target, overlay, signal) {
371
+ if (overlay !== undefined) return contentLineInfo(overlay).count;
372
+ await vfs.captureExpected(target);
373
+
374
+ try { return await countContentLines(target, signal); }
375
+ catch (error) {
376
+ if (error.code !== "ENOENT") throw error;
377
+
378
+ return 0;
379
+ }
380
+ }
381
+
382
+ async function snapshotSmallFile(vfs, target, overlay) {
383
+ try { return overlay !== undefined ? overlay : await vfs.read(target, { maxBytes: WRITE_DIFF_MAX_READ_BYTES, preserveRead: true }); }
384
+ catch (error) {
385
+ if (error?.code !== "ENOENT") throw error;
386
+
387
+ return "";
388
+ }
389
+ }
390
+
391
+ async function existingStat(target) {
392
+ try { return await fs.stat(target); }
393
+ catch (error) {
394
+ if (error.code !== "ENOENT") throw error;
395
+ }
396
+ }
397
+
398
+ /** Prior body (or line count) for a write receipt / CAS, without always materializing huge files. */
399
+ export async function writeSnapshot(vfs, target, signal) {
400
+ const stat = await existingStat(target);
401
+ const overlay = vfs.getOverlay(target);
402
+ const existingBytes = overlay !== undefined ? Buffer.byteLength(overlay, "utf8") : stat?.size;
403
+
404
+ if (existingBytes !== undefined && existingBytes > WRITE_DIFF_MAX_READ_BYTES) {
405
+ return { previous: "", removedLines: await snapshotLargeFile(vfs, target, overlay, signal), overlay, existingBytes };
406
+ }
407
+
408
+ return { previous: await snapshotSmallFile(vfs, target, overlay), removedLines: undefined, overlay, existingBytes };
409
+ }
410
+
411
+ export async function countContentLines(target, signal) {
412
+ const file = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
413
+
414
+ try {
415
+ const stat = await file.stat();
416
+
417
+ if (!stat.isFile()) return null;
418
+ let newlines = 0;
419
+ let last = -1;
420
+ let total = 0;
421
+
422
+ for await (const chunk of file.createReadStream({ autoClose: false, signal })) {
423
+ for (let i = 0; i < chunk.length; i++) if (chunk[i] === 10) newlines++;
424
+ last = chunk.at(-1);
425
+ total += chunk.length;
426
+ }
427
+
428
+ return total === 0 ? 0 : newlines + (last === 10 ? 0 : 1);
429
+ } finally { await file.close(); }
430
+ }
431
+
432
+ export function contentLineInfo(text, previewLimit = 0) {
433
+ if (text === "") return { count: 0, preview: [], newlines: 0 };
434
+ const preview = [];
435
+ let count = 0;
436
+ let start = 0;
437
+
438
+ while (start <= text.length) {
439
+ const newline = text.indexOf("\n", start);
440
+ const end = newline < 0 ? text.length : newline;
441
+
442
+ if (end === text.length && end === start && text.endsWith("\n")) break;
443
+ if (preview.length < previewLimit) preview.push(text.slice(start, end).replace(/\r$/, ""));
444
+ count++;
445
+ if (newline < 0) break;
446
+ start = newline + 1;
447
+ }
448
+
449
+ return { count, preview, newlines: count - Number(!text.endsWith("\n")) };
450
+ }
451
+
452
+ export function boundedEditDiff(target, original, matches) {
453
+ const rendered = matches.slice(0, MAX_DIFF_MATCHES);
454
+ const lines = [];
455
+ let shift = 0;
456
+ let added = 0;
457
+ let removed = 0;
458
+
459
+ for (const match of matches) {
460
+ removed += contentLineInfo(match.oldText).count;
461
+ added += contentLineInfo(match.newText).count;
462
+ }
463
+
464
+ for (const match of rendered) {
465
+ const oldInfo = contentLineInfo(match.oldText, 32);
466
+ const newInfo = contentLineInfo(match.newText, 32);
467
+ const start = lineNumberAt(original, match.index);
468
+ const nextStart = start + shift;
469
+
470
+ for (let i = 0; i < oldInfo.preview.length; i++) lines.push({ type: "remove", lineNum: start + i, newLineNum: nextStart + i, text: oldInfo.preview[i] });
471
+ for (let i = 0; i < newInfo.preview.length; i++) lines.push({ type: "add", lineNum: nextStart + i, newLineNum: nextStart + i, text: newInfo.preview[i] });
472
+
473
+ shift += newInfo.newlines - oldInfo.newlines;
474
+ }
475
+
476
+ return { path: target, op: "edit", added, removed, lines, omittedMatches: matches.length - rendered.length };
477
+ }
478
+
479
+ export function boundedWriteDiff(target, content, removed) {
480
+ const added = contentLineInfo(content, 64);
481
+
482
+ return {
483
+ path: target,
484
+ op: "write",
485
+ added: added.count,
486
+ removed: removed ?? 0,
487
+ displayLineCount: (removed ?? 0) + added.count,
488
+ lines: added.preview.map((text, i) => ({ type: "add", lineNum: i + 1, text })),
489
+ };
490
+ }
491
+
492
+ export function formatDirectoryEntry(name, type, size = 0) {
493
+ const sizeSuffix = size ? `, ${size} bytes` : "";
494
+
495
+ return `${name}${type === "dir" ? "/" : ""} (${type}${sizeSuffix})`;
496
+ }
497
+
498
+ export async function formatLsEntry(dirPath, entry) {
499
+ const isDir = entry.isDirectory();
500
+ const isSym = entry.isSymbolicLink();
501
+ const typeLabel = isDir ? "dir" : isSym ? "sym" : "file";
502
+ let size = 0;
503
+
504
+ try {
505
+ if (!isDir && !isSym) {
506
+ const st = await fs.stat(path.join(dirPath, entry.name));
507
+ size = st.size;
508
+ }
509
+ } catch {}
510
+
511
+ return formatDirectoryEntry(entry.name, typeLabel, size);
512
+ }