pi-supernova 0.1.0 → 0.3.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/README.md +196 -173
- package/{CHANGELOG.md → docs/CHANGELOG.md} +57 -1
- package/index.js +88 -75
- package/package.json +12 -31
- package/{catalog.js → src/bridge/catalog.js} +9 -7
- package/{host-bridge.js → src/bridge/host-bridge.js} +280 -123
- package/src/bridge/native-tools.js +155 -0
- package/src/bridge/pi-extension.ts +2 -0
- package/{config.js → src/config/config.js} +1 -1
- package/{evidence.js → src/context/evidence.js} +23 -12
- package/{outline.js → src/context/outline.js} +1 -1
- package/{repo-index.js → src/context/repo-index.js} +25 -15
- package/{search.js → src/context/search.js} +34 -1
- package/src/context/snap.js +237 -0
- package/{surface.js → src/context/surface.js} +1 -1
- package/{diff.js → src/fs/diff.js} +7 -5
- package/{patch.js → src/fs/patch.js} +1 -1
- package/{vfs.js → src/fs/vfs.js} +55 -14
- package/{workspace.js → src/fs/workspace.js} +22 -6
- package/{bottleneck.js → src/output/bottleneck.js} +23 -6
- package/{format.js → src/output/format.js} +20 -1
- package/{guest-worker.js → src/runtime/guest-worker.js} +90 -21
- package/{parallel.js → src/runtime/parallel.js} +68 -1
- package/{runtime.js → src/runtime/runtime.js} +14 -5
- package/{omp-frame.js → src/ui/omp-frame.js} +1 -1
- package/{render-measure.js → src/ui/render-measure.js} +27 -1
- package/{render.js → src/ui/render.js} +42 -20
- package/snap.js +0 -248
- /package/{config.default.json → src/config/config.default.json} +0 -0
- /package/{fuzzy.js → src/context/fuzzy.js} +0 -0
- /package/{ledger.js → src/context/ledger.js} +0 -0
- /package/{check.js → src/fs/check.js} +0 -0
- /package/{decode.js → src/shared/decode.js} +0 -0
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { packageHostResult, hostResultFailed } from "../output/bottleneck.js";
|
|
6
|
+
import { isString, isNumber, isFunction, isObject } from "../shared/decode.js";
|
|
7
|
+
import { isMutatingTool, runParallelWave, createNativeScheduler } from "../runtime/parallel.js";
|
|
7
8
|
import { unknownToolMessage } from "./catalog.js";
|
|
8
|
-
import { extractStructuralSurface } from "
|
|
9
|
-
import { buildEditDiff, buildMultiEditDiff, buildPatchDiff, buildWriteDiff } from "
|
|
10
|
-
import { executeSnap } from "
|
|
11
|
-
import { selectEvidence } from "
|
|
12
|
-
import { WorkspaceIndex } from "
|
|
13
|
-
import { outlineFile } from "
|
|
14
|
-
import { SeenLedger } from "
|
|
15
|
-
import { quickCheck } from "
|
|
16
|
-
import { declaredName } from "
|
|
17
|
-
import { CausalVfs } from "
|
|
18
|
-
import { applyPatchToText } from "
|
|
19
|
-
import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash } from "
|
|
20
|
-
import { fuzzyFind, grepIndexed, listIndexed, listWithTools, rgGrepArgs } from "
|
|
9
|
+
import { extractStructuralSurface } from "../context/surface.js";
|
|
10
|
+
import { buildEditDiff, buildMultiEditDiff, buildPatchDiff, buildWriteDiff } from "../fs/diff.js";
|
|
11
|
+
import { executeSnap } from "../context/snap.js";
|
|
12
|
+
import { selectEvidence } from "../context/evidence.js";
|
|
13
|
+
import { WorkspaceIndex } from "../context/repo-index.js";
|
|
14
|
+
import { outlineFile } from "../context/outline.js";
|
|
15
|
+
import { SeenLedger } from "../context/ledger.js";
|
|
16
|
+
import { quickCheck } from "../fs/check.js";
|
|
17
|
+
import { declaredName } from "../context/repo-index.js";
|
|
18
|
+
import { CausalVfs } from "../fs/vfs.js";
|
|
19
|
+
import { applyPatchToText } from "../fs/patch.js";
|
|
20
|
+
import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash } from "../fs/workspace.js";
|
|
21
|
+
import { fuzzyFind, grepIndexed, listIndexed, listWithTools, rgGrepArgs, referencesForNames } from "../context/search.js";
|
|
21
22
|
|
|
22
23
|
function textResult(text, details) {
|
|
23
24
|
return {
|
|
@@ -55,15 +56,21 @@ function looksLikePath(target) {
|
|
|
55
56
|
);
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
function resolveReadPath(cwd, target) {
|
|
60
|
+
if (!isString(target) || !target.trim()) throw new Error("read requires path");
|
|
61
|
+
const input = target.trim();
|
|
62
|
+
return path.resolve(cwd, input === "~" ? homedir() : input.startsWith("~/") ? path.join(homedir(), input.slice(2)) : input);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function probeExistingPath(cwd, targetParam, vfs) {
|
|
66
|
+
const targetPath = resolveReadPath(cwd, targetParam);
|
|
67
|
+
if (vfs.getOverlay(targetPath) !== undefined) return { path: targetPath, directory: false };
|
|
61
68
|
try {
|
|
62
69
|
const st = await fs.stat(targetPath);
|
|
63
|
-
|
|
64
|
-
return targetPath;
|
|
70
|
+
return { path: targetPath, directory: st.isDirectory() };
|
|
65
71
|
} catch (err) {
|
|
66
72
|
if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
|
|
73
|
+
if (vfs.getOverlayPaths().some(file => file.startsWith(targetPath + path.sep))) return { path: targetPath, directory: true };
|
|
67
74
|
return null;
|
|
68
75
|
}
|
|
69
76
|
}
|
|
@@ -96,6 +103,11 @@ function applyReplacements(target, content, requestedEdits) {
|
|
|
96
103
|
return { updated, matches };
|
|
97
104
|
}
|
|
98
105
|
|
|
106
|
+
function formatDirectoryEntry(name, type, size = 0) {
|
|
107
|
+
const sizeSuffix = size ? `, ${size} bytes` : "";
|
|
108
|
+
return `${name}${type === "dir" ? "/" : ""} (${type}${sizeSuffix})`;
|
|
109
|
+
}
|
|
110
|
+
|
|
99
111
|
async function formatLsEntry(dirPath, entry) {
|
|
100
112
|
const isDir = entry.isDirectory();
|
|
101
113
|
const isSym = entry.isSymbolicLink();
|
|
@@ -107,58 +119,109 @@ async function formatLsEntry(dirPath, entry) {
|
|
|
107
119
|
size = st.size;
|
|
108
120
|
}
|
|
109
121
|
} catch {}
|
|
110
|
-
|
|
111
|
-
return `${entry.name}${isDir ? "/" : ""} (${typeLabel}${sizeSuffix})`;
|
|
122
|
+
return formatDirectoryEntry(entry.name, typeLabel, size);
|
|
112
123
|
}
|
|
113
124
|
|
|
114
|
-
function createNativeAdapters(getCwd, vfs, config, index, ledger) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
}
|
|
125
|
+
function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
|
|
126
|
+
const reads = createNativeScheduler();
|
|
127
|
+
async function sourceRead(query, searchDir, signal, params = {}) {
|
|
128
|
+
const cwd = getCwd();
|
|
129
|
+
const includeHidden = path.relative(cwd, searchDir).split(path.sep)
|
|
130
|
+
.some(segment => segment.startsWith(".") && segment.length > 1);
|
|
131
|
+
const result = await executeSnap({ query, searchDir, root: cwd, includeHidden,
|
|
132
|
+
pathContext: { frecency: index.frecency, currentFile: index.lastTouched },
|
|
133
|
+
overlayText: p => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), signal });
|
|
134
|
+
return openSource(result, params, signal);
|
|
135
|
+
}
|
|
132
136
|
|
|
133
|
-
|
|
134
|
-
|
|
137
|
+
async function openSource(result, params, signal) {
|
|
138
|
+
const cwd = getCwd();
|
|
139
|
+
if (result.status !== "found") return textResult(JSON.stringify(result), { isSnap: true });
|
|
140
|
+
signal?.throwIfAborted();
|
|
141
|
+
const opened = await readFile(path.resolve(cwd, result.path), { ...params, about: undefined }, result.line);
|
|
142
|
+
const block = opened.content[0];
|
|
143
|
+
if (block.type !== "text") throw new Error("source resolution requires a text file; read the image path directly");
|
|
144
|
+
const { firstLine, lastLine, sourceChars, nextOffset, complete } = opened.details;
|
|
145
|
+
const source = { status: "found", path: result.path, line: result.line, lines: [firstLine, lastLine],
|
|
146
|
+
text: block.text.slice(0, sourceChars), complete, nextOffset };
|
|
147
|
+
return textResult(params.resolve ? JSON.stringify(source) : "// " + result.path + ":" + firstLine + "-" + lastLine + "\n" + block.text,
|
|
148
|
+
{ ...opened.details, isSnap: true });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function readDirectory(dirPath, signal) {
|
|
152
|
+
signal?.throwIfAborted();
|
|
153
|
+
const rows = new Map();
|
|
154
|
+
for (const file of vfs.getOverlayPaths()) {
|
|
155
|
+
const relative = path.relative(dirPath, file);
|
|
156
|
+
if (!relative || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) continue;
|
|
157
|
+
const [name, child] = relative.split(path.sep);
|
|
158
|
+
rows.set(name, child === undefined
|
|
159
|
+
? formatDirectoryEntry(name, "file", Buffer.byteLength(vfs.getOverlay(file), "utf8"))
|
|
160
|
+
: formatDirectoryEntry(name, "dir"));
|
|
161
|
+
}
|
|
162
|
+
let entries;
|
|
163
|
+
try { entries = await fs.readdir(dirPath, { withFileTypes: true }); } catch (error) {
|
|
164
|
+
if (error.code !== "ENOENT" || rows.size === 0) throw error;
|
|
165
|
+
entries = [];
|
|
166
|
+
}
|
|
167
|
+
for (const entry of entries) if (!rows.has(entry.name)) rows.set(entry.name, await formatLsEntry(dirPath, entry));
|
|
168
|
+
return textResult([...rows.values()].join("\n"), { path: dirPath, count: rows.size });
|
|
169
|
+
}
|
|
135
170
|
|
|
136
|
-
|
|
171
|
+
async function readAdapter(params, signal) {
|
|
172
|
+
signal?.throwIfAborted();
|
|
173
|
+
const cwd = getCwd();
|
|
174
|
+
const targetParam = params?.path ?? params?.target;
|
|
175
|
+
if (Array.isArray(targetParam)) {
|
|
176
|
+
if (targetParam.some(p => !isString(p) || !p.trim())) throw new Error("read paths must be non-empty strings");
|
|
177
|
+
if (targetParam.length > 64) throw new Error("read accepts at most 64 paths per batch");
|
|
178
|
+
const results = await Promise.all(targetParam.map(async p => {
|
|
137
179
|
try {
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
180
|
+
const block = (await readAdapter({ ...params, path: p }, signal)).content[0];
|
|
181
|
+
return { text: block.type === "image" ? block : block.text };
|
|
182
|
+
} catch (error) {
|
|
183
|
+
signal?.throwIfAborted();
|
|
184
|
+
return { text: `[read error: ${p}] ${error.message}`, error: { path: p, message: error.message } };
|
|
185
|
+
}
|
|
186
|
+
}));
|
|
187
|
+
signal?.throwIfAborted();
|
|
188
|
+
return textResult("", { count: results.length, batch: true, independent: params._independent === true, items: results.map(r => r.text), itemErrors: results.map(r => r.error?.message ?? null), errors: results.filter(r => r.error).map(r => r.error) });
|
|
189
|
+
}
|
|
190
|
+
return reads.schedule("read", () => readSingle(params, cwd, targetParam, signal), signal);
|
|
191
|
+
}
|
|
148
192
|
|
|
149
|
-
|
|
150
|
-
|
|
193
|
+
async function readSingle(params, cwd, targetParam, signal) {
|
|
194
|
+
if (isString(params?.query)) {
|
|
195
|
+
const scope = targetParam && targetParam !== params.query ? resolveReadPath(cwd, targetParam) : cwd;
|
|
196
|
+
return sourceRead(params.query, scope, signal, params);
|
|
197
|
+
}
|
|
198
|
+
const existing = await probeExistingPath(cwd, targetParam, vfs);
|
|
199
|
+
if (existing) {
|
|
200
|
+
if (!existing.directory) return params.resolve
|
|
201
|
+
? openSource({ status: "found", path: relativeSlash(cwd, existing.path), line: params.offset ?? 1 }, params, signal)
|
|
202
|
+
: readFile(existing.path, params);
|
|
203
|
+
return isString(params?.about) ? sourceRead(params.about, existing.path, signal, params) : readDirectory(existing.path, signal);
|
|
204
|
+
}
|
|
205
|
+
if (!looksLikePath(targetParam)) return sourceRead(targetParam, cwd, signal, params);
|
|
206
|
+
const targetPath = resolveReadPath(cwd, targetParam);
|
|
207
|
+
return readFile(targetPath, params);
|
|
151
208
|
}
|
|
152
209
|
|
|
153
210
|
/** Plain text, a line window, or (with `about`) a relevance-folded outline of the whole file. */
|
|
154
|
-
async function readFile(targetPath, params) {
|
|
211
|
+
async function readFile(targetPath, params, sourceLine) {
|
|
155
212
|
const cwd = getCwd();
|
|
156
213
|
const rel = relativeSlash(cwd, targetPath);
|
|
214
|
+
const mime = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp" }[path.extname(targetPath).toLowerCase()];
|
|
215
|
+
if (mime) {
|
|
216
|
+
if ((await fs.stat(targetPath)).size > 20 * 1024 * 1024) throw new Error("image exceeds 20 MiB; resize it before reading");
|
|
217
|
+
const staged = vfs.getOverlay(targetPath);
|
|
218
|
+
const bytes = staged === undefined ? await fs.readFile(targetPath) : Buffer.from(staged);
|
|
219
|
+
return { content: [{ type: "image", mimeType: mime, data: bytes.toString("base64") }], details: { path: targetPath } };
|
|
220
|
+
}
|
|
157
221
|
const text = await vfs.read(targetPath);
|
|
158
222
|
index.touch(rel);
|
|
159
223
|
if (isString(params?.about)) {
|
|
160
|
-
const
|
|
161
|
-
const entry = pending === undefined ? index.entry(targetPath) : WorkspaceIndex.fromText(targetPath, pending);
|
|
224
|
+
const entry = WorkspaceIndex.fromText(targetPath, text);
|
|
162
225
|
const outline = entry && outlineFile(entry, rel, params.about, outlineOptions(params, await referenceFinder(cwd, targetPath)));
|
|
163
226
|
if (outline) {
|
|
164
227
|
recordOutlineOrigins(rel, outline.text);
|
|
@@ -166,65 +229,115 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
|
|
|
166
229
|
}
|
|
167
230
|
}
|
|
168
231
|
const explicit = isNumber(params?.offset) || isNumber(params?.limit);
|
|
169
|
-
const
|
|
170
|
-
const
|
|
232
|
+
const budget = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - (params.resolve ? 1024 : 256));
|
|
233
|
+
const offset = params?.offset ?? (sourceLine && text.length > budget ? Math.max(1, sourceLine - 2) : 1);
|
|
234
|
+
const firstLine = isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1;
|
|
235
|
+
const sliced = sliceLines(text, offset, params?.limit);
|
|
236
|
+
if (sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget)) {
|
|
237
|
+
let cap = budget - 160;
|
|
238
|
+
if (params.resolve) {
|
|
239
|
+
// Budget the actual JSON string, not a pessimistic fixed escape multiplier.
|
|
240
|
+
let low = 0, high = Math.max(0, cap);
|
|
241
|
+
while (low < high) {
|
|
242
|
+
const mid = Math.ceil((low + high) / 2);
|
|
243
|
+
if (JSON.stringify(sliced.slice(0, mid)).length <= budget - 160) low = mid;
|
|
244
|
+
else high = mid - 1;
|
|
245
|
+
}
|
|
246
|
+
cap = low;
|
|
247
|
+
}
|
|
248
|
+
const end = sliced.lastIndexOf("\n", cap);
|
|
249
|
+
if (end < 0) throw new Error(`line ${firstLine} exceeds the read budget; use bash to inspect a bounded substring`);
|
|
250
|
+
const body = sliced.slice(0, end + 1);
|
|
251
|
+
const next = firstLine + body.split("\n").length - 1;
|
|
252
|
+
return textResult(body + `\n[read truncated; continue with read({path:${JSON.stringify(rel)}, offset:${next}})]`, { path: targetPath, outputTruncated: true, nextOffset: next, firstLine, lastLine: next - 1, sourceChars: body.length, complete: false });
|
|
253
|
+
}
|
|
171
254
|
ledger.recordOrigin(rel, firstLine, sliced.split("\n"), explicit);
|
|
172
|
-
return textResult(sliced, { path: targetPath });
|
|
255
|
+
return textResult(sliced, { path: targetPath, firstLine, lastLine: firstLine + sliced.split("\n").length - 1 - Number(sliced.endsWith("\n")), sourceChars: sliced.length, complete: sliced === text });
|
|
173
256
|
}
|
|
174
257
|
|
|
175
258
|
/**
|
|
176
259
|
* The edit result answers the follow-ups a model would otherwise spend turns on: the post-edit
|
|
177
|
-
* lines with numbers
|
|
178
|
-
*
|
|
260
|
+
* lines with numbers, a quick structural check, and bounded lexical reference hints.
|
|
261
|
+
* These do not replace tests or semantic caller resolution.
|
|
179
262
|
*/
|
|
180
|
-
async function editSummary(cwd, target, original, updated, diff) {
|
|
263
|
+
async function editSummary(cwd, target, original, updated, diff, signal) {
|
|
181
264
|
const rel = relativeSlash(cwd, target);
|
|
182
265
|
const newLines = updated.split("\n");
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
266
|
+
const ranges = [];
|
|
267
|
+
const positions = diff.lines.filter(row => row.type !== "context")
|
|
268
|
+
.map(row => Math.min(newLines.length, row.newLineNum ?? row.lineNum)).sort((a, b) => a - b);
|
|
269
|
+
for (const line of positions) {
|
|
270
|
+
const start = Math.max(1, line - 2), end = Math.min(newLines.length, line + 2);
|
|
271
|
+
if (ranges.length && start <= ranges.at(-1).end + 1) ranges.at(-1).end = Math.max(ranges.at(-1).end, end);
|
|
272
|
+
else ranges.push({ start, end });
|
|
273
|
+
}
|
|
274
|
+
const blocks = [];
|
|
275
|
+
const perRange = Math.max(1, Math.floor(40 / Math.max(1, ranges.length)));
|
|
276
|
+
for (const { start, end } of ranges) {
|
|
277
|
+
const last = Math.min(end, start + perRange - 1);
|
|
278
|
+
const lines = newLines.slice(start - 1, last);
|
|
279
|
+
ledger.recordOrigin(rel, start, lines);
|
|
280
|
+
blocks.push("edited " + rel + ":" + start + "-" + last + "\n" + lines.map((line, i) => String(start + i).padStart(5) + " " + line).join("\n"));
|
|
281
|
+
if (last < end) blocks.push("[continue with read({path:" + JSON.stringify(rel) + ",offset:" + (last + 1) + ",limit:" + (end - last) + "})]");
|
|
282
|
+
}
|
|
283
|
+
let out = blocks.join("\n");
|
|
190
284
|
const check = quickCheck(updated, path.extname(target));
|
|
191
285
|
if (check && !check.ok) out += `\ncheck: ${check.message}`;
|
|
192
|
-
const refs = await changedDeclarationRefs(cwd, target, original, updated, diff);
|
|
286
|
+
const refs = await changedDeclarationRefs(cwd, target, original, updated, diff, signal);
|
|
193
287
|
if (refs) out += `\n${refs}`;
|
|
194
288
|
return out;
|
|
195
289
|
}
|
|
196
290
|
|
|
197
|
-
async function changedDeclarationRefs(cwd, target, original, updated, diff) {
|
|
291
|
+
async function changedDeclarationRefs(cwd, target, original, updated, diff, signal) {
|
|
198
292
|
// Diff rows carry the replaced fragments; declarations live on whole file lines.
|
|
199
293
|
const oldLines = original.split("\n");
|
|
200
294
|
const newLines = updated.split("\n");
|
|
201
295
|
const names = new Set();
|
|
296
|
+
const spans = new Map();
|
|
202
297
|
for (const l of diff.lines) {
|
|
203
298
|
if (l.type === "context") continue;
|
|
204
|
-
const
|
|
299
|
+
const number = l.type === "remove" ? l.lineNum : l.newLineNum ?? l.lineNum;
|
|
300
|
+
const name = declaredName((l.type === "remove" ? oldLines : newLines)[number - 1] ?? "");
|
|
205
301
|
if (name) names.add(name);
|
|
302
|
+
else {
|
|
303
|
+
if (!spans.has(l.type)) spans.set(l.type, WorkspaceIndex.spansOf(WorkspaceIndex.fromText(target, l.type === "remove" ? original : updated)));
|
|
304
|
+
const owner = spans.get(l.type).find(span => span.start <= number && number <= span.end);
|
|
305
|
+
if (owner?.name) names.add(owner.name);
|
|
306
|
+
}
|
|
307
|
+
if (names.size >= 3) break;
|
|
206
308
|
}
|
|
207
309
|
if (names.size === 0) return "";
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
310
|
+
try {
|
|
311
|
+
const { references, incomplete } = await referencesForNames({ root: cwd, names: [...names].slice(0, 3),
|
|
312
|
+
excludePath: target, overlayText: file => vfs.getOverlay(file), pendingPaths: vfs.getOverlayPaths(), signal });
|
|
313
|
+
const parts = [];
|
|
314
|
+
for (const [name, refs] of references) {
|
|
315
|
+
if (refs.length) parts.push(name + " also referenced in " + refs.slice(0, 6).join(", ") + (refs.length > 6 ? " (more matches)" : ""));
|
|
316
|
+
}
|
|
317
|
+
if (incomplete) parts.push("references incomplete: search budget reached");
|
|
318
|
+
return parts.join("\n");
|
|
319
|
+
} catch (error) {
|
|
320
|
+
signal?.throwIfAborted();
|
|
321
|
+
return "references unavailable: " + error.message;
|
|
213
322
|
}
|
|
214
|
-
return parts.join("\n");
|
|
215
323
|
}
|
|
216
324
|
|
|
217
|
-
const SOURCE_REF = /((?:[\w.@-]
|
|
325
|
+
const SOURCE_REF = /((?:\/|[A-Za-z]:[\\/])?(?:[\w.@-]+[\\/])*[\w.@-]+\.(?:m?[jt]sx?|c[jt]s|py|rs|go|java|kt|rb|php|c|cc|cpp|h|hpp|cs|swift|json|ya?ml|toml))(?::|\()(\d+)/g;
|
|
218
326
|
|
|
219
|
-
/**
|
|
220
|
-
function sourceWindow(cwd, file, lineNo) {
|
|
221
|
-
const candidate = path.resolve(
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
327
|
+
/** Fresh bounded source window for a diagnostic; no index warmup or stale cached bodies. */
|
|
328
|
+
async function sourceWindow(cwd, commandCwd, file, lineNo) {
|
|
329
|
+
const candidate = path.resolve(commandCwd, file);
|
|
330
|
+
let text, rel;
|
|
331
|
+
try {
|
|
332
|
+
const root = await fs.realpath(cwd);
|
|
333
|
+
if (!candidate.startsWith(path.resolve(cwd) + path.sep) && !candidate.startsWith(root + path.sep)) return null;
|
|
334
|
+
const real = await fs.realpath(candidate);
|
|
335
|
+
if (!real.startsWith(root + path.sep) || (await fs.stat(real)).size > 1024 * 1024) return null;
|
|
336
|
+
text = await fs.readFile(real, "utf8");
|
|
337
|
+
rel = relativeSlash(root, real);
|
|
338
|
+
} catch { return null; }
|
|
339
|
+
const raw = text.split("\n");
|
|
226
340
|
if (lineNo < 1 || lineNo > raw.length) return null;
|
|
227
|
-
const rel = relativeSlash(cwd, candidate);
|
|
228
341
|
const start = Math.max(1, lineNo - 2);
|
|
229
342
|
const rows = [];
|
|
230
343
|
for (let l = start; l <= Math.min(raw.length, lineNo + 2); l++) rows.push((l === lineNo ? "►" : " ") + String(l).padStart(4) + " " + raw[l - 1]);
|
|
@@ -233,16 +346,16 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
|
|
|
233
346
|
}
|
|
234
347
|
|
|
235
348
|
/** A failing command names path:line; the model wants those lines next. Attach them (≤4 sites). */
|
|
236
|
-
function sourceForReferences(cwd, output) {
|
|
349
|
+
async function sourceForReferences(cwd, commandCwd, output) {
|
|
237
350
|
const seen = new Set();
|
|
238
351
|
const blocks = [];
|
|
239
352
|
for (const m of output.matchAll(SOURCE_REF)) {
|
|
240
353
|
const key = m[1] + ":" + m[2];
|
|
241
354
|
if (seen.has(key)) continue;
|
|
355
|
+
if (seen.size >= 4) break;
|
|
242
356
|
seen.add(key);
|
|
243
|
-
const block = sourceWindow(cwd, m[1], Number(m[2]));
|
|
357
|
+
const block = await sourceWindow(cwd, commandCwd, m[1], Number(m[2]));
|
|
244
358
|
if (block) blocks.push(block);
|
|
245
|
-
if (blocks.length >= 4) break;
|
|
246
359
|
}
|
|
247
360
|
return blocks.length ? "\n--- source\n" + blocks.join("\n") : "";
|
|
248
361
|
}
|
|
@@ -263,19 +376,20 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
|
|
|
263
376
|
|
|
264
377
|
/** Where else a name appears (declaration line excluded), for outlines and edit results. */
|
|
265
378
|
async function referenceFinder(cwd, targetPath) {
|
|
266
|
-
const files = await index.files(cwd);
|
|
379
|
+
const files = [...new Set([...await index.files(cwd), ...vfs.getOverlayPaths()])];
|
|
267
380
|
if (!index.canScan(files)) return () => [];
|
|
268
381
|
return (name, excludeLine) => {
|
|
269
382
|
if (!name || name.length < 3) return [];
|
|
270
383
|
const escaped = name.replace(/[$]/g, (c) => "\\" + c);
|
|
271
384
|
const regex = new RegExp("\\b" + escaped + "\\b");
|
|
272
385
|
return index
|
|
273
|
-
.grepRows(files, regex, cwd)
|
|
386
|
+
.grepRows(files, regex, cwd, file => vfs.getOverlay(file))
|
|
274
387
|
.filter((r) => !(r.line === excludeLine && r.rel === relativeSlash(cwd, targetPath)))
|
|
275
388
|
.map((r) => r.rel + ":" + r.line);
|
|
276
389
|
};
|
|
277
390
|
}
|
|
278
391
|
|
|
392
|
+
hooks.summarizeEdit = editSummary;
|
|
279
393
|
return {
|
|
280
394
|
read: readAdapter,
|
|
281
395
|
async write(params, signal) {
|
|
@@ -286,13 +400,15 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
|
|
|
286
400
|
const content = params.content;
|
|
287
401
|
let prevText = "";
|
|
288
402
|
try {
|
|
289
|
-
prevText = await vfs.read(target);
|
|
290
|
-
} catch {}
|
|
403
|
+
prevText = await vfs.read(target, { preserveRead: true });
|
|
404
|
+
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
291
405
|
const { speculative } = await vfs.write(target, content);
|
|
292
406
|
index.touch(relativeSlash(cwd, target));
|
|
293
407
|
const diff = buildWriteDiff(target, prevText, content);
|
|
294
408
|
const tag = speculative ? " (speculative)" : "";
|
|
295
|
-
|
|
409
|
+
const check = quickCheck(content, path.extname(target));
|
|
410
|
+
const warning = check && !check.ok ? "\ncheck: " + check.message : "";
|
|
411
|
+
return textResult(`wrote ${target}${tag}${warning}`, { path: target, speculative, diff });
|
|
296
412
|
},
|
|
297
413
|
async edit(params, signal) {
|
|
298
414
|
const cwd = getCwd();
|
|
@@ -310,7 +426,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
|
|
|
310
426
|
matches.length === 1
|
|
311
427
|
? buildEditDiff(target, content, matches[0].oldText, matches[0].newText)
|
|
312
428
|
: buildMultiEditDiff(target, content, matches);
|
|
313
|
-
const summary = await editSummary(cwd, target, content, updated, diff);
|
|
429
|
+
const summary = await editSummary(cwd, target, content, updated, diff, signal);
|
|
314
430
|
return textResult(summary, { path: target, speculative, diff });
|
|
315
431
|
},
|
|
316
432
|
async apply_patch(params, signal) {
|
|
@@ -330,8 +446,9 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
|
|
|
330
446
|
const { resultText, hunkCount } = applyPatchToText(original, params.patch);
|
|
331
447
|
const { speculative } = await vfs.write(target, resultText);
|
|
332
448
|
const diff = buildPatchDiff(target, params.patch);
|
|
333
|
-
|
|
334
|
-
|
|
449
|
+
index.touch(relativeSlash(cwd, target));
|
|
450
|
+
const summary = await editSummary(cwd, target, original, resultText, diff, signal);
|
|
451
|
+
return textResult(summary, {
|
|
335
452
|
path: target,
|
|
336
453
|
hunks: hunkCount,
|
|
337
454
|
speculative,
|
|
@@ -354,9 +471,9 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
|
|
|
354
471
|
searchDir: snapTarget,
|
|
355
472
|
root: cwd,
|
|
356
473
|
includeHidden,
|
|
357
|
-
index,
|
|
358
474
|
overlayText: (p) => vfs.getOverlay(p),
|
|
359
475
|
pendingPaths: vfs.getOverlayPaths(),
|
|
476
|
+
signal,
|
|
360
477
|
});
|
|
361
478
|
return textResult(JSON.stringify(res, null, 2), res);
|
|
362
479
|
},
|
|
@@ -383,30 +500,37 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
|
|
|
383
500
|
},
|
|
384
501
|
async bash(params, signal) {
|
|
385
502
|
const cwd = getCwd();
|
|
386
|
-
const
|
|
387
|
-
if (!command) throw new Error("bash requires command");
|
|
503
|
+
const literal = params?._directArgv === true;
|
|
504
|
+
if (literal && (!isString(params.command) || !Array.isArray(params.args) || params.args.some(arg => !isString(arg)))) throw new Error("bash argv requires a command string and an array of string args");
|
|
505
|
+
const command = literal ? String(params.command) : unwrapIfFullyQuoted(String(params?.command ?? "").trim());
|
|
506
|
+
if (!command.trim()) throw new Error("bash requires command");
|
|
388
507
|
const targetCwd = params?.cwd ? await resolveWorkspacePath(cwd, params.cwd, "bash cwd", true) : cwd;
|
|
389
508
|
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
const argv = ["bash", "-c", command];
|
|
509
|
+
// String commands keep shell semantics; literal argv needs no quoting or shell startup.
|
|
510
|
+
const argv = literal ? [command, ...params.args] : ["bash", "-c", command];
|
|
393
511
|
|
|
394
512
|
const transactionBarrier = await vfs.prepareExternalMutation("bash");
|
|
395
513
|
let res;
|
|
396
514
|
try {
|
|
397
515
|
res = await runCommand(argv, {
|
|
398
516
|
cwd: targetCwd,
|
|
517
|
+
env: hooks.commandEnv(),
|
|
518
|
+
commandLabel: literal ? command : undefined,
|
|
399
519
|
timeoutMs: params?.timeoutMs,
|
|
400
520
|
signal,
|
|
401
521
|
maxOutputChars: config.maxCallResultChars,
|
|
402
522
|
});
|
|
523
|
+
} catch (error) {
|
|
524
|
+
if (!signal?.aborted) error.message += await sourceForReferences(cwd, targetCwd, error.message);
|
|
525
|
+
throw error;
|
|
403
526
|
} finally {
|
|
404
527
|
vfs.invalidateCache();
|
|
405
528
|
index.invalidate();
|
|
529
|
+
clearPathCache();
|
|
406
530
|
}
|
|
407
531
|
const { stdout, stderr } = res;
|
|
408
532
|
let text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
|
|
409
|
-
if (res.exitCode !== 0) text += sourceForReferences(cwd, text);
|
|
533
|
+
if (res.exitCode !== 0) text += await sourceForReferences(cwd, targetCwd, text);
|
|
410
534
|
return {
|
|
411
535
|
content: [{ type: "text", text }],
|
|
412
536
|
details: { exitCode: res.exitCode, signal: res.signal, outputTruncated: res.outputTruncated, transactionBarrier },
|
|
@@ -464,13 +588,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
|
|
|
464
588
|
async ls(params, signal) {
|
|
465
589
|
const cwd = getCwd();
|
|
466
590
|
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 });
|
|
591
|
+
return readDirectory(dirPath, signal);
|
|
474
592
|
},
|
|
475
593
|
};
|
|
476
594
|
}
|
|
@@ -478,12 +596,13 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
|
|
|
478
596
|
export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger }) {
|
|
479
597
|
const index = registry?.index ?? new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
|
|
480
598
|
const ledger = runLedger ?? new SeenLedger({ window: config.seenWindow ?? 40 });
|
|
481
|
-
const vfs = new CausalVfs(() => index.invalidate());
|
|
599
|
+
const vfs = new CausalVfs(() => index.invalidate(), target => resolveWorkspacePath(getCwd(), target, "commit", false, true));
|
|
482
600
|
const executors = registry?.executors ?? new Map();
|
|
483
601
|
const definitions = registry?.definitions ?? new Map();
|
|
484
602
|
const sharedRegistry = registry ?? { executors, definitions, index, callSeq: 0 };
|
|
485
603
|
let closed = false;
|
|
486
|
-
const
|
|
604
|
+
const hooks = {};
|
|
605
|
+
const natives = createNativeAdapters(getCwd, vfs, config, index, ledger, hooks);
|
|
487
606
|
let callCount = 0;
|
|
488
607
|
let activeCtx = null;
|
|
489
608
|
let hostSession = null;
|
|
@@ -491,6 +610,22 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
491
610
|
let activeSignal = undefined;
|
|
492
611
|
let trace = [];
|
|
493
612
|
let callListener = null;
|
|
613
|
+
const scheduler = createNativeScheduler();
|
|
614
|
+
hooks.commandEnv = () => {
|
|
615
|
+
const env = { ...process.env };
|
|
616
|
+
const current = {
|
|
617
|
+
PI_SESSION_ID: activeCtx?.sessionManager?.getSessionId?.(),
|
|
618
|
+
PI_SESSION_FILE: activeCtx?.sessionManager?.getSessionFile?.(),
|
|
619
|
+
PI_PROVIDER: activeCtx?.model?.provider,
|
|
620
|
+
PI_MODEL: activeCtx?.model?.id,
|
|
621
|
+
PI_REASONING_LEVEL: activeCtx?.thinkingLevel,
|
|
622
|
+
};
|
|
623
|
+
for (const [key, value] of Object.entries(current)) {
|
|
624
|
+
if (isString(value)) env[key] = value;
|
|
625
|
+
else delete env[key];
|
|
626
|
+
}
|
|
627
|
+
return env;
|
|
628
|
+
};
|
|
494
629
|
|
|
495
630
|
if (!registry && pi && isFunction(pi.registerTool)) {
|
|
496
631
|
const original = pi.registerTool.bind(pi);
|
|
@@ -532,8 +667,12 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
532
667
|
|
|
533
668
|
function isCallable(name) {
|
|
534
669
|
if (name === "supernova" || (config.excludeTools ?? []).includes(name)) return false;
|
|
670
|
+
if (hostSession && (hostSession.isDisposed || hostSession.sessionManager.getSessionId() !== boundSessionId)) return false;
|
|
671
|
+
// An internal adapter belongs to Supernova, not the host's visible tool list.
|
|
672
|
+
const nativeOwned = Object.hasOwn(natives, name) && !executors.has(name)
|
|
673
|
+
&& (!hostSession || !definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin");
|
|
674
|
+
if (nativeOwned) return true;
|
|
535
675
|
if (hostSession) {
|
|
536
|
-
if (hostSession.isDisposed || hostSession.sessionManager.getSessionId() !== boundSessionId) return false;
|
|
537
676
|
if (!hostSession.getEvalBridgeToolNames().includes(name) && definitions.has(name)) return false;
|
|
538
677
|
return !!hostTool(name) || (Object.hasOwn(natives, name) && (!definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin"));
|
|
539
678
|
}
|
|
@@ -610,7 +749,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
610
749
|
callCount += 1;
|
|
611
750
|
if (callCount > maxCalls) {
|
|
612
751
|
throw new Error(
|
|
613
|
-
`host call budget exceeded (${maxCalls} calls per program):
|
|
752
|
+
`host call budget exceeded (${maxCalls} calls per program): split the work across programs`,
|
|
614
753
|
);
|
|
615
754
|
}
|
|
616
755
|
if (activeSignal?.aborted) throw new Error("aborted");
|
|
@@ -622,7 +761,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
622
761
|
const excluded = new Set(config.excludeTools || []);
|
|
623
762
|
if (name === "supernova" || excluded.has(name)) {
|
|
624
763
|
throw new Error(
|
|
625
|
-
|
|
764
|
+
`${name} is blocked (excluded / non-reentrant).`,
|
|
626
765
|
);
|
|
627
766
|
}
|
|
628
767
|
}
|
|
@@ -652,7 +791,8 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
652
791
|
assertCallableTarget(name);
|
|
653
792
|
if (!isCallable(name)) throw new Error(unknownToolMessage(name, [...definitions.keys(), ...Object.keys(natives)].filter(isCallable)));
|
|
654
793
|
|
|
655
|
-
const
|
|
794
|
+
const command = { apply_patch: "edit", surface: "read", evidence: "read", snap: "read" }[name] ?? name;
|
|
795
|
+
const record = { name: command, adapter: name, args: args || {}, time: Date.now() };
|
|
656
796
|
trace.push(record);
|
|
657
797
|
notifyCall(record);
|
|
658
798
|
|
|
@@ -702,8 +842,9 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
702
842
|
|
|
703
843
|
async function call(name, args) {
|
|
704
844
|
if (!isString(name) || !name) throw new Error("nova.call requires a tool name");
|
|
705
|
-
const
|
|
706
|
-
|
|
845
|
+
const invoke = async () => packageHostResult(await invokeRaw(name, args), config);
|
|
846
|
+
const kind = isMutatingTool(name, config, args, definitions.get(name)) ? "write" : "read";
|
|
847
|
+
return scheduler.schedule(kind, invoke, activeSignal);
|
|
707
848
|
}
|
|
708
849
|
|
|
709
850
|
async function callMany(calls) {
|
|
@@ -736,6 +877,21 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
736
877
|
isCallable,
|
|
737
878
|
externalNames,
|
|
738
879
|
supportsBatchRead: () => !hostTool("read") && !executors.has("read"),
|
|
880
|
+
// Windows command shims need shell handling; preserve the existing route there.
|
|
881
|
+
supportsNativeArgv: () => process.platform !== "win32" && !hostTool("bash") && !executors.has("bash"),
|
|
882
|
+
summarizeEdit: (target, before, after, diff) => hooks.summarizeEdit(getCwd(), target, before, after, diff),
|
|
883
|
+
invalidateFiles() { vfs.invalidateCache(); index.invalidate(); clearPathCache(); },
|
|
884
|
+
fileOperations: {
|
|
885
|
+
access: (target, mode) => fs.access(target, mode),
|
|
886
|
+
readFile: async target => Buffer.from(await vfs.read(target), "utf8"),
|
|
887
|
+
// VFS owns parent creation and atomic replacement, inside Pi's file queue.
|
|
888
|
+
mkdir: async () => {},
|
|
889
|
+
async writeFile(target, content) {
|
|
890
|
+
await resolveWorkspacePath(getCwd(), target, "write", false);
|
|
891
|
+
await vfs.write(target, content);
|
|
892
|
+
index.touch(relativeSlash(getCwd(), target));
|
|
893
|
+
},
|
|
894
|
+
},
|
|
739
895
|
fork(options) {
|
|
740
896
|
return createHostBridge({ pi, config, getCwd: options.getCwd, registry: sharedRegistry, ledger: ledger.fork() });
|
|
741
897
|
},
|
|
@@ -744,6 +900,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
744
900
|
resetCallBudget,
|
|
745
901
|
getTrace,
|
|
746
902
|
setCallListener,
|
|
903
|
+
barrier: run => scheduler.schedule("write", run, activeSignal),
|
|
747
904
|
beginSpeculation,
|
|
748
905
|
commitSpeculation,
|
|
749
906
|
rollbackSpeculation,
|