pi-supernova 0.0.6 → 0.0.8

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/CHANGELOG.md CHANGED
@@ -2,6 +2,39 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.0.8] - 2026-09-04
6
+
7
+ ### Changed
8
+
9
+ - Result card redesigned as an aligned ledger: one row per call (status · tool · duration · `exit N` · `+a/-r` · target), no tree stems or spacer rows, durations humanized (`6.2s`), multi-line commands shown as their first line plus `…+N lines`, and paths fitted to width with the basename kept. Trace records now carry per-call `ms` and non-zero `exitCode`.
10
+ - Guest programs run in a warm worker thread. A hard timeout or abort now terminates synchronous loops (`while (true) {}`), `process.exit()` only ends the program, and `maxHeapMb` (V8 `resourceLimits` plus a process-RSS watchdog for Bun) stops memory blow-ups — none of these can take the host down anymore. Warm-worker overhead is ~0.1ms per program and ~20µs per `nova.call`.
11
+ - Result text is a compact JS literal (`ok 12ms` header, unquoted keys, no separator whitespace, one item per line only past 120 columns): ~43% fewer tokens than the previous pretty JSON. Logs appear under `--- logs` only when present.
12
+ - `maxReturnChars` default lowered from 200000 to 32000.
13
+ - Return values that JSON cannot express are rendered instead of collapsing to `[object Object]`: circular references, `Map`, `Set`, `BigInt`, `Error`, functions, typed arrays.
14
+ - A program that finishes without a `return` statement says so instead of printing `null`.
15
+ - Guest runtime errors include `(line:col)` on Node.
16
+
17
+ - Errors teach: unknown tool names get `Did you mean "read"?` (OSA distance over callable tools), and budget, timeout, missing-file, path-escape, and edit-mismatch errors name the exact fix.
18
+ - `nova.call` envelopes are lean when returned: `details` is reachable but non-enumerable and `truncated:false` is omitted (53 → 13 tokens for a dumped `bash` result). Tool description rewritten as signatures (327 → 252 prompt tokens per turn, more information).
19
+ - Internals: `host-bridge.js` split into `vfs.js`, `patch.js`, `workspace.js`; `format.js` holds the text-shaping kernel so the UI no longer imports result packaging. Every function is at CC ≤ 10 (was: 14 above, max 43).
20
+
21
+ ### Fixed
22
+
23
+ - `read([...paths])` is a single batched host call instead of one call per path, so reading 300 files no longer exhausts `maxBridgeCalls`; the native batch adapter also returned `null` items.
24
+ - Result truncation no longer splits a surrogate pair, which produced a lone surrogate the model API rejects.
25
+ - `bash` failures throw `command failed (exit N): <cmd>` plus output instead of a JSON details blob; stdout/stderr are joined without a blank line; truncated output is marked.
26
+ - `read("dir")` reports a directory instead of silently running a concept search.
27
+ - The VFS read cache is cleared at the start of every program, so files edited outside supernova are never read stale.
28
+ - Arrow programs with default parameters containing parentheses or a leading comment are recognized and executed instead of silently returning `null`.
29
+ - `snap` ranks deterministically (`rg --files` order is sorted) and its content-grep fallback is case-insensitive, so `quasar handshake` finds `exactQuasarHandshake` instead of returning the first listed file.
30
+
31
+ ## [0.0.7] - 2026-09-03
32
+
33
+ ### Fixed
34
+
35
+ - `bash` / `exec` run through `bash -c` (and strip a wrapping quote pair), so `git status` and `echo $PATH` are not looked up as a single binary name.
36
+ - `nova.search` / `nova.describe` return promises, so `.catch()` in guest programs works.
37
+
5
38
  ## [0.0.6] - 2026-09-03
6
39
 
7
40
  ### Changed
package/README.md CHANGED
@@ -25,7 +25,7 @@ Load **before** other tool-owning packages so `registerTool` capture works. Rest
25
25
  | Parallel reads | Ad hoc | `callMany` Auto / `parallel()` |
