pi-supernova 0.0.7 → 0.0.11
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 +56 -0
- package/README.md +27 -16
- package/bottleneck.js +32 -41
- package/catalog.js +72 -22
- package/config.default.json +2 -1
- package/config.js +19 -10
- package/diff.js +12 -4
- package/evidence.js +446 -0
- package/format.js +95 -0
- package/guest-worker.js +345 -0
- package/host-bridge.js +215 -463
- package/index.js +107 -97
- package/omp-frame.js +68 -51
- package/package.json +8 -1
- package/parallel.js +19 -20
- package/patch.js +106 -0
- package/render-measure.js +63 -49
- package/render.js +203 -166
- package/repo-index.js +179 -0
- package/runtime.js +266 -241
- package/snap.js +153 -151
- package/surface.js +16 -30
- package/vfs.js +144 -0
- package/workspace.js +129 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,62 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.0.11] - 2026-09-04
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `evidence(query, {k?, path?, maxChars?})` — zero-token evidence selection over the codebase after Zero-Mem (arXiv:2607.29377), implemented 1:1 with the paper's non-generative pipeline: declared spans are the context units and identifiers the entities (eq.3), entity–span weights `w(d,e)=c(e,d)/Σc` (eq.4), file→span→line hierarchy (eq.5, eq.11), a deterministic query profile and relational/local route (eq.6–7), lexical entity alignment and one IDF-damped co-occurrence propagation step (eq.8–9), personalized PageRank `π=(1−γ)r+γPᵀπ` over spans (eq.10, γ=0.85, 10 iterations, factored through the entity layer so it is O(nnz)), per-view min-max normalisation and ρ-weighted fusion (eq.12–13, ρ=0.7), closure with definition bridges and in-file neighbours (eq.14), and calibration that filters by boundary/answer type/lexical support and ranks by type compatibility (eq.15). Returns top-K (default 5, per the paper's Top-5 ≈ Top-10 finding) verbatim source spans with path and line provenance under a 6000-char budget. Across 8 understanding questions on this repo the correct span ranks first and the result costs **68% fewer tokens** than reading the files the question spans (43,916 → 14,147). Warm latency 1–4ms.
|
|
10
|
+
- Structural surface now records column-0 `const/let/var` bindings, so module-level tables are their own spans (also sharpens `snap`).
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Tool guidance: "To understand code, call `evidence(question)` and read only the returned spans; read whole files only to edit them" (Agent Zero Memory's L0→L1→L2 read discipline; Harness-of-Harness progressive disclosure).
|
|
15
|
+
|
|
16
|
+
## [0.0.10] - 2026-09-04
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- In-process workspace index (`repo-index.js`): one gitignore-aware `rg --files` per 10s window, then file text, lowercase lines, declared names, and structural surfaces are cached per path and validated by mtime. `snap`, `grep`, `glob`, and `find` are served from it without spawning (trees over 4000 files fall back to `rg`). Warm latencies: snap 14ms → 0.36ms, grep 4.8ms → 0.15ms, glob 4.7ms → 0.06ms; a warm `nova.call` is ~20µs and the program floor is ~50µs.
|
|
21
|
+
- `bash` and any on-disk write or commit invalidate the file list, so a file created by a shell command is visible to the next `glob` in the same program.
|
|
22
|
+
- Workspace-path realpath checks are cached per program (two syscalls per call before).
|
|
23
|
+
- Live card updates are coalesced to one host re-render per 40ms frame; a tight loop of calls no longer pays a TUI render per call.
|
|
24
|
+
- Fewer result tokens: `snap` returns a workspace-relative path and a 7-line context window (`►36 text`); `grep` rows are relative; `nova.search` hits drop `callable:true`; `nova.describe` omits `required:false` and the redundant `signature` line.
|
|
25
|
+
|
|
26
|
+
## [0.0.9] - 2026-09-04
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- `snap` returns the defining file and line. `const x = fn(...)` call sites matched the definition regex and earned definition credit, so the busiest caller outranked the definer; definition credit now requires the declared name to contain a query token, mention credit is capped per file, and the anchor is the surface item with the most token matches (`resolveWorkspacePath`, not `getResolvedCwd`).
|
|
31
|
+
- Success cards draw a visible frame. `borderMuted` is background-level in OMP themes, so only error cards had a border; success uses `dim`.
|
|
32
|
+
- Failed calls show their error on the row instead of `done`; rows with no target show nothing.
|
|
33
|
+
- `read([...paths])` rows read `2 files: a.js, b.js` instead of a comma-joined path list.
|
|
34
|
+
|
|
35
|
+
## [0.0.8] - 2026-09-04
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- 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`.
|
|
40
|
+
- 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`.
|
|
41
|
+
- 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.
|
|
42
|
+
- `maxReturnChars` default lowered from 200000 to 32000.
|
|
43
|
+
- Return values that JSON cannot express are rendered instead of collapsing to `[object Object]`: circular references, `Map`, `Set`, `BigInt`, `Error`, functions, typed arrays.
|
|
44
|
+
- A program that finishes without a `return` statement says so instead of printing `null`.
|
|
45
|
+
- Guest runtime errors include `(line:col)` on Node.
|
|
46
|
+
|
|
47
|
+
- 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.
|
|
48
|
+
- `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).
|
|
49
|
+
- 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).
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- `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.
|
|
54
|
+
- Result truncation no longer splits a surrogate pair, which produced a lone surrogate the model API rejects.
|
|
55
|
+
- `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.
|
|
56
|
+
- `read("dir")` reports a directory instead of silently running a concept search.
|
|
57
|
+
- The VFS read cache is cleared at the start of every program, so files edited outside supernova are never read stale.
|
|
58
|
+
- Arrow programs with default parameters containing parentheses or a leading comment are recognized and executed instead of silently returning `null`.
|
|
59
|
+
- `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.
|
|
60
|
+
|
|
5
61
|
## [0.0.7] - 2026-09-03
|
|
6
62
|
|
|
7
63
|
### Fixed
|
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
|
|
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,26 @@ async () => {
|
|
|
46
46
|
}
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
Globals: `nova` / `tools`, `parallel`, `pipeline`, `console`, plus shorthand `read
|
|
49
|
+
Globals: `nova` / `tools`, `parallel`, `pipeline`, `console`, plus shorthand `read` (path or path array), `write`, `edit`, `patch`, `evidence`, `surface`, `snap`, `bash`, and `exec`.
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
Read discipline that keeps context small: `surface(path)` (names only) → `evidence(question)` (the spans that answer it) → `read(path, offset, limit)` only for the lines you will edit.
|
|
52
|
+
|
|
53
|
+
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.
|
|
54
|
+
|
|
55
|
+
Unified Pi/OMP card — one aligned row per call (status · tool · duration · target) with bounded mutation diffs:
|
|
52
56
|
|
|
53
57
|
```text
|
|
54
|
-
╭─── nova:
|
|
55
|
-
│
|
|
56
|
-
│ │
|
|
57
|
-
│
|
|
58
|
-
│
|
|
59
|
-
│
|
|
60
|
-
|
|
58
|
+
╭─── nova: 4 calls · 6.9s ─────────────────────────────────────╮
|
|
59
|
+
│ ✓ bash 6.2s python3 - <<'EOF' …+3 lines │
|
|
60
|
+
│ × bash 120ms exit 3 pytest -q │
|
|
61
|
+
│ ✓ read 3ms packages/pi-supernova/host-bridge.js │
|
|
62
|
+
│ ✓ edit 4ms +1/-1 src/a.ts │
|
|
63
|
+
│ -143 │ - const oldValue = before; │
|
|
64
|
+
│ +143 │ + const newValue = after; │
|
|
65
|
+
╰───────────────────────────────────────────────────────────────╯
|
|
61
66
|
```
|
|
62
67
|
|
|
63
|
-
|
|
68
|
+
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
69
|
|
|
65
70
|
---
|
|
66
71
|
|
|
@@ -72,9 +77,10 @@ Collapsed cards show bounded edit/write/patch hunks immediately. Press Enter for
|
|
|
72
77
|
| `nova.describe(name)` | Parameter summary on demand |
|
|
73
78
|
| `nova.call(name, args)` | Host tool or native adapter |
|
|
74
79
|
| `nova.callMany([{name,args}])` | Auto parallel wave — iterable array with `.mode` / `.results` |
|
|
80
|
+
| `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 |
|
|
75
81
|
| `nova.surface(path)` | Structural outline for a source file |
|
|
76
|
-
| `nova.snap(query, searchRoot?)` |
|
|
77
|
-
| `nova.has(name)` | Whether a catalog or native tool is callable |
|
|
82
|
+
| `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 |
|
|
83
|
+
| `nova.has(name)` | Whether a catalog or native tool is callable (sync) |
|
|
78
84
|
| `parallel(thunks)` / `pipeline(items, …stages)` | Raw `Promise.all` helpers |
|
|
79
85
|
| `nova.speculate(fn)` | Counterfactual branch (rollback / commit) |
|
|
80
86
|
|
|
@@ -93,12 +99,15 @@ Optional `~/.pi/agent/supernova.json` or `~/.omp/agent/supernova.json`
|
|
|
93
99
|
"maxCodeChars": 48000,
|
|
94
100
|
"maxBridgeCalls": 256,
|
|
95
101
|
"maxCallResultChars": 65536,
|
|
96
|
-
"maxReturnChars":
|
|
102
|
+
"maxReturnChars": 32000,
|
|
103
|
+
"maxHeapMb": 512,
|
|
97
104
|
"maxSearchResults": 12,
|
|
98
105
|
"spillDir": null
|
|
99
106
|
}
|
|
100
107
|
```
|
|
101
108
|
|
|
109
|
+
`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.
|
|
110
|
+
|
|
102
111
|
Defaults also set `excludeTools` (includes `supernova` and DCE helpers). An empty `"excludeTools": []` **replaces** those defaults — omit the key unless you mean that.
|
|
103
112
|
|
|
104
113
|
Slash: `/supernova` — catalog size + captured executors.
|
|
@@ -126,7 +135,7 @@ Pair with DCE last if you use it: `omp install npm:pi-deferred-context-engine`.
|
|
|
126
135
|
|
|
127
136
|
| Symptom | Fix |
|
|
128
137
|
|---------|-----|
|
|
129
|
-
| `
|
|
138
|
+
| `unknown tool "…"` | Follow the `Did you mean` hint, or `nova.search("")`; for host tools install supernova **first**; restart; `/supernova` |
|
|
130
139
|
| `Rendered line exceeds terminal width` | ≥0.0.1 and restart so `render.js` reloads |
|
|
131
140
|
| `callMany` / not iterable | ≥0.0.1 — return is an array with `.mode` / `.results` |
|
|
132
141
|
| Extension missing on OMP | `omp install npm:pi-supernova` (needs `"omp".extensions`) |
|
|
@@ -135,8 +144,10 @@ Pair with DCE last if you use it: `omp install npm:pi-deferred-context-engine`.
|
|
|
135
144
|
|
|
136
145
|
## Limitations
|
|
137
146
|
|
|
138
|
-
- Guest JS is **unsandboxed**. Adapter path jails are not a boundary against `import("node:fs")`.
|
|
147
|
+
- 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.
|
|
148
|
+
- Guest error messages carry `(line:col)` on Node; Bun's engine does not expose guest-relative positions.
|
|
139
149
|
- `bash` / mutating tools flush speculative writes (transaction barrier); error rollback cannot undo that.
|
|
150
|
+
- 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).
|
|
140
151
|
- Pre-1.0 package — APIs and TUI may still evolve between minor releases.
|
|
141
152
|
|
|
142
153
|
## License
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
121
|
+
logTruncated,
|
|
131
122
|
};
|
|
132
123
|
}
|
package/catalog.js
CHANGED
|
@@ -33,6 +33,15 @@ export const NATIVE_TOOL_DEFINITIONS = [
|
|
|
33
33
|
path: { type: "string", description: "Optional workspace search root; explicitly targeting a hidden directory includes its hidden files, but Git metadata is always excluded" },
|
|
34
34
|
}, required: ["query"] },
|
|
35
35
|
},
|
|
36
|
+
{
|
|
37
|
+
name: "evidence", description: "Top-K source spans (with path and line provenance) that answer a concept question; read these instead of whole files.",
|
|
38
|
+
parameters: { type: "object", properties: {
|
|
39
|
+
query: { type: "string", description: "Concept, symbol, or question" },
|
|
40
|
+
path: { type: "string", description: "Optional search root" },
|
|
41
|
+
k: { type: "number", description: "Main spans to return (default 5)" },
|
|
42
|
+
maxChars: { type: "number", description: "Total text budget (default 6000)" },
|
|
43
|
+
}, required: ["query"] },
|
|
44
|
+
},
|
|
36
45
|
{
|
|
37
46
|
name: "surface", description: "Extract a structural outline from a workspace source file.",
|
|
38
47
|
parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
|
|
@@ -82,6 +91,13 @@ export function mergeNativeToolDefinitions(tools, capturedNames = []) {
|
|
|
82
91
|
return merged;
|
|
83
92
|
}
|
|
84
93
|
|
|
94
|
+
function sourcePathOf(tool) {
|
|
95
|
+
if (tool.sourceInfo && isString(tool.sourceInfo.path)) return tool.sourceInfo.path;
|
|
96
|
+
if (isString(tool.extensionPath)) return tool.extensionPath;
|
|
97
|
+
if (isString(tool.sourcePath)) return tool.sourcePath;
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
85
101
|
function normalizeTool(tool) {
|
|
86
102
|
if (!tool || !isObject(tool)) return null;
|
|
87
103
|
const name = isString(tool.name) ? tool.name : "";
|
|
@@ -93,14 +109,7 @@ function normalizeTool(tool) {
|
|
|
93
109
|
description,
|
|
94
110
|
descLower: description.toLowerCase(),
|
|
95
111
|
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,
|
|
112
|
+
sourcePath: sourcePathOf(tool),
|
|
104
113
|
};
|
|
105
114
|
}
|
|
106
115
|
|
|
@@ -142,17 +151,22 @@ export function searchCatalog(catalog, query, limit = 12) {
|
|
|
142
151
|
for (const row of catalog) {
|
|
143
152
|
const score = scoreRow(row, tokens);
|
|
144
153
|
if (score <= 0 && tokens.length > 0) continue;
|
|
145
|
-
scored.push({
|
|
146
|
-
name: row.name,
|
|
147
|
-
description: row.description.slice(0, 160),
|
|
148
|
-
score,
|
|
149
|
-
callable: true,
|
|
150
|
-
});
|
|
154
|
+
scored.push({ name: row.name, description: row.description.slice(0, 160), score });
|
|
151
155
|
}
|
|
152
156
|
scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
|
153
157
|
return scored.slice(0, Math.max(1, limit)).map(({ score: _s, ...hit }) => hit);
|
|
154
158
|
}
|
|
155
159
|
|
|
160
|
+
function fieldSummary(key, schema, required) {
|
|
161
|
+
const s = schema && isObject(schema) ? schema : {};
|
|
162
|
+
// Only signal-bearing keys: `required:false` and empty descriptions cost tokens and say nothing.
|
|
163
|
+
return {
|
|
164
|
+
type: s.type || (Array.isArray(s.anyOf) ? "union" : "unknown"),
|
|
165
|
+
required: required.has(key) || undefined,
|
|
166
|
+
description: isString(s.description) ? s.description.slice(0, 120) : undefined,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
156
170
|
function schemaSummary(parameters) {
|
|
157
171
|
if (!parameters || !isObject(parameters)) return { type: "unknown" };
|
|
158
172
|
const props = parameters.properties;
|
|
@@ -165,20 +179,57 @@ function schemaSummary(parameters) {
|
|
|
165
179
|
const required = new Set(Array.isArray(parameters.required) ? parameters.required : []);
|
|
166
180
|
const fields = {};
|
|
167
181
|
for (const [key, schema] of Object.entries(props)) {
|
|
168
|
-
|
|
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
|
-
};
|
|
182
|
+
fields[key] = fieldSummary(key, schema, required);
|
|
174
183
|
}
|
|
175
184
|
return { type: "object", fields };
|
|
176
185
|
}
|
|
177
186
|
|
|
187
|
+
/** Optimal string alignment distance: insert/delete/substitute/adjacent-transpose cost 1. */
|
|
188
|
+
function editDistance(a, b) {
|
|
189
|
+
const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...new Array(b.length).fill(0)]);
|
|
190
|
+
for (let j = 1; j <= b.length; j++) rows[0][j] = j;
|
|
191
|
+
for (let i = 1; i <= a.length; i++) {
|
|
192
|
+
for (let j = 1; j <= b.length; j++) {
|
|
193
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
194
|
+
let best = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
|
|
195
|
+
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);
|
|
196
|
+
rows[i][j] = best;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return rows[a.length][b.length];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Closest tool names for a mistyped name: substring hits first, then a length-scaled edit distance. */
|
|
203
|
+
export function suggestNames(name, candidates, limit = 3) {
|
|
204
|
+
const needle = String(name || "").toLowerCase();
|
|
205
|
+
if (!needle) return [];
|
|
206
|
+
const maxDistance = Math.max(1, Math.floor(needle.length / 3));
|
|
207
|
+
const scored = [];
|
|
208
|
+
for (const candidate of candidates) {
|
|
209
|
+
const lower = candidate.toLowerCase();
|
|
210
|
+
if (lower === needle) continue;
|
|
211
|
+
const distance = lower.includes(needle) || needle.includes(lower) ? 1 : editDistance(needle, lower);
|
|
212
|
+
if (distance <= maxDistance) scored.push({ candidate, distance });
|
|
213
|
+
}
|
|
214
|
+
scored.sort(
|
|
215
|
+
(a, b) =>
|
|
216
|
+
a.distance - b.distance ||
|
|
217
|
+
Math.abs(a.candidate.length - needle.length) - Math.abs(b.candidate.length - needle.length) ||
|
|
218
|
+
a.candidate.localeCompare(b.candidate),
|
|
219
|
+
);
|
|
220
|
+
return scored.slice(0, limit).map((s) => s.candidate);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function unknownToolMessage(name, candidates) {
|
|
224
|
+
const close = suggestNames(name, candidates);
|
|
225
|
+
const hint = close.length ? ` Did you mean ${close.map((c) => JSON.stringify(c)).join(", ")}?` : "";
|
|
226
|
+
return `unknown tool "${name}".${hint} Use nova.search("") to list every callable tool.`;
|
|
227
|
+
}
|
|
228
|
+
|
|
178
229
|
export function describeTool(catalog, name) {
|
|
179
230
|
const row = catalog.find((t) => t.name === name);
|
|
180
231
|
if (!row) {
|
|
181
|
-
return { ok: false, error:
|
|
232
|
+
return { ok: false, error: unknownToolMessage(name, catalog.map((t) => t.name)) };
|
|
182
233
|
}
|
|
183
234
|
if (!row._described) {
|
|
184
235
|
row._described = {
|
|
@@ -187,7 +238,6 @@ export function describeTool(catalog, name) {
|
|
|
187
238
|
description: row.description,
|
|
188
239
|
parameters: schemaSummary(row.parameters),
|
|
189
240
|
sourcePath: row.sourcePath,
|
|
190
|
-
signature: `await nova.call(${JSON.stringify(row.name)}, args)`,
|
|
191
241
|
};
|
|
192
242
|
}
|
|
193
243
|
return row._described;
|
package/config.default.json
CHANGED
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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+)
|
|
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
|
-
|
|
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 (
|
|
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
|
|
93
|
+
} else {
|
|
86
94
|
lines.push({ type: "context", lineNum: newLineNum, text: patchLine.slice(1) });
|
|
87
95
|
oldLineNum += 1;
|
|
88
96
|
newLineNum += 1;
|