pi-supernova 0.10.1 → 0.11.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 (81) hide show
  1. package/README.md +376 -221
  2. package/docs/CHANGELOG.md +107 -5
  3. package/docs/TOKEN_COSTS.md +149 -324
  4. package/index.js +40 -11
  5. package/package.json +4 -3
  6. package/src/adapters/bash.js +40 -10
  7. package/src/adapters/edit.js +77 -23
  8. package/src/adapters/errors.js +4 -0
  9. package/src/adapters/index.js +1 -0
  10. package/src/adapters/list.js +6 -3
  11. package/src/adapters/read-focus.js +6 -0
  12. package/src/adapters/read-image.js +17 -9
  13. package/src/adapters/read-indexed.js +72 -0
  14. package/src/adapters/read-text.js +19 -2
  15. package/src/adapters/read.js +57 -13
  16. package/src/adapters/refs.js +17 -7
  17. package/src/adapters/write.js +14 -21
  18. package/src/bridge/host-bridge.js +152 -39
  19. package/src/bridge/invoke.js +3 -1
  20. package/src/bridge/tool-registry.js +132 -16
  21. package/src/context/evidence-graph.js +1 -0
  22. package/src/context/evidence-rank.js +1 -0
  23. package/src/context/evidence.js +4 -2
  24. package/src/context/fuzzy.js +31 -8
  25. package/src/context/outline.js +33 -9
  26. package/src/context/query.js +7 -2
  27. package/src/context/repo-index.js +8 -3
  28. package/src/context/search-files.js +20 -0
  29. package/src/context/search.js +25 -10
  30. package/src/context/snap-search.js +28 -6
  31. package/src/context/snap.js +8 -3
  32. package/src/context/source-entry.js +64 -28
  33. package/src/context/spans.js +1 -0
  34. package/src/contract/bash.js +16 -7
  35. package/src/contract/edit.js +26 -7
  36. package/src/contract/program.js +2 -1
  37. package/src/contract/read.js +46 -46
  38. package/src/fs/background.js +10 -2
  39. package/src/fs/check.js +844 -46
  40. package/src/fs/commit.js +89 -15
  41. package/src/fs/diff.js +12 -9
  42. package/src/fs/directory.js +14 -4
  43. package/src/fs/file-io.js +28 -7
  44. package/src/fs/glob.js +93 -11
  45. package/src/fs/json-read.js +3 -2
  46. package/src/fs/json-size.js +2 -1
  47. package/src/fs/lines.js +49 -2
  48. package/src/fs/patch.js +26 -10
  49. package/src/fs/process-tree.js +19 -1
  50. package/src/fs/read-window.js +36 -20
  51. package/src/fs/source-window.js +5 -10
  52. package/src/fs/text-ops.js +39 -19
  53. package/src/fs/vfs.js +296 -41
  54. package/src/fs/workspace.js +24 -6
  55. package/src/output/bottleneck.js +50 -25
  56. package/src/output/final.js +20 -0
  57. package/src/output/format.js +97 -36
  58. package/src/output/outcome.js +25 -9
  59. package/src/runtime/batch-input.js +17 -3
  60. package/src/runtime/guest-api.js +32 -7
  61. package/src/runtime/guest-worker.js +22 -4
  62. package/src/runtime/parallel.js +9 -49
  63. package/src/runtime/program-batch.js +53 -28
  64. package/src/runtime/program-file.js +2 -1
  65. package/src/runtime/program.js +94 -41
  66. package/src/runtime/reference.js +10 -9
  67. package/src/runtime/runtime.js +86 -17
  68. package/src/runtime/worker-pool.js +10 -1
  69. package/src/shared/decode.js +9 -8
  70. package/src/shared/image-worker.js +7 -0
  71. package/src/shared/image.js +58 -15
  72. package/src/shared/png.js +18 -0
  73. package/src/shared/result.js +30 -9
  74. package/src/shared/syntax-context.js +7 -1
  75. package/src/shared/utf8.js +11 -13
  76. package/src/ui/host-render.js +4 -0
  77. package/src/ui/omp-frame.js +2 -0
  78. package/src/ui/progress.js +2 -1
  79. package/src/ui/render-measure.js +11 -0
  80. package/src/ui/render.js +92 -31
  81. package/src/ui/trace.js +8 -5
