pi-supernova 0.0.15 → 0.2.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,37 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.0] - 2026-09-04
6
+
7
+ ### Changed
8
+
9
+ - `read`, `write`, `edit`, and `bash` are the main interface. Source questions and directory selection use `read`; no separate search-tool discovery is needed.
10
+ - Internal adapters remain callable when the host hides their top-level tools. Explicit exclusions, delegated-tool permissions, and session guards still apply.
11
+ - Source selection favors declarations over callers. It reports ambiguous, missing, and incomplete results instead of arbitrary file choices.
12
+ - Bounded ripgrep searches replace whole-repository content loading for source questions. Large source files remain searchable; context and signatures have explicit limits.
13
+
14
+ ### Fixed
15
+
16
+ - Directory reads include staged entries before commit. Explicit test-directory searches include test files without extra query words.
17
+
18
+ ## [0.1.0] - 2026-09-04
19
+
20
+ ### Fixed
21
+
22
+ - Each program uses a separate worker and transaction. Late callbacks, idle worker errors, concurrent runs, startup cancellation, and startup deadlines no longer share state.
23
+ - Command deadlines stop descendants. Signal exits report failure. Native commits recover partial replacements; writes reject missing content and edits reject overlapping matches.
24
+ - Unified patches handle zero-context insertions, empty files, final-newline changes, and CRLF. Reads see external changes; evidence search includes staged files.
25
+ - 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.
26
+ - 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.
27
+
28
+ ### Changed
29
+
30
+ - Acorn parses supported function and body forms. Return hints ignore comments and nested callbacks. Invalid collection arguments report errors.
31
+ - The result ledger checks text equality, retains explicit-read pins across aliases, expires source records, and accepts a zero window.
32
+ - Terminal width checks use Unicode graphemes. Expanded cards wrap all bounded result and log text without a preview-line limit.
33
+ - Pi 0.85.0 native adapters respect active-tool changes. Pi has no cross-extension execution API; the README states this limit.
34
+ - Live checks cover Pi 0.85.0 and OMP 18.1.10, including expanded terminal output.
35
+
5
36
  ## [0.0.15] - 2026-09-04
6
37
 
7
38
  ### Removed
