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
|
@@ -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;
|
|
@@ -150,7 +151,7 @@ export class WorkspaceIndex {
|
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
/** Absolute, sorted file list for a root; gitignore-aware via rg; cached for LIST_TTL_MS. */
|
|
153
|
-
async files(root, includeHidden = false) {
|
|
154
|
+
async files(root, includeHidden = false, signal) {
|
|
154
155
|
const key = root + "\0" + (includeHidden ? "h" : "");
|
|
155
156
|
const cached = this.lists.get(key);
|
|
156
157
|
const ttl = this.watch(root) ? WATCHED_TTL_MS : LIST_TTL_MS;
|
|
@@ -159,13 +160,21 @@ export class WorkspaceIndex {
|
|
|
159
160
|
if (includeHidden) args.push("--hidden");
|
|
160
161
|
args.push("-g", "!.git/**", "-g", "!**/.git/**", root);
|
|
161
162
|
let files = [];
|
|
163
|
+
let error;
|
|
164
|
+
let truncated = false;
|
|
165
|
+
let missing = false;
|
|
162
166
|
try {
|
|
163
|
-
const res = await this.runCommand(args, { cwd: root, timeoutMs: 15_000 });
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
+
const res = await this.runCommand(args, { cwd: root, timeoutMs: 15_000, signal });
|
|
168
|
+
if (res.exitCode !== 0 && res.exitCode !== 1) error = res.stderr.trim() || "rg exited with status " + res.exitCode;
|
|
169
|
+
truncated = res.outputTruncated === true;
|
|
170
|
+
const output = truncated && !res.stdout.endsWith("\n") ? res.stdout.slice(0, res.stdout.lastIndexOf("\n") + 1) : res.stdout;
|
|
171
|
+
files = output.split("\n").filter(Boolean).map(f => path.resolve(root, f)).sort();
|
|
172
|
+
} catch (err) {
|
|
173
|
+
signal?.throwIfAborted();
|
|
174
|
+
error = err.message;
|
|
175
|
+
missing = !fs.existsSync(root);
|
|
167
176
|
}
|
|
168
|
-
this.lists.set(key, { files, at: Date.now() });
|
|
177
|
+
this.lists.set(key, { files, at: Date.now(), error, truncated, missing });
|
|
169
178
|
return files;
|
|
170
179
|
}
|
|
171
180
|
|
|
@@ -202,9 +211,9 @@ export class WorkspaceIndex {
|
|
|
202
211
|
static linesOf(entry) {
|
|
203
212
|
if (entry.lines) return entry.lines;
|
|
204
213
|
const raw = entry.text.split("\n");
|
|
205
|
-
const lower =
|
|
206
|
-
const defNames =
|
|
207
|
-
const idents =
|
|
214
|
+
const lower = [];
|
|
215
|
+
const defNames = [];
|
|
216
|
+
const idents = [];
|
|
208
217
|
for (let i = 0; i < raw.length; i++) {
|
|
209
218
|
const trimmed = raw[i].trim();
|
|
210
219
|
lower[i] = trimmed.toLowerCase();
|
|
@@ -257,11 +266,12 @@ export class WorkspaceIndex {
|
|
|
257
266
|
}
|
|
258
267
|
|
|
259
268
|
/** Structured grep rows {rel, line, text, def}; def marks lines whose declared name itself matches. */
|
|
260
|
-
grepRows(files, regex, root) {
|
|
269
|
+
grepRows(files, regex, root, overlayText = () => undefined) {
|
|
261
270
|
const out = [];
|
|
262
271
|
const nameRegex = new RegExp(regex.source, "i");
|
|
263
272
|
for (const filePath of files) {
|
|
264
|
-
const
|
|
273
|
+
const pending = overlayText(filePath);
|
|
274
|
+
const e = pending === undefined ? this.entry(filePath) : WorkspaceIndex.fromText(filePath, pending);
|
|
265
275
|
if (!e || !regex.test(e.text)) continue;
|
|
266
276
|
const { raw, defNames } = WorkspaceIndex.linesOf(e);
|
|
267
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");
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { isString } from "../shared/decode.js";
|
|
3
|
+
import { truncateChars } from "../output/format.js";
|
|
4
|
+
import * as fs from "node:fs/promises";
|
|
5
|
+
import { extractStructuralSurface } from "./surface.js";
|
|
6
|
+
import { rankPaths } from "./fuzzy.js";
|
|
7
|
+
import { isTestPath, runCommand, relativeSlash } from "../fs/workspace.js";
|
|
8
|
+
|
|
9
|
+
const STOP_WORDS = new Set([
|
|
10
|
+
"the", "a", "an", "and", "or", "in", "on", "at", "to", "for", "of", "with",
|
|
11
|
+
"by", "from", "is", "it", "this", "that", "where", "how", "what", "which",
|
|
12
|
+
"file", "code", "function", "class", "method", "find", "get", "look", "are", "does", "do",
|
|
13
|
+
]);
|
|
14
|
+
const SOURCE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".rs", ".py", ".go"]);
|
|
15
|
+
const TYPED_EXT = new Set([".ts", ".tsx", ".rs", ".go"]);
|
|
16
|
+
const MAX_SEARCH_CHARS = 2 * 1024 * 1024;
|
|
17
|
+
const MAX_ALTERNATIVES = 3;
|
|
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
|
+
|
|
25
|
+
export function tokenizeQuery(query) {
|
|
26
|
+
if (!isString(query) || !query.trim()) return { tokens: [], wantsTest: false, wantsType: false, wantsDoc: false };
|
|
27
|
+
const words = query.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-zA-Z0-9_]+/);
|
|
28
|
+
return {
|
|
29
|
+
tokens: [...new Set(words.filter(word => word.length > 1 && !STOP_WORDS.has(word)))],
|
|
30
|
+
wantsTest: words.some(word => ["test", "tests", "testing", "spec", "specs"].includes(word)),
|
|
31
|
+
wantsType: words.some(word => ["type", "types", "interface", "interfaces", "schema", "schemas"].includes(word)),
|
|
32
|
+
wantsDoc: words.some(word => ["doc", "docs", "documentation", "readme"].includes(word)),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function scorePathTopology(filePath, tokens, flags) {
|
|
37
|
+
const normalized = filePath.replaceAll("\\", "/").toLowerCase();
|
|
38
|
+
const parts = normalized.split("/");
|
|
39
|
+
if (parts.some(part => ["node_modules", "dist", "target"].includes(part))) return -100;
|
|
40
|
+
const test = isTestPath(normalized);
|
|
41
|
+
if (test && !flags.wantsTest) return -50;
|
|
42
|
+
if (!test && flags.wantsTest) return -20;
|
|
43
|
+
const base = path.basename(normalized);
|
|
44
|
+
const words = normalized.split(/[^a-zA-Z0-9]+/);
|
|
45
|
+
const ext = path.extname(normalized);
|
|
46
|
+
let score = SOURCE_EXT.has(ext) && !flags.wantsDoc ? 5 : 0;
|
|
47
|
+
if (flags.wantsType && TYPED_EXT.has(ext)) score += 10;
|
|
48
|
+
for (const token of tokens) {
|
|
49
|
+
if (base === token || base.startsWith(token + ".")) score += 60;
|
|
50
|
+
else if (base.includes(token)) score += 30;
|
|
51
|
+
else if (words.includes(token)) score += 15;
|
|
52
|
+
else if (normalized.includes(token)) score += 5;
|
|
53
|
+
}
|
|
54
|
+
return score;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function inScope(filePath, dir, includeHidden) {
|
|
58
|
+
const relative = path.relative(dir, filePath);
|
|
59
|
+
if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) return false;
|
|
60
|
+
const parts = relative.split(path.sep);
|
|
61
|
+
return !parts.includes(".git") && (includeHidden || !parts.some(part => part.startsWith(".") && part.length > 1));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function makeCandidate(filePath, dir, query, tokens, flags) {
|
|
65
|
+
const relative = path.relative(dir, filePath);
|
|
66
|
+
const lower = relative.toLowerCase();
|
|
67
|
+
const base = path.basename(lower);
|
|
68
|
+
const exactPath = lower === query.toLowerCase() || base === query.toLowerCase()
|
|
69
|
+
|| base.slice(0, -path.extname(base).length) === query.toLowerCase();
|
|
70
|
+
return { path: filePath, pathScore: scorePathTopology(relative, tokens, flags), exactPath,
|
|
71
|
+
pathCoverage: tokens.filter(token => lower.includes(token)).length,
|
|
72
|
+
matched: new Set(), exactDefinition: false, definitionCoverage: 0, lineCoverage: 0,
|
|
73
|
+
line: 1, signature: "", context: new Map(), recent: [], anchorScore: -1 };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
|
|
77
|
+
const text = raw.replace(/\r?\n$/, "");
|
|
78
|
+
const lower = text.toLowerCase();
|
|
79
|
+
if (isMatch) {
|
|
80
|
+
const matches = tokens.filter(token => lower.includes(token));
|
|
81
|
+
for (const token of matches) candidate.matched.add(token);
|
|
82
|
+
const ext = path.extname(candidate.path).toLowerCase();
|
|
83
|
+
const items = SOURCE_EXT.has(ext) ? extractStructuralSurface(text, ext).items : [];
|
|
84
|
+
let declaration;
|
|
85
|
+
let definitionCoverage = 0;
|
|
86
|
+
let exact = false;
|
|
87
|
+
for (const item of items) {
|
|
88
|
+
const name = item.name.toLowerCase();
|
|
89
|
+
const itemExact = name === query.toLowerCase();
|
|
90
|
+
const coverage = tokens.filter(token => name.includes(token)).length;
|
|
91
|
+
if (itemExact || coverage > definitionCoverage) { declaration = item; definitionCoverage = coverage; exact = itemExact; }
|
|
92
|
+
if (exact) break;
|
|
93
|
+
}
|
|
94
|
+
const score = (exact ? 10000 : 0) + definitionCoverage * 40 + matches.length;
|
|
95
|
+
if (score > candidate.anchorScore) {
|
|
96
|
+
candidate.anchorScore = score;
|
|
97
|
+
candidate.line = lineNumber;
|
|
98
|
+
candidate.signature = truncateChars(declaration?.signature ?? "", 240, "signature").text;
|
|
99
|
+
candidate.exactDefinition = exact;
|
|
100
|
+
candidate.definitionCoverage = definitionCoverage;
|
|
101
|
+
candidate.lineCoverage = matches.length;
|
|
102
|
+
candidate.context.clear();
|
|
103
|
+
for (const [number, line] of candidate.recent) if (number >= lineNumber - 2) candidate.context.set(number, line);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const excerpt = truncateChars(text, 240, "source line").text;
|
|
107
|
+
if (lineNumber >= candidate.line - 2 && lineNumber <= candidate.line + 4) candidate.context.set(lineNumber, excerpt);
|
|
108
|
+
candidate.recent.push([lineNumber, excerpt]);
|
|
109
|
+
if (candidate.recent.length > 2) candidate.recent.shift();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function inspectOverlay(candidate, text, needles, query, tokens) {
|
|
113
|
+
const lines = text.split("\n");
|
|
114
|
+
const matches = [];
|
|
115
|
+
for (let i = 0; i < lines.length; i++) if (needles.some(needle => lines[i].toLowerCase().includes(needle))) matches.push(i);
|
|
116
|
+
for (const i of matches) inspectLine(candidate, i + 1, lines[i], query, tokens, true);
|
|
117
|
+
candidate.context.clear();
|
|
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);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles }) {
|
|
122
|
+
const needles = exact ? [query.toLowerCase()] : tokens;
|
|
123
|
+
const args = ["rg", "--json", "--fixed-strings", "--ignore-case", "--before-context", "2", "--after-context", "4"];
|
|
124
|
+
if (includeHidden) args.push("--hidden");
|
|
125
|
+
args.push("-g", "!.git/**", "-g", "!**/.git/**");
|
|
126
|
+
for (const needle of needles) args.push("-e", needle);
|
|
127
|
+
args.push("--", dir);
|
|
128
|
+
const response = diskFiles ? await run(args, { cwd: dir, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS, signal })
|
|
129
|
+
: { stdout: "", stderr: "", exitCode: 1 };
|
|
130
|
+
if (response.exitCode !== 0 && response.exitCode !== 1) throw new Error("source search failed: " + response.stderr.trim());
|
|
131
|
+
const candidates = new Map();
|
|
132
|
+
const records = response.stdout.split("\n");
|
|
133
|
+
for (let i = 0; i < records.length; i++) {
|
|
134
|
+
if ((i & 127) === 0) signal?.throwIfAborted();
|
|
135
|
+
if (!records[i]) continue;
|
|
136
|
+
let record;
|
|
137
|
+
try { record = JSON.parse(records[i]); } catch (error) {
|
|
138
|
+
if (response.outputTruncated && i === records.length - 1) break;
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
if (record.type !== "match" && record.type !== "context") continue;
|
|
142
|
+
const data = record.data;
|
|
143
|
+
if (!data?.path?.text || !isString(data.lines?.text)) continue;
|
|
144
|
+
const filePath = path.resolve(dir, data.path.text);
|
|
145
|
+
if (!inScope(filePath, dir, includeHidden) || overlayText(filePath) !== undefined) continue;
|
|
146
|
+
let candidate = candidates.get(filePath);
|
|
147
|
+
if (!candidate) {
|
|
148
|
+
candidate = makeCandidate(filePath, dir, query, tokens, flags);
|
|
149
|
+
candidates.set(filePath, candidate);
|
|
150
|
+
}
|
|
151
|
+
inspectLine(candidate, data.line_number, data.lines.text, query, tokens, record.type === "match");
|
|
152
|
+
}
|
|
153
|
+
for (const filePath of pendingPaths) {
|
|
154
|
+
const pending = overlayText(filePath);
|
|
155
|
+
if (pending === undefined) continue;
|
|
156
|
+
const candidate = makeCandidate(filePath, dir, query, tokens, flags);
|
|
157
|
+
inspectOverlay(candidate, pending, needles, query, tokens);
|
|
158
|
+
if (candidate.matched.size) candidates.set(filePath, candidate);
|
|
159
|
+
}
|
|
160
|
+
return { candidates, truncated: response.outputTruncated === true };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function rankScore(candidate, tokenCount) {
|
|
164
|
+
return (candidate.exactDefinition ? 10000 : 0) + (candidate.exactPath ? 500 : 0)
|
|
165
|
+
+ candidate.definitionCoverage / tokenCount * 100 + candidate.matched.size / tokenCount * 30
|
|
166
|
+
+ candidate.pathCoverage / tokenCount * 20 + candidate.lineCoverage / tokenCount * 10
|
|
167
|
+
+ Math.max(-40, Math.min(20, candidate.pathScore / 5));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function location(candidate, root) {
|
|
171
|
+
const context = candidate.context;
|
|
172
|
+
return { path: path.relative(root, candidate.path), line: candidate.line, signature: candidate.signature,
|
|
173
|
+
context: [...context].sort((a, b) => a[0] - b[0]).map(([line, text]) => (line === candidate.line ? "►" : " ") + line + " " + text) };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function executeSnap({ query, searchDir, root, includeHidden = false, run = runCommand, overlayText = () => undefined, pendingPaths = [], pathContext = {}, signal }) {
|
|
177
|
+
const flags = tokenizeQuery(query);
|
|
178
|
+
const tokens = [...new Set(flags.tokens.map(stem))];
|
|
179
|
+
if (tokens.length === 0) throw new Error("read requires a file path or a searchable source question");
|
|
180
|
+
if (tokens.length > 16) throw new Error("source question is too broad; use at most 16 keywords");
|
|
181
|
+
query = query.trim();
|
|
182
|
+
const dir = path.resolve(searchDir || process.cwd());
|
|
183
|
+
if (dir.split(path.sep).includes(".git")) throw new Error("cannot search Git metadata");
|
|
184
|
+
signal?.throwIfAborted();
|
|
185
|
+
flags.wantsTest ||= isTestPath(path.relative(root ?? dir, dir));
|
|
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
|
+
});
|
|
191
|
+
const empty = { path: null, line: null, signature: "", confidence: 0, context: [] };
|
|
192
|
+
const exact = /^[a-zA-Z_$][\w$]*$/.test(query);
|
|
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;
|
|
205
|
+
const relative = path.relative(dir, filePath).toLowerCase();
|
|
206
|
+
if (!tokens.some(token => relative.includes(token))) continue;
|
|
207
|
+
if (exact && tokens.length > 1 && !relative.includes(query.toLowerCase())) continue;
|
|
208
|
+
const candidate = makeCandidate(filePath, dir, query, tokens, flags);
|
|
209
|
+
if (candidate.pathScore > 0) search.candidates.set(filePath, candidate);
|
|
210
|
+
}
|
|
211
|
+
const ranked = [...search.candidates.values()].filter(candidate => candidate.pathScore > -50)
|
|
212
|
+
.map(candidate => ({ ...candidate, score: rankScore(candidate, tokens.length) }))
|
|
213
|
+
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
|
|
214
|
+
const incomplete = search.truncated || listing.outputTruncated === true;
|
|
215
|
+
const relativeRoot = root ?? dir;
|
|
216
|
+
const candidates = ranked.slice(0, MAX_ALTERNATIVES).map(candidate => location(candidate, relativeRoot));
|
|
217
|
+
if (incomplete) return { ...empty, status: "incomplete", candidates, message: "Search output exceeded its budget. Narrow the directory with read(path, {about: question})." };
|
|
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
|
+
}
|
|
229
|
+
const best = ranked[0];
|
|
230
|
+
const second = ranked[1];
|
|
231
|
+
const margin = second ? (best.score - second.score) / Math.max(1, best.score) : 1;
|
|
232
|
+
const coverage = Math.max(best.matched.size, best.pathCoverage) / tokens.length;
|
|
233
|
+
const uniqueExact = best.exactDefinition && !second?.exactDefinition || best.exactPath && !second?.exactPath && !second?.exactDefinition;
|
|
234
|
+
if (!uniqueExact && (coverage < 0.6 || margin < 0.15 || best.definitionCoverage / tokens.length < 0.5)) return { ...empty, status: "ambiguous", candidates };
|
|
235
|
+
const confidence = uniqueExact ? 0.95 : Math.min(0.85, 0.5 + coverage * 0.2 + margin * 0.15);
|
|
236
|
+
return { ...candidates[0], status: "found", confidence: Number(confidence.toFixed(2)) };
|
|
237
|
+
}
|