26
26
  | Hosts | Separate packages | Same tarball for Pi **and** OMP |
27
27
 
28
- Guest code is an in-process `AsyncFunction` (same trust class as host `bash`) — not an OS/VM sandbox. Tool calls still go through `nova.call`.
28
+ Guest code runs as an `AsyncFunction` in a worker thread (same trust class as host `bash`) — not an OS/VM sandbox. Tool calls go through `nova.call` RPC to the host thread; a hard timeout, abort, `process.exit`, or a memory blow-up terminates the worker without touching the harness.
29
29
 
30
30
  ---
31
31
 
@@ -46,21 +46,24 @@ async () => {
46
46
  }
47
47
  ```
48
48
 
49
- Globals: `nova` / `tools`, `parallel`, `pipeline`, `console`, plus shorthand `read`, `write`, `edit`, `patch`, `surface`, `snap`, `bash`, and `exec`.
49
+ Globals: `nova` / `tools`, `parallel`, `pipeline`, `console`, plus shorthand `read` (path or path array), `write`, `edit`, `patch`, `surface`, `snap`, `bash`, and `exec`.
50
50
 
51
- Unified Pi/OMP card with bounded mutation diffs visible while collapsed:
51
+ The returned value is rendered as a compact JS literal (unquoted keys, one item per line only when a container exceeds 120 columns) and capped at `maxReturnChars`. Strings are returned raw. This costs ~43% fewer tokens than pretty JSON — return small shaped values, not raw file dumps.
52
+
53
+ Unified Pi/OMP card — one aligned row per call (status · tool · duration · target) with bounded mutation diffs:
52
54
 
53
55
  ```text
54
- ╭─── nova: 2 calls · 84ms ───────────────────────────────╮
55
- ├─ read packages/pi-supernova/host-bridge.js
56
- │ │
57
- └─ edit +1/-1 packages/pi-supernova/diff.js
58
- -143 - const oldValue = before;
59
- +143 │ + const newValue = after;
60
- ╰─────────────────────────────────────────────────────────╯
56
+ ╭─── nova: 4 calls · 6.9s ─────────────────────────────────────╮
57
+ │ ✓ bash 6.2s python3 - <<'EOF' …+3 lines
58
+ × bash 120ms exit 3 pytest -q
59
+ │ ✓ read 3ms packages/pi-supernova/host-bridge.js
60
+ edit 4ms +1/-1 src/a.ts
61
+ -143 │ - const oldValue = before;
62
+ │ +143 │ + const newValue = after; │
63
+ ╰───────────────────────────────────────────────────────────────╯
61
64
  ```
62
65
 
63
- Collapsed cards show bounded edit/write/patch hunks immediately. Press Enter for a larger hunk budget, logs, and the returned value.
66
+ Multi-line commands show their first line plus a hidden-line count. Press Enter for a larger hunk budget, the full returned value, and logs.
64
67
 
65
68
  ---
66
69
 
@@ -74,7 +77,7 @@ Collapsed cards show bounded edit/write/patch hunks immediately. Press Enter for
74
77
  | `nova.callMany([{name,args}])` | Auto parallel wave — iterable array with `.mode` / `.results` |
75
78
  | `nova.surface(path)` | Structural outline for a source file |
76
79
  | `nova.snap(query, searchRoot?)` | Most relevant source path, line, signature, confidence, and context |
77
- | `nova.has(name)` | Whether a catalog or native tool is callable |
80
+ | `nova.has(name)` | Whether a catalog or native tool is callable (sync) |
78
81
  | `parallel(thunks)` / `pipeline(items, …stages)` | Raw `Promise.all` helpers |
79
82
  | `nova.speculate(fn)` | Counterfactual branch (rollback / commit) |
80
83
 
@@ -93,12 +96,15 @@ Optional `~/.pi/agent/supernova.json` or `~/.omp/agent/supernova.json`
93
96
  "maxCodeChars": 48000,
94
97
  "maxBridgeCalls": 256,
95
98
  "maxCallResultChars": 65536,
96
- "maxReturnChars": 200000,
99
+ "maxReturnChars": 32000,
100
+ "maxHeapMb": 512,
97
101
  "maxSearchResults": 12,
98
102
  "spillDir": null
99
103
  }
100
104
  ```
