pi-supernova 0.2.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 -196
- package/{CHANGELOG.md → docs/CHANGELOG.md} +44 -1
- package/index.js +81 -68
- package/package.json +12 -31
- package/{catalog.js → src/bridge/catalog.js} +7 -5
- package/{host-bridge.js → src/bridge/host-bridge.js} +232 -87
- 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} +11 -9
- package/{search.js → src/context/search.js} +34 -1
- package/{snap.js → src/context/snap.js} +51 -31
- 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/{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
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { loadConfig } from "../config/config.js";
|
|
5
|
+
import { isString } from "../shared/decode.js";
|
|
6
|
+
import { createHostBridge } from "./host-bridge.js";
|
|
7
|
+
import { buildPatchDiff } from "../fs/diff.js";
|
|
8
|
+
import { createNativeScheduler } from "../runtime/parallel.js";
|
|
9
|
+
import { truncateChars } from "../output/format.js";
|
|
10
|
+
import { clearPathCache, resolveWorkspacePath } from "../fs/workspace.js";
|
|
11
|
+
|
|
12
|
+
export const NATIVE_NAMES = Object.freeze(["read", "edit", "write", "bash"]);
|
|
13
|
+
|
|
14
|
+
function userPath(cwd, input) {
|
|
15
|
+
if (!isString(input) || !input.trim()) throw new Error("path must be a non-empty string");
|
|
16
|
+
const value = input.trim().replace(/^@/, "");
|
|
17
|
+
return path.resolve(cwd, value === "~" ? homedir() : value.startsWith("~/") ? path.join(homedir(), value.slice(2)) : value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function boundText(result, maxChars) {
|
|
21
|
+
const total = result.content.reduce((sum, block) => sum + (block.type === "text" ? block.text.length : 0), 0);
|
|
22
|
+
const truncated = total > maxChars;
|
|
23
|
+
const notice = truncated ? truncateChars("\n[Truncated: read files individually or narrow the line range/question.]", maxChars, "read-result").text : "";
|
|
24
|
+
let remaining = maxChars - notice.length;
|
|
25
|
+
const content = result.content.map(block => {
|
|
26
|
+
if (block.type !== "text") return block; // Images remain image attachments.
|
|
27
|
+
const bounded = truncateChars(block.text, remaining, "read-result");
|
|
28
|
+
remaining -= bounded.text.length;
|
|
29
|
+
return { ...block, text: bounded.text };
|
|
30
|
+
});
|
|
31
|
+
if (notice) content.push({ type: "text", text: notice });
|
|
32
|
+
const details = { ...result.details };
|
|
33
|
+
if (truncated) details.outputTruncated = true;
|
|
34
|
+
return { ...result, content, details };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Familiar Pi tool definitions with Supernova's source engine and atomic VFS. */
|
|
38
|
+
export function registerNativeTools(pi, host, config = loadConfig()) {
|
|
39
|
+
let cwd = process.cwd();
|
|
40
|
+
const base = createHostBridge({ pi: null, config: { ...config, seenWindow: 0 }, getCwd: () => cwd });
|
|
41
|
+
const scheduler = createNativeScheduler();
|
|
42
|
+
const factories = {
|
|
43
|
+
read: host.createReadToolDefinition,
|
|
44
|
+
edit: host.createEditToolDefinition,
|
|
45
|
+
write: host.createWriteToolDefinition,
|
|
46
|
+
bash: host.createBashToolDefinition,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function settingsFor(ctx) {
|
|
50
|
+
return host.SettingsManager?.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted?.() === true });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function readOne(args, signal, ctx, bridge, id) {
|
|
54
|
+
const target = userPath(ctx.cwd, args.path);
|
|
55
|
+
let stat;
|
|
56
|
+
try { stat = await fs.stat(target); }
|
|
57
|
+
catch (error) { if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error; }
|
|
58
|
+
signal?.throwIfAborted();
|
|
59
|
+
const image = /\.(png|jpe?g|gif|webp|bmp)$/i.test(target);
|
|
60
|
+
if (stat?.isFile() && (args.about === undefined || image)) {
|
|
61
|
+
// Pi retains image handling, full text, line windows, truncation metadata,
|
|
62
|
+
// and actionable continuation offsets. Do not summarize or dedupe these.
|
|
63
|
+
return factories.read(ctx.cwd, { autoResizeImages: image ? settingsFor(ctx)?.getImageAutoResize() : undefined }).execute(id, { ...args, path: target }, signal, undefined, ctx);
|
|
64
|
+
}
|
|
65
|
+
return boundText(await bridge.natives.read({ ...args, path: stat ? target : args.path }, signal), config.maxCallResultChars);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function execute(name, id, args, signal, onUpdate, context) {
|
|
69
|
+
const ctx = { ...context, cwd: context?.cwd || cwd };
|
|
70
|
+
const bridge = base.fork({ getCwd: () => ctx.cwd });
|
|
71
|
+
bridge.bindCallContext(ctx, signal);
|
|
72
|
+
signal?.throwIfAborted();
|
|
73
|
+
try {
|
|
74
|
+
if (name === "read") {
|
|
75
|
+
if (!Array.isArray(args.path)) return await readOne(args, signal, ctx, bridge, id);
|
|
76
|
+
if (args.path.some(p => !isString(p) || !p.trim())) throw new Error("read paths must be non-empty strings");
|
|
77
|
+
if (args.path.length > 64) throw new Error("read path arrays support at most 64 entries; use smaller batches");
|
|
78
|
+
const groups = [], errors = [];
|
|
79
|
+
// Bound fan-out even when a model submits a large path array.
|
|
80
|
+
for (let offset = 0; offset < args.path.length; offset += 8) {
|
|
81
|
+
const results = await Promise.all(args.path.slice(offset, offset + 8).map(async file => {
|
|
82
|
+
try {
|
|
83
|
+
const result = await readOne({ ...args, path: file }, signal, ctx, bridge, id);
|
|
84
|
+
return { content: [{ type: "text", text: "File: " + file }, ...result.content] };
|
|
85
|
+
} catch (error) {
|
|
86
|
+
signal?.throwIfAborted();
|
|
87
|
+
errors.push({ path: file, message: error.message });
|
|
88
|
+
return { content: [{ type: "text", text: "[read error: " + file + "] " + error.message }] };
|
|
89
|
+
}
|
|
90
|
+
}));
|
|
91
|
+
groups.push(...results);
|
|
92
|
+
}
|
|
93
|
+
const content = [];
|
|
94
|
+
let remaining = config.maxCallResultChars, outputTruncated = false;
|
|
95
|
+
for (let i = 0; i < groups.length; i++) {
|
|
96
|
+
const bounded = boundText(groups[i], Math.floor(remaining / (groups.length - i)));
|
|
97
|
+
remaining -= bounded.content.reduce((sum, block) => sum + (block.type === "text" ? block.text.length : 0), 0);
|
|
98
|
+
outputTruncated ||= bounded.details.outputTruncated === true;
|
|
99
|
+
content.push(...bounded.content);
|
|
100
|
+
}
|
|
101
|
+
return { content, details: { batch: true, count: args.path.length, errors, outputTruncated } };
|
|
102
|
+
}
|
|
103
|
+
if (name === "bash") {
|
|
104
|
+
const settings = settingsFor(ctx);
|
|
105
|
+
try { return await factories.bash(ctx.cwd, { shellPath: settings?.getShellPath(), commandPrefix: settings?.getShellCommandPrefix() }).execute(id, args, signal, onUpdate, ctx); }
|
|
106
|
+
finally { bridge.invalidateFiles(); }
|
|
107
|
+
}
|
|
108
|
+
clearPathCache();
|
|
109
|
+
const target = await resolveWorkspacePath(ctx.cwd, userPath(ctx.cwd, args.path), name);
|
|
110
|
+
const ops = bridge.fileOperations;
|
|
111
|
+
let before, after;
|
|
112
|
+
const tool = factories[name](ctx.cwd, { operations: {
|
|
113
|
+
...ops,
|
|
114
|
+
async readFile(file) { const buffer = await ops.readFile(file); before = buffer.toString("utf8"); return buffer; },
|
|
115
|
+
async writeFile(file, content) { await ops.writeFile(file, content); after = content; },
|
|
116
|
+
} });
|
|
117
|
+
// Pi's edit/write implementations hold its shared canonical per-file queue
|
|
118
|
+
// over the entire mutation, including our atomic replacement operation.
|
|
119
|
+
const result = await tool.execute(id, { ...args, path: target }, signal, onUpdate, ctx);
|
|
120
|
+
if (name === "edit" && before !== undefined && after !== undefined && result.details?.patch) {
|
|
121
|
+
const summary = await bridge.summarizeEdit(target, before, after, buildPatchDiff(target, result.details.patch));
|
|
122
|
+
result.content = [{ type: "text", text: summary }];
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
} finally { bridge.close(); }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const name of NATIVE_NAMES) {
|
|
129
|
+
const definition = factories[name](cwd);
|
|
130
|
+
const tool = {
|
|
131
|
+
...definition,
|
|
132
|
+
execute(id, args, signal, onUpdate, ctx) {
|
|
133
|
+
return scheduler.schedule(name, () => execute(name, id, args, signal, onUpdate, ctx), signal);
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
if (name === "read") {
|
|
137
|
+
tool.description += " Also reads directories or finds source from a symbol/question passed as path. Use about to focus a file or directory on a question. An array of paths returns all readable files and labels individual errors.";
|
|
138
|
+
tool.parameters = { ...definition.parameters, properties: {
|
|
139
|
+
...definition.parameters.properties,
|
|
140
|
+
path: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" }, maxItems: 64 }], description: "File, directory, source question, or up to 64 paths to read" },
|
|
141
|
+
about: { type: "string", description: "Focus on this question or symbol; source selection reports uncertainty instead of guessing" },
|
|
142
|
+
} };
|
|
143
|
+
tool.promptGuidelines = [...(definition.promptGuidelines || []), "read can find source from a symbol or question; check its selection status before choosing a file. Plain file reads preserve full text within the stated limits."];
|
|
144
|
+
}
|
|
145
|
+
pi.registerTool(tool);
|
|
146
|
+
}
|
|
147
|
+
pi.on("session_start", (_event, ctx) => {
|
|
148
|
+
cwd = ctx?.cwd || cwd;
|
|
149
|
+
base.invalidateFiles();
|
|
150
|
+
});
|
|
151
|
+
pi.registerCommand("supernova", {
|
|
152
|
+
description: "Show Supernova native runtime status",
|
|
153
|
+
handler: async (_args, ctx) => ctx.ui.notify("Supernova runtime: read, edit, write, bash; " + scheduler.stats.calls + " calls, " + scheduler.stats.readWaves + " read waves, peak " + scheduler.stats.peakParallelReads + " parallel reads. Mutations are ordered; plain reads are not summarized or deduplicated.", "info"),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
|
-
import { isString, isObject } from "
|
|
5
|
+
import { isString, isObject } from "../shared/decode.js";
|
|
6
6
|
|
|
7
7
|
const require = createRequire(import.meta.url);
|
|
8
8
|
const DEFAULTS = require("./config.default.json");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import { WorkspaceIndex } from "./repo-index.js";
|
|
3
|
-
import { tokenizeQuery, scorePathTopology } from "./snap.js";
|
|
4
|
-
import { isTestPath } from "
|
|
3
|
+
import { tokenizeQuery, scorePathTopology, stem } from "./snap.js";
|
|
4
|
+
import { isTestPath } from "../fs/workspace.js";
|
|
5
5
|
|
|
6
6
|
// Zero-token evidence selection over source code, after Zero-Mem (arXiv:2607.29377).
|
|
7
7
|
// The codebase is the interaction history H; declared spans are the context units;
|
|
@@ -38,11 +38,7 @@ const RELATION_WORDS = new Set(["calls", "caller", "callers", "uses", "usages",
|
|
|
38
38
|
const HUB_FRACTION = 0.25;
|
|
39
39
|
const HUB_MIN = 8;
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
export function stem(token) {
|
|
43
|
-
if (token.length < 5) return token;
|
|
44
|
-
return token.replace(/(ations?|ings?|ed|es|e|s|ly|ers?)$/, (m) => (token.length - m.length >= 4 ? "" : m));
|
|
45
|
-
}
|
|
41
|
+
export { stem } from "./snap.js";
|
|
46
42
|
|
|
47
43
|
function splitIdentifier(name) {
|
|
48
44
|
return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
@@ -73,9 +69,9 @@ function spansOf(entry, filePath, maxSpanLines) {
|
|
|
73
69
|
const base = { path: filePath, entry, lower: lines.lower, lines };
|
|
74
70
|
const declared = WorkspaceIndex.spansOf(entry);
|
|
75
71
|
if (declared.length === 0) {
|
|
76
|
-
return [{ ...base, id: filePath + ":1", start: 1, end: Math.min(lines.raw.length, maxSpanLines), name: path.basename(filePath), kind: "file" }];
|
|
72
|
+
return [{ ...base, id: filePath + ":1", start: 1, end: Math.min(lines.raw.length, maxSpanLines), name: path.basename(filePath), kind: "file", sourceEnd: lines.raw.length }];
|
|
77
73
|
}
|
|
78
|
-
return declared.map((s, i) => ({ ...base, ...s, id: filePath + ":" + s.start, end: Math.min(s.end, s.start + maxSpanLines - 1), index: i }));
|
|
74
|
+
return declared.map((s, i) => ({ ...base, ...s, sourceEnd: s.end, id: filePath + ":" + s.start, end: Math.min(s.end, s.start + maxSpanLines - 1), index: i }));
|
|
79
75
|
}
|
|
80
76
|
|
|
81
77
|
function spanLines(span) {
|
|
@@ -316,7 +312,7 @@ function candidateFiles(files, profile, index, limit, overlayText) {
|
|
|
316
312
|
if (s > 0) scored.push({ f, s });
|
|
317
313
|
}
|
|
318
314
|
scored.sort((a, b) => b.s - a.s);
|
|
319
|
-
const chosen = new Set(
|
|
315
|
+
const chosen = new Set();
|
|
320
316
|
const anchors = (profile.subjects.length ? profile.subjects : profile.keywords).map((a) => a.toLowerCase()).filter((a) => a.length > 2);
|
|
321
317
|
const pendingHits = files.filter(file => {
|
|
322
318
|
const pending = overlayText(file);
|
|
@@ -327,6 +323,10 @@ function candidateFiles(files, profile, index, limit, overlayText) {
|
|
|
327
323
|
if (chosen.size >= limit) break;
|
|
328
324
|
if (profile.flags.wantsTest || scorePathTopology(f, profile.keywords, profile.flags) > -50) chosen.add(f);
|
|
329
325
|
}
|
|
326
|
+
for (const { f } of scored) {
|
|
327
|
+
if (chosen.size >= limit) break;
|
|
328
|
+
chosen.add(f);
|
|
329
|
+
}
|
|
330
330
|
return { files: [...chosen], fileScores: new Map(scored.map(({ f, s }) => [f, s])) };
|
|
331
331
|
}
|
|
332
332
|
|
|
@@ -370,11 +370,22 @@ function render(spans, picks, fused, opts, root) {
|
|
|
370
370
|
const span = spans[i];
|
|
371
371
|
const lines = span.lines.raw.slice(span.start - 1, span.end);
|
|
372
372
|
let text = lines.join("\n");
|
|
373
|
-
if (text.length > budget)
|
|
373
|
+
if (text.length > budget) {
|
|
374
|
+
const end = text.lastIndexOf("\n", budget);
|
|
375
|
+
if (end < 0) {
|
|
376
|
+
if (out.length) break;
|
|
377
|
+
throw new Error("evidence source line exceeds maxChars; increase the budget or read the file directly");
|
|
378
|
+
}
|
|
379
|
+
text = text.slice(0, end);
|
|
380
|
+
}
|
|
381
|
+
const lastLine = span.start + text.split("\n").length - 1;
|
|
382
|
+
const truncated = lastLine < span.sourceEnd;
|
|
374
383
|
budget -= text.length;
|
|
375
384
|
out.push({
|
|
376
385
|
path: path.relative(root, span.path) || span.path,
|
|
377
|
-
lines: [span.start,
|
|
386
|
+
lines: [span.start, lastLine],
|
|
387
|
+
truncated: truncated || undefined,
|
|
388
|
+
nextOffset: truncated ? lastLine + 1 : undefined,
|
|
378
389
|
name: span.name,
|
|
379
390
|
kind: span.kind,
|
|
380
391
|
why,
|
|
@@ -70,7 +70,7 @@ export function outlineFile(entry, relPath, about, options = {}) {
|
|
|
70
70
|
const parts = [];
|
|
71
71
|
const headerEnd = Math.min(spans[0].start - 1, opts.headerLines);
|
|
72
72
|
if (headerEnd > 0) {
|
|
73
|
-
const header = raw.slice(0, headerEnd)
|
|
73
|
+
const header = raw.slice(0, headerEnd);
|
|
74
74
|
if (header.length) parts.push(header.map((l, i) => String(i + 1).padStart(5) + " " + l).join("\n"));
|
|
75
75
|
if (spans[0].start - 1 > opts.headerLines) parts.push(" … " + (spans[0].start - 1 - opts.headerLines) + " more header lines");
|
|
76
76
|
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { extractStructuralSurface } from "./surface.js";
|
|
4
|
-
import { isFunction } from "
|
|
4
|
+
import { isFunction } from "../shared/decode.js";
|
|
5
5
|
import { Frecency } from "./fuzzy.js";
|
|
6
|
-
import { relativeSlash } from "
|
|
6
|
+
import { relativeSlash } from "../fs/workspace.js";
|
|
7
7
|
|
|
8
8
|
// In-process workspace index: the gitignore-aware file list comes from one
|
|
9
9
|
// \`rg --files\` spawn and is then reused; file text, lowercase text, and the
|
|
10
|
-
// structural surface are cached per path and validated by mtime.
|
|
11
|
-
//
|
|
10
|
+
// structural surface are cached per path and validated by mtime. Explicit evidence
|
|
11
|
+
// and internal indexed searches use this cache; ordinary source reads and mutation
|
|
12
|
+
// reference hints do not require it. Metadata validation is not content identity.
|
|
12
13
|
|
|
13
14
|
// With a working fs.watch the list only refreshes on change; the TTL is the fallback when watching fails.
|
|
14
15
|
const LIST_TTL_MS = 10_000;
|
|
@@ -210,9 +211,9 @@ export class WorkspaceIndex {
|
|
|
210
211
|
static linesOf(entry) {
|
|
211
212
|
if (entry.lines) return entry.lines;
|
|
212
213
|
const raw = entry.text.split("\n");
|
|
213
|
-
const lower =
|
|
214
|
-
const defNames =
|
|
215
|
-
const idents =
|
|
214
|
+
const lower = [];
|
|
215
|
+
const defNames = [];
|
|
216
|
+
const idents = [];
|
|
216
217
|
for (let i = 0; i < raw.length; i++) {
|
|
217
218
|
const trimmed = raw[i].trim();
|
|
218
219
|
lower[i] = trimmed.toLowerCase();
|
|
@@ -265,11 +266,12 @@ export class WorkspaceIndex {
|
|
|
265
266
|
}
|
|
266
267
|
|
|
267
268
|
/** Structured grep rows {rel, line, text, def}; def marks lines whose declared name itself matches. */
|
|
268
|
-
grepRows(files, regex, root) {
|
|
269
|
+
grepRows(files, regex, root, overlayText = () => undefined) {
|
|
269
270
|
const out = [];
|
|
270
271
|
const nameRegex = new RegExp(regex.source, "i");
|
|
271
272
|
for (const filePath of files) {
|
|
272
|
-
const
|
|
273
|
+
const pending = overlayText(filePath);
|
|
274
|
+
const e = pending === undefined ? this.entry(filePath) : WorkspaceIndex.fromText(filePath, pending);
|
|
273
275
|
if (!e || !regex.test(e.text)) continue;
|
|
274
276
|
const { raw, defNames } = WorkspaceIndex.linesOf(e);
|
|
275
277
|
const rel = relativeSlash(root, filePath);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
+
import { isString } from "../shared/decode.js";
|
|
2
3
|
import { WorkspaceIndex, globToRegExp } from "./repo-index.js";
|
|
3
4
|
import { rankPaths, smartCase, fuzzyMatch } from "./fuzzy.js";
|
|
4
|
-
import { runCommand, relativeSlash } from "
|
|
5
|
+
import { runCommand, relativeSlash } from "../fs/workspace.js";
|
|
5
6
|
|
|
6
7
|
// Search served from the in-process index: fuzzy path find (fff port), smart-case grep with
|
|
7
8
|
// definition-first rows and fuzzy fallback, glob listing. rg is spawned only for trees too
|
|
@@ -11,6 +12,38 @@ function textResult(text, details) {
|
|
|
11
12
|
return { content: [{ type: "text", text: String(text ?? "") }], details: details || {} };
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
/** One bounded direct search for all changed names; no repository index or per-name spawn. */
|
|
16
|
+
export async function referencesForNames({ root, names, excludePath, overlayText, pendingPaths, signal, run = runCommand }) {
|
|
17
|
+
const references = new Map(names.map(name => [name, []]));
|
|
18
|
+
const patterns = names.map(name => new RegExp("(?<![\\w$])" + name.replaceAll("$", "\\$") + "(?![\\w$])"));
|
|
19
|
+
const add = (file, line, text) => {
|
|
20
|
+
if (file === excludePath) return;
|
|
21
|
+
for (let i = 0; i < names.length; i++) {
|
|
22
|
+
const hits = references.get(names[i]);
|
|
23
|
+
if (hits.length < 7 && patterns[i].test(text)) hits.push(relativeSlash(root, file) + ":" + line);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const result = await run(["rg", "--json", "--fixed-strings", ...names.flatMap(name => ["-e", name]), "--", root],
|
|
27
|
+
{ cwd: root, signal, timeoutMs: 5000, maxOutputChars: 65536 });
|
|
28
|
+
if (result.exitCode !== 0 && result.exitCode !== 1) throw new Error(result.stderr.trim() || "reference search failed");
|
|
29
|
+
const records = result.stdout.split("\n");
|
|
30
|
+
for (let i = 0; i < records.length; i++) {
|
|
31
|
+
signal?.throwIfAborted();
|
|
32
|
+
if (!records[i]) continue;
|
|
33
|
+
let record;
|
|
34
|
+
try { record = JSON.parse(records[i]); }
|
|
35
|
+
catch (error) { if (result.outputTruncated && i === records.length - 1) break; throw error; }
|
|
36
|
+
if (record.type !== "match" || !isString(record.data?.path?.text) || !isString(record.data.lines?.text)) continue;
|
|
37
|
+
const file = path.resolve(root, record.data.path.text);
|
|
38
|
+
if (overlayText(file) === undefined) add(file, record.data.line_number, record.data.lines.text);
|
|
39
|
+
}
|
|
40
|
+
for (const file of pendingPaths) {
|
|
41
|
+
const text = overlayText(file);
|
|
42
|
+
if (text !== undefined) text.split("\n").forEach((line, i) => add(file, i + 1, line));
|
|
43
|
+
}
|
|
44
|
+
return { references, incomplete: result.outputTruncated === true };
|
|
45
|
+
}
|
|
46
|
+
|
|
14
47
|
export function rgGrepArgs(pattern, params, searchPath) {
|
|
15
48
|
const args = ["--line-number", "--no-heading", "--color", "never"];
|
|
16
49
|
if (params?.caseSensitive !== true) args.push("--ignore-case");
|
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
-
import { isString } from "
|
|
3
|
-
import { truncateChars } from "
|
|
4
|
-
import
|
|
2
|
+
import { isString } from "../shared/decode.js";
|
|
3
|
+
import { truncateChars } from "../output/format.js";
|
|
4
|
+
import * as fs from "node:fs/promises";
|
|
5
5
|
import { extractStructuralSurface } from "./surface.js";
|
|
6
|
-
import {
|
|
6
|
+
import { rankPaths } from "./fuzzy.js";
|
|
7
|
+
import { isTestPath, runCommand, relativeSlash } from "../fs/workspace.js";
|
|
7
8
|
|
|
8
9
|
const STOP_WORDS = new Set([
|
|
9
10
|
"the", "a", "an", "and", "or", "in", "on", "at", "to", "for", "of", "with",
|
|
10
11
|
"by", "from", "is", "it", "this", "that", "where", "how", "what", "which",
|
|
11
|
-
"file", "code", "function", "class", "method", "find", "get", "look",
|
|
12
|
+
"file", "code", "function", "class", "method", "find", "get", "look", "are", "does", "do",
|
|
12
13
|
]);
|
|
13
14
|
const SOURCE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".rs", ".py", ".go"]);
|
|
14
15
|
const TYPED_EXT = new Set([".ts", ".tsx", ".rs", ".go"]);
|
|
15
16
|
const MAX_SEARCH_CHARS = 2 * 1024 * 1024;
|
|
16
17
|
const MAX_ALTERNATIVES = 3;
|
|
17
18
|
|
|
19
|
+
/** Light suffix stripping so "terminated" ⊇ "terminat" matches "terminate"; deterministic, no dictionary. */
|
|
20
|
+
export function stem(token) {
|
|
21
|
+
if (token.length < 5) return token;
|
|
22
|
+
return token.replace(/(ations?|ings?|ed|es|e|s|ly|ers?)$/, (m) => (token.length - m.length >= 4 ? "" : m));
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
export function tokenizeQuery(query) {
|
|
19
26
|
if (!isString(query) || !query.trim()) return { tokens: [], wantsTest: false, wantsType: false, wantsDoc: false };
|
|
20
27
|
const words = query.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-zA-Z0-9_]+/);
|
|
@@ -111,19 +118,20 @@ function inspectOverlay(candidate, text, needles, query, tokens) {
|
|
|
111
118
|
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
119
|
}
|
|
113
120
|
|
|
114
|
-
async function contentCandidates({ dir, includeHidden, query, tokens, flags,
|
|
121
|
+
async function contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles }) {
|
|
115
122
|
const needles = exact ? [query.toLowerCase()] : tokens;
|
|
116
123
|
const args = ["rg", "--json", "--fixed-strings", "--ignore-case", "--before-context", "2", "--after-context", "4"];
|
|
117
124
|
if (includeHidden) args.push("--hidden");
|
|
118
125
|
args.push("-g", "!.git/**", "-g", "!**/.git/**");
|
|
119
126
|
for (const needle of needles) args.push("-e", needle);
|
|
120
127
|
args.push("--", dir);
|
|
121
|
-
const response = diskFiles ? await
|
|
128
|
+
const response = diskFiles ? await run(args, { cwd: dir, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS, signal })
|
|
122
129
|
: { stdout: "", stderr: "", exitCode: 1 };
|
|
123
130
|
if (response.exitCode !== 0 && response.exitCode !== 1) throw new Error("source search failed: " + response.stderr.trim());
|
|
124
131
|
const candidates = new Map();
|
|
125
132
|
const records = response.stdout.split("\n");
|
|
126
133
|
for (let i = 0; i < records.length; i++) {
|
|
134
|
+
if ((i & 127) === 0) signal?.throwIfAborted();
|
|
127
135
|
if (!records[i]) continue;
|
|
128
136
|
let record;
|
|
129
137
|
try { record = JSON.parse(records[i]); } catch (error) {
|
|
@@ -134,7 +142,7 @@ async function contentCandidates({ dir, includeHidden, query, tokens, flags, fil
|
|
|
134
142
|
const data = record.data;
|
|
135
143
|
if (!data?.path?.text || !isString(data.lines?.text)) continue;
|
|
136
144
|
const filePath = path.resolve(dir, data.path.text);
|
|
137
|
-
if (!
|
|
145
|
+
if (!inScope(filePath, dir, includeHidden) || overlayText(filePath) !== undefined) continue;
|
|
138
146
|
let candidate = candidates.get(filePath);
|
|
139
147
|
if (!candidate) {
|
|
140
148
|
candidate = makeCandidate(filePath, dir, query, tokens, flags);
|
|
@@ -142,7 +150,7 @@ async function contentCandidates({ dir, includeHidden, query, tokens, flags, fil
|
|
|
142
150
|
}
|
|
143
151
|
inspectLine(candidate, data.line_number, data.lines.text, query, tokens, record.type === "match");
|
|
144
152
|
}
|
|
145
|
-
for (const filePath of
|
|
153
|
+
for (const filePath of pendingPaths) {
|
|
146
154
|
const pending = overlayText(filePath);
|
|
147
155
|
if (pending === undefined) continue;
|
|
148
156
|
const candidate = makeCandidate(filePath, dir, query, tokens, flags);
|
|
@@ -159,21 +167,15 @@ function rankScore(candidate, tokenCount) {
|
|
|
159
167
|
+ Math.max(-40, Math.min(20, candidate.pathScore / 5));
|
|
160
168
|
}
|
|
161
169
|
|
|
162
|
-
function location(candidate, root
|
|
163
|
-
|
|
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]));
|
|
169
|
-
}
|
|
170
|
+
function location(candidate, root) {
|
|
171
|
+
const context = candidate.context;
|
|
170
172
|
return { path: path.relative(root, candidate.path), line: candidate.line, signature: candidate.signature,
|
|
171
173
|
context: [...context].sort((a, b) => a[0] - b[0]).map(([line, text]) => (line === candidate.line ? "►" : " ") + line + " " + text) };
|
|
172
174
|
}
|
|
173
175
|
|
|
174
|
-
export async function executeSnap({ query, searchDir, root, includeHidden = false,
|
|
176
|
+
export async function executeSnap({ query, searchDir, root, includeHidden = false, run = runCommand, overlayText = () => undefined, pendingPaths = [], pathContext = {}, signal }) {
|
|
175
177
|
const flags = tokenizeQuery(query);
|
|
176
|
-
const
|
|
178
|
+
const tokens = [...new Set(flags.tokens.map(stem))];
|
|
177
179
|
if (tokens.length === 0) throw new Error("read requires a file path or a searchable source question");
|
|
178
180
|
if (tokens.length > 16) throw new Error("source question is too broad; use at most 16 keywords");
|
|
179
181
|
query = query.trim();
|
|
@@ -181,17 +183,25 @@ export async function executeSnap({ query, searchDir, root, includeHidden = fals
|
|
|
181
183
|
if (dir.split(path.sep).includes(".git")) throw new Error("cannot search Git metadata");
|
|
182
184
|
signal?.throwIfAborted();
|
|
183
185
|
flags.wantsTest ||= isTestPath(path.relative(root ?? dir, dir));
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
186
|
+
pendingPaths = pendingPaths.filter(file => inScope(file, dir, includeHidden));
|
|
187
|
+
const diskFiles = await fs.stat(dir).then(stat => stat.isDirectory(), error => {
|
|
188
|
+
if (error.code !== "ENOENT" || !pendingPaths.length) throw error;
|
|
189
|
+
return false;
|
|
190
|
+
});
|
|
189
191
|
const empty = { path: null, line: null, signature: "", confidence: 0, context: [] };
|
|
190
|
-
if (fileSet.size === 0 && !listing?.truncated) return { ...empty, status: "not_found" };
|
|
191
192
|
const exact = /^[a-zA-Z_$][\w$]*$/.test(query);
|
|
192
|
-
const search = await contentCandidates({ dir, includeHidden, query, tokens, flags,
|
|
193
|
-
|
|
194
|
-
|
|
193
|
+
const search = await contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles });
|
|
194
|
+
// A declaration hit needs no prerequisite file listing or persistent index.
|
|
195
|
+
// Bare names can name files, even when callers mention the same word.
|
|
196
|
+
const needsPaths = !search.candidates.size || (exact && ![...search.candidates.values()].some(candidate => candidate.exactDefinition));
|
|
197
|
+
const listing = needsPaths && !search.truncated && diskFiles
|
|
198
|
+
? await run(["rg", "--files", "--null", ...(includeHidden ? ["--hidden"] : []), "-g", "!.git/**", "-g", "!**/.git/**", dir], { cwd: dir, signal, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS })
|
|
199
|
+
: { stdout: "", exitCode: 1 };
|
|
200
|
+
if (listing.exitCode !== 0 && listing.exitCode !== 1) throw new Error("source file listing failed: " + listing.stderr.trim());
|
|
201
|
+
const paths = [...new Set([...listing.stdout.split("\0").filter(Boolean).map(file => path.resolve(dir, file)), ...pendingPaths])]
|
|
202
|
+
.filter(file => inScope(file, dir, includeHidden));
|
|
203
|
+
for (const filePath of paths) {
|
|
204
|
+
if (!inScope(filePath, dir, includeHidden) || search.candidates.has(filePath)) continue;
|
|
195
205
|
const relative = path.relative(dir, filePath).toLowerCase();
|
|
196
206
|
if (!tokens.some(token => relative.includes(token))) continue;
|
|
197
207
|
if (exact && tokens.length > 1 && !relative.includes(query.toLowerCase())) continue;
|
|
@@ -201,11 +211,21 @@ export async function executeSnap({ query, searchDir, root, includeHidden = fals
|
|
|
201
211
|
const ranked = [...search.candidates.values()].filter(candidate => candidate.pathScore > -50)
|
|
202
212
|
.map(candidate => ({ ...candidate, score: rankScore(candidate, tokens.length) }))
|
|
203
213
|
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
|
|
204
|
-
const incomplete = search.truncated || listing
|
|
214
|
+
const incomplete = search.truncated || listing.outputTruncated === true;
|
|
205
215
|
const relativeRoot = root ?? dir;
|
|
206
|
-
const candidates = ranked.slice(0, MAX_ALTERNATIVES).map(candidate => location(candidate, relativeRoot
|
|
216
|
+
const candidates = ranked.slice(0, MAX_ALTERNATIVES).map(candidate => location(candidate, relativeRoot));
|
|
207
217
|
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)
|
|
218
|
+
if (!ranked.length) {
|
|
219
|
+
// Reuse bounded filename discovery; fuzzy rank never authorizes a source selection.
|
|
220
|
+
const eligible = exact && query.length >= 4 && query.length <= 64;
|
|
221
|
+
const limited = eligible && paths.length > 1024;
|
|
222
|
+
const fuzzy = eligible ? rankPaths(query, paths.slice(0, 1024).map(file => relativeSlash(relativeRoot, file)),
|
|
223
|
+
{ ...pathContext, maxTypos: 1 }).filter(hit => hit.score > 0).slice(0, MAX_ALTERNATIVES) : [];
|
|
224
|
+
if (fuzzy.length || limited) return { ...empty, status: limited ? "incomplete" : "ambiguous",
|
|
225
|
+
candidates: fuzzy.map(hit => ({ path: hit.path, line: 1, context: [], match: "fuzzy" })),
|
|
226
|
+
message: limited ? "No literal match; fuzzy hints cover only 1024 paths. Narrow the directory." : "No literal match. Fuzzy filename hints are not selected source; read an explicit path." };
|
|
227
|
+
return { ...empty, status: "not_found" };
|
|
228
|
+
}
|
|
209
229
|
const best = ranked[0];
|
|
210
230
|
const second = ranked[1];
|
|
211
231
|
const margin = second ? (best.score - second.score) / Math.max(1, best.score) : 1;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import { isString } from "
|
|
2
|
+
import { isString } from "../shared/decode.js";
|
|
3
3
|
|
|
4
4
|
export function buildEditDiff(filePath, originalText, oldText, newText) {
|
|
5
5
|
const fileLines = contentLines(originalText);
|
|
@@ -40,12 +40,14 @@ export function buildMultiEditDiff(filePath, originalText, replacements) {
|
|
|
40
40
|
buildEditDiff(filePath, originalText, oldText, newText),
|
|
41
41
|
);
|
|
42
42
|
const lines = [];
|
|
43
|
-
|
|
43
|
+
let shift = 0;
|
|
44
|
+
for (const [index, part] of parts.entries()) {
|
|
44
45
|
for (const line of part.lines) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
if (line.type === "context") continue;
|
|
47
|
+
lines.push(line.type === "add" ? { ...line, lineNum: line.lineNum + shift }
|
|
48
|
+
: { ...line, newLineNum: Math.max(1, line.lineNum + shift) });
|
|
48
49
|
}
|
|
50
|
+
shift += replacements[index].newText.split("\n").length - replacements[index].oldText.split("\n").length;
|
|
49
51
|
}
|
|
50
52
|
return {
|
|
51
53
|
path: filePath,
|