pi-supernova 0.5.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 (50) hide show
  1. package/README.md +97 -11
  2. package/docs/CHANGELOG.md +150 -0
  3. package/docs/TOKEN_COSTS.md +71 -29
  4. package/index.js +126 -82
  5. package/package.json +2 -2
  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 +30 -220
  15. package/src/bridge/host-bridge.js +142 -1032
  16. package/src/bridge/invoke.js +35 -0
  17. package/src/bridge/native-tools.js +1 -188
  18. package/src/context/evidence.js +142 -70
  19. package/src/context/fuzzy.js +61 -22
  20. package/src/context/ledger.js +43 -24
  21. package/src/context/outline.js +26 -12
  22. package/src/context/repo-index.js +242 -71
  23. package/src/context/search.js +189 -56
  24. package/src/context/snap.js +306 -150
  25. package/src/context/spans.js +2 -1
  26. package/src/context/surface.js +29 -14
  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 +19 -7
  31. package/src/fs/diff.js +18 -7
  32. package/src/fs/json-read.js +66 -35
  33. package/src/fs/patch.js +97 -51
  34. package/src/fs/source-window.js +82 -0
  35. package/src/fs/text-ops.js +512 -0
  36. package/src/fs/vfs.js +289 -162
  37. package/src/fs/workspace.js +122 -105
  38. package/src/output/bottleneck.js +211 -107
  39. package/src/output/format.js +112 -63
  40. package/src/runtime/guest-deny-imports.js +34 -0
  41. package/src/runtime/guest-worker.js +306 -213
  42. package/src/runtime/parallel.js +99 -63
  43. package/src/runtime/program-batch.js +189 -69
  44. package/src/runtime/program-file.js +6 -3
  45. package/src/runtime/reference.js +13 -12
  46. package/src/runtime/runtime.js +327 -176
  47. package/src/shared/decode.js +61 -27
  48. package/src/ui/omp-frame.js +70 -46
  49. package/src/ui/render-measure.js +51 -29
  50. package/src/ui/render.js +242 -146
@@ -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
+ }