pi-supernova 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +196 -173
- package/{CHANGELOG.md → docs/CHANGELOG.md} +57 -1
- package/index.js +88 -75
- package/package.json +12 -31
- package/{catalog.js → src/bridge/catalog.js} +9 -7
- package/{host-bridge.js → src/bridge/host-bridge.js} +280 -123
- package/src/bridge/native-tools.js +155 -0
- package/src/bridge/pi-extension.ts +2 -0
- package/{config.js → src/config/config.js} +1 -1
- package/{evidence.js → src/context/evidence.js} +23 -12
- package/{outline.js → src/context/outline.js} +1 -1
- package/{repo-index.js → src/context/repo-index.js} +25 -15
- package/{search.js → src/context/search.js} +34 -1
- package/src/context/snap.js +237 -0
- package/{surface.js → src/context/surface.js} +1 -1
- package/{diff.js → src/fs/diff.js} +7 -5
- package/{patch.js → src/fs/patch.js} +1 -1
- package/{vfs.js → src/fs/vfs.js} +55 -14
- package/{workspace.js → src/fs/workspace.js} +22 -6
- package/{bottleneck.js → src/output/bottleneck.js} +23 -6
- package/{format.js → src/output/format.js} +20 -1
- package/{guest-worker.js → src/runtime/guest-worker.js} +90 -21
- package/{parallel.js → src/runtime/parallel.js} +68 -1
- package/{runtime.js → src/runtime/runtime.js} +14 -5
- package/{omp-frame.js → src/ui/omp-frame.js} +1 -1
- package/{render-measure.js → src/ui/render-measure.js} +27 -1
- package/{render.js → src/ui/render.js} +42 -20
- package/snap.js +0 -248
- /package/{config.default.json → src/config/config.default.json} +0 -0
- /package/{fuzzy.js → src/context/fuzzy.js} +0 -0
- /package/{ledger.js → src/context/ledger.js} +0 -0
- /package/{check.js → src/fs/check.js} +0 -0
- /package/{decode.js → src/shared/decode.js} +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Worker } from "node:worker_threads";
|
|
2
2
|
import { parse } from "acorn";
|
|
3
3
|
import { performance } from "node:perf_hooks";
|
|
4
|
-
import { packageFinalReturn } from "
|
|
5
|
-
import {
|
|
4
|
+
import { packageFinalReturn } from "../output/bottleneck.js";
|
|
5
|
+
import { truncateChars } from "../output/format.js";
|
|
6
|
+
import { isFunction, isObject, isString } from "../shared/decode.js";
|
|
6
7
|
|
|
7
8
|
const WORKER_URL = new URL("./guest-worker.js", import.meta.url);
|
|
8
9
|
const ABORT_MESSAGE = "supernova timed out or aborted: pass timeoutMs to allow longer runs, or split the program";
|
|
@@ -52,7 +53,10 @@ function prepareProgram(code) {
|
|
|
52
53
|
|
|
53
54
|
function spawnWorker(config) {
|
|
54
55
|
const maxHeapMb = config.maxHeapMb ?? 512;
|
|
55
|
-
|
|
56
|
+
// An inline bootstrap accepts inherited --input-type from stdin/eval SDK hosts.
|
|
57
|
+
// Keep Node's automatic flag inheritance: explicitly copying execArgv can
|
|
58
|
+
// reintroduce process-only V8 flags that Worker rejects under node --test.
|
|
59
|
+
const worker = new Worker("import(" + JSON.stringify(WORKER_URL.href) + ")", { eval: true, resourceLimits: { maxOldGenerationSizeMb: maxHeapMb } });
|
|
56
60
|
const handle = { worker, maxHeapMb, dead: false, ready: null };
|
|
57
61
|
// This listener also owns errors between readiness and a run's listeners.
|
|
58
62
|
worker.on("error", () => { handle.dead = true; });
|
|
@@ -111,6 +115,10 @@ export function warmGuestWorker(config = {}) {
|
|
|
111
115
|
return handle.ready;
|
|
112
116
|
}
|
|
113
117
|
|
|
118
|
+
export function stopWarmGuestWorker() {
|
|
119
|
+
return killWorker(idleWorker);
|
|
120
|
+
}
|
|
121
|
+
|
|
114
122
|
const RPC_METHODS = {
|
|
115
123
|
call: (nova, args) => nova.call(args[0], args[1]),
|
|
116
124
|
callMany: async (nova, args) => {
|
|
@@ -128,7 +136,7 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
|
|
|
128
136
|
const started = performance.now();
|
|
129
137
|
const wall = () => Math.round(performance.now() - started);
|
|
130
138
|
const logs = [];
|
|
131
|
-
const fail = (error) => ({ ok: false, error, logs, logTruncated, wallMs: wall() });
|
|
139
|
+
const fail = (error) => ({ ok: false, error: truncateChars(String(error), config.maxReturnChars ?? 32000, "error").text, logs, logTruncated, wallMs: wall() });
|
|
132
140
|
let logTruncated = false;
|
|
133
141
|
if (!isString(code) || !code.trim()) return fail("code must be a non-empty string");
|
|
134
142
|
if (code.length > (config.maxCodeChars ?? 48000)) return fail("code exceeds " + (config.maxCodeChars ?? 48000) + " characters");
|
|
@@ -234,7 +242,7 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
|
|
|
234
242
|
try {
|
|
235
243
|
const packed = packageFinalReturn(msg.value, logs, config);
|
|
236
244
|
void complete({ ok: true, result: packed.returnValue, resultText: packed.returnText,
|
|
237
|
-
returnTruncated: packed.returnTruncated, undefinedReturn: msg.undefinedReturn === true,
|
|
245
|
+
returnTruncated: packed.returnTruncated, images: packed.images, undefinedReturn: msg.undefinedReturn === true,
|
|
238
246
|
logs: packed.logs, logTruncated: logTruncated || packed.logTruncated });
|
|
239
247
|
} catch (err) {
|
|
240
248
|
void complete(fail(err.message));
|
|
@@ -260,6 +268,7 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
|
|
|
260
268
|
if (wall() >= timeoutMs) return abort();
|
|
261
269
|
handle.worker.postMessage({ op: "run", runId, prepared, available,
|
|
262
270
|
batchRead: nova.batchRead !== false,
|
|
271
|
+
nativeArgv: nova.nativeArgv === true,
|
|
263
272
|
limits: { maxLogLines: config.maxLogLines ?? 100, maxLogLineChars: config.maxLogLineChars ?? 4096 } });
|
|
264
273
|
} catch (err) {
|
|
265
274
|
cancelHost();
|
|
@@ -4,8 +4,34 @@ import stringWidth from "string-width";
|
|
|
4
4
|
const ELLIPSIS = "…";
|
|
5
5
|
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
6
6
|
|
|
7
|
+
const widthCache = new Map();
|
|
8
|
+
let cachedWidthChars = 0;
|
|
9
|
+
const MAX_WIDTH_CACHE_CHARS = 512_000;
|
|
10
|
+
|
|
7
11
|
export function measureWidth(text) {
|
|
8
|
-
|
|
12
|
+
const raw = String(text ?? "");
|
|
13
|
+
const cached = widthCache.get(raw);
|
|
14
|
+
if (cached !== undefined) return cached;
|
|
15
|
+
const normalized = raw.replace(/\t/g, " ");
|
|
16
|
+
// eslint-disable-next-line no-control-regex -- intentional ANSI SGR recognition
|
|
17
|
+
const plain = normalized.replace(/\x1b\[(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?m/g, "");
|
|
18
|
+
// ASCII and these single-column chrome glyphs need no Unicode segmentation.
|
|
19
|
+
// Any other character/control/escape sequence uses the full oracle.
|
|
20
|
+
const width = /^[\x20-\x7e\u2500-\u257f\u00b7\u00d7\u2026\u2713\u2717]*$/.test(plain)
|
|
21
|
+
? plain.length
|
|
22
|
+
: stringWidth(normalized);
|
|
23
|
+
// Cache immutable text only, never host/theme/result objects. Bound both
|
|
24
|
+
// bookkeeping and retained text; unusually long lines bypass retention.
|
|
25
|
+
if (raw.length <= 4096) {
|
|
26
|
+
while (widthCache.size >= 4096 || cachedWidthChars + raw.length > MAX_WIDTH_CACHE_CHARS) {
|
|
27
|
+
const oldest = widthCache.keys().next().value;
|
|
28
|
+
widthCache.delete(oldest);
|
|
29
|
+
cachedWidthChars -= oldest.length;
|
|
30
|
+
}
|
|
31
|
+
widthCache.set(raw, width);
|
|
32
|
+
cachedWidthChars += raw.length;
|
|
33
|
+
}
|
|
34
|
+
return width;
|
|
9
35
|
}
|
|
10
36
|
|
|
11
37
|
function takePrefix(text, width) {
|
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { stripVTControlCharacters } from "node:util";
|
|
14
|
-
import { isString, isObject, isFunction } from "
|
|
14
|
+
import { isString, isObject, isFunction } from "../shared/decode.js";
|
|
15
15
|
import { measureWidth, hardTruncate, clampLine, fitPath, wrapLine } from "./render-measure.js";
|
|
16
16
|
import { novaFramedBlock, novaStatusLine } from "./omp-frame.js";
|
|
17
|
-
import { formatValue } from "
|
|
17
|
+
import { formatValue } from "../output/format.js";
|
|
18
18
|
|
|
19
19
|
export { measureWidth, hardTruncate, clampLine };
|
|
20
20
|
|
|
@@ -42,14 +42,10 @@ function formatDiffRows(diff, theme, maxShown = 6) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
function stripUnsafeControls(value) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const isDeleteOrC1 = codePoint >= 0x7f && codePoint <= 0x9f;
|
|
50
|
-
if (!isC0 && !isDeleteOrC1) clean += character;
|
|
51
|
-
}
|
|
52
|
-
return clean;
|
|
45
|
+
// Exactly the C0/DEL/C1 ranges previously filtered code point by code point.
|
|
46
|
+
// Native replacement avoids rebuilding every already-clean Unicode string.
|
|
47
|
+
// eslint-disable-next-line no-control-regex -- intentional terminal-control filtering
|
|
48
|
+
return value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "");
|
|
53
49
|
}
|
|
54
50
|
|
|
55
51
|
function cleanBlockText(value) {
|
|
@@ -195,11 +191,20 @@ function parseDiffLine(rawLine) {
|
|
|
195
191
|
return null;
|
|
196
192
|
}
|
|
197
193
|
|
|
194
|
+
// Text diffs are immutable, even when hosts replace trace snapshots on each frame.
|
|
195
|
+
// Cache only parsed data, not theme, paths or mutable result objects.
|
|
196
|
+
const textDiffCache = new Map();
|
|
197
|
+
let cachedDiffChars = 0;
|
|
198
|
+
const MAX_CACHED_DIFF_CHARS = 1_000_000;
|
|
199
|
+
|
|
198
200
|
function normalizeTraceDiff(item) {
|
|
199
201
|
const diff = item?.diff;
|
|
200
202
|
if (isObject(diff)) return diff;
|
|
201
203
|
if (!isString(diff) || !diff.trim()) return undefined;
|
|
204
|
+
const cached = textDiffCache.get(diff);
|
|
205
|
+
if (cached) return cached;
|
|
202
206
|
const lines = [];
|
|
207
|
+
let displayLineCount = 0;
|
|
203
208
|
let added = 0;
|
|
204
209
|
let removed = 0;
|
|
205
210
|
for (const rawLine of cleanBlockText(diff).split("\n")) {
|
|
@@ -207,10 +212,21 @@ function normalizeTraceDiff(item) {
|
|
|
207
212
|
if (!parsed) continue;
|
|
208
213
|
if (parsed.type === "add") added += 1;
|
|
209
214
|
else if (parsed.type === "remove") removed += 1;
|
|
210
|
-
|
|
215
|
+
displayLineCount++;
|
|
216
|
+
if (lines.length < 24) lines.push(parsed);
|
|
211
217
|
}
|
|
212
218
|
if (lines.length === 0) return undefined;
|
|
213
|
-
|
|
219
|
+
const parsed = { added, removed, lines, displayLineCount };
|
|
220
|
+
if (diff.length <= MAX_CACHED_DIFF_CHARS) {
|
|
221
|
+
while (textDiffCache.size >= 24 || cachedDiffChars + diff.length > MAX_CACHED_DIFF_CHARS) {
|
|
222
|
+
const oldest = textDiffCache.keys().next().value;
|
|
223
|
+
textDiffCache.delete(oldest);
|
|
224
|
+
cachedDiffChars -= oldest.length;
|
|
225
|
+
}
|
|
226
|
+
textDiffCache.set(diff, parsed);
|
|
227
|
+
cachedDiffChars += diff.length;
|
|
228
|
+
}
|
|
229
|
+
return parsed;
|
|
214
230
|
}
|
|
215
231
|
|
|
216
232
|
function operationsFromTrace(trace) {
|
|
@@ -305,8 +321,9 @@ function formatOpRow(theme, op, width, isPartial, isError) {
|
|
|
305
321
|
return prefix.trimEnd();
|
|
306
322
|
}
|
|
307
323
|
|
|
308
|
-
function
|
|
309
|
-
|
|
324
|
+
function traceFor(payload, context) {
|
|
325
|
+
const trace = payload?.trace || context?.state?.trace;
|
|
326
|
+
return Array.isArray(trace) ? trace : [];
|
|
310
327
|
}
|
|
311
328
|
|
|
312
329
|
function resultLines(value, width) {
|
|
@@ -317,7 +334,7 @@ function resultLines(value, width) {
|
|
|
317
334
|
function appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, isError) {
|
|
318
335
|
for (const op of ops.slice(0, maxOps)) {
|
|
319
336
|
lines.push(formatOpRow(theme, op, width, isPartial, isError));
|
|
320
|
-
if (!op.diff || !isObject(op.diff)) continue;
|
|
337
|
+
if (maxDiffLines === 0 || !op.diff || !isObject(op.diff)) continue;
|
|
321
338
|
for (const row of formatDiffRows(op.diff, theme, maxDiffLines)) lines.push(" " + row);
|
|
322
339
|
}
|
|
323
340
|
if (ops.length > maxOps) lines.push(theme.fg("dim", ` … ${ops.length - maxOps} more calls`));
|
|
@@ -338,14 +355,19 @@ function appendTail(lines, theme, payload, expanded, isError, width) {
|
|
|
338
355
|
}
|
|
339
356
|
}
|
|
340
357
|
|
|
341
|
-
function buildBodyLines(theme, width, { payload, context,
|
|
342
|
-
const
|
|
358
|
+
function buildBodyLines(theme, width, { payload, context, expanded, isPartial, isError }) {
|
|
359
|
+
const trace = traceFor(payload, context);
|
|
343
360
|
const maxOps = expanded ? 24 : 8;
|
|
344
|
-
const maxDiffLines = expanded ? 24 : 8;
|
|
361
|
+
const maxDiffLines = expanded ? 24 : isPartial ? 0 : 8;
|
|
362
|
+
// Select before parsing diffs: invisible history must not consume a frame.
|
|
363
|
+
// While running, show current activity rather than the first completed calls.
|
|
364
|
+
const visible = isPartial ? trace.slice(-maxOps) : trace.slice(0, maxOps);
|
|
365
|
+
const ops = operationsFromTrace(visible);
|
|
345
366
|
const lines = [];
|
|
346
367
|
appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, isError);
|
|
368
|
+
if (trace.length > maxOps) lines.push(theme.fg("dim", ` … ${trace.length - maxOps} ${isPartial ? "earlier" : "more"} calls`));
|
|
347
369
|
appendTail(lines, theme, payload, expanded, isError, width);
|
|
348
|
-
return { lines, opCount:
|
|
370
|
+
return { lines, opCount: trace.length };
|
|
349
371
|
}
|
|
350
372
|
|
|
351
373
|
function describeCard(model, opCount) {
|
|
@@ -404,7 +426,7 @@ export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextAr
|
|
|
404
426
|
contextArg,
|
|
405
427
|
);
|
|
406
428
|
|
|
407
|
-
const payload = result?.details;
|
|
429
|
+
const payload = result?.details ?? (result?.isError ? { ok: false, error: result.content?.filter(block => block.type === "text").map(block => block.text).join("\n") } : undefined);
|
|
408
430
|
syncState(context, payload);
|
|
409
431
|
|
|
410
432
|
const isError = result?.isError || payload?.ok === false;
|
package/snap.js
DELETED
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
import * as path from "node:path";
|
|
3
|
-
import { isString } from "./decode.js";
|
|
4
|
-
import { WorkspaceIndex } from "./repo-index.js";
|
|
5
|
-
import { isTestPath } from "./workspace.js";
|
|
6
|
-
|
|
7
|
-
const STOP_WORDS = new Set([
|
|
8
|
-
"the", "a", "an", "and", "or", "in", "on", "at", "to", "for", "of", "with",
|
|
9
|
-
"by", "from", "is", "it", "this", "that", "where", "how", "what", "which",
|
|
10
|
-
"file", "code", "function", "class", "method", "find", "get", "look",
|
|
11
|
-
]);
|
|
12
|
-
|
|
13
|
-
export function tokenizeQuery(query) {
|
|
14
|
-
if (!isString(query) || !query.trim()) {
|
|
15
|
-
return { tokens: [], wantsTest: false, wantsType: false, wantsDoc: false };
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const raw = query
|
|
19
|
-
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
20
|
-
.toLowerCase()
|
|
21
|
-
.split(/[^a-zA-Z0-9_]+/);
|
|
22
|
-
|
|
23
|
-
const tokens = raw.filter((t) => t.length > 1 && !STOP_WORDS.has(t));
|
|
24
|
-
const queryLower = query.toLowerCase();
|
|
25
|
-
|
|
26
|
-
return {
|
|
27
|
-
tokens: [...new Set(tokens)],
|
|
28
|
-
wantsTest: queryLower.includes("test") || queryLower.includes("spec"),
|
|
29
|
-
wantsType: queryLower.includes("type") || queryLower.includes("interface") || queryLower.includes("schema"),
|
|
30
|
-
wantsDoc: queryLower.includes("doc") || queryLower.includes("readme"),
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const SOURCE_EXT = new Set([".ts", ".js", ".mjs", ".rs", ".py", ".go"]);
|
|
35
|
-
const TYPED_EXT = new Set([".ts", ".d.ts", ".rs", ".go"]);
|
|
36
|
-
const VENDOR_SEGMENTS = ["node_modules/", "dist/", "target/"];
|
|
37
|
-
|
|
38
|
-
function tokenPathScore(token, basename, pathParts, norm) {
|
|
39
|
-
if (basename === token || basename.startsWith(token + ".")) return 60;
|
|
40
|
-
if (basename.includes(token)) return 30;
|
|
41
|
-
if (pathParts.includes(token)) return 15;
|
|
42
|
-
if (norm.includes(token)) return 5;
|
|
43
|
-
return 0;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function extensionBonus(ext, { wantsDoc, wantsType }) {
|
|
47
|
-
let bonus = 0;
|
|
48
|
-
if (SOURCE_EXT.has(ext) && !wantsDoc) bonus += 5;
|
|
49
|
-
if (wantsType && TYPED_EXT.has(ext)) bonus += 10;
|
|
50
|
-
return bonus;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function scorePathTopology(filePath, tokens, flags) {
|
|
54
|
-
const norm = filePath.replaceAll("\\", "/").toLowerCase();
|
|
55
|
-
const isTest = norm.includes("test") || norm.includes("spec") || norm.includes("__tests__");
|
|
56
|
-
if (isTest && !flags.wantsTest) return -50;
|
|
57
|
-
if (!isTest && flags.wantsTest) return -20;
|
|
58
|
-
if (VENDOR_SEGMENTS.some((segment) => norm.includes(segment))) return -100;
|
|
59
|
-
|
|
60
|
-
const basename = path.basename(norm);
|
|
61
|
-
const pathParts = norm.split(/[^a-zA-Z0-9]+/);
|
|
62
|
-
let score = extensionBonus(path.extname(norm), flags);
|
|
63
|
-
for (const token of tokens) score += tokenPathScore(token, basename, pathParts, norm);
|
|
64
|
-
return score;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function isSkippableLine(lower) {
|
|
68
|
-
return !lower || lower.startsWith("//") || lower.startsWith("#") || lower.startsWith("*");
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** A line defines a token only when the declared name contains it; `const x = foo(token)` is a mention. */
|
|
72
|
-
function lineScoreFor(lower, tokens, definedName) {
|
|
73
|
-
let lineScore = 0;
|
|
74
|
-
for (const token of tokens) {
|
|
75
|
-
if (!lower.includes(token)) continue;
|
|
76
|
-
lineScore += definedName.includes(token) ? 40 : 5;
|
|
77
|
-
}
|
|
78
|
-
return lineScore;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Mentions are capped so a file that calls a symbol many times cannot outrank the file that defines it.
|
|
82
|
-
const MAX_MENTION_SCORE = 60;
|
|
83
|
-
|
|
84
|
-
function scoreContentDefinitions(entry, tokens) {
|
|
85
|
-
const { lower, defNames } = WorkspaceIndex.linesOf(entry);
|
|
86
|
-
let defScore = 0;
|
|
87
|
-
let mentionScore = 0;
|
|
88
|
-
let bestLine = 1;
|
|
89
|
-
let bestLineScore = 0;
|
|
90
|
-
for (let i = 0; i < lower.length; i++) {
|
|
91
|
-
if (isSkippableLine(lower[i])) continue;
|
|
92
|
-
const lineScore = lineScoreFor(lower[i], tokens, defNames[i]);
|
|
93
|
-
if (lineScore > bestLineScore) {
|
|
94
|
-
bestLineScore = lineScore;
|
|
95
|
-
bestLine = i + 1;
|
|
96
|
-
}
|
|
97
|
-
if (defNames[i]) defScore += lineScore;
|
|
98
|
-
else mentionScore += lineScore;
|
|
99
|
-
}
|
|
100
|
-
return { totalScore: defScore + Math.min(mentionScore, MAX_MENTION_SCORE), bestLine, bestLineScore };
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function relativeHasSegment(relativePath, segmentName) {
|
|
104
|
-
return relativePath.split(path.sep).includes(segmentName);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function relativeHasHiddenSegment(relativePath) {
|
|
108
|
-
return relativePath.split(path.sep).some((segment) => segment.startsWith(".") && segment.length > 1);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
async function listCandidateFiles(dir, includeHidden, index) {
|
|
112
|
-
return (await index.files(dir, includeHidden)).slice();
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function mergePendingPaths(fileList, pendingPaths, dir, includeHidden = false) {
|
|
116
|
-
const resolvedDir = path.resolve(dir);
|
|
117
|
-
const seenPaths = new Set(fileList.map((filePath) => path.resolve(filePath)));
|
|
118
|
-
for (const pendingPath of pendingPaths) {
|
|
119
|
-
const absolutePath = path.resolve(pendingPath);
|
|
120
|
-
const relativePath = path.relative(resolvedDir, absolutePath);
|
|
121
|
-
const escapesDir = relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
|
|
122
|
-
const hiddenRelativePath = relativeHasHiddenSegment(relativePath);
|
|
123
|
-
if (escapesDir || relativeHasSegment(relativePath, ".git") || (!includeHidden && hiddenRelativePath) || seenPaths.has(absolutePath)) continue;
|
|
124
|
-
seenPaths.add(absolutePath);
|
|
125
|
-
fileList.push(absolutePath);
|
|
126
|
-
}
|
|
127
|
-
return fileList;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function mergeGrepHits(candidates, grepHits) {
|
|
131
|
-
const seen = new Set(candidates);
|
|
132
|
-
for (const h of grepHits) {
|
|
133
|
-
if (seen.has(h)) continue;
|
|
134
|
-
seen.add(h);
|
|
135
|
-
candidates.push(h);
|
|
136
|
-
if (candidates.length >= 15) break;
|
|
137
|
-
}
|
|
138
|
-
return candidates;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function expandCandidatesWithGrep(candidates, fileList, tokens, flags, index) {
|
|
142
|
-
if (candidates.length >= 5) return candidates;
|
|
143
|
-
const salient = tokens.filter((t) => t.length > 2).slice(0, 4);
|
|
144
|
-
const scope = flags.wantsTest ? fileList : fileList.filter((f) => !isTestPath(f));
|
|
145
|
-
const hits = index.filesContaining(scope, salient, true);
|
|
146
|
-
mergeGrepHits(candidates, hits);
|
|
147
|
-
if (candidates.length === 0) return fileList.slice(0, 5);
|
|
148
|
-
return candidates;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function scoreSurfaceItems(items, tokens, fallbackLine) {
|
|
152
|
-
let bonus = 0;
|
|
153
|
-
let best = null;
|
|
154
|
-
let bestMatches = 0;
|
|
155
|
-
for (const item of items) {
|
|
156
|
-
const nameLower = item.name.toLowerCase();
|
|
157
|
-
const matches = tokens.filter((token) => nameLower.includes(token)).length;
|
|
158
|
-
if (matches === 0) continue;
|
|
159
|
-
bonus += matches * (item.isExport ? 80 : 50);
|
|
160
|
-
if (matches > bestMatches) {
|
|
161
|
-
bestMatches = matches;
|
|
162
|
-
best = item;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
return { bonus, signature: best?.signature ?? "", anchorLine: best?.line ?? fallbackLine };
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function scoreCandidateContents(candidates, tokens, flags, index, overlayText) {
|
|
169
|
-
const candidateScores = [];
|
|
170
|
-
for (const filePath of candidates) {
|
|
171
|
-
const pending = overlayText(filePath);
|
|
172
|
-
const entry = pending === undefined ? index.entry(filePath) : WorkspaceIndex.fromText(filePath, pending);
|
|
173
|
-
if (!entry) continue;
|
|
174
|
-
const content = entry.text;
|
|
175
|
-
const { totalScore, bestLine, bestLineScore } = scoreContentDefinitions(entry, tokens);
|
|
176
|
-
const surface = WorkspaceIndex.surfaceOf(entry);
|
|
177
|
-
const { bonus: surfaceBonus, signature, anchorLine } = scoreSurfaceItems(surface.items, tokens, bestLine);
|
|
178
|
-
const lowerPath = filePath.toLowerCase();
|
|
179
|
-
const isTestFile = lowerPath.includes("test") || lowerPath.includes("spec");
|
|
180
|
-
const testAdjustment = !isTestFile ? 0 : (flags.wantsTest ? 100 : -200);
|
|
181
|
-
candidateScores.push({
|
|
182
|
-
path: filePath,
|
|
183
|
-
score: totalScore + surfaceBonus + (bestLineScore * 2) + testAdjustment,
|
|
184
|
-
anchorLine,
|
|
185
|
-
signature,
|
|
186
|
-
content,
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
candidateScores.sort((a, b) => b.score - a.score);
|
|
190
|
-
return candidateScores;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function rankCandidates(fileList, tokens, flags, index, overlayText) {
|
|
194
|
-
const scoredPaths = [];
|
|
195
|
-
for (const f of fileList) {
|
|
196
|
-
const score = scorePathTopology(f, tokens, flags);
|
|
197
|
-
if (score > 0) scoredPaths.push({ path: f, score });
|
|
198
|
-
}
|
|
199
|
-
scoredPaths.sort((a, b) => b.score - a.score);
|
|
200
|
-
const selected = scoredPaths.filter((p) => p.score >= 25).slice(0, 10).map((p) => p.path);
|
|
201
|
-
const candidates = expandCandidatesWithGrep(selected, fileList, tokens, flags, index);
|
|
202
|
-
const candidateScores = scoreCandidateContents(candidates, tokens, flags, index, overlayText);
|
|
203
|
-
return { candidates, candidateScores };
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function buildSnapResult(candidates, candidateScores, fileList, root) {
|
|
207
|
-
const relative = (p) => path.relative(root, p) || p;
|
|
208
|
-
if (candidateScores.length === 0 || candidateScores[0].score <= 0) {
|
|
209
|
-
return { path: relative(candidates[0] || fileList[0]), line: 1, signature: "", confidence: 0.3, context: [] };
|
|
210
|
-
}
|
|
211
|
-
const best = candidateScores[0];
|
|
212
|
-
const lines = best.content.split("\n");
|
|
213
|
-
// Two lines before and four after: enough to confirm the hit; read() is the tool for more.
|
|
214
|
-
const startLine = Math.max(1, best.anchorLine - 2);
|
|
215
|
-
const endLine = Math.min(lines.length, best.anchorLine + 4);
|
|
216
|
-
const context = [];
|
|
217
|
-
for (let l = startLine; l <= endLine; l++) {
|
|
218
|
-
const marker = l === best.anchorLine ? "►" : " ";
|
|
219
|
-
context.push(marker + l + " " + lines[l - 1]);
|
|
220
|
-
}
|
|
221
|
-
const confidence = Math.min(0.98, Math.max(0.65, best.score / 150));
|
|
222
|
-
return {
|
|
223
|
-
path: relative(best.path),
|
|
224
|
-
line: best.anchorLine,
|
|
225
|
-
signature: best.signature,
|
|
226
|
-
confidence: Number(confidence.toFixed(2)),
|
|
227
|
-
context,
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export async function executeSnap({ query, searchDir, root, includeHidden = false, index, overlayText = () => undefined, pendingPaths = [] }) {
|
|
232
|
-
const { tokens, wantsTest, wantsType, wantsDoc } = tokenizeQuery(query);
|
|
233
|
-
if (tokens.length === 0) {
|
|
234
|
-
throw new Error("snap requires at least one searchable concept keyword");
|
|
235
|
-
}
|
|
236
|
-
const dir = searchDir || process.cwd();
|
|
237
|
-
if (path.resolve(dir).split(path.sep).includes(".git")) {
|
|
238
|
-
throw new Error("snap cannot search Git metadata");
|
|
239
|
-
}
|
|
240
|
-
const fileList = await listCandidateFiles(dir, includeHidden, index);
|
|
241
|
-
mergePendingPaths(fileList, pendingPaths, dir, includeHidden);
|
|
242
|
-
if (fileList.length === 0) {
|
|
243
|
-
throw new Error(`no files found to search in ${dir}`);
|
|
244
|
-
}
|
|
245
|
-
const flags = { wantsTest, wantsDoc, wantsType };
|
|
246
|
-
const { candidates, candidateScores } = rankCandidates(fileList, tokens, flags, index, overlayText);
|
|
247
|
-
return buildSnapResult(candidates, candidateScores, fileList, root ?? dir);
|
|
248
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|