pi-supernova 0.0.11 → 0.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fuzzy.js ADDED
@@ -0,0 +1,182 @@
1
+ // Typo-resistant fuzzy path matching and frecency, ported from fff (dmtrKovalenko/fff)
2
+ // to plain JS so path search stays in-process: no binary, no spawn.
3
+ //
4
+ // fff pieces reproduced here:
5
+ // - frizbee-style fuzzy match with max_typos (skipped needle chars), boundary / consecutive /
6
+ // capitalization bonuses, smart-case (uppercase in query ⇒ case-sensitive)
7
+ // - filename bonus: exact filename +40% of base, filename match +20%
8
+ // - frecency boost: base × frecency / 100, AI-mode decay (3-day half-life, 7-day window)
9
+ // plus modification-recency boosts (30s/5m/15m/1h/4h thresholds)
10
+ // - git-modified boost: +15% of base
11
+ // - distance penalty from the current (last touched) file: −1 per directory hop, floor −20
12
+
13
+ const AI_DECAY = Math.LN2 / 3; // per day
14
+ const AI_MAX_HISTORY_DAYS = 7;
15
+ const MAX_TIMESTAMPS_PER_FILE = 128;
16
+ const AI_MODIFICATION_THRESHOLDS = [[16, 30], [8, 300], [4, 900], [2, 3600], [1, 14400]]; // [boost, seconds]
17
+
18
+ export class Frecency {
19
+ constructor() {
20
+ this.access = new Map(); // path → number[] (epoch seconds, newest last)
21
+ }
22
+
23
+ record(filePath, at = Date.now() / 1000) {
24
+ let list = this.access.get(filePath);
25
+ if (!list) this.access.set(filePath, (list = []));
26
+ list.push(at);
27
+ if (list.length > MAX_TIMESTAMPS_PER_FILE) list.splice(0, list.length - MAX_TIMESTAMPS_PER_FILE);
28
+ }
29
+
30
+ /** Σ exp(−λ·age) over accesses in the window, plus a step boost for a recently modified file. */
31
+ score(filePath, mtimeSec, now = Date.now() / 1000) {
32
+ let total = 0;
33
+ const cutoff = now - AI_MAX_HISTORY_DAYS * 86400;
34
+ for (const t of this.access.get(filePath) || []) {
35
+ if (t < cutoff) continue;
36
+ total += Math.exp(-AI_DECAY * ((now - t) / 86400));
37
+ }
38
+ if (mtimeSec) {
39
+ const age = now - mtimeSec;
40
+ for (const [boost, seconds] of AI_MODIFICATION_THRESHOLDS) {
41
+ if (age <= seconds) {
42
+ total += boost;
43
+ break;
44
+ }
45
+ }
46
+ }
47
+ return total;
48
+ }
49
+ }
50
+
51
+ const SEPARATORS = new Set(["/", "\\", "_", "-", ".", " "]);
52
+
53
+ function isBoundary(hay, i) {
54
+ if (i === 0) return true;
55
+ const prev = hay[i - 1];
56
+ if (SEPARATORS.has(prev)) return true;
57
+ const c = hay[i];
58
+ return c >= "A" && c <= "Z" && !(prev >= "A" && prev <= "Z");
59
+ }
60
+
61
+ /**
62
+ * Greedy forward match with backward tightening (fzf v1). Returns null or
63
+ * { score, start, end }. Score: +16 boundary, +8 consecutive, +4 case match, −1 per gap char.
64
+ */
65
+ function matchOnce(needle, hay, caseSensitive) {
66
+ const hayCmp = caseSensitive ? hay : hay.toLowerCase();
67
+ const nCmp = caseSensitive ? needle : needle.toLowerCase();
68
+ let hi = 0;
69
+ let firstAt = -1;
70
+ for (let ni = 0; ni < nCmp.length; ni++) {
71
+ hi = hayCmp.indexOf(nCmp[ni], hi);
72
+ if (hi < 0) return null;
73
+ if (firstAt < 0) firstAt = hi;
74
+ hi++;
75
+ }
76
+ const end = hi;
77
+ // Tighten: walk backwards from end to find the latest possible start.
78
+ let start = end;
79
+ for (let ni = nCmp.length - 1; ni >= 0; ni--) {
80
+ start = hayCmp.lastIndexOf(nCmp[ni], start - 1);
81
+ }
82
+ return { score: scoreAlignment(needle, nCmp, hay, hayCmp, start), start, end };
83
+ }
84
+
85
+ /** +16 boundary, +8 consecutive, +4 exact-case, −1 per skipped haystack char. */
86
+ function scoreAlignment(needle, nCmp, hay, hayCmp, start) {
87
+ let score = 0;
88
+ let prev = -2;
89
+ let cursor = start;
90
+ for (let ni = 0; ni < nCmp.length; ni++) {
91
+ const at = hayCmp.indexOf(nCmp[ni], cursor);
92
+ score += isBoundary(hay, at) ? 16 : 0;
93
+ score += at === prev + 1 ? 8 : 0;
94
+ score += hay[at] === needle[ni] ? 4 : 0;
95
+ score -= prev >= 0 ? at - prev - 1 : 0;
96
+ prev = at;
97
+ cursor = at + 1;
98
+ }
99
+ return score;
100
+ }
101
+
102
+ /** Best match allowing up to maxTypos skipped needle characters. */
103
+ export function fuzzyMatch(needle, hay, { maxTypos = 0, caseSensitive = false } = {}) {
104
+ const direct = matchOnce(needle, hay, caseSensitive);
105
+ if (direct) return { ...direct, typos: 0, exact: hay.toLowerCase() === needle.toLowerCase() };
106
+ if (maxTypos <= 0 || needle.length < 3) return null;
107
+ let best = null;
108
+ for (let i = 0; i < needle.length; i++) {
109
+ const shorter = needle.slice(0, i) + needle.slice(i + 1);
110
+ const m = fuzzyMatch(shorter, hay, { maxTypos: maxTypos - 1, caseSensitive });
111
+ if (!m) continue;
112
+ const scored = { ...m, score: m.score - 12, typos: m.typos + 1, exact: false };
113
+ if (!best || scored.score > best.score) best = scored;
114
+ }
115
+ return best;
116
+ }
117
+
118
+ export function smartCase(query) {
119
+ return /[A-Z]/.test(query);
120
+ }
121
+
122
+ /** fff distance penalty: directory hops from the current file's directory, floor −20. */
123
+ function distancePenalty(currentDir, candidateDir) {
124
+ if (!currentDir) return 0;
125
+ const a = currentDir.split("/").filter(Boolean);
126
+ const b = candidateDir.split("/").filter(Boolean);
127
+ let common = 0;
128
+ while (common < a.length && common < b.length && a[common] === b[common]) common++;
129
+ const depth = a.length - common;
130
+ return Math.max(-20, -depth);
131
+ }
132
+
133
+ /**
134
+ * Rank file paths for a query the fff way. paths are workspace-relative "/"-joined.
135
+ * ctx: { frecency: Frecency, mtimeOf: (path) => sec, modified: Set(path), currentFile?: string, maxTypos }
136
+ */
137
+ export function rankPaths(query, paths, ctx = {}) {
138
+ const parts = query.trim().split(/\s+/).filter((p) => p.length >= 2);
139
+ if (parts.length === 0) return [];
140
+ const caseSensitive = smartCase(query);
141
+ const maxTypos = ctx.maxTypos ?? (parts[0].length >= 6 ? 2 : parts[0].length >= 4 ? 1 : 0);
142
+ const currentDir = ctx.currentFile ? ctx.currentFile.slice(0, ctx.currentFile.lastIndexOf("/") + 1) : "";
143
+ const out = [];
144
+ for (const rel of paths) {
145
+ const matched = matchParts(parts, rel, maxTypos, caseSensitive);
146
+ if (!matched) continue;
147
+ const { base, first, exact } = matched;
148
+ const filenameStart = rel.lastIndexOf("/") + 1;
149
+ const boosts = filenameBonus(base, rel, filenameStart, first, parts[0]) + contextBoost(base, rel, ctx) + distancePenalty(currentDir, rel.slice(0, filenameStart));
150
+ out.push({ path: rel, score: base + boosts, exact, typos: first.typos });
151
+ }
152
+ out.sort((a, b) => b.score - a.score || a.path.length - b.path.length || a.path.localeCompare(b.path));
153
+ return out;
154
+ }
155
+
156
+ /** Every query part must match; later parts get at most one typo (fff narrows per part). Score is the average. */
157
+ function matchParts(parts, rel, maxTypos, caseSensitive) {
158
+ let sum = 0;
159
+ let first = null;
160
+ let exact = true;
161
+ for (let pi = 0; pi < parts.length; pi++) {
162
+ const m = fuzzyMatch(parts[pi], rel, { maxTypos: pi === 0 ? maxTypos : Math.min(maxTypos, 1), caseSensitive });
163
+ if (!m) return null;
164
+ first ??= m;
165
+ sum += m.score;
166
+ exact = exact && m.exact;
167
+ }
168
+ return { base: Math.max(1, Math.round(sum / parts.length)), first, exact };
169
+ }
170
+
171
+ /** fff: exact filename +40% of base, any filename match +20%. */
172
+ function filenameBonus(base, rel, filenameStart, first, needle) {
173
+ if (first.start < filenameStart) return 0;
174
+ return rel.slice(filenameStart).toLowerCase() === needle.toLowerCase() ? Math.floor((base * 2) / 5) : Math.floor(base / 5);
175
+ }
176
+
177
+ /** fff: frecency boost base·f/100 and +15% for git-modified files. */
178
+ function contextBoost(base, rel, ctx) {
179
+ const frecency = ctx.frecency ? ctx.frecency.score(rel, ctx.mtimeOf?.(rel)) : 0;
180
+ const gitBoost = ctx.modified?.has(rel) ? Math.floor((base * 15) / 100) : 0;
181
+ return Math.floor((base * frecency) / 100) + gitBoost;
182
+ }
package/guest-worker.js CHANGED
@@ -10,7 +10,7 @@ const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor;
10
10
  const compiledCache = new Map();
