pi-supernova 0.0.15 → 0.1.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.1.0] - 2026-09-04
6
+
7
+ ### Fixed
8
+
9
+ - Each program uses a separate worker and transaction. Late callbacks, idle worker errors, concurrent runs, startup cancellation, and startup deadlines no longer share state.
10
+ - Command deadlines stop descendants. Signal exits report failure. Native commits recover partial replacements; writes reject missing content and edits reject overlapping matches.
11
+ - Unified patches handle zero-context insertions, empty files, final-newline changes, and CRLF. Reads see external changes; evidence search includes staged files.
12
+ - OMP tool calls use the current session’s enabled, permission-aware registry. Discovery supplies complete schemas. Batch options, failure envelopes, scheduling, and call identifiers remain consistent.
13
+ - Output limits cover aggregate batch text, markers, and spill footers. Details remain valid JSON; logs report truncation. DataView ranges and own `__proto__` values survive serialization.
14
+
15
+ ### Changed
16
+
17
+ - Acorn parses supported function and body forms. Return hints ignore comments and nested callbacks. Invalid collection arguments report errors.
18
+ - The result ledger checks text equality, retains explicit-read pins across aliases, expires source records, and accepts a zero window.
19
+ - Terminal width checks use Unicode graphemes. Expanded cards wrap all bounded result and log text without a preview-line limit.
20
+ - Pi 0.85.0 native adapters respect active-tool changes. Pi has no cross-extension execution API; the README states this limit.
21
+ - Live checks cover Pi 0.85.0 and OMP 18.1.10, including expanded terminal output.
22
+
5
23
  ## [0.0.15] - 2026-09-04
6
24
 
7
25
  ### Removed
package/README.md CHANGED
@@ -12,7 +12,13 @@ pi install npm:pi-supernova
12
12
  omp install npm:pi-supernova
13
13
  ```
14
14
 
15
- Load **before** other tool-owning packages so `registerTool` capture works. Restart the host after install.
15
+ Restart the host after install. OMP uses the current session’s enabled tool registry, independent of extension load order.
16
+
17
+ Pi 0.85.0 supplies tool schemas but has no cross-extension execution API. Supernova uses native adapters and executors registered through its API instance.
18
+
19
+ An unrelated Pi extension’s tools are not callable through Supernova. OMP 18.1.10 supports those calls through its session registry.
20
+
21
+ The runtime, transaction, patch, output-limit, and renderer fixes share the same code on both hosts. Disabled configured tools are not callable.
16
22
 
17
23
  ---
18
24
 
@@ -25,7 +31,11 @@ Load **before** other tool-owning packages so `registerTool` capture works. Rest
25
31
  | Parallel reads | Ad hoc | `callMany` Auto / `parallel()` |
26
32
  | Hosts | Separate packages | Same tarball for Pi **and** OMP |
27
33
 
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.
34
+ Guest code runs as an `AsyncFunction` in a new worker for each program. It has the same trust level as host `bash`.
35
+
36
+ The host parses the bounded source with Acorn before execution. Tool calls use RPC. Deadlines and cancellation cover worker startup, tool discovery, and execution.
37
+
38
+ A completed worker cannot supply callbacks or globals to another program. Concurrent programs have separate transactions, traces, cancellation signals, and call budgets.
29
39
 
30
40
  ---
31
41
 
@@ -69,7 +79,9 @@ Unified Pi/OMP card: one aligned row per call (status · tool · duration · tar
69
79
  ╰───────────────────────────────────────────────────────────────╯
70
80
  ```
71
81
 
72
- 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.
82
+ Multi-line commands show their first line and a hidden-line count. Expand the card to show the complete bounded return value and logs.
83
+
84
+ Expanded text wraps at the terminal width without a preview-line limit. Operation lists and mutation diffs keep their separate 24-row budgets.
73
85
 
74
86
  ---
75
87
 
@@ -78,7 +90,7 @@ Multi-line commands show their first line plus a hidden-line count. Press Enter
78
90
  | API | Role |
79
91
  |-----|------|
80
92
  | `nova.search(query, limit?)` | Thin catalog hits |
