pi-supernova 0.0.8 → 0.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +6 -2
- package/catalog.js +12 -8
- package/evidence.js +446 -0
- package/guest-worker.js +4 -3
- package/host-bridge.js +90 -29
- package/index.js +36 -4
- package/package.json +3 -1
- package/render.js +14 -4
- package/repo-index.js +179 -0
- package/snap.js +91 -108
- package/surface.js +4 -1
- package/vfs.js +7 -1
- package/workspace.js +19 -2
package/host-bridge.js
CHANGED
|
@@ -8,9 +8,11 @@ import { unknownToolMessage } from "./catalog.js";
|
|
|
8
8
|
import { extractStructuralSurface } from "./surface.js";
|
|
9
9
|
import { buildEditDiff, buildMultiEditDiff, buildPatchDiff, buildWriteDiff } from "./diff.js";
|
|
10
10
|
import { executeSnap } from "./snap.js";
|
|
11
|
+
import { selectEvidence } from "./evidence.js";
|
|
12
|
+
import { WorkspaceIndex, globToRegExp } from "./repo-index.js";
|
|
11
13
|
import { CausalVfs } from "./vfs.js";
|
|
12
14
|
import { applyPatchToText } from "./patch.js";
|
|
13
|
-
import { resolveWorkspacePath, runCommand } from "./workspace.js";
|
|
15
|
+
import { resolveWorkspacePath, runCommand, clearPathCache } from "./workspace.js";
|
|
14
16
|
|
|
15
17
|
function textResult(text, details) {
|
|
16
18
|
return {
|
|
@@ -113,7 +115,61 @@ async function formatLsEntry(dirPath, entry) {
|
|
|
113
115
|
return `${entry.name}${isDir ? "/" : ""} (${typeLabel}${sizeSuffix})`;
|
|
114
116
|
}
|
|
115
117
|
|
|
116
|
-
function
|
|
118
|
+
function rgGrepArgs(pattern, params, searchPath) {
|
|
119
|
+
const args = ["--line-number", "--no-heading", "--color", "never"];
|
|
120
|
+
if (params?.caseSensitive !== true) args.push("--ignore-case");
|
|
121
|
+
if (params?.glob) args.push("--glob", String(params.glob));
|
|
122
|
+
args.push("--", pattern, searchPath);
|
|
123
|
+
return args;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** rg --files, then find(1) when rg is unavailable; both accept an optional glob/name pattern. */
|
|
127
|
+
async function listWithTools(searchDir, pattern, cwd, signal) {
|
|
128
|
+
const args = ["--files"];
|
|
129
|
+
if (pattern) args.push("-g", pattern);
|
|
130
|
+
const res = await runCommand(["rg", ...args, searchDir], { cwd, timeoutMs: 30_000, signal }).catch(() => null);
|
|
131
|
+
if (res && (res.exitCode === 0 || res.exitCode === 1)) return textResult(res.stdout, { via: "rg" });
|
|
132
|
+
const findArgs = [searchDir];
|
|
133
|
+
if (pattern) findArgs.push("-name", pattern);
|
|
134
|
+
const findRes = await runCommand(["find", ...findArgs], { cwd, timeoutMs: 30_000, signal });
|
|
135
|
+
return textResult(findRes.stdout, { via: "find" });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** rg-compatible grep served from the index; null when the pattern or tree needs real rg. */
|
|
139
|
+
async function grepIndexed(index, pattern, params, searchPath, cwd) {
|
|
140
|
+
let regex;
|
|
141
|
+
try {
|
|
142
|
+
regex = new RegExp(pattern, params?.caseSensitive === true ? "" : "i");
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
let files = await index.files(searchPath);
|
|
147
|
+
if (!index.canScan(files)) return null;
|
|
148
|
+
if (params?.glob) {
|
|
149
|
+
const matcher = globToRegExp(String(params.glob));
|
|
150
|
+
files = files.filter((f) => matcher.test(path.relative(cwd, f).split(path.sep).join("/")));
|
|
151
|
+
}
|
|
152
|
+
const rows = index.grep(files, regex, cwd);
|
|
153
|
+
return rows.length ? rows.join("\n") + "\n" : "";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** rg --files [-g pattern] served from the index; null when the tree is too large. */
|
|
157
|
+
async function listIndexed(index, root, cwd, pattern) {
|
|
158
|
+
const files = await index.files(root);
|
|
159
|
+
if (!index.canScan(files)) return null;
|
|
160
|
+
const rel = files.map((f) => path.relative(cwd, f).split(path.sep).join("/"));
|
|
161
|
+
if (!pattern) return rel.length ? rel.join("\n") + "\n" : "";
|
|
162
|
+
let matcher;
|
|
163
|
+
try {
|
|
164
|
+
matcher = globToRegExp(pattern);
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
const hits = rel.filter((f) => matcher.test(f));
|
|
169
|
+
return hits.length ? hits.join("\n") + "\n" : "";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function createNativeAdapters(getCwd, vfs, config, index) {
|
|
117
173
|
async function readAdapter(params, signal) {
|
|
118
174
|
const cwd = getCwd();
|
|
119
175
|
const targetParam = params?.path ?? params?.target;
|
|
@@ -143,8 +199,9 @@ function createNativeAdapters(getCwd, vfs, config) {
|
|
|
143
199
|
const snapRes = await executeSnap({
|
|
144
200
|
query: targetParam,
|
|
145
201
|
searchDir: cwd,
|
|
146
|
-
|
|
147
|
-
|
|
202
|
+
index,
|
|
203
|
+
overlayText: (p) => vfs.getOverlay(p),
|
|
204
|
+
pendingPaths: vfs.getOverlayPaths(),
|
|
148
205
|
});
|
|
149
206
|
return textResult(JSON.stringify(snapRes, null, 2), { ...snapRes, isSnap: true });
|
|
150
207
|
} catch {}
|
|
@@ -243,19 +300,25 @@ function createNativeAdapters(getCwd, vfs, config) {
|
|
|
243
300
|
const res = await executeSnap({
|
|
244
301
|
query: params.query,
|
|
245
302
|
searchDir: snapTarget,
|
|
303
|
+
root: cwd,
|
|
246
304
|
includeHidden,
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
// Jail each snap candidate (symlink files must not escape the workspace).
|
|
250
|
-
const jailed = await resolveWorkspacePath(cwd, candidate, "snap", false);
|
|
251
|
-
return vfs.read(jailed);
|
|
252
|
-
},
|
|
253
|
-
},
|
|
254
|
-
runCommand: (argv, opts) => runCommand(argv, { cwd: snapTarget, signal, ...opts }),
|
|
305
|
+
index,
|
|
306
|
+
overlayText: (p) => vfs.getOverlay(p),
|
|
255
307
|
pendingPaths: vfs.getOverlayPaths(),
|
|
256
308
|
});
|
|
257
309
|
return textResult(JSON.stringify(res, null, 2), res);
|
|
258
310
|
},
|
|
311
|
+
async evidence(params, signal) {
|
|
312
|
+
const cwd = getCwd();
|
|
313
|
+
if (!isString(params?.query) || !params.query.trim()) throw new Error("evidence requires query");
|
|
314
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
315
|
+
const searchDir = params?.path ? await resolveWorkspacePath(cwd, params.path, "evidence", true) : cwd;
|
|
316
|
+
const options = {};
|
|
317
|
+
if (Number.isInteger(params?.k) && params.k > 0) options.k = params.k;
|
|
318
|
+
if (Number.isInteger(params?.maxChars) && params.maxChars > 0) options.maxChars = params.maxChars;
|
|
319
|
+
const res = await selectEvidence({ query: params.query, root: cwd, searchDir, index, overlayText: (p) => vfs.getOverlay(p), options });
|
|
320
|
+
return textResult(JSON.stringify(res), { route: res.route, count: res.spans.length });
|
|
321
|
+
},
|
|
259
322
|
async surface(params, signal) {
|
|
260
323
|
const cwd = getCwd();
|
|
261
324
|
const target = await resolveWorkspacePath(cwd, params?.path, "surface", false);
|
|
@@ -286,6 +349,7 @@ function createNativeAdapters(getCwd, vfs, config) {
|
|
|
286
349
|
});
|
|
287
350
|
} finally {
|
|
288
351
|
vfs.invalidateCache();
|
|
352
|
+
index.invalidate();
|
|
289
353
|
}
|
|
290
354
|
const { stdout, stderr } = res;
|
|
291
355
|
const text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
|
|
@@ -300,11 +364,9 @@ function createNativeAdapters(getCwd, vfs, config) {
|
|
|
300
364
|
const pattern = String(params?.pattern || "");
|
|
301
365
|
if (!pattern) throw new Error("grep requires pattern");
|
|
302
366
|
const searchPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "grep", true) : cwd;
|
|
303
|
-
const
|
|
304
|
-
if (
|
|
305
|
-
|
|
306
|
-
args.push("--", pattern, searchPath);
|
|
307
|
-
const res = await runCommand(["rg", ...args], { cwd, timeoutMs: 30_000, signal });
|
|
367
|
+
const indexed = await grepIndexed(index, pattern, params, searchPath, cwd);
|
|
368
|
+
if (indexed !== null) return textResult(indexed, { exitCode: indexed ? 0 : 1, via: "index" });
|
|
369
|
+
const res = await runCommand(["rg", ...rgGrepArgs(pattern, params, searchPath)], { cwd, timeoutMs: 30_000, signal });
|
|
308
370
|
if (res.exitCode !== 0 && res.exitCode !== 1) {
|
|
309
371
|
throw new Error(res.stderr.trim() || `rg exited ${res.exitCode}`);
|
|
310
372
|
}
|
|
@@ -314,6 +376,8 @@ function createNativeAdapters(getCwd, vfs, config) {
|
|
|
314
376
|
const cwd = getCwd();
|
|
315
377
|
const pattern = String(params?.pattern || "");
|
|
316
378
|
if (!pattern) throw new Error("glob requires pattern");
|
|
379
|
+
const indexed = await listIndexed(index, cwd, cwd, pattern);
|
|
380
|
+
if (indexed !== null) return textResult(indexed, { via: "index" });
|
|
317
381
|
const rg = await runCommand(["rg", "--files", "-g", pattern], { cwd, timeoutMs: 30_000, signal }).catch(
|
|
318
382
|
() => null,
|
|
319
383
|
);
|
|
@@ -333,16 +397,10 @@ function createNativeAdapters(getCwd, vfs, config) {
|
|
|
333
397
|
const searchDir = params?.path ? await resolveWorkspacePath(cwd, params.path, "find", true) : cwd;
|
|
334
398
|
const pattern = params?.pattern || params?.glob;
|
|
335
399
|
if (signal?.aborted) throw new Error("aborted");
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
return textResult(res.stdout, { via: "rg" });
|
|
341
|
-
}
|
|
342
|
-
const findArgs = [searchDir];
|
|
343
|
-
if (pattern) findArgs.push("-name", String(pattern));
|
|
344
|
-
const findRes = await runCommand(["find", ...findArgs], { cwd, timeoutMs: 30_000, signal });
|
|
345
|
-
return textResult(findRes.stdout, { via: "find" });
|
|
400
|
+
const globPattern = pattern ? String(pattern) : null;
|
|
401
|
+
const indexed = await listIndexed(index, searchDir, cwd, globPattern);
|
|
402
|
+
if (indexed !== null) return textResult(indexed, { via: "index" });
|
|
403
|
+
return listWithTools(searchDir, globPattern, cwd, signal);
|
|
346
404
|
},
|
|
347
405
|
async ls(params, signal) {
|
|
348
406
|
const cwd = getCwd();
|
|
@@ -359,9 +417,10 @@ function createNativeAdapters(getCwd, vfs, config) {
|
|
|
359
417
|
}
|
|
360
418
|
|
|
361
419
|
export function createHostBridge({ pi, config, getCwd }) {
|
|
362
|
-
const
|
|
420
|
+
const index = new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
|
|
421
|
+
const vfs = new CausalVfs(() => index.invalidate());
|
|
363
422
|
const executors = new Map();
|
|
364
|
-
const natives = createNativeAdapters(getCwd, vfs, config);
|
|
423
|
+
const natives = createNativeAdapters(getCwd, vfs, config, index);
|
|
365
424
|
let callCount = 0;
|
|
366
425
|
let activeCtx = null;
|
|
367
426
|
let activeSignal = undefined;
|
|
@@ -395,6 +454,7 @@ export function createHostBridge({ pi, config, getCwd }) {
|
|
|
395
454
|
trace = [];
|
|
396
455
|
// Files may change between programs (editor, git); never serve a stale run.
|
|
397
456
|
vfs.invalidateCache();
|
|
457
|
+
clearPathCache();
|
|
398
458
|
}
|
|
399
459
|
|
|
400
460
|
function getTrace() {
|
|
@@ -514,6 +574,7 @@ export function createHostBridge({ pi, config, getCwd }) {
|
|
|
514
574
|
} catch (error) {
|
|
515
575
|
record.ok = false;
|
|
516
576
|
record.ms = Date.now() - record.time;
|
|
577
|
+
record.error = error instanceof Error ? error.message : String(error);
|
|
517
578
|
notifyCall(record);
|
|
518
579
|
throw error;
|
|
519
580
|
}
|
package/index.js
CHANGED
|
@@ -31,14 +31,44 @@ function result(text, details) {
|
|
|
31
31
|
return { content: [{ type: "text", text }], details };
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
const PROGRESS_FRAME_MS = 40;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Live trace updates for the card. The first update is immediate (seeds the result slot);
|
|
38
|
+
* later ones are coalesced to one host re-render per frame so a tight loop of nova.calls
|
|
39
|
+
* is not throttled by the TUI. A throwing host callback must never break the run.
|
|
40
|
+
*/
|
|
35
41
|
function progressEmitter(onUpdate) {
|
|
36
|
-
if (!isFunction(onUpdate)) return () => {};
|
|
37
|
-
|
|
42
|
+
if (!isFunction(onUpdate)) return Object.assign(() => {}, { flush() {} });
|
|
43
|
+
let pending = null;
|
|
44
|
+
let timer = null;
|
|
45
|
+
const send = (trace) => {
|
|
38
46
|
try {
|
|
39
47
|
onUpdate({ content: [{ type: "text", text: "" }], details: { trace, running: true } });
|
|
40
48
|
} catch {}
|
|
41
49
|
};
|
|
50
|
+
const flush = () => {
|
|
51
|
+
timer = null;
|
|
52
|
+
if (pending === null) return;
|
|
53
|
+
const trace = pending;
|
|
54
|
+
pending = null;
|
|
55
|
+
send(trace);
|
|
56
|
+
};
|
|
57
|
+
const emit = (trace) => {
|
|
58
|
+
if (timer === null && pending === null) {
|
|
59
|
+
send(trace);
|
|
60
|
+
timer = setTimeout(flush, PROGRESS_FRAME_MS);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
pending = trace;
|
|
64
|
+
if (timer === null) timer = setTimeout(flush, PROGRESS_FRAME_MS);
|
|
65
|
+
};
|
|
66
|
+
emit.flush = () => {
|
|
67
|
+
if (timer !== null) clearTimeout(timer);
|
|
68
|
+
pending = null;
|
|
69
|
+
timer = null;
|
|
70
|
+
};
|
|
71
|
+
return emit;
|
|
42
72
|
}
|
|
43
73
|
|
|
44
74
|
function logsBlock(outcome, tail = "") {
|
|
@@ -73,6 +103,7 @@ Globals (async):
|
|
|
73
103
|
read(path|paths, offset?, limit?) → text | text[]
|
|
74
104
|
write(path, text) · edit(path, oldText, newText) · patch(path, unifiedDiff)
|
|
75
105
|
bash(cmd, {cwd?, timeoutMs?}) → output, throws on non-zero exit · exec(cmd, argv?) quotes argv
|
|
106
|
+
evidence(query, {k?}) → {spans: [{path, lines, name, text}]} top-K spans that answer a question — use before read
|
|
76
107
|
snap(query, root?) → {path, line, signature, context} · surface(path) → {items: [{name, kind, line}]}
|
|
77
108
|
nova.call(name, args) → {ok, value} for any host tool · nova.callMany([{name, args}]) parallel when read-only
|
|
78
109
|
nova.search(query) → [{name, description}] · nova.describe(name) → parameters · nova.has(name) sync
|
|
@@ -148,7 +179,7 @@ export default function piSupernova(pi) {
|
|
|
148
179
|
description: TOOL_DESCRIPTION,
|
|
149
180
|
promptSnippet: "Compose host tools in one JavaScript program",
|
|
150
181
|
promptGuidelines: [
|
|
151
|
-
"Use supernova for multi-step tool work: loops, filtering, parallel reads, read→edit chains. Return a compact shaped value; keep raw tool output inside the program.",
|
|
182
|
+
"Use supernova for multi-step tool work: loops, filtering, parallel reads, read→edit chains. To understand code, call evidence(question) and read only the returned spans; read whole files only to edit them. Return a compact shaped value; keep raw tool output inside the program.",
|
|
152
183
|
],
|
|
153
184
|
parameters: Type.Object({
|
|
154
185
|
code: Type.String({ description: "JavaScript program: async body or arrow function." }),
|
|
@@ -180,6 +211,7 @@ export default function piSupernova(pi) {
|
|
|
180
211
|
outcome = await runProgram(params, runController.signal, abortRun);
|
|
181
212
|
} finally {
|
|
182
213
|
bridge.setCallListener(null);
|
|
214
|
+
emitProgress.flush();
|
|
183
215
|
signal?.removeEventListener("abort", abortRun);
|
|
184
216
|
}
|
|
185
217
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-supernova",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.11",
|
|
4
4
|
"description": "Dual-host CodeMode for Pi/OMP: progressive tool discovery, result bottleneck, and Amdahl Auto parallel.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "AdityaVG13",
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
"parallel.js",
|
|
31
31
|
"runtime.js",
|
|
32
32
|
"guest-worker.js",
|
|
33
|
+
"repo-index.js",
|
|
34
|
+
"evidence.js",
|
|
33
35
|
"format.js",
|
|
34
36
|
"patch.js",
|
|
35
37
|
"vfs.js",
|
package/render.js
CHANGED
|
@@ -178,7 +178,7 @@ function displayOperation(tool, target, diff, ok, item) {
|
|
|
178
178
|
const rawName = cleanInlineText(tool);
|
|
179
179
|
if (!rawName) return null;
|
|
180
180
|
const normalized = rawName === "apply_patch" ? "patch" : rawName;
|
|
181
|
-
return { tool: normalized, target, diff, ok, ms: item?.ms, exitCode: item?.exitCode, time: item?.time };
|
|
181
|
+
return { tool: normalized, target, diff, ok, ms: item?.ms, exitCode: item?.exitCode, time: item?.time, error: item?.error };
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
function isTheme(value) {
|
|
@@ -271,6 +271,11 @@ export function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs
|
|
|
271
271
|
throw new Error("supernova renderResult: theme missing (expected Pi or OMP signature)");
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
function batchTarget(paths) {
|
|
275
|
+
const names = paths.map((p) => String(p).replace(/\\/g, "/").split("/").pop());
|
|
276
|
+
return `${paths.length} files: ${names.join(", ")}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
274
279
|
const OPERATION_TARGETS = [
|
|
275
280
|
[(item) => item?.name === "snap", (item, args) => {
|
|
276
281
|
const query = args.query ? `"${args.query}"` : "";
|
|
@@ -278,6 +283,7 @@ const OPERATION_TARGETS = [
|
|
|
278
283
|
return `${query} → ${args.path}`;
|
|
279
284
|
}],
|
|
280
285
|
[(item) => item?.name === "search", (item, args) => (args.query ? `"${args.query}"` : "")],
|
|
286
|
+
[(item, args) => Array.isArray(args.path), (item, args) => batchTarget(args.path)],
|
|
281
287
|
[(item, args) => args.path, (item, args) => String(args.path)],
|
|
282
288
|
[(item) => item?.diff?.path, (item) => String(item.diff.path)],
|
|
283
289
|
[(item, args) => args.target && isString(args.target), (item, args) => args.target],
|
|
@@ -405,8 +411,11 @@ function formatOpRow(theme, op, width, isPartial, isError) {
|
|
|
405
411
|
prefix += theme.fg("toolDiffAdded", added) + theme.fg("dim", "/") + theme.fg("toolDiffRemoved", removed) + " ";
|
|
406
412
|
used += added.length + 1 + removed.length + 1;
|
|
407
413
|
}
|
|
408
|
-
const
|
|
409
|
-
|
|
414
|
+
const budget = Math.max(1, width - used);
|
|
415
|
+
const target = formatTarget(op, budget);
|
|
416
|
+
if (target) return prefix + theme.fg("muted", target);
|
|
417
|
+
if (op.ok === false && op.error) return prefix + theme.fg("error", clampLine(cleanInlineText(op.error), budget));
|
|
418
|
+
return prefix.trimEnd();
|
|
410
419
|
}
|
|
411
420
|
|
|
412
421
|
function operationsFor(payload, context, args) {
|
|
@@ -486,7 +495,8 @@ class UnifiedResultCard {
|
|
|
486
495
|
}),
|
|
487
496
|
sections: view.lines.length > 0 ? [{ lines: view.lines }] : [],
|
|
488
497
|
state: model.isError ? "error" : model.isPartial ? "pending" : "success",
|
|
489
|
-
|
|
498
|
+
// borderMuted is invisible on OMP's card background; dim matches the duration column.
|
|
499
|
+
borderColor: model.isError ? "error" : "dim",
|
|
490
500
|
width: frameWidth,
|
|
491
501
|
};
|
|
492
502
|
});
|
package/repo-index.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { extractStructuralSurface } from "./surface.js";
|
|
4
|
+
|
|
5
|
+
// In-process workspace index: the gitignore-aware file list comes from one
|
|
6
|
+
// \`rg --files\` spawn and is then reused; file text, lowercase text, and the
|
|
7
|
+
// structural surface are cached per path and validated by mtime. snap/grep/glob
|
|
8
|
+
// read from here instead of spawning, so a warm call is sub-millisecond.
|
|
9
|
+
|
|
10
|
+
const LIST_TTL_MS = 10_000;
|
|
11
|
+
const MAX_INDEXED_FILES = 4000;
|
|
12
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
13
|
+
const BINARY_EXT = new Set([
|
|
14
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip", ".gz", ".tgz", ".tar", ".bz2", ".xz", ".7z",
|
|
15
|
+
".woff", ".woff2", ".ttf", ".otf", ".eot", ".mp3", ".mp4", ".mov", ".wav", ".ogg", ".webm", ".wasm", ".class",
|
|
16
|
+
".jar", ".so", ".dylib", ".dll", ".exe", ".bin", ".o", ".a", ".node", ".lock", ".sqlite", ".sqlite3", ".db",
|
|
17
|
+
]);
|
|
18
|
+
const REGEX_SPECIAL = /[.+^${}()|\\]/g;
|
|
19
|
+
const IDENT_TOKEN = /[A-Za-z_$][\w$]*/g;
|
|
20
|
+
const EMPTY = Object.freeze([]);
|
|
21
|
+
const DEF_PATTERN = /^(?:pub\s+)?(?:export\s+)?(?:async\s+)?(?:default\s+)?(function|class|def|fn|const|let|interface|type|struct|enum)\s+([a-zA-Z0-9_$]+)/;
|
|
22
|
+
|
|
23
|
+
export function isTextCandidate(filePath) {
|
|
24
|
+
return !BINARY_EXT.has(path.extname(filePath).toLowerCase());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Translate one glob token at index i → [regexSource, nextIndex]. */
|
|
28
|
+
function globToken(glob, i) {
|
|
29
|
+
const ch = glob[i];
|
|
30
|
+
if (ch === "*" && glob[i + 1] === "*") {
|
|
31
|
+
const slashAfter = glob[i + 2] === "/";
|
|
32
|
+
return [slashAfter ? "(?:.*/)?" : ".*", i + (slashAfter ? 3 : 2)];
|
|
33
|
+
}
|
|
34
|
+
if (ch === "*") return ["[^/]*", i + 1];
|
|
35
|
+
if (ch === "?") return ["[^/]", i + 1];
|
|
36
|
+
if (ch === "{" || ch === "[") return globGroup(glob, i, ch);
|
|
37
|
+
return [ch.replace(REGEX_SPECIAL, "\\$&"), i + 1];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** {a,b} alternation or [..] class starting at i. */
|
|
41
|
+
function globGroup(glob, i, open) {
|
|
42
|
+
const close = open === "{" ? "}" : "]";
|
|
43
|
+
const end = glob.indexOf(close, i);
|
|
44
|
+
if (end < 0) throw new SyntaxError("unclosed " + open + " in glob");
|
|
45
|
+
const inner = glob.slice(i + 1, end);
|
|
46
|
+
const source = open === "{" ? "(?:" + inner.split(",").map(globBody).join("|") + ")" : "[" + inner + "]";
|
|
47
|
+
return [source, end + 1];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function globBody(glob) {
|
|
51
|
+
let source = "";
|
|
52
|
+
let i = 0;
|
|
53
|
+
while (i < glob.length) {
|
|
54
|
+
const [piece, next] = globToken(glob, i);
|
|
55
|
+
source += piece;
|
|
56
|
+
i = next;
|
|
57
|
+
}
|
|
58
|
+
return source;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** gitignore-style glob (rg -g) → RegExp over a "/"-separated relative path. No slash ⇒ basename match anywhere. */
|
|
62
|
+
export function globToRegExp(glob) {
|
|
63
|
+
const body = globBody(glob);
|
|
64
|
+
return new RegExp(glob.includes("/") ? "^" + body + "$" : "(?:^|/)" + body + "$");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class WorkspaceIndex {
|
|
68
|
+
constructor(runCommand) {
|
|
69
|
+
this.runCommand = runCommand;
|
|
70
|
+
this.lists = new Map();
|
|
71
|
+
this.entries = new Map();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
invalidate() {
|
|
75
|
+
this.lists.clear();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Absolute, sorted file list for a root; gitignore-aware via rg; cached for LIST_TTL_MS. */
|
|
79
|
+
async files(root, includeHidden = false) {
|
|
80
|
+
const key = root + "\0" + (includeHidden ? "h" : "");
|
|
81
|
+
const cached = this.lists.get(key);
|
|
82
|
+
if (cached && Date.now() - cached.at < LIST_TTL_MS) return cached.files;
|
|
83
|
+
const args = ["rg", "--files"];
|
|
84
|
+
if (includeHidden) args.push("--hidden");
|
|
85
|
+
args.push("-g", "!.git/**", "-g", "!**/.git/**", root);
|
|
86
|
+
let files = [];
|
|
87
|
+
try {
|
|
88
|
+
const res = await this.runCommand(args, { cwd: root, timeoutMs: 15_000 });
|
|
89
|
+
files = res.stdout.split("\n").map((f) => f.trim()).filter(Boolean).map((f) => path.resolve(root, f)).sort();
|
|
90
|
+
} catch {
|
|
91
|
+
files = [];
|
|
92
|
+
}
|
|
93
|
+
this.lists.set(key, { files, at: Date.now() });
|
|
94
|
+
return files;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Cached {text, lower, ext, surface?} for a file, re-read when mtime/size changed. Null for unreadable, binary, or huge files. */
|
|
98
|
+
entry(filePath) {
|
|
99
|
+
if (!isTextCandidate(filePath)) return null;
|
|
100
|
+
let stat;
|
|
101
|
+
try {
|
|
102
|
+
stat = fs.statSync(filePath);
|
|
103
|
+
} catch {
|
|
104
|
+
this.entries.delete(filePath);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return null;
|
|
108
|
+
const cached = this.entries.get(filePath);
|
|
109
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached;
|
|
110
|
+
let text;
|
|
111
|
+
try {
|
|
112
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
if (text.includes("\0")) return null;
|
|
117
|
+
const created = { text, lower: text.toLowerCase(), mtimeMs: stat.mtimeMs, size: stat.size, ext: path.extname(filePath), surface: undefined, lines: undefined };
|
|
118
|
+
this.entries.set(filePath, created);
|
|
119
|
+
return created;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
static fromText(filePath, text) {
|
|
123
|
+
return { text, lower: text.toLowerCase(), ext: path.extname(filePath), surface: undefined, lines: undefined };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Per-line raw text, lowercase text, declared identifier (or ""), and identifier tokens — computed once per entry. */
|
|
127
|
+
static linesOf(entry) {
|
|
128
|
+
if (entry.lines) return entry.lines;
|
|
129
|
+
const raw = entry.text.split("\n");
|
|
130
|
+
const lower = new Array(raw.length);
|
|
131
|
+
const defNames = new Array(raw.length);
|
|
132
|
+
const idents = new Array(raw.length);
|
|
133
|
+
for (let i = 0; i < raw.length; i++) {
|
|
134
|
+
const trimmed = raw[i].trim();
|
|
135
|
+
lower[i] = trimmed.toLowerCase();
|
|
136
|
+
defNames[i] = DEF_PATTERN.exec(trimmed)?.[2].toLowerCase() ?? "";
|
|
137
|
+
idents[i] = trimmed.match(IDENT_TOKEN) || EMPTY;
|
|
138
|
+
}
|
|
139
|
+
entry.lines = { raw, lower, defNames, idents };
|
|
140
|
+
return entry.lines;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
static surfaceOf(entry) {
|
|
144
|
+
if (!entry.surface) entry.surface = extractStructuralSurface(entry.text, entry.ext);
|
|
145
|
+
return entry.surface;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** True when the list is small enough to scan in-process instead of spawning rg. */
|
|
149
|
+
canScan(files) {
|
|
150
|
+
return files.length <= MAX_INDEXED_FILES;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Files whose lowercase text contains any (or every) needle; needles are lowercase. */
|
|
154
|
+
filesContaining(files, needles, anyOf) {
|
|
155
|
+
const hits = [];
|
|
156
|
+
for (const filePath of files) {
|
|
157
|
+
const e = this.entry(filePath);
|
|
158
|
+
if (!e) continue;
|
|
159
|
+
const found = anyOf ? needles.some((n) => e.lower.includes(n)) : needles.every((n) => e.lower.includes(n));
|
|
160
|
+
if (found) hits.push(filePath);
|
|
161
|
+
}
|
|
162
|
+
return hits;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** rg-style "path:line:text" rows over the indexed files, paths relative to root. */
|
|
166
|
+
grep(files, regex, root) {
|
|
167
|
+
const out = [];
|
|
168
|
+
for (const filePath of files) {
|
|
169
|
+
const e = this.entry(filePath);
|
|
170
|
+
if (!e || !regex.test(e.text)) continue;
|
|
171
|
+
const lines = e.text.split("\n");
|
|
172
|
+
const rel = path.relative(root, filePath) || filePath;
|
|
173
|
+
for (let i = 0; i < lines.length; i++) {
|
|
174
|
+
if (regex.test(lines[i])) out.push(rel + ":" + (i + 1) + ":" + lines[i]);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
}
|