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 +13 -0
- package/README.md +47 -24
- package/catalog.js +4 -4
- package/host-bridge.js +62 -50
- package/index.js +13 -13
- package/package.json +2 -2
- package/repo-index.js +14 -6
- package/snap.js +183 -214
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
|
[](https://nodejs.org)
|
|
6
6
|
[](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
|
|
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
|
-
|
|
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 |
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
+
The confidence value is a ranking heuristic, not a measured probability. Duplicate declarations do not produce a confident winner.
|
|
62
73
|
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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?)` |
|
|
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
|
-
|
|
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 "…"` |
|
|
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 (`
|
|
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
|
|
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
|
|
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:
|
|
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: "
|
|
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
|
|
59
|
-
const targetPath = await resolveWorkspacePath(cwd, targetParam, "read",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
91
|
-
read(path|paths, offset?, limit?) → text
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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: "
|
|
153
|
+
promptSnippet: "Use read, write, edit, and bash in one program",
|
|
154
154
|
promptGuidelines: [
|
|
155
|
-
"Use
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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(
|
|
28
|
-
wantsTest:
|
|
29
|
-
wantsType:
|
|
30
|
-
wantsDoc:
|
|
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
|
|
55
|
-
const
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 (
|
|
76
|
-
|
|
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
|
|
47
|
+
return score;
|
|
79
48
|
}
|
|
80
49
|
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
190
|
-
return candidateScores;
|
|
152
|
+
return { candidates, truncated: response.outputTruncated === true };
|
|
191
153
|
}
|
|
192
154
|
|
|
193
|
-
function
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
207
|
-
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
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
|
}
|