package/README.md CHANGED
@@ -5,14 +5,22 @@
5
5
  [![node](https://img.shields.io/node/v/pi-supernova.svg)](https://nodejs.org)
6
6
  [![pi-package](https://img.shields.io/badge/pi--package-extension-7aa2f7)](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md)
7
7
 
8
- **CodeMode for [Pi](https://pi.dev) and [OMP](https://omp.sh).** One tool, one guest program: progressive discovery, a hard result bottleneck, and Amdahl-aware parallel waves.
8
+ **CodeMode for [Pi](https://pi.dev) and [OMP](https://omp.sh).** One program with `read`, `write`, `edit`, and `bash`. Search, source context, and result limits stay inside these commands.
9
9
 
10
10
  ```bash
11
11
  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
+ Both hosts use the same runtime, transactions, and result limits. Hiding a top-level native tool does not remove its internal Supernova adapter.
22
+
23
+ Use `excludeTools` to disable an internal adapter. Delegated extension tools still require host permission and active status. Replaced or disposed sessions cannot call tools.
16
24
 
17
25
  ---
18
26
 
@@ -20,39 +28,63 @@ Load **before** other tool-owning packages so `registerTool` capture works. Rest
20
28
 
21
29
  | | Stock multi-tool prompt | **pi-supernova** |
22
30
  |--|-------------------------|------------------|
23
- | Schema tax | Every tool in context | Thin catalog + on-demand `describe` |
31
+ | Schema tax | Every tool in context | Four known commands; optional host schemas on demand |
24
32
  | Multi-step | Model stitches turns | One in-process program |
25
33
  | Parallel reads | Ad hoc | `callMany` Auto / `parallel()` |
26
34
  | Hosts | Separate packages | Same tarball for Pi **and** OMP |
27
35
 
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.
36
+ Guest code runs as an `AsyncFunction` in a new worker for each program. It has the same trust level as host `bash`.
37
+
38
+ The host parses the bounded source with Acorn before execution. Tool calls use RPC. Deadlines and cancellation cover worker startup, tool discovery, and execution.
39
+
40
+ A completed worker cannot supply callbacks or globals to another program. Concurrent programs have separate transactions, traces, cancellation signals, and call budgets.
29
41
 
30
42
  ---
31
43
 
32
44
  ## Quick example
33
45
 
34
46
  ```js
35
- async () => {
36
- const hits = await nova.search("read file contents");
37
- await nova.describe("read");
47
+ const hit = JSON.parse(await read("src", {about: "validateJwtRefreshToken"}));
48
+ if (hit.status !== "found") return hit;
49
+ return await read(hit.path, {about: "expired tokens"});
50
+ ```
38
51
 
39
- const a = await nova.call("read", { path: "src/index.ts" });
40
- const wave = await nova.callMany([
41
- { name: "read", args: { path: "a.ts" } },
42
- { name: "read", args: { path: "b.ts" } },
43
- ]);
52
+ No catalog lookup or separate search command is needed.
44
53
 
45
- return { preview: a.value.slice(0, 200), mode: wave.mode, n: wave.length };
46
- }
47
- ```
54
+ | Command | Result |
55
+ |---------|--------|
56
+ | `read(file, offset?, limit?)` | File text; line numbers start at one |
57
+ | `read(files)` | An array of file texts |
58
+ | `read(directory)` | Direct directory entries |
59
+ | `read("symbol or question")` | JSON text with source status, path, line, signature, and context |
60
+ | `read(path, {about: question})` | Relevant file bodies, or source selection inside a directory |
61
+ | `write(path, text)` | Write a file |
62
+ | `edit(path, oldText, newText)` | Post-edit lines, a structural check, and references |
63
+ | `bash(command, options?)` | Bounded command output; failure throws |
64
+
65
+ ### Source selection
66
+
67
+ `read` reports `found`, `ambiguous`, `not_found`, or `incomplete`.
68
+ Only `found` selects a path. Uncertain results contain at most three candidates.
69
+
70
+ Identifier declarations take priority over callers and filenames. Natural-language selection uses text matches, not semantic inference. Use a symbol or a narrower directory when needed.
71
+
72
+ The confidence value is a ranking heuristic, not a measured probability. Duplicate declarations do not produce a confident winner.
73
+
74
+ Search uses ripgrep without loading every file into the text cache. It can locate declarations beyond the 512 KiB whole-file cache limit.
48
75
 
49
- Globals: `nova`, `parallel`, `pipeline`, `console`, plus shorthand `read` (path or path array), `write`, `edit`, `patch`, `evidence`, `surface`, `snap`, `bash`, and `exec`.
76
+ Each candidate contains up to seven source lines. Signatures and individual lines have a 240-character limit, with explicit truncation markers.
50
77
 
51
- Read discipline that keeps context small: `evidence(question)` across the repo, or `read(path, {about: question})` for one file (full structure, only relevant bodies expanded, ~65% fewer tokens than the file) → `read(path, offset, limit)` only for the lines you will edit.
78
+ File-list and content-search output have separate 2,097,152-character budgets. Incomplete searches report their status instead of selecting a file.
52
79
 
53
- Results never repeat what the model already saw: a run of lines from an earlier result collapses to `⋯ 23 lines same as #12 · host-bridge.js:40–62 ⋯`; changed lines always show; `read(path, a, n)` shows a cited range again. `edit` returns the post-edit lines, a structural check, and who else references a changed declaration; a failing `bash` attaches the source behind the `path:line` it printed. A read→edit→verify loop costs ~15% of the naive token count.
80
+ The measured 10,001-file fixture returned a 199-character result. This is about 50 tokens at four characters per token, not an exact token count.
54
81
 
55
- File search is an in-process port of [fff](https://github.com/dmtrKovalenko/fff): `glob("hostbrdge")` finds `host-bridge.js` (typo-tolerant, frecency- and git-status-ranked), `grep` is smart-case with declaration lines first and a fuzzy fallback. None of it spawns a process.
82
+ In the 23-question source check, 17 questions selected the correct file and six returned candidates. No result selected a wrong file.
83
+ All 15 identifier questions selected the correct file. Natural-language questions can still miss the intended file in their candidates.
84
+
85
+ Search scans disk on each question. Warm searches can be slower than the previous full-content cache, but do not retain every source file.
86
+
87
+ Already-seen result lines collapse to references. `read(path, firstLine, lineCount)` shows a range again. `edit` returns changed lines without a verification read.
56
88
 
57
89
  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.
58
90
 
@@ -69,27 +101,36 @@ Unified Pi/OMP card: one aligned row per call (status · tool · duration · tar
69
101
  ╰───────────────────────────────────────────────────────────────╯
70
102
  ```
71
103
 
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.
104
+ Multi-line commands show their first line and a hidden-line count. Expand the card to show the complete bounded return value and logs.
105
+
106
+ Expanded text wraps at the terminal width without a preview-line limit. Operation lists and mutation diffs keep their separate 24-row budgets.
73
107
 
74
108
  ---
75
109
 
76
- ## API
110
+ ## Optional APIs
77
111
 
78
112
  | API | Role |
79
113
  |-----|------|
80
114
  | `nova.search(query, limit?)` | Thin catalog hits |
81
- | `nova.describe(name)` | Parameter summary on demand |
115
+ | `nova.describe(name)` | Complete JSON input schema on demand; explicit failure when no usable schema is available |
82
116
  | `nova.call(name, args)` | Host tool or native adapter |
83
117
  | `nova.callMany([{name,args}])` | Auto parallel wave; iterable array with `.mode` / `.results` |
84
118
  | `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 |
85
- | `read(path, {about})` | Whole-file outline with only the relevant bodies expanded; folded bodies show `line … N lines` |
86
119
  | `nova.surface(path)` | Structural outline for a source file |
87
- | `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 |
120
+ | `nova.snap(query, searchRoot?)` | Structured source-selection result; the same engine as a source question passed to `read` |
88
121
  | `nova.has(name)` | Whether a catalog or native tool is callable (sync) |
89
- | `parallel(thunks)` / `pipeline(items, …stages)` | Raw `Promise.all` helpers |
122
+ | `parallel(thunks)` / `pipeline(items, …stages)` | Array-based helpers; pipeline stages must be functions |
90
123
  | `nova.speculate(fn)` | Counterfactual branch (rollback / commit) |
91
124
 
92
- Root Snap searches ignore hidden files. Passing a hidden search root includes hidden files beneath that root; Git metadata is always excluded.
125
+ `nova.call` returns an explicit `{ok, value}` envelope. Convenience helpers throw when a host tool reports failure.
126
+
127
+ `callMany` runs known read-only tools concurrently. Unknown tools and mutating actions run in order, including LSP rename operations.
128
+
129
+ `nova.describe` preserves required fields, unions, enums, nested objects, and numeric constraints. OMP ArkType and Zod schemas convert to JSON Schema.
130
+
131
+ Source questions ignore hidden files by default. An explicit hidden directory includes its hidden files. Git metadata stays excluded.
132
+
133
+ An explicit test directory includes test files without extra query words. Directory reads include staged entries before commit.
93
134
 
94
135
  ---
95
136
 
@@ -112,11 +153,17 @@ Optional `~/.pi/agent/supernova.json` or `~/.omp/agent/supernova.json`
112
153
  }
113
154
  ```
114
155
 
156
+ `maxCallResultChars` bounds text per call. A batch shares one text budget across all items; it does not repeat a joined copy.
157
+
158
+ Envelope fields and JSON encoding add overhead. Details have a separate 2,000-character JSON budget. Truncation flags cover returns, raw objects, and logs.
159
+
160
+ An optional `spillDir` retains complete truncated text. Inline spill footers and truncation markers fit within the configured text limit.
161
+
115
162
  `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
163
 
117
164
  Defaults also set `excludeTools` (includes `supernova` and DCE helpers). An empty `"excludeTools": []` **replaces** those defaults, so omit the key unless you mean that.
118
165
 
119
- Slash command `/supernova`: catalog size, captured executors, and session token stats.
166
+ Slash command `/supernova`: callable catalog size, external tools, native adapters, and session token stats.
120
167
 
121
168
  ---
122
169
 
@@ -141,8 +188,8 @@ Pair with DCE last if you use it: `omp install npm:pi-deferred-context-engine`.
141
188
 
142
189
  | Symptom | Fix |
143
190
  |---------|-----|
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 |
191
+ | `unknown tool "…"` | Check `excludeTools` for native adapters. Enable delegated tools in the host. Restart after package changes. |
192
+ | `Rendered line exceeds terminal width` | Install the current package and restart so the Unicode width code reloads. |
146
193
  | `callMany` / not iterable | ≥0.0.1; the return is an array with `.mode` / `.results` |
147
194
  | Extension missing on OMP | `omp install npm:pi-supernova` (needs `"omp".extensions`) |
148
195
 
@@ -152,9 +199,16 @@ Pair with DCE last if you use it: `omp install npm:pi-deferred-context-engine`.
152
199
 
153
200
  - 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
201
  - 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.
202
+
203
+ ## Transactions and file freshness
204
+
205
+ - `bash` and mutating host tools commit pending writes before execution. Later rollback cannot undo those changes or external effects.
206
+ - Native commits stage replacements and backups before installation. A commit failure restores earlier replacements; a recovery failure reports retained backup paths.
207
+ - A nested `nova.speculate` branch cannot call external mutators. Await each branch before returning.
208
+ - Native reads use current disk content unless a staged write replaces it. Evidence search includes new staged files.
209
+ - Workspace file-list updates use filesystem watchers. Without a working watcher, external new files can take 10 seconds to appear in indexed searches.
210
+
211
+ This is a pre-1.0 package. APIs and the terminal display can change between minor releases.
158
212
 
159
213
  ## Research and prior art
160
214
 
@@ -163,7 +217,7 @@ Supernova's retrieval and result shaping implement published methods. Where a pa
163
217
  | Work | What we use it for | Where |
164
218
  |------|--------------------|-------|
165
219
  | **Zero-Mem: Zero-Token Memory Operations for LLM Agents**, Xiao, Zhu, Zhang, Chen, Hong, Zhuang, Zhang, Chen, Ouyang, Ren, Huang (arXiv:2607.29377) | `evidence(query)`: entity–context graph with co-occurrence weights (eq. 3–4), turn/window/episode hierarchy as line/span/file (eq. 5, 11), query profile and relational/local routing (eq. 6–7), lexical entity alignment and one propagation step (eq. 8–9), personalized PageRank over spans (eq. 10), per-view normalisation and ρ-weighted fusion (eq. 12–13), closure with bridges and neighbours (eq. 14), deterministic calibration (eq. 15). Top-K = 5 follows the paper's Top-5 ≈ Top-10 finding. | `evidence.js` |
166
- | **Agent Zero Memory: Provenance-Aware Long-Term Memory for LLM Agents**, Zhu, Wu (arXiv:2608.29606) | Every returned unit carries provenance (path, line range, verbatim text); the L0→L1→L2 read discipline (`surface` → `evidence`/`read(path, {about})` → `read(path, offset, limit)`); the citation-lock idea that a model should only cite what it actually opened. | `evidence.js`, `outline.js`, tool guidance |
220
+ | **Agent Zero Memory: Provenance-Aware Long-Term Memory for LLM Agents**, Zhu, Wu (arXiv:2608.29606) | Every returned unit carries provenance (path, line range, verbatim text); the L0→L1→L2 read discipline (`read(query)` → `read(path, {about})` → `read(path, offset, limit)`); the citation-lock idea that a model should only cite what it actually opened. | `evidence.js`, `outline.js`, tool guidance |
167
221
  | **Harness-of-Harness: Multi-Day Autonomous Software Development with Continual Improvement**, Yan, Su, et al. (arXiv:2609.01481) | Progressive disclosure (index first, detail on demand) and carrying evidence forward instead of reconstructing it from code. | outline / result shaping |
168
222
  | **Act More, Decide Less: Skill-Guided Adaptive Action Chunking for Long-Horizon LLM Agents**, Yang, Jin, Zhao, et al. (arXiv:2609.02042) | Framing: one supernova program is an action chunk (one model decision, many primitive actions, stop at the first failing one). | runtime design |
169
223
  | **fff**, Dmitriy Kovalenko, MIT, <https://github.com/dmtrKovalenko/fff> | File search. We reimplemented fff's ranking in plain JavaScript after reading its Rust sources (`crates/fff-core/src/score.rs`, `dbs/frecency.rs`, `path_utils.rs`); the formulas and constants are fff's, the code is ours, and nothing runs out of process. Ported: typo-tolerant fuzzy path matching with boundary/consecutive/case bonuses and smart-case; exact-filename +40% and filename +20% bonuses; frecency boost `base·f/100` with fff's AI-mode decay (3-day half-life, 7-day window) and modification-recency steps (30s/5m/15m/1h/4h); git-modified +15%; directory-distance penalty from the current file (−1 per hop, floor −20); definition-first result hinting; fuzzy fallback on zero literal matches; weak-match cutoff; watcher-driven index refresh. Not ported: fff's SIMD/frizbee matcher (ours is an fzf-style greedy match with backward tightening), LMDB persistence (frecency is per session), and the MCP/Neovim surfaces. | `fuzzy.js`, `repo-index.js`, `host-bridge.js` |
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
@@ -4,13 +4,13 @@ import { isString, isObject } from "./decode.js";
4
4
  const NATIVE_TOOL_DEFINITIONS = [
5
5
  {
6
6
  name: "read",
7
- description: "Read UTF-8 workspace files by path, or resolve a concept query to source. Supports path arrays, offset, and limit.",
7
+ description: "Read files, directories, or source questions. Directory questions use about. Source selection reports uncertainty instead of guessing.",
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 or directory, source question, 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" },
13
- about: { type: "string", description: "Question or symbol: returns the whole file as an outline with only the relevant bodies expanded" },
13
+ about: { type: "string", description: "Question or symbol: expand relevant file bodies, or select source inside a directory" },
14
14
  } },
15
15
  },
16
16
  {
@@ -28,7 +28,7 @@ const NATIVE_TOOL_DEFINITIONS = [
28
28
  parameters: { type: "object", properties: { path: { type: "string" }, patch: { type: "string" } }, required: ["patch"] },
29
29
  },
30
30
  {
31
- name: "snap", description: "Resolve a concept query to the most relevant workspace source location.",
31
+ name: "snap", description: "Select source with found, ambiguous, not_found, or incomplete status. The read command uses the same engine.",
32
32
  parameters: { type: "object", properties: {
33
33
  query: { type: "string", description: "Source concept to resolve" },
34
34
  path: { type: "string", description: "Optional workspace search root; explicitly targeting a hidden directory includes its hidden files, but Git metadata is always excluded" },
@@ -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 = {