81
- | `nova.describe(name)` | Parameter summary on demand |
93
+ | `nova.describe(name)` | Complete JSON input schema on demand; explicit failure when no usable schema is available |
82
94
  | `nova.call(name, args)` | Host tool or native adapter |
83
95
  | `nova.callMany([{name,args}])` | Auto parallel wave; iterable array with `.mode` / `.results` |
84
96
  | `nova.evidence(query, {k?, path?, maxChars?})` | Top-K source spans (path, lines, verbatim text) that answer a question. Zero-token evidence selection after Zero-Mem; ~68% fewer tokens than reading the files |
@@ -86,9 +98,15 @@ Multi-line commands show their first line plus a hidden-line count. Press Enter
86
98
  | `nova.surface(path)` | Structural outline for a source file |
87
99
  | `nova.snap(query, searchRoot?)` | Defining file (workspace-relative), line, signature, confidence, and context for a concept; served from the in-process index in well under 1ms |
88
100
  | `nova.has(name)` | Whether a catalog or native tool is callable (sync) |
89
- | `parallel(thunks)` / `pipeline(items, …stages)` | Raw `Promise.all` helpers |
101
+ | `parallel(thunks)` / `pipeline(items, …stages)` | Array-based helpers; pipeline stages must be functions |
90
102
  | `nova.speculate(fn)` | Counterfactual branch (rollback / commit) |
91
103
 
104
+ `nova.call` returns an explicit `{ok, value}` envelope. Convenience helpers throw when a host tool reports failure.
105
+
106
+ `callMany` runs known read-only tools concurrently. Unknown tools and mutating actions run in order, including LSP rename operations.
107
+
108
+ `nova.describe` preserves required fields, unions, enums, nested objects, and numeric constraints. OMP ArkType and Zod schemas convert to JSON Schema.
109
+
92
110
  Root Snap searches ignore hidden files. Passing a hidden search root includes hidden files beneath that root; Git metadata is always excluded.
93
111
 
94
112
  ---
@@ -112,11 +130,17 @@ Optional `~/.pi/agent/supernova.json` or `~/.omp/agent/supernova.json`
112
130
  }
113
131
  ```
114
132
 
133
+ `maxCallResultChars` bounds text per call. A batch shares one text budget across all items; it does not repeat a joined copy.
134
+
135
+ Envelope fields and JSON encoding add overhead. Details have a separate 2,000-character JSON budget. Truncation flags cover returns, raw objects, and logs.
136
+
137
+ An optional `spillDir` retains complete truncated text. Inline spill footers and truncation markers fit within the configured text limit.
138
+
115
139
  `seenWindow` is how many programs back the seen-ledger remembers (set 0 to disable collapsing). `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.
116
140
 
117
141
  Defaults also set `excludeTools` (includes `supernova` and DCE helpers). An empty `"excludeTools": []` **replaces** those defaults, so omit the key unless you mean that.
118
142
 
119
- Slash command `/supernova`: catalog size, captured executors, and session token stats.
143
+ Slash command `/supernova`: callable catalog size, external tools, native adapters, and session token stats.
120
144
 
121
145
  ---
122
146
 
@@ -141,8 +165,8 @@ Pair with DCE last if you use it: `omp install npm:pi-deferred-context-engine`.
141
165
 
142
166
  | Symptom | Fix |
143
167
  |---------|-----|
144
- | `unknown tool "…"` | Follow the `Did you mean` hint, or `nova.search("")`; for host tools install supernova **first**; restart; `/supernova` |
145
- | `Rendered line exceeds terminal width` | ≥0.0.1 and restart so `render.js` reloads |
168
+ | `unknown tool "…"` | Use `nova.search("")` and `/supernova`. Enable the tool in the current session. Restart after package changes. |
169
+ | `Rendered line exceeds terminal width` | Install the current package and restart so the Unicode width code reloads. |
146
170
  | `callMany` / not iterable | ≥0.0.1; the return is an array with `.mode` / `.results` |
147
171
  | Extension missing on OMP | `omp install npm:pi-supernova` (needs `"omp".extensions`) |
148
172
 
@@ -152,9 +176,16 @@ Pair with DCE last if you use it: `omp install npm:pi-deferred-context-engine`.
152
176
 
153
177
  - 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.