11
11
  const COMPILED_CACHE_MAX = 256;
12
12
  const PARAMS = [
13
- "nova", "tools", "console", "parallel", "pipeline",
13
+ "nova", "console", "parallel", "pipeline",
14
14
  "read", "write", "edit", "patch", "surface", "snap", "evidence", "bash", "exec", "speculate",
15
15
  ];
16
16
  // V8 and JSC both place the body on the line after the synthesized header.
@@ -228,20 +228,22 @@ function buildGuestApi(available) {
228
228
  return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
229
229
  }
230
230
  },
231
- surface: async (filePath) => unwrapJsonValue(await rpc("surface", [filePath])),
231
+ surface: async (filePath) => unwrapJsonValue(await rpc("call", ["surface", { path: filePath }])),
232
232
  evidence: async (query, opts) => unwrapJsonValue(await rpc("call", ["evidence", { query, ...opts }])),
233
- snap: async (query, targetPath) => unwrapJsonValue(await rpc("snap", [query, targetPath])),
233
+ snap: async (query, targetPath) => unwrapJsonValue(await rpc("call", ["snap", { query, path: targetPath }])),
234
234
  has: (name) => availableSet.has(name),
235
235
  };
236
236
 
237
- const read = async (p, offset, limit) => {
237
+ // read(path, offset?, limit?) or read(path, { about, offset, limit, maxChars })
238
+ const readArgs = (p, a, b) => (isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b });
239
+ const read = async (p, a, b) => {
238
240
  if (Array.isArray(p)) {
239
- const res = await nova.call("read", { path: p, offset, limit });
241
+ const res = await nova.call("read", readArgs(p, a, b));
240
242
  if (Array.isArray(res?.items)) return res.items;
241
243
  // Captured host executor without batch support: fan out.
242
- return Promise.all(p.map((item) => read(item, offset, limit)));
244
+ return Promise.all(p.map((item) => read(item, a, b)));
243
245
  }
244
- return unwrapValue(await nova.call("read", { path: p, offset, limit }));
246
+ return unwrapValue(await nova.call("read", readArgs(p, a, b)));
245
247
  };