101
105
 
106
+ `maxHeapMb` caps the guest worker heap (V8 `resourceLimits` on Node) and arms a process-RSS watchdog that terminates a runaway program on both Node and Bun.
107
+
102
108
  Defaults also set `excludeTools` (includes `supernova` and DCE helpers). An empty `"excludeTools": []` **replaces** those defaults — omit the key unless you mean that.
103
109
 
104
110
  Slash: `/supernova` — catalog size + captured executors.
@@ -126,7 +132,7 @@ Pair with DCE last if you use it: `omp install npm:pi-deferred-context-engine`.
126
132
 
127
133
  | Symptom | Fix |
128
134
  |---------|-----|
129
- | `no executor for tool "…"` | Install supernova **first**; restart; `/supernova` |
135
+ | `unknown tool "…"` | Follow the `Did you mean` hint, or `nova.search("")`; for host tools install supernova **first**; restart; `/supernova` |
130
136
  | `Rendered line exceeds terminal width` | ≥0.0.1 and restart so `render.js` reloads |
131
137
  | `callMany` / not iterable | ≥0.0.1 — return is an array with `.mode` / `.results` |
132
138
  | Extension missing on OMP | `omp install npm:pi-supernova` (needs `"omp".extensions`) |
@@ -135,7 +141,8 @@ Pair with DCE last if you use it: `omp install npm:pi-deferred-context-engine`.
135
141
 
136
142
  ## Limitations
137
143
 
138
- - Guest JS is **unsandboxed**. Adapter path jails are not a boundary against `import("node:fs")`.
144
+ - Guest JS is **unsandboxed**. Adapter path jails are not a boundary against `import("node:fs")`. The worker only contains hangs, exits, and memory — not intent.
145
+ - Guest error messages carry `(line:col)` on Node; Bun's engine does not expose guest-relative positions.
139
146
  - `bash` / mutating tools flush speculative writes (transaction barrier); error rollback cannot undo that.
140
147
  - Pre-1.0 package — APIs and TUI may still evolve between minor releases.
141
148
 
package/bottleneck.js CHANGED
@@ -1,42 +1,13 @@
1
-
2
1
  import * as fs from "node:fs";
3
2
  import * as path from "node:path";
4
3
  import { randomUUID } from "node:crypto";
5
4
  import { isString, isObject } from "./decode.js";
6
-
7
- export function truncateChars(text, maxChars, label = "value") {
8
- const normalized = isString(text) ? text : String(text ?? "");
9
- const numericLimit = Number(maxChars);
10
- const limit = Number.isFinite(numericLimit) ? Math.max(0, Math.floor(numericLimit)) : numericLimit === Infinity ? normalized.length : 0;
11
- if (normalized.length <= limit) return { text: normalized, truncated: false };
12
- if (limit <= 100) {
13
- return {
14
- text: normalized.slice(0, limit),
15
- truncated: true,
16
- originalChars: normalized.length,
17
- };
18
- }
19
- const head = Math.floor(limit * 0.7);
20
- let tail = Math.max(0, limit - head);
21
- let marker = "";
22
- let previousTail = -1;
23
- while (tail !== previousTail) {
24
- previousTail = tail;
25
- const omitted = normalized.length - head - tail;
26
- marker = `\n…[${label} truncated ${omitted} chars]…\n`;
27
- tail = Math.max(0, limit - head - marker.length);
28
- }
29
- return {
30
- text: normalized.slice(0, head) + marker + (tail > 0 ? normalized.slice(-tail) : ""),
31
- truncated: true,
32
- originalChars: normalized.length,
33
- };
34
- }
5
+ import { truncateChars, formatValue } from "./format.js";
35
6
 