154
178
  - Guest error messages carry `(line:col)` on Node; Bun's engine does not expose guest-relative positions.
155
- - `bash` / mutating tools flush speculative writes (transaction barrier); error rollback cannot undo that.
156
- - The workspace index refreshes its file list every 10s or on any supernova mutation; a file created by an external process can take up to 10s to appear in `glob`/`snap` (`read` is never stale).
157
- - Pre-1.0 package: APIs and TUI may still evolve between minor releases.
179
+
180
+ ## Transactions and file freshness
181
+
182
+ - `bash` and mutating host tools commit pending writes before execution. Later rollback cannot undo those changes or external effects.
183
+ - Native commits stage replacements and backups before installation. A commit failure restores earlier replacements; a recovery failure reports retained backup paths.
184
+ - A nested `nova.speculate` branch cannot call external mutators. Await each branch before returning.
185
+ - Native reads use current disk content unless a staged write replaces it. Evidence search includes new staged files.
186
+ - Workspace file-list updates use filesystem watchers. Without a working watcher, external new files can take 10 seconds to appear in indexed searches.
187
+
188
+ This is a pre-1.0 package. APIs and the terminal display can change between minor releases.
158
189
 
159
190
  ## Research and prior art
160
191
 
package/bottleneck.js CHANGED
@@ -4,120 +4,121 @@ import { randomUUID } from "node:crypto";
4
4
  import { isString, isObject } from "./decode.js";
5
5
  import { truncateChars, formatValue } from "./format.js";
6
6
 
