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
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ // Compatibility for installations that still point at the former entrypoint.
2
+ export { default } from "../../index.js";
@@ -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 "./decode.js";
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 "./workspace.js";
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
- /** Light suffix stripping so "terminated" ⊇ "terminat" matches "terminate"; deterministic, no dictionary. */
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(scored.slice(0, limit).map(({ f }) => f));
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) text = text.slice(0, Math.max(0, budget - 1)) + "…";
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, span.start + lines.length - 1],
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).filter((l) => l.trim());
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 "./decode.js";
4
+ import { isFunction } from "../shared/decode.js";
5
5
  import { Frecency } from "./fuzzy.js";
6
- import { relativeSlash } from "./workspace.js";
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. snap/grep/glob
11
- // read from here instead of spawning, so a warm call is sub-millisecond.
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
- files = res.stdout.split("\n").map((f) => f.trim()).filter(Boolean).map((f) => path.resolve(root, f)).sort();
165
- } catch {
166
- files = [];
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 = new Array(raw.length);
206
- const defNames = new Array(raw.length);
207
- const idents = new Array(raw.length);
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 e = this.entry(filePath);
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 "./workspace.js";
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
+ }
@@ -1,5 +1,5 @@
1
1
 
2
- import { isString } from "./decode.js";
2
+ import { isString } from "../shared/decode.js";
3
3
 
4
4
  function scanPython(lines) {
5
5
  const items = [];