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.
Files changed (33) hide show
  1. package/README.md +196 -173
  2. package/{CHANGELOG.md → docs/CHANGELOG.md} +57 -1
  3. package/index.js +88 -75
  4. package/package.json +12 -31
  5. package/{catalog.js → src/bridge/catalog.js} +9 -7
  6. package/{host-bridge.js → src/bridge/host-bridge.js} +280 -123
  7. package/src/bridge/native-tools.js +155 -0
  8. package/src/bridge/pi-extension.ts +2 -0
  9. package/{config.js → src/config/config.js} +1 -1
  10. package/{evidence.js → src/context/evidence.js} +23 -12
  11. package/{outline.js → src/context/outline.js} +1 -1
  12. package/{repo-index.js → src/context/repo-index.js} +25 -15
  13. package/{search.js → src/context/search.js} +34 -1
  14. package/src/context/snap.js +237 -0
  15. package/{surface.js → src/context/surface.js} +1 -1
  16. package/{diff.js → src/fs/diff.js} +7 -5
  17. package/{patch.js → src/fs/patch.js} +1 -1
  18. package/{vfs.js → src/fs/vfs.js} +55 -14
  19. package/{workspace.js → src/fs/workspace.js} +22 -6
  20. package/{bottleneck.js → src/output/bottleneck.js} +23 -6
  21. package/{format.js → src/output/format.js} +20 -1
  22. package/{guest-worker.js → src/runtime/guest-worker.js} +90 -21
  23. package/{parallel.js → src/runtime/parallel.js} +68 -1
  24. package/{runtime.js → src/runtime/runtime.js} +14 -5
  25. package/{omp-frame.js → src/ui/omp-frame.js} +1 -1
  26. package/{render-measure.js → src/ui/render-measure.js} +27 -1
  27. package/{render.js → src/ui/render.js} +42 -20
  28. package/snap.js +0 -248
  29. /package/{config.default.json → src/config/config.default.json} +0 -0
  30. /package/{fuzzy.js → src/context/fuzzy.js} +0 -0
  31. /package/{ledger.js → src/context/ledger.js} +0 -0
  32. /package/{check.js → src/fs/check.js} +0 -0
  33. /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 "./bottleneck.js";
5
- import { isFunction, isObject, isString } from "./decode.js";
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
- const worker = new Worker(WORKER_URL, { resourceLimits: { maxOldGenerationSizeMb: maxHeapMb } });
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();
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { clampLine, measureWidth } from "./render-measure.js";
9
- import { isFunction, isString } from "./decode.js";
9
+ import { isFunction, isString } from "../shared/decode.js";
10
10
 
11
11
  const DEFAULT_BOX = {
12
12
  topLeft: "╭",
@@ -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
- return stringWidth(String(text ?? "").replace(/\t/g, " "));
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 "./decode.js";
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 "./format.js";
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
- let clean = "";
46
- for (const character of value) {
47
- const codePoint = character.codePointAt(0);
48
- const isC0 = codePoint <= 0x08 || codePoint === 0x0b || codePoint === 0x0c || (codePoint >= 0x0e && codePoint <= 0x1f);
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
- lines.push(parsed);
215
+ displayLineCount++;
216
+ if (lines.length < 24) lines.push(parsed);
211
217
  }
212
218
  if (lines.length === 0) return undefined;
213
- return { path: item?.args?.path || "", op: item?.name, added, removed, lines };
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 operationsFor(payload, context) {
309
- return operationsFromTrace(payload?.trace || context?.state?.trace || []);
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, args, expanded, isPartial, isError }) {
342
- const ops = operationsFor(payload, context, args);
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: ops.length };
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