pi-supernova 0.1.0 → 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,19 @@
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
+
5
18
  ## [0.1.0] - 2026-09-04
6
19
 
7
20
  ### Fixed
package/README.md CHANGED
@@ -5,7 +5,7 @@
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
@@ -18,7 +18,9 @@ Pi 0.85.0 supplies tool schemas but has no cross-extension execution API. Supern
18
18
 
19
19
  An unrelated Pi extension’s tools are not callable through Supernova. OMP 18.1.10 supports those calls through its session registry.
20
20
 
21
- The runtime, transaction, patch, output-limit, and renderer fixes share the same code on both hosts. Disabled configured tools are not callable.
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.
22
24
 
23
25
  ---
24
26
 
@@ -26,7 +28,7 @@ The runtime, transaction, patch, output-limit, and renderer fixes share the same
26
28
 
27
29
  | | Stock multi-tool prompt | **pi-supernova** |
28
30
  |--|-------------------------|------------------|
29
- | 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 |
30
32
  | Multi-step | Model stitches turns | One in-process program |
31
33
  | Parallel reads | Ad hoc | `callMany` Auto / `parallel()` |
32
34
  | Hosts | Separate packages | Same tarball for Pi **and** OMP |
@@ -42,27 +44,47 @@ A completed worker cannot supply callbacks or globals to another program. Concur
42
44
  ## Quick example
43
45
 
44
46
  ```js
45
- async () => {
46
- const hits = await nova.search("read file contents");
47
- 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
+ ```
48
51
 
49
- const a = await nova.call("read", { path: "src/index.ts" });
50
- const wave = await nova.callMany([
51
- { name: "read", args: { path: "a.ts" } },
52
- { name: "read", args: { path: "b.ts" } },
53
- ]);
52
+ No catalog lookup or separate search command is needed.
54
53
 
55
- return { preview: a.value.slice(0, 200), mode: wave.mode, n: wave.length };
56
- }
57
- ```
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.
58
69
 
59
- Globals: `nova`, `parallel`, `pipeline`, `console`, plus shorthand `read` (path or path array), `write`, `edit`, `patch`, `evidence`, `surface`, `snap`, `bash`, and `exec`.
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.
60
71
 
61
- 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.
72
+ The confidence value is a ranking heuristic, not a measured probability. Duplicate declarations do not produce a confident winner.
62
73
 
63
- 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.
74
+ Search uses ripgrep without loading every file into the text cache. It can locate declarations beyond the 512 KiB whole-file cache limit.
64
75
 
65
- 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.
76
+ Each candidate contains up to seven source lines. Signatures and individual lines have a 240-character limit, with explicit truncation markers.
77
+
78
+ File-list and content-search output have separate 2,097,152-character budgets. Incomplete searches report their status instead of selecting a file.
79
+
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.
81
+
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.
66
88
 
67
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.
68
90
 
@@ -85,7 +107,7 @@ Expanded text wraps at the terminal width without a preview-line limit. Operatio
85
107
 
86
108
  ---
87
109
 
88
- ## API
110
+ ## Optional APIs
89
111
 
90
112
  | API | Role |
91
113
  |-----|------|
@@ -94,9 +116,8 @@ Expanded text wraps at the terminal width without a preview-line limit. Operatio
94
116
  | `nova.call(name, args)` | Host tool or native adapter |
95
117
  | `nova.callMany([{name,args}])` | Auto parallel wave; iterable array with `.mode` / `.results` |
96
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 |
97
- | `read(path, {about})` | Whole-file outline with only the relevant bodies expanded; folded bodies show `line … N lines` |
98
119
  | `nova.surface(path)` | Structural outline for a source file |
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 |
120
+ | `nova.snap(query, searchRoot?)` | Structured source-selection result; the same engine as a source question passed to `read` |
100
121
  | `nova.has(name)` | Whether a catalog or native tool is callable (sync) |
101
122
  | `parallel(thunks)` / `pipeline(items, …stages)` | Array-based helpers; pipeline stages must be functions |
102
123
  | `nova.speculate(fn)` | Counterfactual branch (rollback / commit) |
@@ -107,7 +128,9 @@ Expanded text wraps at the terminal width without a preview-line limit. Operatio
107
128
 
108
129
  `nova.describe` preserves required fields, unions, enums, nested objects, and numeric constraints. OMP ArkType and Zod schemas convert to JSON Schema.
109
130
 
110
- Root Snap searches ignore hidden files. Passing a hidden search root includes hidden files beneath that root; Git metadata is always excluded.
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.
111
134
 
112
135
  ---
113
136
 
@@ -165,7 +188,7 @@ Pair with DCE last if you use it: `omp install npm:pi-deferred-context-engine`.
165
188
 
166
189
  | Symptom | Fix |
167
190
  |---------|-----|
168
- | `unknown tool "…"` | Use `nova.search("")` and `/supernova`. Enable the tool in the current session. Restart after package changes. |
191
+ | `unknown tool "…"` | Check `excludeTools` for native adapters. Enable delegated tools in the host. Restart after package changes. |
169
192
  | `Rendered line exceeds terminal width` | Install the current package and restart so the Unicode width code reloads. |
170
193
  | `callMany` / not iterable | ≥0.0.1; the return is an array with `.mode` / `.results` |
171
194
  | Extension missing on OMP | `omp install npm:pi-supernova` (needs `"omp".extensions`) |
@@ -194,7 +217,7 @@ Supernova's retrieval and result shaping implement published methods. Where a pa
194
217
  | Work | What we use it for | Where |
195
218
  |------|--------------------|-------|
196
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` |
197
- | **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 |
198
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 |
199
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 |
200
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/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: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], description: "Workspace-relative file path, concept query, or array of paths" },
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" },
package/host-bridge.js CHANGED
@@ -55,15 +55,15 @@ function looksLikePath(target) {
55
55
  );