@@ -20,7 +20,7 @@ export function viewSpan(value) {
20
20
  }
21
21
 
22
22
  export function isEditView(value) {
23
- return isObject(value) && !Array.isArray(value) && isString(value.path) && value.path.trim() && isString(value.text) && (value.status === undefined || value.status === "found") && viewSpan(value);
23
+ return isObject(value) && !Array.isArray(value) && isString(value.path) && value.path.length > 0 && isString(value.text) && (value.status === undefined || value.status === "found") && viewSpan(value);
24
24
  }
25
25
 
26
26
  function classifyViewEdit(p, oldText, newText) {
@@ -60,7 +60,7 @@ function normalizeEditArgs(p, oldText, newText) {
60
60
  function namedEditArgs(p, oldText, newText) {
61
61
  const args = normalizeEditArgs(p, oldText, newText);
62
62
 
63
- if (!isString(args.path) || !args.path.trim()) throw new Error(EDIT_USAGE);
63
+ if (!isString(args.path) || args.path.length === 0) throw new Error(EDIT_USAGE);
64
64
 
65
65
  return args;
66
66
  }
@@ -82,18 +82,32 @@ function classifyReplacements(args) {
82
82
 
83
83
  if (!Array.isArray(edits) || !edits.length) throw new Error(EDIT_USAGE);
84
84
 
85
- for (const e of edits) if (!isString(e?.oldText) || !e.oldText.length || !isString(e?.newText)) throw new Error(EDIT_USAGE + "; replacements require non-empty oldText and string newText");
85
+ for (const [i, edit] of edits.entries()) {
86
+ if (!validReplacement(edit)) throw new Error(replacementError(edit));
87
+
88
+ if (args.edits !== undefined) assertEditOptions(edit, REPLACEMENT_OPTION_KEYS, "edit " + (i + 1) + " of " + edits.length);
89
+ }
86
90
 
87
91
  return { kind: "edits", command: "edit", args };
88
92
  }
89
93
 
94
+ function validReplacement(edit) {
95
+ return isString(edit?.oldText) && !!edit.oldText.length && isString(edit?.newText);
96
+ }
97
+
98
+ function replacementError(edit) {
99
+ return EDIT_USAGE + "; replacements require non-empty oldText and string newText" + (isString(edit?.oldText) && !edit.oldText.length ? "; to insert, include adjacent existing text in oldText and repeat it in newText" : "");
100
+ }
101
+
90
102
  const EDIT_OPTION_KEYS = ["path", "oldText", "newText", "edits", "patch"];
91
103
 
104
+ const REPLACEMENT_OPTION_KEYS = ["oldText", "newText"];
105
+
92
106
  /** A view object carries host fields; only named replacements are validated. */
93
- function assertEditOptions(args) {
94
- const unknown = Object.keys(args).filter(key => !EDIT_OPTION_KEYS.includes(key));
107
+ function assertEditOptions(args, allowed = EDIT_OPTION_KEYS, label = "edit") {
108
+ const unknown = Object.keys(args).filter(key => !allowed.includes(key));
95
109
 
96
- if (unknown.length) throw new Error("edit does not accept option " + unknown.map(key => JSON.stringify(key)).join(", ") + "; supported options are path, oldText, newText, edits, patch");
110
+ if (unknown.length) throw new Error(label + " does not accept option " + unknown.map(key => JSON.stringify(key)).join(", ") + "; supported options are " + allowed.join(", "));
97
111
  }
98
112
 
99
113
  function classifyNamedEdit(p, oldText, newText) {
@@ -106,7 +120,12 @@ function classifyNamedEdit(p, oldText, newText) {
106
120
 
107
121
  /** Guest signature → { command, args } for one host call. */
108
122
  export function classifyEdit(p, oldText, newText) {
109
- if (isFunction(p)) return { kind: "checkpoint", fn: p };
123
+ if (isFunction(p)) {
124
+ if (oldText !== undefined || newText !== undefined) throw new Error(EDIT_USAGE);
125
+
126
+ return { kind: "checkpoint", fn: p };
127
+ }
128
+
110
129
  if (isEditView(p) && isString(oldText) && (newText === undefined || isString(newText))) return classifyViewEdit(p, oldText, newText);
111
130
 
112
131
  return classifyNamedEdit(p, oldText, newText);
@@ -9,7 +9,8 @@ try {
9
9
  Type = require("typebox").Type;
10
10
  } catch {
11
11
  Type = {
12
- Object: (props, opts) => ({ type: "object", properties: props || {}, additionalProperties: false, ...opts }),
12
+ // Match Type.Object defaults; explicit nested-entry guards live in opts.
13
+ Object: (props, opts) => ({ type: "object", properties: props || {}, ...opts }),
13
14
  String: (opts) => ({ type: "string", ...opts }),
14
15
  Unknown: (opts) => ({ ...opts }),
15
16
  Array: (items, opts) => ({ type: "array", items, ...opts }),
@@ -1,11 +1,11 @@
1
- import { errorMessage, isString, isObject, isNumber, looksLikePath } from "../shared/decode.js";
1
+ import { errorMessage, isString, isObject, looksLikePath } from "../shared/decode.js";
2
2
  import { foldJsonSelectorAlias, sessionJsonArgs, validateJsonRead } from "../fs/json-read.js";
3
3
 
4
4
  export const SESSION_URI = /^(?:agent|artifact):\/\//i;
5
5
 
6
- const BOOL_KEYS = ["resolve", "complete", "outline", "evidence"];
6
+ const BOOL_KEYS = ["resolve", "complete", "outline", "evidence", "indexed"];
7
7
 
8
- const READ_OPTION_KEYS = ["path", "target", "about", "query", "offset", "limit", "json", "resolve", "complete", "outline", "evidence", "maxChars", "_independent"];
8
+ const READ_OPTION_KEYS = ["path", "target", "about", "query", "offset", "limit", "json", "resolve", "complete", "outline", "evidence", "maxChars", "indexed", "_independent"];
9
9
 
10
10
  /** Unknown options used to be dropped silently: {start,end} read the whole file. */
11
11
  function assertReadOptions(args) {
@@ -26,25 +26,28 @@ export function isSessionUri(value) {
26
26
 
27
27
  /** Guest call shape → one options object. */
28
28
  export function gatherReadArgs(p, a, b) {
29
- if (isObject(p) && !Array.isArray(p)) {
30
- const args = { ...p, path: p.path ?? p.target ?? p.query };
31
-
32
- if (p.path === undefined && p.target !== undefined) delete args.target;
33
-
34
- return args;
35
- }
29
+ if (isObject(p) && !Array.isArray(p)) return gatherObjectReadArgs(p);
36
30
 
37
31
  // Only the string shorthand guesses between a path and a symbol. Explicit
38
32
  // path/target objects and path arrays must not turn missing files into search.
39
33
  return autoResolve(isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b });
40
34
  }
41
35
 
36
+ function gatherObjectReadArgs(p) {
37
+ // Query text is not a scope, even when it looks like a filename.
38
+ const args = { ...p, path: p.path ?? p.target ?? (p.query !== undefined ? "." : undefined) };
39
+
40
+ if (p.path === undefined && p.target !== undefined) delete args.target;
41
+
42
+ return args;
43
+ }
44
+
42
45
  export function assertReadPaths(targetParam) {
43
46
  if (!Array.isArray(targetParam)) return;
44
47
 
45
48
  if (targetParam.length > 64) throw new Error("read accepts at most 64 paths per batch");
46
49
 
47
- for (const item of targetParam) if (!isString(item) || !item.trim()) throw new Error("read paths must be non-empty strings");
50
+ for (const item of targetParam) if (!isString(item) || item.length === 0) throw new Error("read paths must be non-empty strings");
48
51
  }
49
52
 
50
53
  function assertReadFlags(args) {
@@ -57,11 +60,19 @@ function assertReadFlags(args) {
57
60
  if (args.query !== undefined && !isString(args.query)) throw new Error("read query must be a string");
58
61
  }
59
62
 
60
- function assertExclusiveRead(args) {
63
+ function assertFocusModes(args) {
61
64
  const focusModes = [args.about !== undefined, args.query !== undefined, args.outline === true].filter(Boolean).length;
62
65
 
63
66
  if (focusModes > 1 || (args.outline === true && args.evidence === true)) throw new Error("read accepts only one of about, query, outline, or evidence");
64
67
 
68
+ return focusModes;
69
+ }
70
+
71
+ function assertExclusiveRead(args) {
72
+ if (args.indexed === true && indexedConflict(args)) throw new Error("indexed:true requires a source query, not another read mode or explicit window");
73
+
74
+ const focusModes = assertFocusModes(args);
75
+
65
76
  if (args.resolve === true && args.complete === true) throw new Error("read accepts either resolve or complete, not both");
66
77
 
67
78
  if ((focusModes === 1 || args.evidence === true) && args.complete === true) throw new Error("complete:true requires a raw file read, not a source view");
@@ -70,7 +81,7 @@ function assertExclusiveRead(args) {
70
81
  function autoResolve(args) {
71
82
  if (!isString(args.path) || args.resolve !== undefined || args.complete === true || args.json !== undefined) return args;
72
83
 
73
- if (args.about !== undefined || args.query !== undefined || args.offset !== undefined || args.limit !== undefined || looksLikePath(args.path) || isSessionUri(args.path)) return args;
84
+ if (hasDefinedOption(args, ["about", "query", "offset", "limit"]) || looksLikePath(args.path) || isSessionUri(args.path)) return args;
74
85
 
75
86
  return { ...args, resolve: true };
76
87
  }
@@ -103,20 +114,30 @@ export function needsProbe(params) {
103
114
  * Kind of read after exclusive modes are already validated.
104
115
  * `existing` is the probe result, or null/undefined when needsProbe is false or the path is missing.
105
116
  */
106
- function classifyExisting(params, existing) {
107
- if (existing.directory) {
108
- if (params.json !== undefined) throw new Error("JSON read requires a file, not a directory");
117
+ function hasDefinedOption(args, keys) {
118
+ return keys.some(key => args[key] !== undefined);
119
+ }
109
120
 
110
- return isString(params.about)
111
- ? { kind: "snap", query: params.about, scoped: true, existing }
112
- : { kind: "dir", existing };
113
- }
121
+ function indexedConflict(args) {
122
+ return hasDefinedOption(args, ["about", "json", "offset", "limit"]) || ["outline", "evidence", "complete"].some(key => args[key] === true);
123
+ }
124
+
125
+ function classifyDirectory(params, existing) {
126
+ if (params.json !== undefined) throw new Error("JSON read requires a file, not a directory");
127
+
128
+ return isString(params.about)
129
+ ? { kind: "snap", query: params.about, scoped: true, existing }
130
+ : { kind: "dir", existing };
131
+ }
132
+
133
+ function classifyExisting(params, existing) {
134
+ if (existing.directory) return classifyDirectory(params, existing);
114
135
 
115
136
  if (params.resolve === true && isString(params.about)) {
116
137
  throw new Error("resolve:true cannot combine with about on a file; use about for a focused outline or resolve for source text");
117
138
  }
118
139
 
119
- if (isString(params.about) && existing.size > 512 * 1024) return { kind: "focus", existing, about: params.about };
140
+ if (isString(params.about) && (existing.size > 512 * 1024 || /\.(?:md|markdown)$/i.test(existing.path))) return { kind: "focus", existing, about: params.about };
120
141
 
121
142
  return { kind: params.resolve ? "open" : "file", existing };
122
143
  }
@@ -166,37 +187,16 @@ function jsonSelectorNote(args) {
166
187
  return args.json === undefined ? "" : " (" + (Array.isArray(args.json) ? args.json.join(", ") : String(args.json)) + ")";
167
188
  }
168
189
 
169
- export const ROUTING_STATUS = "too_large";
170
-
171
- const ROUTING_PREFIX = '{"status":"too_large",';
172
-
173
- export function isRoutingPayload(value) {
174
- return isString(value) && value.startsWith(ROUTING_PREFIX);
175
- }
176
-
177
- function isRoutingObject(parsed) {
178
- return isObject(parsed) && parsed.status === ROUTING_STATUS && isString(parsed.path)
179
- && isNumber(parsed.chars) && (Array.isArray(parsed.keys) || isNumber(parsed.length));
180
- }
181
-
182
190
  function decodeByArgs(args, value) {
183
191
  return (args.resolve || args.json !== undefined || args.outline || args.evidence) && isString(value);
184
192
  }
185
193
 
186
194
  export function decodeReadValue(args, value) {
187
- const sniffed = isRoutingPayload(value);
195
+ // Decode only by requested mode, never by fields inside literal host text.
196
+ if (!decodeByArgs(args, value)) return value;
188
197
 
189
- if (!sniffed && !decodeByArgs(args, value)) return value;
190
-
191
- try {
192
- const parsed = JSON.parse(value);
193
-
194
- return sniffed && !isRoutingObject(parsed) ? value : parsed;
195
- } catch (error) {
196
- if (sniffed && !decodeByArgs(args, value)) return value;
197
-
198
- throw jsonReadError(args, error);
199
- }
198
+ try { return JSON.parse(value); }
199
+ catch (error) { throw jsonReadError(args, error); }
200
200
  }
201
201
 
202
202
  function jsonReadError(args, error) {
@@ -36,6 +36,14 @@ function wake(job) {
36
36
  for (const notify of job.waiters) notify();
37
37
  }
38
38
 
39
+ // Decoded streams contain complete pairs, but retention/cursor slicing may
40
+ // land on a low surrogate. Deliver only the next complete code point.
41
+ function completeCharacterStart(text, index) {
42
+ const code = text.charCodeAt(index);
43
+
44
+ return code >= 0xdc00 && code <= 0xdfff ? index + 1 : index;
45
+ }
46
+
39
47
  function appendOutput(job, chunk) {
40
48
  job.total += chunk.length;
41
49
  job.output = (job.output + chunk).slice(-OUTPUT_CHARS);
@@ -45,12 +53,12 @@ function appendOutput(job, chunk) {
45
53
  function snapshot(job, cursor = 0) {
46
54
  if (cursor > job.total) throw new Error("terminal cursor is beyond available output");
47
55
  const start = job.total - job.output.length;
48
- const outputStart = Math.max(start, cursor);
56
+ const outputStart = start + completeCharacterStart(job.output, Math.max(0, cursor - start));
49
57
 
50
58
  const result = {
51
59
  sessionId:job.id, pid:job.child.pid, status:job.status, pty:job.pty,
52
60
  exitCode:job.exitCode, signal:job.signal, outputStart, cursor:job.total,
53
- truncated:cursor < start, output:job.output.slice(outputStart-start),
61
+ truncated:cursor < outputStart, output:job.output.slice(outputStart-start),
54
62
  };
55
63
 
56
64
  if (job.error) result.error = job.error;