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/CHANGELOG.md +56 -3
- package/README.md +33 -13
- package/bottleneck.js +1 -1
- package/catalog.js +8 -7
- package/check.js +166 -0
- package/config.default.json +1 -0
- package/config.js +1 -1
- package/decode.js +0 -10
- package/evidence.js +13 -30
- package/fuzzy.js +182 -0
- package/guest-worker.js +10 -8
- package/host-bridge.js +152 -103
- package/index.js +31 -40
- package/ledger.js +178 -0
- package/omp-frame.js +7 -22
- package/outline.js +80 -0
- package/package.json +6 -1
- package/render-measure.js +2 -30
- package/render.js +32 -146
- package/repo-index.js +105 -11
- package/runtime.js +3 -20
- package/search.js +141 -0
- package/snap.js +1 -6
- package/surface.js +22 -13
- package/vfs.js +0 -5
- package/workspace.js +14 -0
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", "
|
|
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",
|
|
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",
|
|
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
|
-
|
|
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",
|
|
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,
|
|
244
|
+
return Promise.all(p.map((item) => read(item, a, b)));
|
|
243
245
|
}
|
|
244
|
-
return unwrapValue(await nova.call("read",
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
681
|
+
ledger,
|
|
633
682
|
};
|
|
634
683
|
}
|