36
7
  export function serializeBounded(value, maxChars, label = "value") {
37
8
  let serialized;
38
9
  try {
39
- serialized = isString(value) ? value : JSON.stringify(value, null, 2);
10
+ serialized = isString(value) ? value : JSON.stringify(value);
40
11
  } catch {
41
12
  serialized = String(value);
42
13
  }
@@ -79,13 +50,28 @@ function maybeSpill(cappedText, fullText, config) {
79
50
  }
80
51
  }
81
52
 
53
+ function batchItems(details, maxChars) {
54
+ if (!isObject(details) || details.batch !== true) return undefined;
55
+ if (!Array.isArray(details.items)) return undefined;
56
+ return details.items.map((item) => truncateChars(item, maxChars, "host-result").text);
57
+ }
58
+
59
+ function spillSuffix(capped, text, config) {
60
+ const spill = maybeSpill(capped.text, text, config);
61
+ if (!spill?.pointer) return { spill, value: capped.text };
62
+ return { spill, value: `${capped.text}\n\n[full output spilled to ${spill.pointer}]` };
63
+ }
64
+
82
65
  export function packageHostResult(raw, config) {
83
66
  const maxChars = config.maxCallResultChars ?? 65536;
84
67
  const isError = isObject(raw) && raw.isError === true;
85
68
  const details = isObject(raw) ? raw.details : undefined;
86
69
  const upstreamTruncated = isObject(details) && details.outputTruncated === true;
87
70
  const text = extractRawString(raw, maxChars);
88
- const summarizedDetails = details === undefined ? undefined : summarizeDetails(details, 2000);
71
+ const items = batchItems(details, maxChars);
72
+ // Batch items travel as their own field; keep the details summary small and parseable.
73
+ const summarizedDetails =
74
+ details === undefined ? undefined : summarizeDetails(items ? { ...details, items: undefined } : details, 2000);
89
75
 
90
76
  const capped = truncateChars(text, maxChars, "host-result");
91
77
  if (!capped.truncated) {
@@ -94,13 +80,11 @@ export function packageHostResult(raw, config) {
94
80
  value: capped.text,
95
81
  truncated: upstreamTruncated,
96
82
  details: summarizedDetails,
83
+ items,
97
84
  };
98
85
  }
99
86
 
100
- const spill = maybeSpill(capped.text, text, config);
101
- const value = spill?.pointer
102
- ? `${capped.text}\n\n[full output spilled to ${spill.pointer}]`
103
- : capped.text;
87
+ const { spill, value } = spillSuffix(capped, text, config);
104
88
 
105
89
  return {
106
90
  ok: !isError,
@@ -109,24 +93,31 @@ export function packageHostResult(raw, config) {
109
93
  originalChars: capped.originalChars,
110
94
  spill: spill?.pointer,
111
95
  details: summarizedDetails,
96
+ items,
112
97
  };
113
98
  }
114
99
 
115
- export function packageFinalReturn(value, logs, config) {
116
- const maxReturn = config.maxReturnChars ?? 200000;
117
- const serialized = serializeBounded(value, maxReturn, "return");
100
+ function clipLogs(logs, config) {
118
101
  const logLines = Array.isArray(logs) ? logs : [];
119
102
  const maxLogLines = config.maxLogLines ?? 100;
120
103
  const maxLogLineChars = config.maxLogLineChars ?? 4096;
121
104
  const clippedLogs = logLines.slice(0, maxLogLines).map((line) => {
122
105
  const s = isString(line) ? line : String(line);
123
- return s.length > maxLogLineChars ? s.slice(0, maxLogLineChars) + "…" : s;
106
+ if (s.length <= maxLogLineChars) return s;
107
+ return s.slice(0, maxLogLineChars) + "…";
124
108
  });
109
+ return { clippedLogs, logTruncated: logLines.length > maxLogLines };
110
+ }
111
+
112
+ export function packageFinalReturn(value, logs, config) {
113
+ const maxReturn = config.maxReturnChars ?? 32000;
114
+ const serialized = truncateChars(isString(value) ? value.replace(/\n+$/, "") : formatValue(value), maxReturn, "return");
115
+ const { clippedLogs, logTruncated } = clipLogs(logs, config);
125
116
  return {
126
117
  returnValue: serialized.truncated ? serialized.text : value,
127
118
  returnText: serialized.text,
128
119
  returnTruncated: serialized.truncated,
129
120
  logs: clippedLogs,
130
- logTruncated: logLines.length > maxLogLines,
121
+ logTruncated,
131
122
  };
132
123
  }
package/catalog.js CHANGED
@@ -82,6 +82,13 @@ export function mergeNativeToolDefinitions(tools, capturedNames = []) {
82
82
  return merged;
83
83
  }
84
84
 
85
+ function sourcePathOf(tool) {
86
+ if (tool.sourceInfo && isString(tool.sourceInfo.path)) return tool.sourceInfo.path;
87
+ if (isString(tool.extensionPath)) return tool.extensionPath;
88
+ if (isString(tool.sourcePath)) return tool.sourcePath;
89
+ return undefined;
90
+ }
91
+
85
92
  function normalizeTool(tool) {
86
93
  if (!tool || !isObject(tool)) return null;
87
94
  const name = isString(tool.name) ? tool.name : "";
@@ -93,14 +100,7 @@ function normalizeTool(tool) {
93
100
  description,
94
101
  descLower: description.toLowerCase(),
95
102
  parameters: tool.parameters,
96
- sourcePath:
97
- tool.sourceInfo && isString(tool.sourceInfo.path)
98
- ? tool.sourceInfo.path
99
- : isString(tool.extensionPath)
100
- ? tool.extensionPath
101
- : isString(tool.sourcePath)
102
- ? tool.sourcePath
103
- : undefined,
103
+ sourcePath: sourcePathOf(tool),
104
104
  };
105
105
  }
106
106
 
@@ -153,6 +153,15 @@ export function searchCatalog(catalog, query, limit = 12) {
153
153
  return scored.slice(0, Math.max(1, limit)).map(({ score: _s, ...hit }) => hit);
154
154
  }
155
155
 
156
+ function fieldSummary(key, schema, required) {
157
+ const s = schema && isObject(schema) ? schema : {};
158
+ return {
159
+ type: s.type || (Array.isArray(s.anyOf) ? "union" : "unknown"),
160
+ required: required.has(key),
161
+ description: isString(s.description) ? s.description.slice(0, 120) : undefined,
162
+ };
163
+ }
164
+
156
165
  function schemaSummary(parameters) {
157
166
  if (!parameters || !isObject(parameters)) return { type: "unknown" };
158
167
  const props = parameters.properties;
@@ -165,20 +174,57 @@ function schemaSummary(parameters) {
165
174
  const required = new Set(Array.isArray(parameters.required) ? parameters.required : []);
166
175
  const fields = {};
167
176
  for (const [key, schema] of Object.entries(props)) {
168
- const s = schema && isObject(schema) ? schema : {};
169
- fields[key] = {
170
- type: s.type || (Array.isArray(s.anyOf) ? "union" : "unknown"),
171
- required: required.has(key),
172
- description: isString(s.description) ? s.description.slice(0, 120) : undefined,
173
- };
177
+ fields[key] = fieldSummary(key, schema, required);
174
178
  }
175
179
  return { type: "object", fields };
176
180
  }
177
181
 
182
+ /** Optimal string alignment distance: insert/delete/substitute/adjacent-transpose cost 1. */
183
+ function editDistance(a, b) {
184
+ const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...new Array(b.length).fill(0)]);
185
+ for (let j = 1; j <= b.length; j++) rows[0][j] = j;
186
+ for (let i = 1; i <= a.length; i++) {
187
+ for (let j = 1; j <= b.length; j++) {
188
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
189
+ let best = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
190
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) best = Math.min(best, rows[i - 2][j - 2] + 1);
191
+ rows[i][j] = best;
192
+ }
193
+ }
194
+ return rows[a.length][b.length];
195
+ }
196
+
197
+ /** Closest tool names for a mistyped name: substring hits first, then a length-scaled edit distance. */
198
+ export function suggestNames(name, candidates, limit = 3) {
199
+ const needle = String(name || "").toLowerCase();
200
+ if (!needle) return [];
201
+ const maxDistance = Math.max(1, Math.floor(needle.length / 3));
202
+ const scored = [];
203
+ for (const candidate of candidates) {
204
+ const lower = candidate.toLowerCase();
205
+ if (lower === needle) continue;
206
+ const distance = lower.includes(needle) || needle.includes(lower) ? 1 : editDistance(needle, lower);
207
+ if (distance <= maxDistance) scored.push({ candidate, distance });
208
+ }
209
+ scored.sort(
210
+ (a, b) =>
211
+ a.distance - b.distance ||
212
+ Math.abs(a.candidate.length - needle.length) - Math.abs(b.candidate.length - needle.length) ||
213
+ a.candidate.localeCompare(b.candidate),
214
+ );
215
+ return scored.slice(0, limit).map((s) => s.candidate);
216
+ }
217
+
218
+ export function unknownToolMessage(name, candidates) {
219
+ const close = suggestNames(name, candidates);
220
+ const hint = close.length ? ` Did you mean ${close.map((c) => JSON.stringify(c)).join(", ")}?` : "";
221
+ return `unknown tool "${name}".${hint} Use nova.search("") to list every callable tool.`;
222
+ }
223
+
178
224
  export function describeTool(catalog, name) {
179
225
  const row = catalog.find((t) => t.name === name);
180
226
  if (!row) {
181
- return { ok: false, error: `unknown tool: ${name}` };
227
+ return { ok: false, error: unknownToolMessage(name, catalog.map((t) => t.name)) };
182
228
  }
183
229
  if (!row._described) {
184
230
  row._described = {
@@ -3,7 +3,8 @@
3
3
  "maxCodeChars": 48000,
4
4
  "maxBridgeCalls": 256,
5
5
  "maxCallResultChars": 65536,
6
- "maxReturnChars": 200000,
6
+ "maxReturnChars": 32000,
7
+ "maxHeapMb": 512,
7
8
  "maxLogLines": 100,
8
9
  "maxLogLineChars": 4096,
9
10
  "maxSearchResults": 12,
package/config.js CHANGED
@@ -1,4 +1,3 @@
1
-
2
1
  import * as fs from "node:fs";
3
2
  import * as os from "node:os";
4
3
  import * as path from "node:path";
@@ -18,8 +17,24 @@ const POSITIVE_INTEGER_KEYS = new Set([
18
17
  "maxReturnChars",
19
18
  "maxLogLineChars",
20
19
  "maxSearchResults",
20
+ "maxHeapMb",
21
21
  ]);
22
22
 
23
+ const VALIDATORS = {
24
+ positiveInt: (v) => Number.isInteger(v) && v > 0,
25
+ nonNegativeInt: (v) => Number.isInteger(v) && v >= 0,
26
+ stringArray: (v) => Array.isArray(v) && v.every(isString),
27
+ spillDir: (v) => v === null || (isString(v) && v.length > 0),
28
+ };
29
+
30
+ const KEY_VALIDATOR = new Map();
31
+ for (const k of POSITIVE_INTEGER_KEYS) KEY_VALIDATOR.set(k, VALIDATORS.positiveInt);
32
+ for (const k of NONNEGATIVE_INTEGER_KEYS) KEY_VALIDATOR.set(k, VALIDATORS.nonNegativeInt);
33
+ for (const k of Object.keys(DEFAULTS)) {
34
+ if (Array.isArray(DEFAULTS[k])) KEY_VALIDATOR.set(k, VALIDATORS.stringArray);
35
+ }
36
+ KEY_VALIDATOR.set("spillDir", VALIDATORS.spillDir);
37
+
23
38
  export function packageDefaults() {
24
39
  return structuredClone(DEFAULTS);
25
40
  }
@@ -53,15 +68,9 @@ export function mergeConfig(base, overlay) {
53
68
  const out = { ...base };
54
69
  for (const [key, value] of Object.entries(overlay)) {
55
70
  if (!KNOWN_KEYS.has(key)) continue;
56
- if (POSITIVE_INTEGER_KEYS.has(key)) {
57
- if (Number.isInteger(value) && value > 0) out[key] = value;
58
- } else if (NONNEGATIVE_INTEGER_KEYS.has(key)) {
59
- if (Number.isInteger(value) && value >= 0) out[key] = value;
60
- } else if (Array.isArray(base[key])) {
61
- if (Array.isArray(value) && value.every(isString)) out[key] = value.slice();
62
- } else if (key === "spillDir") {
63
- if (value === null || (isString(value) && value.length > 0)) out[key] = value;
64
- }
71
+ const validate = KEY_VALIDATOR.get(key);
72
+ if (!validate) continue;
73
+ if (validate(value)) out[key] = Array.isArray(value) ? value.slice() : value;
65
74
  }
66
75
  return out;
67
76
  }
package/diff.js CHANGED
@@ -56,6 +56,12 @@ export function buildMultiEditDiff(filePath, originalText, replacements) {
56
56
  };
57
57
  }
58
58
 
59
+ const PATCH_LINE_KINDS = { "-": "remove", "+": "add", " ": "context" };
60
+
61
+ function classifyPatchLine(line) {
62
+ return PATCH_LINE_KINDS[line[0]] || null;
63
+ }
64
+
59
65
  export function buildPatchDiff(filePath, patchText) {
60
66
  const patchLines = isString(patchText) ? patchText.replace(/\r\n/g, "\n").split("\n") : [];
61
67
  const lines = [];
@@ -66,7 +72,7 @@ export function buildPatchDiff(filePath, patchText) {
66
72
  let inHunk = false;
67
73
 
68
74
  for (const patchLine of patchLines) {
69
- const headerMatch = /^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)/.exec(patchLine);
75
+ const headerMatch = (/^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)/).exec(patchLine);
70
76
  if (headerMatch) {
71
77
  oldLineNum = Number(headerMatch[1]);
72
78
  newLineNum = Number(headerMatch[2]);
@@ -74,15 +80,17 @@ export function buildPatchDiff(filePath, patchText) {
74
80
  continue;
75
81
  }
76
82
  if (!inHunk || patchLine.startsWith("\\")) continue;
77
- if (patchLine.startsWith("-")) {
83
+ const kind = classifyPatchLine(patchLine);
84
+ if (!kind) continue;
85
+ if (kind === "remove") {
78
86
  removed += 1;
79
87
  lines.push({ type: "remove", lineNum: oldLineNum, text: patchLine.slice(1) });
80
88
  oldLineNum += 1;
81
- } else if (patchLine.startsWith("+")) {
89
+ } else if (kind === "add") {
82
90
  added += 1;
83
91
  lines.push({ type: "add", lineNum: newLineNum, text: patchLine.slice(1) });
84
92
  newLineNum += 1;
85
- } else if (patchLine.startsWith(" ")) {
93
+ } else {
86
94
  lines.push({ type: "context", lineNum: newLineNum, text: patchLine.slice(1) });
87
95
  oldLineNum += 1;
88
96
  newLineNum += 1;
package/format.js ADDED
@@ -0,0 +1,95 @@
1
+ import { isString } from "./decode.js";
2
+
3
+ export function truncateChars(text, maxChars, label = "value") {
4
+ const normalized = isString(text) ? text : String(text ?? "");
5
+ const numericLimit = Number(maxChars);
6
+ const limit = Number.isFinite(numericLimit) ? Math.max(0, Math.floor(numericLimit)) : numericLimit === Infinity ? normalized.length : 0;
7
+ if (normalized.length <= limit) return { text: normalized, truncated: false };
8
+ if (limit <= 100) {
9
+ return {
10
+ text: normalized.slice(0, headEnd(normalized, limit)),
11
+ truncated: true,
12
+ originalChars: normalized.length,
13
+ };
14
+ }
15
+ const head = headEnd(normalized, Math.floor(limit * 0.7));
16
+ let tail = Math.max(0, limit - head);
17
+ let marker = "";
18
+ let previousTail = -1;
19
+ while (tail !== previousTail) {
20
+ previousTail = tail;
21
+ const omitted = normalized.length - head - tail;
22
+ marker = `\n…[${label} truncated ${omitted} chars]…\n`;
23
+ tail = Math.max(0, limit - head - marker.length);
24
+ }
25
+ const tailStart = tailStartIndex(normalized, tail);
26
+ return {
27
+ text: normalized.slice(0, head) + marker + normalized.slice(tailStart),
28
+ truncated: true,
29
+ originalChars: normalized.length,
30
+ };
31
+ }
32
+
33
+ // Lone surrogates in a tool result make the message invalid UTF-8 at the API
34
+ // boundary, so a cut must never split a surrogate pair.
35
+ function headEnd(text, end) {
36
+ const code = text.charCodeAt(end - 1);
37
+ return code >= 0xd800 && code <= 0xdbff ? end - 1 : end;
38
+ }
39
+
40
+ function tailStartIndex(text, tail) {
41
+ if (tail <= 0) return text.length;
42
+ const start = text.length - tail;
43
+ const code = text.charCodeAt(start);
44
+ return code >= 0xdc00 && code <= 0xdfff ? start + 1 : start;
45
+ }
46
+
47
+ const IDENT_KEY = /^[A-Za-z_$][\w$]*$/;
48
+ const FORMAT_WIDTH = 120;
49
+
50
+ function formatKey(key) {
51
+ return IDENT_KEY.test(key) ? key : JSON.stringify(key);
52
+ }
53
+
54
+ function formatPrimitive(value) {
55
+ if (value === undefined) return "undefined";
56
+ if (typeof value === "number" && !Number.isFinite(value)) return String(value);
57
+ return JSON.stringify(value) ?? String(value);
58
+ }
59
+
60
+ function formatFlatList(value) {
61
+ if (value.length === 0) return "[]";
62
+ let out = "[";
63
+ for (let i = 0; i < value.length; i++) out += (i ? "," : "") + formatFlat(value[i] === undefined ? null : value[i]);
64
+ return out + "]";
65
+ }
66
+
67
+ function formatFlat(value) {
68
+ if (value === null || typeof value !== "object") return formatPrimitive(value);
69
+ if (Array.isArray(value)) return formatFlatList(value);
70
+ let out = "";
71
+ for (const key of Object.keys(value)) {
72
+ if (value[key] === undefined) continue;
73
+ out += (out ? "," : "{") + formatKey(key) + ":" + formatFlat(value[key]);
74
+ }
75
+ return out ? out + "}" : "{}";
76
+ }
77
+
78
+ /**
79
+ * Compact JS-literal rendering for the model: containers that fit in FORMAT_WIDTH
80
+ * stay on one line with no separator whitespace, identifier keys are unquoted,
81
+ * indent is one space. Whitespace is what costs tokens: this measures ~43% fewer
82
+ * than JSON.stringify(value, null, 2) on typical shaped returns (gpt-tokenizer).
83
+ */
84
+ export function formatValue(value, indent = "", width = FORMAT_WIDTH) {
85
+ const flat = formatFlat(value);
86
+ if (value === null || typeof value !== "object" || flat.length + indent.length <= width) return flat;
87
+ const pad = indent + " ";
88
+ if (Array.isArray(value)) {
89
+ if (value.length === 0) return "[]";
90
+ return "[\n" + value.map((item) => pad + formatValue(item === undefined ? null : item, pad, width)).join(",\n") + "\n" + indent + "]";
91
+ }
92
+ const keys = Object.keys(value).filter((key) => value[key] !== undefined);
93
+ if (keys.length === 0) return "{}";
94
+ return "{\n" + keys.map((key) => pad + formatKey(key) + ":" + formatValue(value[key], pad, width)).join(",\n") + "\n" + indent + "}";
95
+ }