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 +18 -0
- package/README.md +42 -11
- package/bottleneck.js +98 -97
- package/catalog.js +4 -29
- package/config.js +1 -2
- package/decode.js +61 -0
- package/evidence.js +14 -5
- package/format.js +16 -17
- package/guest-worker.js +29 -150
- package/host-bridge.js +95 -22
- package/index.js +69 -88
- package/ledger.js +73 -101
- package/omp-frame.js +0 -1
- package/package.json +6 -2
- package/parallel.js +42 -34
- package/patch.js +62 -72
- package/render-measure.js +48 -118
- package/render.js +12 -13
- package/repo-index.js +2 -1
- package/runtime.js +204 -210
- package/vfs.js +114 -70
- package/workspace.js +37 -33
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
|
-
|
|
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
|
|
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
|
|
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)` |
|
|
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)` |
|
|
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,
|
|
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 "…"` |
|
|
145
|
-
| `Rendered line exceeds terminal width` |
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
8
|
-
|
|
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
|
-
|
|
19
|
-
if (!
|
|
20
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function summarizeDetails(details, maxChars) {
|
|
36
|
-
return serializeBounded(details, maxChars, "details").text;
|
|
25
|
+
return json(raw);
|
|
37
26
|
}
|
|
38
27
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
68
|
-
const
|
|
69
|
-
const
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
16
|
-
let tail =
|
|
15
|
+
let head = headEnd(normalized, Math.floor(limit * 0.7));
|
|
16
|
+
let tail = 0;
|
|
17
17
|
let marker = "";
|
|
18
|
-
|
|
19
|
-
while (tail !== previousTail) {
|
|
20
|
-
previousTail = tail;
|
|
18
|
+
for (;;) {
|
|
21
19
|
const omitted = normalized.length - head - tail;
|
|
22
|
-
marker =
|
|
23
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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 "[]";
|