pi-supernova 0.2.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 (32) hide show
  1. package/README.md +196 -196
  2. package/{CHANGELOG.md → docs/CHANGELOG.md} +44 -1
  3. package/index.js +81 -68
  4. package/package.json +12 -31
  5. package/{catalog.js → src/bridge/catalog.js} +7 -5
  6. package/{host-bridge.js → src/bridge/host-bridge.js} +232 -87
  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} +11 -9
  13. package/{search.js → src/context/search.js} +34 -1
  14. package/{snap.js → src/context/snap.js} +51 -31
  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/{config.default.json → src/config/config.default.json} +0 -0
  29. /package/{fuzzy.js → src/context/fuzzy.js} +0 -0
  30. /package/{ledger.js → src/context/ledger.js} +0 -0
  31. /package/{check.js → src/fs/check.js} +0 -0
  32. /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;
@@ -210,9 +211,9 @@ export class WorkspaceIndex {
210
211
  static linesOf(entry) {
211
212
  if (entry.lines) return entry.lines;
212
213
  const raw = entry.text.split("\n");
213
- const lower = new Array(raw.length);
214
- const defNames = new Array(raw.length);
215
- const idents = new Array(raw.length);
214
+ const lower = [];
215
+ const defNames = [];
216
+ const idents = [];
216
217
  for (let i = 0; i < raw.length; i++) {
217
218
  const trimmed = raw[i].trim();
218
219
  lower[i] = trimmed.toLowerCase();
@@ -265,11 +266,12 @@ export class WorkspaceIndex {
265
266
  }
266
267
 
267
268
  /** Structured grep rows {rel, line, text, def}; def marks lines whose declared name itself matches. */
268
- grepRows(files, regex, root) {
269
+ grepRows(files, regex, root, overlayText = () => undefined) {
269
270
  const out = [];
270
271
  const nameRegex = new RegExp(regex.source, "i");
271
272
  for (const filePath of files) {
272
- const e = this.entry(filePath);
273
+ const pending = overlayText(filePath);
274
+ const e = pending === undefined ? this.entry(filePath) : WorkspaceIndex.fromText(filePath, pending);
273
275
  if (!e || !regex.test(e.text)) continue;
274
276
  const { raw, defNames } = WorkspaceIndex.linesOf(e);
275
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");
@@ -1,20 +1,27 @@
1
1
  import * as path from "node:path";
2
- import { isString } from "./decode.js";
3
- import { truncateChars } from "./format.js";
4
- import { WorkspaceIndex } from "./repo-index.js";
2
+ import { isString } from "../shared/decode.js";
3
+ import { truncateChars } from "../output/format.js";
4
+ import * as fs from "node:fs/promises";
5
5
  import { extractStructuralSurface } from "./surface.js";
6
- import { isTestPath } from "./workspace.js";
6
+ import { rankPaths } from "./fuzzy.js";
7
+ import { isTestPath, runCommand, relativeSlash } from "../fs/workspace.js";
7
8
 
8
9
  const STOP_WORDS = new Set([
9
10
  "the", "a", "an", "and", "or", "in", "on", "at", "to", "for", "of", "with",
10
11
  "by", "from", "is", "it", "this", "that", "where", "how", "what", "which",
11
- "file", "code", "function", "class", "method", "find", "get", "look",
12
+ "file", "code", "function", "class", "method", "find", "get", "look", "are", "does", "do",
12
13
  ]);
13
14
  const SOURCE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".rs", ".py", ".go"]);
14
15
  const TYPED_EXT = new Set([".ts", ".tsx", ".rs", ".go"]);
15
16
  const MAX_SEARCH_CHARS = 2 * 1024 * 1024;
16
17
  const MAX_ALTERNATIVES = 3;
17
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
+
18
25
  export function tokenizeQuery(query) {
19
26
  if (!isString(query) || !query.trim()) return { tokens: [], wantsTest: false, wantsType: false, wantsDoc: false };
20
27
  const words = query.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-zA-Z0-9_]+/);
@@ -111,19 +118,20 @@ function inspectOverlay(candidate, text, needles, query, tokens) {
111
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);
112
119
  }
113
120
 
114
- async function contentCandidates({ dir, includeHidden, query, tokens, flags, fileSet, index, overlayText, signal, exact, diskFiles }) {
121
+ async function contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles }) {
115
122
  const needles = exact ? [query.toLowerCase()] : tokens;
116
123
  const args = ["rg", "--json", "--fixed-strings", "--ignore-case", "--before-context", "2", "--after-context", "4"];
117
124
  if (includeHidden) args.push("--hidden");
118
125
  args.push("-g", "!.git/**", "-g", "!**/.git/**");
119
126
  for (const needle of needles) args.push("-e", needle);
120
127
  args.push("--", dir);
121
- const response = diskFiles ? await index.runCommand(args, { cwd: dir, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS, signal })
128
+ const response = diskFiles ? await run(args, { cwd: dir, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS, signal })
122
129
  : { stdout: "", stderr: "", exitCode: 1 };
123
130
  if (response.exitCode !== 0 && response.exitCode !== 1) throw new Error("source search failed: " + response.stderr.trim());
124
131
  const candidates = new Map();
125
132
  const records = response.stdout.split("\n");
126
133
  for (let i = 0; i < records.length; i++) {
134
+ if ((i & 127) === 0) signal?.throwIfAborted();
127
135
  if (!records[i]) continue;
128
136
  let record;
129
137
  try { record = JSON.parse(records[i]); } catch (error) {
@@ -134,7 +142,7 @@ async function contentCandidates({ dir, includeHidden, query, tokens, flags, fil
134
142
  const data = record.data;
135
143
  if (!data?.path?.text || !isString(data.lines?.text)) continue;
136
144
  const filePath = path.resolve(dir, data.path.text);
137
- if (!fileSet.has(filePath) || overlayText(filePath) !== undefined) continue;
145
+ if (!inScope(filePath, dir, includeHidden) || overlayText(filePath) !== undefined) continue;
138
146
  let candidate = candidates.get(filePath);
139
147
  if (!candidate) {
140
148
  candidate = makeCandidate(filePath, dir, query, tokens, flags);
@@ -142,7 +150,7 @@ async function contentCandidates({ dir, includeHidden, query, tokens, flags, fil
142
150
  }
143
151
  inspectLine(candidate, data.line_number, data.lines.text, query, tokens, record.type === "match");
144
152
  }
145
- for (const filePath of fileSet) {
153
+ for (const filePath of pendingPaths) {
146
154
  const pending = overlayText(filePath);
147
155
  if (pending === undefined) continue;
148
156
  const candidate = makeCandidate(filePath, dir, query, tokens, flags);
@@ -159,21 +167,15 @@ function rankScore(candidate, tokenCount) {
159
167
  + Math.max(-40, Math.min(20, candidate.pathScore / 5));
160
168
  }
161
169
 
162
- function location(candidate, root, index, overlayText) {
163
- let context = candidate.context;
164
- if (context.size === 0) {
165
- const pending = overlayText(candidate.path);
166
- const entry = pending === undefined ? index.entry(candidate.path) : WorkspaceIndex.fromText(candidate.path, pending);
167
- const lines = entry?.text.split("\n") ?? [];
168
- context = new Map(lines.slice(0, 7).map((line, i) => [i + 1, truncateChars(line, 240, "source line").text]));
169
- }
170
+ function location(candidate, root) {
171
+ const context = candidate.context;
170
172
  return { path: path.relative(root, candidate.path), line: candidate.line, signature: candidate.signature,
171
173
  context: [...context].sort((a, b) => a[0] - b[0]).map(([line, text]) => (line === candidate.line ? "►" : " ") + line + " " + text) };
172
174
  }
173
175
 
174
- export async function executeSnap({ query, searchDir, root, includeHidden = false, index, overlayText = () => undefined, pendingPaths = [], signal }) {
176
+ export async function executeSnap({ query, searchDir, root, includeHidden = false, run = runCommand, overlayText = () => undefined, pendingPaths = [], pathContext = {}, signal }) {
175
177
  const flags = tokenizeQuery(query);
176
- const { tokens } = flags;
178
+ const tokens = [...new Set(flags.tokens.map(stem))];
177
179
  if (tokens.length === 0) throw new Error("read requires a file path or a searchable source question");
178
180
  if (tokens.length > 16) throw new Error("source question is too broad; use at most 16 keywords");
179
181
  query = query.trim();
@@ -181,17 +183,25 @@ export async function executeSnap({ query, searchDir, root, includeHidden = fals
181
183
  if (dir.split(path.sep).includes(".git")) throw new Error("cannot search Git metadata");
182
184
  signal?.throwIfAborted();
183
185
  flags.wantsTest ||= isTestPath(path.relative(root ?? dir, dir));
184
- const files = await index.files(dir, includeHidden, signal);
185
- const fileSet = new Set(files.filter(file => inScope(file, dir, includeHidden)));
186
- for (const pending of pendingPaths) if (inScope(pending, dir, includeHidden)) fileSet.add(path.resolve(pending));
187
- const listing = index.lists.get(dir + "\0" + (includeHidden ? "h" : ""));
188
- if (listing?.error && !(listing.missing && fileSet.size)) throw new Error("source file listing failed: " + listing.error);
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
+ });
189
191
  const empty = { path: null, line: null, signature: "", confidence: 0, context: [] };
190
- if (fileSet.size === 0 && !listing?.truncated) return { ...empty, status: "not_found" };
191
192
  const exact = /^[a-zA-Z_$][\w$]*$/.test(query);
192
- const search = await contentCandidates({ dir, includeHidden, query, tokens, flags, fileSet, index, overlayText, signal, exact, diskFiles: files.length });
193
- for (const filePath of fileSet) {
194
- if (search.candidates.has(filePath)) continue;
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;
195
205
  const relative = path.relative(dir, filePath).toLowerCase();
196
206
  if (!tokens.some(token => relative.includes(token))) continue;
197
207
  if (exact && tokens.length > 1 && !relative.includes(query.toLowerCase())) continue;
@@ -201,11 +211,21 @@ export async function executeSnap({ query, searchDir, root, includeHidden = fals
201
211
  const ranked = [...search.candidates.values()].filter(candidate => candidate.pathScore > -50)
202
212
  .map(candidate => ({ ...candidate, score: rankScore(candidate, tokens.length) }))
203
213
  .sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
204
- const incomplete = search.truncated || listing?.truncated === true;
214
+ const incomplete = search.truncated || listing.outputTruncated === true;
205
215
  const relativeRoot = root ?? dir;
206
- const candidates = ranked.slice(0, MAX_ALTERNATIVES).map(candidate => location(candidate, relativeRoot, index, overlayText));
216
+ const candidates = ranked.slice(0, MAX_ALTERNATIVES).map(candidate => location(candidate, relativeRoot));
207
217
  if (incomplete) return { ...empty, status: "incomplete", candidates, message: "Search output exceeded its budget. Narrow the directory with read(path, {about: question})." };
208
- if (!ranked.length) return { ...empty, status: "not_found" };
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
+ }
209
229
  const best = ranked[0];
210
230
  const second = ranked[1];
211
231
  const margin = second ? (best.score - second.score) / Math.max(1, best.score) : 1;
@@ -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 = [];
@@ -1,5 +1,5 @@
1
1
 
2
- import { isString } from "./decode.js";
2
+ import { isString } from "../shared/decode.js";
3
3
 
4
4
  export function buildEditDiff(filePath, originalText, oldText, newText) {
5
5
  const fileLines = contentLines(originalText);
@@ -40,12 +40,14 @@ export function buildMultiEditDiff(filePath, originalText, replacements) {
40
40
  buildEditDiff(filePath, originalText, oldText, newText),
41
41
  );
42
42
  const lines = [];
43
- for (const part of parts) {
43
+ let shift = 0;
44
+ for (const [index, part] of parts.entries()) {
44
45
  for (const line of part.lines) {
45
- const previous = lines.at(-1);
46
- if (previous?.type === "context" && line.type === "context" && previous.lineNum === line.lineNum) continue;
47
- lines.push(line);
46
+ if (line.type === "context") continue;
47
+ lines.push(line.type === "add" ? { ...line, lineNum: line.lineNum + shift }
48
+ : { ...line, newLineNum: Math.max(1, line.lineNum + shift) });
48
49
  }
50
+ shift += replacements[index].newText.split("\n").length - replacements[index].oldText.split("\n").length;
49
51
  }
50
52
  return {
51
53
  path: filePath,
@@ -1,4 +1,4 @@
1
- import { isString } from "./decode.js";
1
+ import { isString } from "../shared/decode.js";
2
2
 
3
3
  function parseHunkHeader(line) {
4
4
  const match = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/.exec(line);