56
56
  }
57
57
 
58
- async function probeExistingFile(cwd, targetParam, vfs) {
59
- const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
60
- if (vfs.getOverlay(targetPath) !== undefined || vfs.cache.has(targetPath)) return targetPath;
58
+ async function probeExistingPath(cwd, targetParam, vfs) {
59
+ const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", true);
60
+ if (vfs.getOverlay(targetPath) !== undefined || vfs.cache.has(targetPath)) return { path: targetPath, directory: false };
61
61
  try {
62
62
  const st = await fs.stat(targetPath);
63
- if (st.isDirectory()) throw new Error(`read path is a directory, not a file: ${targetPath} (use ls)`);
64
- return targetPath;
63
+ return { path: targetPath, directory: st.isDirectory() };
65
64
  } catch (err) {
66
65
  if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
66
+ if (vfs.getOverlayPaths().some(file => file.startsWith(targetPath + path.sep))) return { path: targetPath, directory: true };
67
67
  return null;
68
68
  }
69
69
  }
@@ -96,6 +96,11 @@ function applyReplacements(target, content, requestedEdits) {
96
96
  return { updated, matches };
97
97
  }
98
98
 
99
+ function formatDirectoryEntry(name, type, size = 0) {
100
+ const sizeSuffix = size ? `, ${size} bytes` : "";
101
+ return `${name}${type === "dir" ? "/" : ""} (${type}${sizeSuffix})`;
102
+ }
103
+
99
104
  async function formatLsEntry(dirPath, entry) {
100
105
  const isDir = entry.isDirectory();
101
106
  const isSym = entry.isSymbolicLink();
@@ -107,47 +112,55 @@ async function formatLsEntry(dirPath, entry) {
107
112
  size = st.size;
108
113
  }
109
114
  } catch {}
110
- const sizeSuffix = size ? `, ${size} bytes` : "";
111
- return `${entry.name}${isDir ? "/" : ""} (${typeLabel}${sizeSuffix})`;
115
+ return formatDirectoryEntry(entry.name, typeLabel, size);
112
116
  }
113
117
 
114
118
  function createNativeAdapters(getCwd, vfs, config, index, ledger) {
115
- async function readAdapter(params, signal) {
116
- signal?.throwIfAborted();
117
- const cwd = getCwd();
118
- const targetParam = params?.path ?? params?.target;
119
-
120
- if (Array.isArray(targetParam)) {
121
- const results = await Promise.all(
122
- targetParam.map((p) => readAdapter({ ...params, path: p }, signal)),
123
- );
124
- const items = results.map((r) => r.content[0].text);
125
- return textResult("", { count: results.length, batch: true, items });
126
- }
127
-
128
- if (looksLikePath(targetParam)) {
129
- const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
130
- return readFile(targetPath, params);
131
- }
132
-
133
- const existing = await probeExistingFile(cwd, targetParam, vfs);
134
- if (existing) return readFile(existing, params);
135
-
136
- if (isString(targetParam) && targetParam.trim()) {
137
- try {
138
- const snapRes = await executeSnap({
139
- query: targetParam,
140
- searchDir: cwd,
141
- index,
142
- overlayText: (p) => vfs.getOverlay(p),
143
- pendingPaths: vfs.getOverlayPaths(),
144
- });
145
- return textResult(JSON.stringify(snapRes, null, 2), { ...snapRes, isSnap: true });
146
- } catch {}
147
- }
119
+ async function sourceRead(query, searchDir, signal) {
120
+ const cwd = getCwd();
121
+ const includeHidden = path.relative(cwd, searchDir).split(path.sep)
122
+ .some(segment => segment.startsWith(".") && segment.length > 1);
123
+ const result = await executeSnap({ query, searchDir, root: cwd, includeHidden, index,
124
+ overlayText: p => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), signal });
125
+ return textResult(JSON.stringify(result, null, 2), { ...result, isSnap: true });
126
+ }
127
+
128
+ async function readDirectory(dirPath, signal) {
129
+ signal?.throwIfAborted();
130
+ const rows = new Map();
131
+ for (const file of vfs.getOverlayPaths()) {
132
+ const relative = path.relative(dirPath, file);
133
+ if (!relative || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) continue;
134
+ const [name, child] = relative.split(path.sep);
135
+ rows.set(name, child === undefined
136
+ ? formatDirectoryEntry(name, "file", Buffer.byteLength(vfs.getOverlay(file), "utf8"))
137
+ : formatDirectoryEntry(name, "dir"));
138
+ }
139
+ let entries;
140
+ try { entries = await fs.readdir(dirPath, { withFileTypes: true }); } catch (error) {
141
+ if (error.code !== "ENOENT" || rows.size === 0) throw error;
142
+ entries = [];
143
+ }
144
+ for (const entry of entries) if (!rows.has(entry.name)) rows.set(entry.name, await formatLsEntry(dirPath, entry));
145
+ return textResult([...rows.values()].join("\n"), { path: dirPath, count: rows.size });
146
+ }
148
147
 
149
- const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
150
- return readFile(targetPath, params);
148
+ async function readAdapter(params, signal) {
149
+ signal?.throwIfAborted();
150
+ const cwd = getCwd();
151
+ const targetParam = params?.path ?? params?.target;
152
+ if (Array.isArray(targetParam)) {
153
+ const results = await Promise.all(targetParam.map(p => readAdapter({ ...params, path: p }, signal)));
154
+ return textResult("", { count: results.length, batch: true, items: results.map(r => r.content[0].text) });
155
+ }
156
+ const existing = await probeExistingPath(cwd, targetParam, vfs);
157
+ if (existing) {
158
+ if (!existing.directory) return readFile(existing.path, params);
159
+ return isString(params?.about) ? sourceRead(params.about, existing.path, signal) : readDirectory(existing.path, signal);
160
+ }
161
+ if (!looksLikePath(targetParam)) return sourceRead(targetParam, cwd, signal);
162
+ const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
163
+ return readFile(targetPath, params);
151
164
  }
152
165
 
153
166
  /** Plain text, a line window, or (with `about`) a relevance-folded outline of the whole file. */
@@ -357,6 +370,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
357
370
  index,
358
371
  overlayText: (p) => vfs.getOverlay(p),
359
372
  pendingPaths: vfs.getOverlayPaths(),
373
+ signal,
360
374
  });