7
- function serializeBounded(value, maxChars, label = "value") {
8
- let serialized;
9
- try {
10
- serialized = isString(value) ? value : JSON.stringify(value);
11
- } catch {
12
- serialized = String(value);
13
- }
14
- if (serialized === undefined) serialized = "null";
15
- return truncateChars(serialized, maxChars, label);
7
+ function json(value) {
8
+ try { return JSON.stringify(value) ?? "null"; } catch { return JSON.stringify(String(value)); }
16
9
  }
17
-
18
- function extractContentText(content) {
19
- if (!Array.isArray(content)) return "";
20
- return content
21
- .filter((part) => part && isObject(part) && part.type === "text" && isString(part.text))
22
- .map((part) => part.text)
23
- .join("\n");
10
+ function detailsOf(raw) {
11
+ const details = raw?.details;
12
+ if (!isString(details)) return details;
13
+ try { return JSON.parse(details); } catch { return details; }
24
14
  }
25
-
26
- function extractRawString(raw, maxChars) {
15
+ export function hostResultFailed(raw) {
16
+ const details = detailsOf(raw);
17
+ return raw?.isError === true || details?.ok === false || (Number.isInteger(details?.exitCode) && details.exitCode !== 0);
18
+ }
19
+ function extractRawString(raw) {
27
20
  if (raw == null) return "";
28
21
  if (isString(raw)) return raw;
29
22
  if (!isObject(raw)) return String(raw);
30
- if (Array.isArray(raw.content)) return extractContentText(raw.content);
23
+ if (Array.isArray(raw.content)) return raw.content.filter(part => part?.type === "text" && isString(part.text)).map(part => part.text).join("\n");
31
24
  if (isString(raw.text)) return raw.text;
32
- return serializeBounded(raw, maxChars, "host-result").text;
33
- }
34
-
35
- function summarizeDetails(details, maxChars) {
36
- return serializeBounded(details, maxChars, "details").text;
25
+ return json(raw);
37
26
  }
38
27
 
39
- function maybeSpill(cappedText, fullText, config) {
40
- if (fullText.length <= (config.maxCallResultChars ?? 65536)) return null;
41
- if (!isString(config.spillDir) || config.spillDir.length === 0) return null;
42
- try {
43
- const dir = config.spillDir;
44
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
45
- const filePath = path.join(dir, `${Date.now()}-${randomUUID().slice(0, 8)}.txt`);
46
- fs.writeFileSync(filePath, fullText, { encoding: "utf8", mode: 0o600 });
47
- return { pointer: filePath };
48
- } catch {
49
- return null;
50
- }
51
- }
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);
28
+ /** Bound JSON before serialization; preserve small scalar fields such as exitCode. */
29
+ function summarizeDetails(value, budget = 2000) {
30
+ const encoded = json(value);
31
+ if (encoded.length <= budget) return encoded;
32
+ const snapshot = JSON.parse(encoded);
33
+ const fit = (input, limit) => {
34
+ const serialized = json(input);
35
+ if (serialized.length <= limit) return input;
36
+ if (isString(input)) {
37
+ let low = 0;
38
+ let high = Math.min(input.length, limit);
39
+ while (low < high) {
40
+ const mid = Math.ceil((low + high) / 2);
41
+ if (json(truncateChars(input, mid).text).length <= limit) low = mid;
42
+ else high = mid - 1;
43
+ }
44
+ return truncateChars(input, low).text;
45
+ }
46
+ if (!isObject(input) && !Array.isArray(input)) return null;
47
+ const out = Array.isArray(input) ? [] : { truncated: true };
48
+ const entries = Object.entries(input);
49
+ if (!Array.isArray(input)) entries.sort((a, b) => json(a[1]).length - json(b[1]).length);
50
+ for (const [key, child] of entries) {
51
+ const used = json(out).length;
52
+ const overhead = Array.isArray(out) ? 1 : json(key).length + 2;
53
+ const available = limit - used - overhead;
54
+ if (available < 4) break;
55
+ const bounded = fit(child, available);
56
+ if (Array.isArray(out)) out.push(bounded);
57
+ else Object.defineProperty(out, key, { value: bounded, enumerable: true, configurable: true });
58
+ if (json(out).length > limit) {
59
+ if (Array.isArray(out)) out.pop();
60
+ else delete out[key];
61
+ }
62
+ }
63
+ return out;
64
+ };
65
+ return json(fit(snapshot, budget));
57
66
  }
58
67
 
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}]` };
68
+ function spill(fullText, config) {
69
+ if (!isString(config.spillDir) || !config.spillDir) return undefined;
70
+ fs.mkdirSync(config.spillDir, { recursive: true, mode: 0o700 });
71
+ const file = path.join(config.spillDir, Date.now() + "-" + randomUUID().slice(0, 8) + ".txt");
72
+ fs.writeFileSync(file, fullText, { encoding: "utf8", mode: 0o600, flag: "wx" });
73
+ return file;
63
74
  }
64
75
 
65
76
  export function packageHostResult(raw, config) {
66
77
  const maxChars = config.maxCallResultChars ?? 65536;
67
- const isError = isObject(raw) && raw.isError === true;
68
- const details = isObject(raw) ? raw.details : undefined;
69
- const upstreamTruncated = isObject(details) && details.outputTruncated === true;
70
- const text = extractRawString(raw, maxChars);
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);
75
-
78
+ const details = detailsOf(raw);
79
+ const batch = details?.batch === true && Array.isArray(details.items) ? details.items : undefined;
80
+ const text = batch ? "" : extractRawString(raw);
76
81
  const capped = truncateChars(text, maxChars, "host-result");
77
- if (!capped.truncated) {
78
- return {
79
- ok: !isError,
80
- value: capped.text,
81
- truncated: upstreamTruncated,
82
- details: summarizedDetails,
83
- items,
84
- };
82
+ let truncated = capped.truncated || details?.outputTruncated === true;
83
+ const result = { ok: !hostResultFailed(raw), value: capped.text, truncated };
84
+ if (details !== undefined) result.details = summarizeDetails(batch ? { ...details, items: undefined } : details);
85
+ if (batch) {
86
+ let remaining = maxChars;
87
+ result.items = batch.map((item, index) => {
88
+ const bounded = truncateChars(item, Math.floor(remaining / (batch.length - index)), "host-result");
89
+ remaining -= bounded.text.length;
90
+ truncated ||= bounded.truncated;
91
+ return bounded.text;
92
+ });
85
93
  }
86
-
87
- const { spill, value } = spillSuffix(capped, text, config);
88
-
89
- return {
90
- ok: !isError,
91
- value,
92
- truncated: true,
93
- originalChars: capped.originalChars,
94
- spill: spill?.pointer,
95
- details: summarizedDetails,
96
- items,
97
- };
98
- }
99
-
100
- function clipLogs(logs, config) {
101
- const logLines = Array.isArray(logs) ? logs : [];
102
- const maxLogLines = config.maxLogLines ?? 100;
103
- const maxLogLineChars = config.maxLogLineChars ?? 4096;
104
- const clippedLogs = logLines.slice(0, maxLogLines).map((line) => {
105
- const s = isString(line) ? line : String(line);
106
- if (s.length <= maxLogLineChars) return s;
107
- return s.slice(0, maxLogLineChars) + "…";
108
- });
109
- return { clippedLogs, logTruncated: logLines.length > maxLogLines };
94
+ result.truncated = truncated;
95
+ if (truncated) {
96
+ result.originalChars = batch ? batch.reduce((sum, item) => sum + String(item).length, 0) : text.length;
97
+ if (config.spillDir) {
98
+ const pointer = spill(batch ? batch.join("\n---\n") : text, config);
99
+ if (pointer) {
100
+ result.spill = pointer;
101
+ if (!batch) {
102
+ const footer = "\n[full output spilled to " + pointer + "]";
103
+ result.value = footer.length <= maxChars
104
+ ? truncateChars(text, maxChars - footer.length, "host-result").text + footer
105
+ : capped.text;
106
+ }
107
+ }
108
+ }
109
+ }
110
+ return result;
110
111
  }
111
112
 
112
113
  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);
116
- return {
117
- returnValue: serialized.truncated ? serialized.text : value,
118
- returnText: serialized.text,
119
- returnTruncated: serialized.truncated,
120
- logs: clippedLogs,
121
- logTruncated,
122
- };
114
+ const serialized = truncateChars(isString(value) ? value : formatValue(value), config.maxReturnChars ?? 32000, "return");
115
+ const maxLines = config.maxLogLines ?? 100;
116
+ let logTruncated = logs.length > maxLines;
117
+ const clipped = logs.slice(0, maxLines).map(line => {
118
+ const result = truncateChars(line, config.maxLogLineChars ?? 4096, "log");
119
+ logTruncated ||= result.truncated;
120
+ return result.text;
121
+ });
122
+ return { returnValue: serialized.truncated ? serialized.text : value, returnText: serialized.text,
123
+ returnTruncated: serialized.truncated, logs: clipped, logTruncated };
123
124
  }
package/catalog.js CHANGED
@@ -6,7 +6,7 @@ const NATIVE_TOOL_DEFINITIONS = [
6
6
  name: "read",
7
7
  description: "Read UTF-8 workspace files by path, or resolve a concept query to source. Supports path arrays, offset, and limit.",
8
8
  parameters: { type: "object", properties: {
9
- path: { type: "string", description: "Workspace-relative file path or concept query" },
9
+ path: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], description: "Workspace-relative file path, concept query, or array of paths" },
10
10
  target: { anyOf: [{ type: "string" }, { type: "array" }], description: "File path/query or array of paths" },
11
11
  offset: { type: "number", description: "One-based starting line" },
12
12
  limit: { type: "number", description: "Maximum lines to return" },
@@ -110,6 +110,7 @@ function normalizeTool(tool) {
110
110
  description,
111
111
  descLower: description.toLowerCase(),
112
112
  parameters: tool.parameters,
113
+ schemaError: tool.schemaError,
113
114
  sourcePath: sourcePathOf(tool),
114
115
  };
115
116
  }
@@ -158,33 +159,6 @@ export function searchCatalog(catalog, query, limit = 12) {
158
159
  return scored.slice(0, Math.max(1, limit)).map(({ score: _s, ...hit }) => hit);
159
160
  }
160
161
 
161
- function fieldSummary(key, schema, required) {
162
- const s = schema && isObject(schema) ? schema : {};
163
- // Only signal-bearing keys: `required:false` and empty descriptions cost tokens and say nothing.
164
- return {
165
- type: s.type || (Array.isArray(s.anyOf) ? "union" : "unknown"),
166
- required: required.has(key) || undefined,
167
- description: isString(s.description) ? s.description.slice(0, 120) : undefined,
168
- };
169
- }
170
-
171
- function schemaSummary(parameters) {
172
- if (!parameters || !isObject(parameters)) return { type: "unknown" };
173
- const props = parameters.properties;
174
- if (!props || !isObject(props)) {
175
- return {
176
- type: parameters.type || "object",
177
- note: "schema present (no enumerable properties)",
178
- };
179
- }
180
- const required = new Set(Array.isArray(parameters.required) ? parameters.required : []);
181
- const fields = {};
182
- for (const [key, schema] of Object.entries(props)) {
183
- fields[key] = fieldSummary(key, schema, required);
184
- }
185
- return { type: "object", fields };
186
- }
187
-
188
162
  /** Optimal string alignment distance: insert/delete/substitute/adjacent-transpose cost 1. */
189
163
  function editDistance(a, b) {
190
164
  const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...new Array(b.length).fill(0)]);
@@ -232,12 +206,13 @@ export function describeTool(catalog, name) {
232
206
  if (!row) {
233
207
  return { ok: false, error: unknownToolMessage(name, catalog.map((t) => t.name)) };
234
208
  }
209
+ if (!isObject(row.parameters)) return { ok: false, name, error: "tool schema unavailable: " + (row.schemaError ?? name) };
235
210
  if (!row._described) {
236
211
  row._described = {
237
212
  ok: true,
238
213
  name: row.name,
239
214
  description: row.description,
240
- parameters: schemaSummary(row.parameters),
215
+ parameters: row.parameters,
241
216
  sourcePath: row.sourcePath,
242
217
  };
243
218
  }
package/config.js CHANGED
@@ -8,7 +8,7 @@ const require = createRequire(import.meta.url);
8
8
  const DEFAULTS = require("./config.default.json");
9
9
 
10
10
  const KNOWN_KEYS = new Set(Object.keys(DEFAULTS));
11
- const NONNEGATIVE_INTEGER_KEYS = new Set(["maxLogLines"]);
11
+ const NONNEGATIVE_INTEGER_KEYS = new Set(["maxLogLines", "seenWindow"]);
12
12
  const POSITIVE_INTEGER_KEYS = new Set([
13
13
  "timeoutMs",
14
14
  "maxCodeChars",
@@ -18,7 +18,6 @@ const POSITIVE_INTEGER_KEYS = new Set([
18
18
  "maxLogLineChars",
19
19
  "maxSearchResults",
20
20
  "maxHeapMb",
21
- "seenWindow",
22
21
  ]);
23
22
 
24
23
  const VALIDATORS = {
package/decode.js CHANGED
@@ -4,3 +4,64 @@ export const isString = (v) => toStr.call(v) === "[object String]";
4
4
  export const isObject = (v) => toStr.call(v) === "[object Object]";
5
5
  export const isFunction = (v) => toStr.call(v) === "[object Function]" || v instanceof Function;
6
6
  export const isNumber = (v) => toStr.call(v) === "[object Number]" && Number.isFinite(v);
7
+
8
+ const MAX_DEPTH = 64;
9
+ const MAX_TYPED_ARRAY = 4096;
10
+
11
+ function plainFromBinary(value) {
12
+ const bytes = value.byteLength;
13
+ if (value instanceof ArrayBuffer) value = new Uint8Array(value);
14
+ else if (value instanceof DataView) value = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
15
+ if (value.length > MAX_TYPED_ARRAY) return "[" + value.constructor.name + " " + bytes + " bytes]";
16
+ return value instanceof BigInt64Array || value instanceof BigUint64Array
17
+ ? Array.from(value, x => x.toString() + "n")
18
+ : Array.from(value);
19
+ }
20
+
21
+ function plainFromMap(value, seen, depth) {
22
+ const allStringKeys = [...value.keys()].every(isString);
23
+ if (!allStringKeys) return [...value].map(([k, v]) => [toPlain(k, seen, depth + 1), toPlain(v, seen, depth + 1)]);
24
+ const out = Object.create(null);
25
+ for (const [k, v] of value) out[k] = toPlain(v, seen, depth + 1);
26
+ return out;
27
+ }
28
+
29
+ function plainFromCollection(value, seen, depth) {
30
+ if (Array.isArray(value)) return value.map((x) => toPlain(x, seen, depth + 1));
31
+ if (value instanceof Set) return [...value].map((x) => toPlain(x, seen, depth + 1));
32
+ if (value instanceof Map) return plainFromMap(value, seen, depth);
33
+ const out = Object.create(null);
34
+ for (const k of Object.keys(value)) out[k] = toPlain(value[k], seen, depth + 1);
35
+ return out;
36
+ }
37
+
38
+ /** Convert any guest value to structured-clone-safe, JSON-shaped data. */
39
+ export function toPlain(value, seen = new Set(), depth = 0) {
40
+ if (value === null || value === undefined) return value;
41
+ const tag = toStr.call(value);
42
+ if (tag === "[object String]" || tag === "[object Number]" || tag === "[object Boolean]") return value.valueOf();
43
+ if (tag === "[object BigInt]") return value.toString() + "n";
44
+ if (isFunction(value)) return "[Function" + (value.name ? " " + value.name : "") + "]";
45
+ if (tag === "[object Symbol]") return value.toString();
46
+ if (depth > MAX_DEPTH) return "[Depth]";
47
+ if (seen.has(value)) return "[Circular]";
48
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString();
49
+ if (value instanceof RegExp) return value.toString();
50
+ if (value instanceof Error) {
51
+ const out = { name: value.name, message: value.message };
52
+ if (value.cause !== undefined) out.cause = toPlain(value.cause, seen, depth + 1);
53
+ return out;
54
+ }
55
+ if (value instanceof Promise) return "[Promise]";
56
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return plainFromBinary(value);
57
+ if (isFunction(value.toJSON)) return toPlain(value.toJSON(), seen, depth + 1);
58
+ seen.add(value);
59
+ try {
60
+ return plainFromCollection(value, seen, depth);
61
+ } finally {
62
+ seen.delete(value);
63
+ }
64
+ }
65
+
66
+ // ---- RPC to the host thread ----
67
+
package/evidence.js CHANGED
@@ -309,7 +309,7 @@ function normalize(scores) {
309
309
 
310
310
  // ---- candidate files (boundary + topology + entity hits) ----
311
311
 
312
- function candidateFiles(files, profile, index, limit) {
312
+ function candidateFiles(files, profile, index, limit, overlayText) {
313
313
  const scored = [];
314
314
  for (const f of files) {
315
315
  const s = scorePathTopology(f, profile.keywords, profile.flags);
@@ -318,7 +318,11 @@ function candidateFiles(files, profile, index, limit) {
318
318
  scored.sort((a, b) => b.s - a.s);
319
319
  const chosen = new Set(scored.slice(0, limit).map(({ f }) => f));
320
320
  const anchors = (profile.subjects.length ? profile.subjects : profile.keywords).map((a) => a.toLowerCase()).filter((a) => a.length > 2);
321
- const hits = anchors.length ? index.filesContaining(files, anchors, true) : [];
321
+ const pendingHits = files.filter(file => {
322
+ const pending = overlayText(file);
323
+ return pending !== undefined && anchors.some(anchor => pending.toLowerCase().includes(anchor));
324
+ });
325
+ const hits = anchors.length ? [...new Set([...pendingHits, ...index.filesContaining(files, anchors, true)])] : [];
322
326
  for (const f of hits) {
323
327
  if (chosen.size >= limit) break;
324
328
  if (profile.flags.wantsTest || scorePathTopology(f, profile.keywords, profile.flags) > -50) chosen.add(f);
@@ -385,14 +389,19 @@ function render(spans, picks, fused, opts, root) {
385
389
  * R(q): top-K provenance-bearing source spans for a concept query, selected without any model call.
386
390
  * @returns {{ route: string, spans: Array<{path, lines, name, kind, why, text}> }}
387
391
  */
388
- export async function selectEvidence({ query, root, searchDir, index, overlayText = () => undefined, options = {} }) {
392
+ export async function selectEvidence({ query, root, searchDir, index, overlayText = () => undefined, pendingPaths = [], options = {} }) {
389
393
  const opts = { ...EVIDENCE_DEFAULTS, ...options };
390
394
  const profile = profileQuery(query);
391
395
  if (profile.keywords.length === 0) throw new Error("evidence requires at least one searchable concept keyword");
392
- const files = await index.files(searchDir || root);
396
+ const searchRoot = path.resolve(searchDir || root);
397
+ const staged = pendingPaths.filter(file => {
398
+ const relative = path.relative(searchRoot, file);
399
+ return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative);
400
+ });
401
+ const files = [...new Set([...await index.files(searchRoot), ...staged])];
393
402
  if (files.length === 0) throw new Error(`no files found to search in ${searchDir || root}`);
394
403
 
395
- const { files: chosenFiles, fileScores } = candidateFiles(files, profile, index, opts.maxCandidateFiles);
404
+ const { files: chosenFiles, fileScores } = candidateFiles(files, profile, index, opts.maxCandidateFiles, overlayText);
396
405
  const spans = [];
397
406
  for (const f of chosenFiles) {
398
407
  const pending = overlayText(f);
package/format.js CHANGED
@@ -1,4 +1,4 @@
1
- import { isString } from "./decode.js";
1
+ import { isString, isObject } from "./decode.js";
2
2
 
3
3
  export function truncateChars(text, maxChars, label = "value") {
4
4
  const normalized = isString(text) ? text : String(text ?? "");
@@ -12,22 +12,21 @@ export function truncateChars(text, maxChars, label = "value") {
12
12
  originalChars: normalized.length,
13
13
  };
14
14
  }
15
- const head = headEnd(normalized, Math.floor(limit * 0.7));
16
- let tail = Math.max(0, limit - head);
15
+ let head = headEnd(normalized, Math.floor(limit * 0.7));
16
+ let tail = 0;
17
17
  let marker = "";
18
- let previousTail = -1;
19
- while (tail !== previousTail) {
20
- previousTail = tail;
18
+ for (;;) {
21
19
  const omitted = normalized.length - head - tail;
22
- marker = `\n…[${label} truncated ${omitted} chars]…\n`;
23
- tail = Math.max(0, limit - head - marker.length);
20
+ marker = "\n…[" + label + " truncated " + omitted + " chars]…\n";
21
+ if (marker.length > limit) return { text: normalized.slice(0, headEnd(normalized, limit)), truncated: true, originalChars: normalized.length };
22
+ const budget = limit - marker.length;
23
+ const nextHead = headEnd(normalized, Math.min(head, budget));
24
+ const nextTail = normalized.length - tailStartIndex(normalized, Math.max(0, budget - nextHead));
25
+ if (nextHead === head && nextTail === tail) break;
26
+ head = nextHead;
27
+ tail = nextTail;
24
28
  }
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
- };
29
+ return { text: normalized.slice(0, head) + marker + normalized.slice(normalized.length - tail), truncated: true, originalChars: normalized.length };
31
30
  }
32
31
 
33
32
  // Lone surrogates in a tool result make the message invalid UTF-8 at the API
@@ -53,7 +52,7 @@ function formatKey(key) {
53
52
 
54
53
  function formatPrimitive(value) {
55
54
  if (value === undefined) return "undefined";
56
- if (typeof value === "number" && !Number.isFinite(value)) return String(value);
55
+ if (Number.isNaN(value) || value === Infinity || value === -Infinity) return String(value);
57
56
  return JSON.stringify(value) ?? String(value);
58
57
  }
59
58
 
@@ -65,7 +64,7 @@ function formatFlatList(value) {
65
64
  }
66
65
 
67
66
  function formatFlat(value) {
68
- if (value === null || typeof value !== "object") return formatPrimitive(value);
67
+ if ((!isObject(value) && !Array.isArray(value))) return formatPrimitive(value);
69
68
  if (Array.isArray(value)) return formatFlatList(value);
70
69
  let out = "";
71
70
  for (const key of Object.keys(value)) {
@@ -83,7 +82,7 @@ function formatFlat(value) {
83
82
  */
84
83
  export function formatValue(value, indent = "", width = FORMAT_WIDTH) {
85
84
  const flat = formatFlat(value);
86
- if (value === null || typeof value !== "object" || flat.length + indent.length <= width) return flat;
85
+ if ((!isObject(value) && !Array.isArray(value)) || flat.length + indent.length <= width) return flat;
87
86
  const pad = indent + " ";
88
87
  if (Array.isArray(value)) {
89
88
  if (value.length === 0) return "[]";