246
248
  const write = async (p, content) => unwrapValue(await nova.call("write", { path: p, content }));
247
249
  const edit = async (p, oldText, newText) => unwrapValue(await nova.call("edit", { path: p, oldText, newText }));
@@ -312,7 +314,7 @@ async function handleRun(msg) {
312
314
  const scopedConsole = makeConsole(runId, limits);
313
315
  try {
314
316
  const value = await compiled(
315
- api.nova, api.nova, scopedConsole, runParallel, runPipeline,
317
+ api.nova, scopedConsole, runParallel, runPipeline,
316
318
  api.read, api.write, api.edit, api.patch, api.surface, api.snap, api.evidence, api.bash, api.exec, api.speculate,
317
319
  );
318
320
  if (runId !== activeRunId) return;
package/host-bridge.js CHANGED
@@ -9,10 +9,15 @@ import { extractStructuralSurface } from "./surface.js";
9
9
  import { buildEditDiff, buildMultiEditDiff, buildPatchDiff, buildWriteDiff } from "./diff.js";
10
10
  import { executeSnap } from "./snap.js";
11
11
  import { selectEvidence } from "./evidence.js";
12
- import { WorkspaceIndex, globToRegExp } from "./repo-index.js";
12
+ import { WorkspaceIndex } from "./repo-index.js";
13
+ import { outlineFile } from "./outline.js";
14
+ import { SeenLedger } from "./ledger.js";
15
+ import { quickCheck } from "./check.js";
16
+ import { declaredName } from "./repo-index.js";
13
17
  import { CausalVfs } from "./vfs.js";
14
18
  import { applyPatchToText } from "./patch.js";
15
- import { resolveWorkspacePath, runCommand, clearPathCache } from "./workspace.js";
19
+ import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash } from "./workspace.js";
20
+ import { fuzzyFind, grepIndexed, listIndexed, listWithTools, rgGrepArgs } from "./search.js";
16
21
 
17
22
  function textResult(text, details) {
18
23
  return {
@@ -63,15 +68,6 @@ async function probeExistingFile(cwd, targetParam, vfs) {
63
68
  }
64
69
  }
65
70
 
66
- function patchModeOf(params) {
67
- return (
68
- isString(params?.patch) ||
69
- (params?.newText === undefined &&
70
- isString(params?.oldText) &&
71
- (params.oldText.includes("@@ -") || params.oldText.startsWith("---")))
72
- );
73
- }
74
-
75
71
  function applyReplacements(target, content, requestedEdits) {
76
72
  if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
77
73
  const matches = requestedEdits.map((replacement) => {
@@ -115,61 +111,7 @@ async function formatLsEntry(dirPath, entry) {
115
111
  return `${entry.name}${isDir ? "/" : ""} (${typeLabel}${sizeSuffix})`;
116
112
  }
117
113
 
118
- function rgGrepArgs(pattern, params, searchPath) {
119
- const args = ["--line-number", "--no-heading", "--color", "never"];
120
- if (params?.caseSensitive !== true) args.push("--ignore-case");
121
- if (params?.glob) args.push("--glob", String(params.glob));
122
- args.push("--", pattern, searchPath);
123
- return args;
124
- }
125
-
126
- /** rg --files, then find(1) when rg is unavailable; both accept an optional glob/name pattern. */
127
- async function listWithTools(searchDir, pattern, cwd, signal) {
128
- const args = ["--files"];
129
- if (pattern) args.push("-g", pattern);
130
- const res = await runCommand(["rg", ...args, searchDir], { cwd, timeoutMs: 30_000, signal }).catch(() => null);
131
- if (res && (res.exitCode === 0 || res.exitCode === 1)) return textResult(res.stdout, { via: "rg" });
132
- const findArgs = [searchDir];
133
- if (pattern) findArgs.push("-name", pattern);
134
- const findRes = await runCommand(["find", ...findArgs], { cwd, timeoutMs: 30_000, signal });
135
- return textResult(findRes.stdout, { via: "find" });
136
- }
137
-
138
- /** rg-compatible grep served from the index; null when the pattern or tree needs real rg. */
139
- async function grepIndexed(index, pattern, params, searchPath, cwd) {
140
- let regex;
141
- try {
142
- regex = new RegExp(pattern, params?.caseSensitive === true ? "" : "i");
143
- } catch {
144
- return null;
145
- }
146
- let files = await index.files(searchPath);
147
- if (!index.canScan(files)) return null;
148
- if (params?.glob) {
149
- const matcher = globToRegExp(String(params.glob));
150
- files = files.filter((f) => matcher.test(path.relative(cwd, f).split(path.sep).join("/")));
151
- }
152
- const rows = index.grep(files, regex, cwd);
153
- return rows.length ? rows.join("\n") + "\n" : "";
154
- }
155
-
156
- /** rg --files [-g pattern] served from the index; null when the tree is too large. */
157
- async function listIndexed(index, root, cwd, pattern) {
158
- const files = await index.files(root);
159
- if (!index.canScan(files)) return null;
160
- const rel = files.map((f) => path.relative(cwd, f).split(path.sep).join("/"));
161
- if (!pattern) return rel.length ? rel.join("\n") + "\n" : "";
162
- let matcher;
163
- try {
164
- matcher = globToRegExp(pattern);
165
- } catch {
166
- return null;
167
- }
168
- const hits = rel.filter((f) => matcher.test(f));
169
- return hits.length ? hits.join("\n") + "\n" : "";
170
- }
171
-
172
- function createNativeAdapters(getCwd, vfs, config, index) {
114
+ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
173
115
  async function readAdapter(params, signal) {
174
116
  const cwd = getCwd();
175
117
  const targetParam = params?.path ?? params?.target;
@@ -184,15 +126,11 @@ function createNativeAdapters(getCwd, vfs, config, index) {
184
126
 
185
127
  if (looksLikePath(targetParam)) {
186
128
  const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
187
- const text = await vfs.read(targetPath);
188
- return textResult(sliceLines(text, params?.offset, params?.limit), { path: targetPath });
129
+ return readFile(targetPath, params);
189
130
  }
190
131
 
191
132
  const existing = await probeExistingFile(cwd, targetParam, vfs);
192
- if (existing) {
193
- const text = await vfs.read(existing);
194
- return textResult(sliceLines(text, params?.offset, params?.limit), { path: existing });
195
- }
133
+ if (existing) return readFile(existing, params);
196
134
 
197
135
  if (isString(targetParam) && targetParam.trim()) {
198
136
  try {
@@ -208,8 +146,133 @@ function createNativeAdapters(getCwd, vfs, config, index) {
208
146
  }
209
147
 
210
148
  const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
211
- const text = await vfs.read(targetPath);
212
- return textResult(sliceLines(text, params?.offset, params?.limit), { path: targetPath });
149
+ return readFile(targetPath, params);
150
+ }
151
+
152
+ /** Plain text, a line window, or (with `about`) a relevance-folded outline of the whole file. */
153
+ async function readFile(targetPath, params) {
154
+ const cwd = getCwd();
155
+ const rel = relativeSlash(cwd, targetPath);
156
+ const text = await vfs.read(targetPath);
157
+ index.touch(rel);
158
+ if (isString(params?.about)) {
159
+ const pending = vfs.getOverlay(targetPath);
160
+ const entry = pending === undefined ? index.entry(targetPath) : WorkspaceIndex.fromText(targetPath, pending);
161
+ const outline = entry && outlineFile(entry, rel, params.about, outlineOptions(params, await referenceFinder(cwd, targetPath)));
162
+ if (outline) {
163
+ recordOutlineOrigins(rel, outline.text);
164
+ return textResult(outline.text, { path: targetPath, outline: true, expanded: outline.expanded, declarations: outline.declarations });
165
+ }
166
+ }
167
+ const explicit = isNumber(params?.offset) || isNumber(params?.limit);
168
+ const firstLine = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
169
+ const sliced = sliceLines(text, params?.offset, params?.limit);
170
+ ledger.recordOrigin(rel, firstLine, sliced.split("\n"), explicit);
171
+ return textResult(sliced, { path: targetPath });
172
+ }
173
+
174
+ /**
175
+ * The edit result answers the follow-ups a model would otherwise spend turns on: the post-edit
176
+ * lines with numbers (so no verification re-read), a quick structural check, and every other
177
+ * place a changed declaration is referenced (so callers are not forgotten).
178
+ */
179
+ async function editSummary(cwd, target, original, updated, diff) {
180
+ const rel = relativeSlash(cwd, target);
181
+ const newLines = updated.split("\n");
182
+ const added = diff.lines.filter((l) => l.type === "add");
183
+ const first = added.length ? Math.max(1, added[0].lineNum - 2) : 1;
184
+ const last = added.length ? Math.min(newLines.length, added[added.length - 1].lineNum + 2) : Math.min(newLines.length, first + 6);
185
+ const window = [];
186
+ for (let l = first; l <= last && window.length < 40; l++) window.push(String(l).padStart(5) + " " + newLines[l - 1]);
187
+ ledger.recordOrigin(rel, first, newLines.slice(first - 1, first - 1 + window.length));
188
+ let out = `edited ${rel}:${first}–${first + window.length - 1}\n${window.join("\n")}`;
189
+ const check = quickCheck(updated, path.extname(target));
190
+ if (check && !check.ok) out += `\ncheck: ${check.message}`;
191
+ const refs = await changedDeclarationRefs(cwd, target, original, updated, diff);
192
+ if (refs) out += `\n${refs}`;
193
+ return out;
194
+ }
195
+
196
+ async function changedDeclarationRefs(cwd, target, original, updated, diff) {
197
+ // Diff rows carry the replaced fragments; declarations live on whole file lines.
198
+ const oldLines = original.split("\n");
199
+ const newLines = updated.split("\n");
200
+ const names = new Set();
201
+ for (const l of diff.lines) {
202
+ if (l.type === "context") continue;
203
+ const name = declaredName((l.type === "remove" ? oldLines : newLines)[l.lineNum - 1] ?? "");
204
+ if (name) names.add(name);
205
+ }
206
+ if (names.size === 0) return "";
207
+ const find = await referenceFinder(cwd, target);
208
+ const parts = [];
209
+ for (const name of [...names].slice(0, 3)) {
210
+ const refs = find(name).filter((r) => !r.startsWith(relativeSlash(cwd, target) + ":"));
211
+ if (refs.length) parts.push(`${name} also referenced in ${refs.slice(0, 6).join(", ")}${refs.length > 6 ? " (+" + (refs.length - 6) + ")" : ""}`);
212
+ }
213
+ return parts.join("\n");
214
+ }
215
+
216
+ const SOURCE_REF = /((?:[\w.@-]+\/)*[\w.@-]+\.(?:m?[jt]sx?|c[jt]s|py|rs|go|java|kt|rb|php|c|cc|cpp|h|hpp|cs|swift|json|ya?ml|toml))(?::|\()(\d+)/g;
217
+
218
+ /** Source window (±2 lines, ► on the cited line) for one path:line, or null when it is outside the workspace/index. */
219
+ function sourceWindow(cwd, file, lineNo) {
220
+ const candidate = path.resolve(cwd, file);
221
+ if (!candidate.startsWith(path.resolve(cwd) + path.sep)) return null;
222
+ const entry = index.entry(candidate);
223
+ if (!entry) return null;
224
+ const { raw } = WorkspaceIndex.linesOf(entry);
225
+ if (lineNo < 1 || lineNo > raw.length) return null;
226
+ const rel = relativeSlash(cwd, candidate);
227
+ const start = Math.max(1, lineNo - 2);
228
+ const rows = [];
229
+ for (let l = start; l <= Math.min(raw.length, lineNo + 2); l++) rows.push((l === lineNo ? "►" : " ") + String(l).padStart(4) + " " + raw[l - 1]);
230
+ ledger.recordOrigin(rel, start, rows);
231
+ return rel + ":" + lineNo + "\n" + rows.join("\n");
232
+ }
233
+
234
+ /** A failing command names path:line; the model wants those lines next. Attach them (≤4 sites). */
235
+ function sourceForReferences(cwd, output) {
236
+ const seen = new Set();
237
+ const blocks = [];
238
+ for (const m of output.matchAll(SOURCE_REF)) {
239
+ const key = m[1] + ":" + m[2];
240
+ if (seen.has(key)) continue;
241
+ seen.add(key);
242
+ const block = sourceWindow(cwd, m[1], Number(m[2]));
243
+ if (block) blocks.push(block);
244
+ if (blocks.length >= 4) break;
245
+ }
246
+ return blocks.length ? "\n--- source\n" + blocks.join("\n") : "";
247
+ }
248
+
249
+ function outlineOptions(params, references) {
250
+ const options = { references };
251
+ if (params?.maxChars) options.maxChars = params.maxChars;
252
+ return options;
253
+ }
254
+
255
+ /** Outline lines carry their own line numbers (" 330 text"); provenance follows them. */
256
+ function recordOutlineOrigins(rel, outlineText) {
257
+ for (const line of outlineText.split("\n")) {
258
+ const m = /^\s*(\d+) (.*)$/.exec(line);
259
+ if (m && !/ … \d+ lines$/.test(line)) ledger.recordOrigin(rel, Number(m[1]), [line]);
260
+ }
261
+ }
262
+
263
+ /** Where else a name appears (declaration line excluded), for outlines and edit results. */
264
+ async function referenceFinder(cwd, targetPath) {
265
+ const files = await index.files(cwd);
266
+ if (!index.canScan(files)) return () => [];
267
+ return (name, excludeLine) => {
268
+ if (!name || name.length < 3) return [];
269
+ const escaped = name.replace(/[$]/g, (c) => "\\" + c);
270
+ const regex = new RegExp("\\b" + escaped + "\\b");
271
+ return index
272
+ .grepRows(files, regex, cwd)
273
+ .filter((r) => !(r.line === excludeLine && r.rel === relativeSlash(cwd, targetPath)))
274
+ .map((r) => r.rel + ":" + r.line);
275
+ };
213
276
  }
214
277
 
215
278
  return {
@@ -224,6 +287,7 @@ function createNativeAdapters(getCwd, vfs, config, index) {
224
287
  prevText = await vfs.read(target);
225
288
  } catch {}
226
289
  const { speculative } = await vfs.write(target, content);
290
+ index.touch(relativeSlash(cwd, target));
227
291
  const diff = buildWriteDiff(target, prevText, content);
228
292
  const tag = speculative ? " (speculative)" : "";
229
293
  return textResult(`wrote ${target}${tag}`, { path: target, speculative, diff });
@@ -233,33 +297,20 @@ function createNativeAdapters(getCwd, vfs, config, index) {
233
297
  const target = await resolveWorkspacePath(cwd, params?.path, "edit", false);
234
298
  if (signal?.aborted) throw new Error("aborted");
235
299
 
236
- if (patchModeOf(params)) {
237
- const patchContent = params.patch || params.oldText;
238
- const original = await vfs.read(target);
239
- const { resultText, hunkCount } = applyPatchToText(original, patchContent);
240
- const { speculative } = await vfs.write(target, resultText);
241
- const diff = buildPatchDiff(target, patchContent);
242
- const tag = speculative ? " (speculative)" : "";
243
- return textResult(`applied ${hunkCount} hunk(s) to ${target}${tag}`, {
244
- path: target,
245
- hunks: hunkCount,
246
- speculative,
247
- diff,
248
- });
249
- }
250
-
251
300
  const requestedEdits = Array.isArray(params?.edits)
252
301
  ? params.edits
253
302
  : [{ oldText: params?.oldText, newText: params?.newText }];
254
303
  const content = await vfs.read(target);
255
304
  const { updated, matches } = applyReplacements(target, content, requestedEdits);
256
305
  const { speculative } = await vfs.write(target, updated);
306
+ index.touch(relativeSlash(cwd, target));
257
307
  const diff =
258
308
  matches.length === 1
259
309
  ? buildEditDiff(target, content, matches[0].oldText, matches[0].newText)
260
310
  : buildMultiEditDiff(target, content, matches);
261
311
  const tag = speculative ? " (speculative)" : "";
262
- return textResult(`edited ${target}${tag}`, { path: target, speculative, diff });
312
+ const summary = await editSummary(cwd, target, content, updated, diff);
313
+ return textResult(summary, { path: target, speculative, diff });
263
314
  },
264
315
  async apply_patch(params, signal) {
265
316
  const cwd = getCwd();
@@ -317,6 +368,7 @@ function createNativeAdapters(getCwd, vfs, config, index) {
317
368
  if (Number.isInteger(params?.k) && params.k > 0) options.k = params.k;
318
369
  if (Number.isInteger(params?.maxChars) && params.maxChars > 0) options.maxChars = params.maxChars;
319
370
  const res = await selectEvidence({ query: params.query, root: cwd, searchDir, index, overlayText: (p) => vfs.getOverlay(p), options });
371
+ for (const span of res.spans) ledger.recordOrigin(span.path, span.lines[0], span.text.split("\n"));
320
372
  return textResult(JSON.stringify(res), { route: res.route, count: res.spans.length });
321
373
  },
322
374
  async surface(params, signal) {
@@ -352,7 +404,8 @@ function createNativeAdapters(getCwd, vfs, config, index) {
352
404
  index.invalidate();
353
405
  }
354
406
  const { stdout, stderr } = res;
355
- const text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
407
+ let text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
408
+ if (res.exitCode !== 0) text += sourceForReferences(cwd, text);
356
409
  return {
357
410
  content: [{ type: "text", text }],
358
411
  details: { exitCode: res.exitCode, outputTruncated: res.outputTruncated, transactionBarrier },
@@ -366,6 +419,7 @@ function createNativeAdapters(getCwd, vfs, config, index) {
366
419
  const searchPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "grep", true) : cwd;
367
420
  const indexed = await grepIndexed(index, pattern, params, searchPath, cwd);
368
421
  if (indexed !== null) return textResult(indexed, { exitCode: indexed ? 0 : 1, via: "index" });
422
+ // Large tree: real rg keeps its own output format.
369
423
  const res = await runCommand(["rg", ...rgGrepArgs(pattern, params, searchPath)], { cwd, timeoutMs: 30_000, signal });
370
424
  if (res.exitCode !== 0 && res.exitCode !== 1) {
371
425
  throw new Error(res.stderr.trim() || `rg exited ${res.exitCode}`);
@@ -376,6 +430,8 @@ function createNativeAdapters(getCwd, vfs, config, index) {
376
430
  const cwd = getCwd();
377
431
  const pattern = String(params?.pattern || "");
378
432
  if (!pattern) throw new Error("glob requires pattern");
433
+ const fuzzy = await fuzzyFind(index, cwd, cwd, pattern);
434
+ if (fuzzy !== null) return textResult(fuzzy, { via: "fuzzy" });
379
435
  const indexed = await listIndexed(index, cwd, cwd, pattern);
380
436
  if (indexed !== null) return textResult(indexed, { via: "index" });
381
437
  const rg = await runCommand(["rg", "--files", "-g", pattern], { cwd, timeoutMs: 30_000, signal }).catch(
@@ -398,6 +454,8 @@ function createNativeAdapters(getCwd, vfs, config, index) {
398
454
  const pattern = params?.pattern || params?.glob;
399
455
  if (signal?.aborted) throw new Error("aborted");
400
456
  const globPattern = pattern ? String(pattern) : null;
457
+ const fuzzy = await fuzzyFind(index, searchDir, cwd, globPattern);
458
+ if (fuzzy !== null) return textResult(fuzzy, { via: "fuzzy" });
401
459
  const indexed = await listIndexed(index, searchDir, cwd, globPattern);
402
460
  if (indexed !== null) return textResult(indexed, { via: "index" });
403
461
  return listWithTools(searchDir, globPattern, cwd, signal);
@@ -418,9 +476,10 @@ function createNativeAdapters(getCwd, vfs, config, index) {
418
476
 
419
477
  export function createHostBridge({ pi, config, getCwd }) {
420
478
  const index = new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
479
+ const ledger = new SeenLedger({ window: config.seenWindow ?? 40 });
421
480
  const vfs = new CausalVfs(() => index.invalidate());
422
481
  const executors = new Map();
423
- const natives = createNativeAdapters(getCwd, vfs, config, index);
482
+ const natives = createNativeAdapters(getCwd, vfs, config, index, ledger);
424
483
  let callCount = 0;
425
484
  let activeCtx = null;
426
485
  let activeSignal = undefined;
@@ -465,10 +524,6 @@ export function createHostBridge({ pi, config, getCwd }) {
465
524
  callListener = isFunction(fn) ? fn : null;
466
525
  }
467
526
 
468
- function hasExecutor(name) {
469
- return executors.has(name) || Object.hasOwn(natives, name);
470
- }
471
-
472
527
  function beginSpeculation() {
473
528
  return vfs.begin();
474
529
  }
@@ -481,10 +536,6 @@ export function createHostBridge({ pi, config, getCwd }) {
481
536
  return vfs.rollback();
482
537
  }
483
538
 
484
- function clearVfsCache() {
485
- vfs.clear();
486
- }
487
-
488
539
  function resultDiff(response) {
489
540
  let details = response?.details;
490
541
  if (isString(details)) {
@@ -620,15 +671,13 @@ export function createHostBridge({ pi, config, getCwd }) {
620
671
  resetCallBudget,
621
672
  getTrace,
622
673
  setCallListener,
623
- hasExecutor,
624
674
  beginSpeculation,
625
675
  commitSpeculation,
626
676
  rollbackSpeculation,
627
- clearVfsCache,
628
677
  getVfsCacheSize: () => vfs.getCacheSize(),
629
678
  getOverlayDepth: () => vfs.getOverlayDepth(),
630
679
  call,
631
680
  callMany,
632
- isMutating: (name) => isMutatingTool(name, config),
681
+ ledger,
633
682
  };
634
683
  }