361
375
  return textResult(JSON.stringify(res, null, 2), res);
362
376
  },
@@ -464,13 +478,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
464
478
  async ls(params, signal) {
465
479
  const cwd = getCwd();
466
480
  const dirPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "ls", true) : cwd;
467
- if (signal?.aborted) throw new Error("aborted");
468
- const entries = await fs.readdir(dirPath, { withFileTypes: true });
469
- const lines = [];
470
- for (const entry of entries) {
471
- lines.push(await formatLsEntry(dirPath, entry));
472
- }
473
- return textResult(lines.join("\n"), { path: dirPath, count: entries.length });
481
+ return readDirectory(dirPath, signal);
474
482
  },
475
483
  };
476
484
  }
@@ -532,8 +540,12 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
532
540
 
533
541
  function isCallable(name) {
534
542
  if (name === "supernova" || (config.excludeTools ?? []).includes(name)) return false;
543
+ if (hostSession && (hostSession.isDisposed || hostSession.sessionManager.getSessionId() !== boundSessionId)) return false;
544
+ // An internal adapter belongs to Supernova, not the host's visible tool list.
545
+ const nativeOwned = Object.hasOwn(natives, name) && !executors.has(name)
546
+ && (!hostSession || !definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin");
547
+ if (nativeOwned) return true;
535
548
  if (hostSession) {
536
- if (hostSession.isDisposed || hostSession.sessionManager.getSessionId() !== boundSessionId) return false;
537
549
  if (!hostSession.getEvalBridgeToolNames().includes(name) && definitions.has(name)) return false;
538
550
  return !!hostTool(name) || (Object.hasOwn(natives, name) && (!definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin"));
539
551
  }
package/index.js CHANGED
@@ -85,19 +85,19 @@ function successText(outcome, call) {
85
85
  const hint = outcome.undefinedReturn ? " (no return statement; add `return` to get a value)" : "";
86
86
  return `ok #${call} ${outcome.wallMs}ms${truncated}${logsBlock(outcome, "\n--- result")}\n${outcome.resultText}${hint}`;
87
87
  }
88
- const TOOL_DESCRIPTION = `Run one JavaScript program that composes host tools. Async body or arrow; \`return\` a small shaped value (compact literal, capped; strings raw; console.log is captured).
88
+ const TOOL_DESCRIPTION = `Run one JavaScript program with four familiar commands: read, write, edit, bash. Use an async body or arrow. Return a small value; strings stay raw.
89
89
 
90
- Globals (async):
91
- read(path|paths, offset?, limit?) → text | text[] · read(path, {about}) → whole-file outline, only relevant bodies expanded
92
- write(path, text) · edit(path, oldText, newText) → post-edit lines (no re-read needed) · patch(path, unifiedDiff)
93
- bash(cmd, {cwd?, timeoutMs?}) → output, throws on non-zero exit · exec(cmd, argv?) quotes argv
94
- evidence(query, {k?}) → {spans: [{path, lines, name, text}]} top-K spans that answer a question; use before read
95
- snap(query, root?) → {path, line, signature, context} · surface(path) → {items: [{name, kind, line}]}
96
- nova.call(name, args) → {ok, value} for any host tool · nova.callMany([{name, args}]) parallel when read-only
97
- nova.search(query) → [{name, description}] · nova.describe(name) → parameters · nova.has(name) sync
98
- parallel(thunks) · pipeline(items, ...stages)
90
+ Native commands (async):
91
+ read(path|paths, offset?, limit?) → file text or text[]; read(directory) → directory entries
92
+ read("symbol or question") → JSON text with source location and context; no separate search tool needed
93
+ read(path, {about: question}) → relevant file bodies, or source selection inside a directory
94
+ write(path, text) → write a file
95
+ edit(path, oldText, newText) → post-edit lines, checks, and references; no verification read needed
96
+ bash(command, {cwd?, timeoutMs?}) → bounded output; throws on non-zero exit
99
97
 
100
- Already-seen lines collapse to "⋯ N lines same as #12 · path:a–b ⋯"; read(path, a, n) re-shows them.`;
98
+ Source selection reports found, ambiguous, not_found, or incomplete. Only found selects a path. Narrow the directory for uncertain results.
99
+ Optional composition: parallel(thunks), pipeline(items, ...stages), nova.call(name, args), nova.callMany(calls). Use nova.search/describe only for other host tools.
100
+ Already-seen lines collapse to references; read(path, firstLine, lineCount) shows them again. console.log is captured.`;
101
101
 
102
102
  export default function piSupernova(pi) {
103
103
  const config = loadConfig();
@@ -150,9 +150,9 @@ export default function piSupernova(pi) {
150
150
  name: "supernova",
151
151
  label: "Supernova",
152
152
  description: TOOL_DESCRIPTION,
153
- promptSnippet: "Compose host tools in one JavaScript program",
153
+ promptSnippet: "Use read, write, edit, and bash in one program",
154
154
  promptGuidelines: [
155
- "Use supernova for multi-step tool work: loops, filtering, parallel reads, read→edit chains. To understand code: evidence(question) across the repo, or read(path, {about: question}) for one file: full structure, only relevant bodies expanded. Plain read(path) only for lines you will edit. Return a compact shaped value; keep raw tool output inside the program.",
155
+ "Use read, write, edit, and bash inside supernova. Start with read(question), or read(directory, {about: question}) for scoped source selection. Read file bodies with read(file, {about: question}). Check source selection status before using its path. Return a compact value.",
156
156
  ],
157
157
  parameters: Type.Object({
158
158
  code: Type.String({ description: "JavaScript program: async body or arrow function." }),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-supernova",
3
- "version": "0.1.0",
4
- "description": "Dual-host CodeMode for Pi/OMP: progressive tool discovery, result bottleneck, and Amdahl Auto parallel.",
3
+ "version": "0.2.0",
4
+ "description": "CodeMode for Pi and OMP: read, write, edit, and bash with source selection and bounded results.",
5
5
  "type": "module",
6
6
  "author": "AdityaVG13",
7
7
  "license": "MIT",
package/repo-index.js CHANGED
@@ -150,7 +150,7 @@ export class WorkspaceIndex {
150
150
  }
151
151
 
152
152
  /** Absolute, sorted file list for a root; gitignore-aware via rg; cached for LIST_TTL_MS. */
153
- async files(root, includeHidden = false) {
153
+ async files(root, includeHidden = false, signal) {
154
154
  const key = root + "\0" + (includeHidden ? "h" : "");
155
155
  const cached = this.lists.get(key);
156
156
  const ttl = this.watch(root) ? WATCHED_TTL_MS : LIST_TTL_MS;
@@ -159,13 +159,21 @@ export class WorkspaceIndex {
159
159
  if (includeHidden) args.push("--hidden");
160
160
  args.push("-g", "!.git/**", "-g", "!**/.git/**", root);
161
161
  let files = [];
162
+ let error;
163
+ let truncated = false;
164
+ let missing = false;
162
165
  try {
163
- const res = await this.runCommand(args, { cwd: root, timeoutMs: 15_000 });
164
- files = res.stdout.split("\n").map((f) => f.trim()).filter(Boolean).map((f) => path.resolve(root, f)).sort();
165
- } catch {
166
- files = [];
166
+ const res = await this.runCommand(args, { cwd: root, timeoutMs: 15_000, signal });
167
+ if (res.exitCode !== 0 && res.exitCode !== 1) error = res.stderr.trim() || "rg exited with status " + res.exitCode;
168
+ truncated = res.outputTruncated === true;
169
+ const output = truncated && !res.stdout.endsWith("\n") ? res.stdout.slice(0, res.stdout.lastIndexOf("\n") + 1) : res.stdout;
170
+ files = output.split("\n").filter(Boolean).map(f => path.resolve(root, f)).sort();
171
+ } catch (err) {
172
+ signal?.throwIfAborted();
173
+ error = err.message;
174
+ missing = !fs.existsSync(root);
167
175
  }
168
- this.lists.set(key, { files, at: Date.now() });
176
+ this.lists.set(key, { files, at: Date.now(), error, truncated, missing });
169
177
  return files;
170
178
  }
171
179
 
package/snap.js CHANGED
@@ -1,7 +1,8 @@
1
-
2
1
  import * as path from "node:path";
3
2
  import { isString } from "./decode.js";
3
+ import { truncateChars } from "./format.js";
4
4
  import { WorkspaceIndex } from "./repo-index.js";
5
+ import { extractStructuralSurface } from "./surface.js";
5
6
  import { isTestPath } from "./workspace.js";
6
7
 
7
8
  const STOP_WORDS = new Set([
@@ -9,240 +10,208 @@ const STOP_WORDS = new Set([
9
10
  "by", "from", "is", "it", "this", "that", "where", "how", "what", "which",
10
11
  "file", "code", "function", "class", "method", "find", "get", "look",
11
12
  ]);
13
+ const SOURCE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".rs", ".py", ".go"]);
14
+ const TYPED_EXT = new Set([".ts", ".tsx", ".rs", ".go"]);
15
+ const MAX_SEARCH_CHARS = 2 * 1024 * 1024;
16
+ const MAX_ALTERNATIVES = 3;
12
17
 
13
18
  export function tokenizeQuery(query) {
14
- if (!isString(query) || !query.trim()) {
15
- return { tokens: [], wantsTest: false, wantsType: false, wantsDoc: false };
16
- }
17
-
18
- const raw = query
19
- .replace(/([a-z])([A-Z])/g, "$1 $2")
20
- .toLowerCase()
21
- .split(/[^a-zA-Z0-9_]+/);
22
-
23
- const tokens = raw.filter((t) => t.length > 1 && !STOP_WORDS.has(t));
24
- const queryLower = query.toLowerCase();
25
-
19
+ if (!isString(query) || !query.trim()) return { tokens: [], wantsTest: false, wantsType: false, wantsDoc: false };
20
+ const words = query.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-zA-Z0-9_]+/);
26
21
  return {
27
- tokens: [...new Set(tokens)],
28
- wantsTest: queryLower.includes("test") || queryLower.includes("spec"),
29
- wantsType: queryLower.includes("type") || queryLower.includes("interface") || queryLower.includes("schema"),
30
- wantsDoc: queryLower.includes("doc") || queryLower.includes("readme"),
22
+ tokens: [...new Set(words.filter(word => word.length > 1 && !STOP_WORDS.has(word)))],
23
+ wantsTest: words.some(word => ["test", "tests", "testing", "spec", "specs"].includes(word)),
24
+ wantsType: words.some(word => ["type", "types", "interface", "interfaces", "schema", "schemas"].includes(word)),
25
+ wantsDoc: words.some(word => ["doc", "docs", "documentation", "readme"].includes(word)),
31
26
  };
32
27
  }
33
28
 
34
- const SOURCE_EXT = new Set([".ts", ".js", ".mjs", ".rs", ".py", ".go"]);
35
- const TYPED_EXT = new Set([".ts", ".d.ts", ".rs", ".go"]);
36
- const VENDOR_SEGMENTS = ["node_modules/", "dist/", "target/"];
37
-
38
- function tokenPathScore(token, basename, pathParts, norm) {
39
- if (basename === token || basename.startsWith(token + ".")) return 60;
40
- if (basename.includes(token)) return 30;
41
- if (pathParts.includes(token)) return 15;
42
- if (norm.includes(token)) return 5;
43
- return 0;
44
- }
45
-
46
- function extensionBonus(ext, { wantsDoc, wantsType }) {
47
- let bonus = 0;
48
- if (SOURCE_EXT.has(ext) && !wantsDoc) bonus += 5;
49
- if (wantsType && TYPED_EXT.has(ext)) bonus += 10;
50
- return bonus;
51
- }
52
-
53
29
  export function scorePathTopology(filePath, tokens, flags) {
54
- const norm = filePath.replaceAll("\\", "/").toLowerCase();
55
- const isTest = norm.includes("test") || norm.includes("spec") || norm.includes("__tests__");
56
- if (isTest && !flags.wantsTest) return -50;
57
- if (!isTest && flags.wantsTest) return -20;
58
- if (VENDOR_SEGMENTS.some((segment) => norm.includes(segment))) return -100;
59
-
60
- const basename = path.basename(norm);
61
- const pathParts = norm.split(/[^a-zA-Z0-9]+/);
62
- let score = extensionBonus(path.extname(norm), flags);
63
- for (const token of tokens) score += tokenPathScore(token, basename, pathParts, norm);
64
- return score;
65
- }
66
-
67
- function isSkippableLine(lower) {
68
- return !lower || lower.startsWith("//") || lower.startsWith("#") || lower.startsWith("*");
69
- }
70
-
71
- /** A line defines a token only when the declared name contains it; `const x = foo(token)` is a mention. */
72
- function lineScoreFor(lower, tokens, definedName) {
73
- let lineScore = 0;
30
+ const normalized = filePath.replaceAll("\\", "/").toLowerCase();
31
+ const parts = normalized.split("/");
32
+ if (parts.some(part => ["node_modules", "dist", "target"].includes(part))) return -100;
33
+ const test = isTestPath(normalized);
34
+ if (test && !flags.wantsTest) return -50;
35
+ if (!test && flags.wantsTest) return -20;
36
+ const base = path.basename(normalized);
37
+ const words = normalized.split(/[^a-zA-Z0-9]+/);
38
+ const ext = path.extname(normalized);
39
+ let score = SOURCE_EXT.has(ext) && !flags.wantsDoc ? 5 : 0;
40
+ if (flags.wantsType && TYPED_EXT.has(ext)) score += 10;
74
41
  for (const token of tokens) {
75
- if (!lower.includes(token)) continue;
76
- lineScore += definedName.includes(token) ? 40 : 5;
42
+ if (base === token || base.startsWith(token + ".")) score += 60;
43
+ else if (base.includes(token)) score += 30;
44
+ else if (words.includes(token)) score += 15;
45
+ else if (normalized.includes(token)) score += 5;
77
46
  }
78
- return lineScore;
47
+ return score;
79
48
  }
80
49
 
81
- // Mentions are capped so a file that calls a symbol many times cannot outrank the file that defines it.
82
- const MAX_MENTION_SCORE = 60;
83
-
84
- function scoreContentDefinitions(entry, tokens) {
85
- const { lower, defNames } = WorkspaceIndex.linesOf(entry);
86
- let defScore = 0;
87
- let mentionScore = 0;
88
- let bestLine = 1;
89
- let bestLineScore = 0;
90
- for (let i = 0; i < lower.length; i++) {
91
- if (isSkippableLine(lower[i])) continue;
92
- const lineScore = lineScoreFor(lower[i], tokens, defNames[i]);
93
- if (lineScore > bestLineScore) {
94
- bestLineScore = lineScore;
95
- bestLine = i + 1;
50
+ function inScope(filePath, dir, includeHidden) {
51
+ const relative = path.relative(dir, filePath);
52
+ if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) return false;
53
+ const parts = relative.split(path.sep);
54
+ return !parts.includes(".git") && (includeHidden || !parts.some(part => part.startsWith(".") && part.length > 1));
55
+ }
56
+
57
+ function makeCandidate(filePath, dir, query, tokens, flags) {
58
+ const relative = path.relative(dir, filePath);
59
+ const lower = relative.toLowerCase();
60
+ const base = path.basename(lower);
61
+ const exactPath = lower === query.toLowerCase() || base === query.toLowerCase()
62
+ || base.slice(0, -path.extname(base).length) === query.toLowerCase();
63
+ return { path: filePath, pathScore: scorePathTopology(relative, tokens, flags), exactPath,
64
+ pathCoverage: tokens.filter(token => lower.includes(token)).length,
65
+ matched: new Set(), exactDefinition: false, definitionCoverage: 0, lineCoverage: 0,
66
+ line: 1, signature: "", context: new Map(), recent: [], anchorScore: -1 };
67
+ }
68
+
69
+ function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
70
+ const text = raw.replace(/\r?\n$/, "");
71
+ const lower = text.toLowerCase();
72
+ if (isMatch) {
73
+ const matches = tokens.filter(token => lower.includes(token));
74
+ for (const token of matches) candidate.matched.add(token);
75
+ const ext = path.extname(candidate.path).toLowerCase();
76
+ const items = SOURCE_EXT.has(ext) ? extractStructuralSurface(text, ext).items : [];
77
+ let declaration;
78
+ let definitionCoverage = 0;
79
+ let exact = false;
80
+ for (const item of items) {
81
+ const name = item.name.toLowerCase();
82
+ const itemExact = name === query.toLowerCase();
83
+ const coverage = tokens.filter(token => name.includes(token)).length;
84
+ if (itemExact || coverage > definitionCoverage) { declaration = item; definitionCoverage = coverage; exact = itemExact; }
85
+ if (exact) break;
86
+ }
87
+ const score = (exact ? 10000 : 0) + definitionCoverage * 40 + matches.length;
88
+ if (score > candidate.anchorScore) {
89
+ candidate.anchorScore = score;
90
+ candidate.line = lineNumber;
91
+ candidate.signature = truncateChars(declaration?.signature ?? "", 240, "signature").text;
92
+ candidate.exactDefinition = exact;
93
+ candidate.definitionCoverage = definitionCoverage;
94
+ candidate.lineCoverage = matches.length;
95
+ candidate.context.clear();
96
+ for (const [number, line] of candidate.recent) if (number >= lineNumber - 2) candidate.context.set(number, line);
96
97
  }
97
- if (defNames[i]) defScore += lineScore;
98
- else mentionScore += lineScore;
99
- }
100
- return { totalScore: defScore + Math.min(mentionScore, MAX_MENTION_SCORE), bestLine, bestLineScore };
101
- }
102
-
103
- function relativeHasSegment(relativePath, segmentName) {
104
- return relativePath.split(path.sep).includes(segmentName);
105
- }
106
-
107
- function relativeHasHiddenSegment(relativePath) {
108
- return relativePath.split(path.sep).some((segment) => segment.startsWith(".") && segment.length > 1);
109
- }
110
-
111
- async function listCandidateFiles(dir, includeHidden, index) {
112
- return (await index.files(dir, includeHidden)).slice();
113
- }
114
-
115
- function mergePendingPaths(fileList, pendingPaths, dir, includeHidden = false) {
116
- const resolvedDir = path.resolve(dir);
117
- const seenPaths = new Set(fileList.map((filePath) => path.resolve(filePath)));
118
- for (const pendingPath of pendingPaths) {
119
- const absolutePath = path.resolve(pendingPath);
120
- const relativePath = path.relative(resolvedDir, absolutePath);
121
- const escapesDir = relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
122
- const hiddenRelativePath = relativeHasHiddenSegment(relativePath);
123
- if (escapesDir || relativeHasSegment(relativePath, ".git") || (!includeHidden && hiddenRelativePath) || seenPaths.has(absolutePath)) continue;
124
- seenPaths.add(absolutePath);
125
- fileList.push(absolutePath);
126
- }
127
- return fileList;
128
- }
129
-
130
- function mergeGrepHits(candidates, grepHits) {
131
- const seen = new Set(candidates);
132
- for (const h of grepHits) {
133
- if (seen.has(h)) continue;
134
- seen.add(h);
135
- candidates.push(h);
136
- if (candidates.length >= 15) break;
137
98
  }
138
- return candidates;
139
- }
140
-
141
- function expandCandidatesWithGrep(candidates, fileList, tokens, flags, index) {
142
- if (candidates.length >= 5) return candidates;
143
- const salient = tokens.filter((t) => t.length > 2).slice(0, 4);
144
- const scope = flags.wantsTest ? fileList : fileList.filter((f) => !isTestPath(f));
145
- const hits = index.filesContaining(scope, salient, true);
146
- mergeGrepHits(candidates, hits);
147
- if (candidates.length === 0) return fileList.slice(0, 5);
148
- return candidates;
149
- }
150
-
151
- function scoreSurfaceItems(items, tokens, fallbackLine) {
152
- let bonus = 0;
153
- let best = null;
154
- let bestMatches = 0;
155
- for (const item of items) {
156
- const nameLower = item.name.toLowerCase();
157
- const matches = tokens.filter((token) => nameLower.includes(token)).length;
158
- if (matches === 0) continue;
159
- bonus += matches * (item.isExport ? 80 : 50);
160
- if (matches > bestMatches) {
161
- bestMatches = matches;
162
- best = item;
99
+ const excerpt = truncateChars(text, 240, "source line").text;
100
+ if (lineNumber >= candidate.line - 2 && lineNumber <= candidate.line + 4) candidate.context.set(lineNumber, excerpt);
101
+ candidate.recent.push([lineNumber, excerpt]);
102
+ if (candidate.recent.length > 2) candidate.recent.shift();
103
+ }
104
+
105
+ function inspectOverlay(candidate, text, needles, query, tokens) {
106
+ const lines = text.split("\n");
107
+ const matches = [];
108
+ for (let i = 0; i < lines.length; i++) if (needles.some(needle => lines[i].toLowerCase().includes(needle))) matches.push(i);
109
+ for (const i of matches) inspectLine(candidate, i + 1, lines[i], query, tokens, true);
110
+ candidate.context.clear();
111
+ for (let i = Math.max(0, candidate.line - 3); i < Math.min(lines.length, candidate.line + 4); i++) candidate.context.set(i + 1, truncateChars(lines[i], 240, "source line").text);
112
+ }
113
+
114
+ async function contentCandidates({ dir, includeHidden, query, tokens, flags, fileSet, index, overlayText, signal, exact, diskFiles }) {
115
+ const needles = exact ? [query.toLowerCase()] : tokens;
116
+ const args = ["rg", "--json", "--fixed-strings", "--ignore-case", "--before-context", "2", "--after-context", "4"];
117
+ if (includeHidden) args.push("--hidden");
118
+ args.push("-g", "!.git/**", "-g", "!**/.git/**");
119
+ for (const needle of needles) args.push("-e", needle);
120
+ args.push("--", dir);
121
+ const response = diskFiles ? await index.runCommand(args, { cwd: dir, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS, signal })
122
+ : { stdout: "", stderr: "", exitCode: 1 };
123
+ if (response.exitCode !== 0 && response.exitCode !== 1) throw new Error("source search failed: " + response.stderr.trim());
124
+ const candidates = new Map();
125
+ const records = response.stdout.split("\n");
126
+ for (let i = 0; i < records.length; i++) {
127
+ if (!records[i]) continue;
128
+ let record;
129
+ try { record = JSON.parse(records[i]); } catch (error) {
130
+ if (response.outputTruncated && i === records.length - 1) break;
131
+ throw error;
163
132
  }
133
+ if (record.type !== "match" && record.type !== "context") continue;
134
+ const data = record.data;
135
+ if (!data?.path?.text || !isString(data.lines?.text)) continue;
136
+ const filePath = path.resolve(dir, data.path.text);
137
+ if (!fileSet.has(filePath) || overlayText(filePath) !== undefined) continue;
138
+ let candidate = candidates.get(filePath);
139
+ if (!candidate) {
140
+ candidate = makeCandidate(filePath, dir, query, tokens, flags);
141
+ candidates.set(filePath, candidate);
142
+ }
143
+ inspectLine(candidate, data.line_number, data.lines.text, query, tokens, record.type === "match");
164
144
  }
165
- return { bonus, signature: best?.signature ?? "", anchorLine: best?.line ?? fallbackLine };
166
- }
167
-
168
- function scoreCandidateContents(candidates, tokens, flags, index, overlayText) {
169
- const candidateScores = [];
170
- for (const filePath of candidates) {
145
+ for (const filePath of fileSet) {
171
146
  const pending = overlayText(filePath);
172
- const entry = pending === undefined ? index.entry(filePath) : WorkspaceIndex.fromText(filePath, pending);
173
- if (!entry) continue;
174
- const content = entry.text;
175
- const { totalScore, bestLine, bestLineScore } = scoreContentDefinitions(entry, tokens);
176
- const surface = WorkspaceIndex.surfaceOf(entry);
177
- const { bonus: surfaceBonus, signature, anchorLine } = scoreSurfaceItems(surface.items, tokens, bestLine);
178
- const lowerPath = filePath.toLowerCase();
179
- const isTestFile = lowerPath.includes("test") || lowerPath.includes("spec");
180
- const testAdjustment = !isTestFile ? 0 : (flags.wantsTest ? 100 : -200);
181
- candidateScores.push({
182
- path: filePath,
183
- score: totalScore + surfaceBonus + (bestLineScore * 2) + testAdjustment,
184
- anchorLine,
185
- signature,
186
- content,
187
- });
147
+ if (pending === undefined) continue;
148
+ const candidate = makeCandidate(filePath, dir, query, tokens, flags);
149
+ inspectOverlay(candidate, pending, needles, query, tokens);
150
+ if (candidate.matched.size) candidates.set(filePath, candidate);
188
151
  }
189
- candidateScores.sort((a, b) => b.score - a.score);
190
- return candidateScores;
152
+ return { candidates, truncated: response.outputTruncated === true };
191
153
  }
192
154
 
193
- function rankCandidates(fileList, tokens, flags, index, overlayText) {
194
- const scoredPaths = [];
195
- for (const f of fileList) {
196
- const score = scorePathTopology(f, tokens, flags);
197
- if (score > 0) scoredPaths.push({ path: f, score });
198
- }
199
- scoredPaths.sort((a, b) => b.score - a.score);
200
- const selected = scoredPaths.filter((p) => p.score >= 25).slice(0, 10).map((p) => p.path);
201
- const candidates = expandCandidatesWithGrep(selected, fileList, tokens, flags, index);
202
- const candidateScores = scoreCandidateContents(candidates, tokens, flags, index, overlayText);
203
- return { candidates, candidateScores };
155
+ function rankScore(candidate, tokenCount) {
156
+ return (candidate.exactDefinition ? 10000 : 0) + (candidate.exactPath ? 500 : 0)
157
+ + candidate.definitionCoverage / tokenCount * 100 + candidate.matched.size / tokenCount * 30
158
+ + candidate.pathCoverage / tokenCount * 20 + candidate.lineCoverage / tokenCount * 10
159
+ + Math.max(-40, Math.min(20, candidate.pathScore / 5));
204
160
  }
205
161
 
206
- function buildSnapResult(candidates, candidateScores, fileList, root) {
207
- const relative = (p) => path.relative(root, p) || p;
208
- if (candidateScores.length === 0 || candidateScores[0].score <= 0) {
209
- return { path: relative(candidates[0] || fileList[0]), line: 1, signature: "", confidence: 0.3, context: [] };
210
- }
211
- const best = candidateScores[0];
212
- const lines = best.content.split("\n");
213
- // Two lines before and four after: enough to confirm the hit; read() is the tool for more.
214
- const startLine = Math.max(1, best.anchorLine - 2);
215
- const endLine = Math.min(lines.length, best.anchorLine + 4);
216
- const context = [];
217
- for (let l = startLine; l <= endLine; l++) {
218
- const marker = l === best.anchorLine ? "►" : " ";
219
- context.push(marker + l + " " + lines[l - 1]);
220
- }
221
- const confidence = Math.min(0.98, Math.max(0.65, best.score / 150));
222
- return {
223
- path: relative(best.path),
224
- line: best.anchorLine,
225
- signature: best.signature,
226
- confidence: Number(confidence.toFixed(2)),
227
- context,
228
- };
229
- }
230
-
231
- export async function executeSnap({ query, searchDir, root, includeHidden = false, index, overlayText = () => undefined, pendingPaths = [] }) {
232
- const { tokens, wantsTest, wantsType, wantsDoc } = tokenizeQuery(query);
233
- if (tokens.length === 0) {
234
- throw new Error("snap requires at least one searchable concept keyword");
235
- }
236
- const dir = searchDir || process.cwd();
237
- if (path.resolve(dir).split(path.sep).includes(".git")) {
238
- throw new Error("snap cannot search Git metadata");
162
+ function location(candidate, root, index, overlayText) {
163
+ let context = candidate.context;
164
+ if (context.size === 0) {
165
+ const pending = overlayText(candidate.path);
166
+ const entry = pending === undefined ? index.entry(candidate.path) : WorkspaceIndex.fromText(candidate.path, pending);
167
+ const lines = entry?.text.split("\n") ?? [];
168
+ context = new Map(lines.slice(0, 7).map((line, i) => [i + 1, truncateChars(line, 240, "source line").text]));
239
169
  }
240
- const fileList = await listCandidateFiles(dir, includeHidden, index);
241
- mergePendingPaths(fileList, pendingPaths, dir, includeHidden);
242
- if (fileList.length === 0) {
243
- throw new Error(`no files found to search in ${dir}`);
170
+ return { path: path.relative(root, candidate.path), line: candidate.line, signature: candidate.signature,
171
+ context: [...context].sort((a, b) => a[0] - b[0]).map(([line, text]) => (line === candidate.line ? "►" : " ") + line + " " + text) };
172
+ }
173
+
174
+ export async function executeSnap({ query, searchDir, root, includeHidden = false, index, overlayText = () => undefined, pendingPaths = [], signal }) {
175
+ const flags = tokenizeQuery(query);
176
+ const { tokens } = flags;
177
+ if (tokens.length === 0) throw new Error("read requires a file path or a searchable source question");
178
+ if (tokens.length > 16) throw new Error("source question is too broad; use at most 16 keywords");
179
+ query = query.trim();
180
+ const dir = path.resolve(searchDir || process.cwd());
181
+ if (dir.split(path.sep).includes(".git")) throw new Error("cannot search Git metadata");
182
+ signal?.throwIfAborted();
183
+ flags.wantsTest ||= isTestPath(path.relative(root ?? dir, dir));
184
+ const files = await index.files(dir, includeHidden, signal);
185
+ const fileSet = new Set(files.filter(file => inScope(file, dir, includeHidden)));
186
+ for (const pending of pendingPaths) if (inScope(pending, dir, includeHidden)) fileSet.add(path.resolve(pending));
187
+ const listing = index.lists.get(dir + "\0" + (includeHidden ? "h" : ""));
188
+ if (listing?.error && !(listing.missing && fileSet.size)) throw new Error("source file listing failed: " + listing.error);
189
+ const empty = { path: null, line: null, signature: "", confidence: 0, context: [] };
190
+ if (fileSet.size === 0 && !listing?.truncated) return { ...empty, status: "not_found" };
191
+ const exact = /^[a-zA-Z_$][\w$]*$/.test(query);
192
+ const search = await contentCandidates({ dir, includeHidden, query, tokens, flags, fileSet, index, overlayText, signal, exact, diskFiles: files.length });
193
+ for (const filePath of fileSet) {
194
+ if (search.candidates.has(filePath)) continue;
195
+ const relative = path.relative(dir, filePath).toLowerCase();
196
+ if (!tokens.some(token => relative.includes(token))) continue;
197
+ if (exact && tokens.length > 1 && !relative.includes(query.toLowerCase())) continue;
198
+ const candidate = makeCandidate(filePath, dir, query, tokens, flags);
199
+ if (candidate.pathScore > 0) search.candidates.set(filePath, candidate);
244
200
  }
245
- const flags = { wantsTest, wantsDoc, wantsType };
246
- const { candidates, candidateScores } = rankCandidates(fileList, tokens, flags, index, overlayText);
247
- return buildSnapResult(candidates, candidateScores, fileList, root ?? dir);
201
+ const ranked = [...search.candidates.values()].filter(candidate => candidate.pathScore > -50)
202
+ .map(candidate => ({ ...candidate, score: rankScore(candidate, tokens.length) }))
203
+ .sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
204
+ const incomplete = search.truncated || listing?.truncated === true;
205
+ const relativeRoot = root ?? dir;
206
+ const candidates = ranked.slice(0, MAX_ALTERNATIVES).map(candidate => location(candidate, relativeRoot, index, overlayText));
207
+ if (incomplete) return { ...empty, status: "incomplete", candidates, message: "Search output exceeded its budget. Narrow the directory with read(path, {about: question})." };
208
+ if (!ranked.length) return { ...empty, status: "not_found" };
209
+ const best = ranked[0];
210
+ const second = ranked[1];
211
+ const margin = second ? (best.score - second.score) / Math.max(1, best.score) : 1;
212
+ const coverage = Math.max(best.matched.size, best.pathCoverage) / tokens.length;
213
+ const uniqueExact = best.exactDefinition && !second?.exactDefinition || best.exactPath && !second?.exactPath && !second?.exactDefinition;
214
+ if (!uniqueExact && (coverage < 0.6 || margin < 0.15 || best.definitionCoverage / tokens.length < 0.5)) return { ...empty, status: "ambiguous", candidates };
215
+ const confidence = uniqueExact ? 0.95 : Math.min(0.85, 0.5 + coverage * 0.2 + margin * 0.15);
216
+ return { ...candidates[0], status: "found", confidence: Number(confidence.toFixed(2)) };
248
217
  }