pi-supernova 0.6.0 → 0.7.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.
- package/README.md +27 -3
- package/docs/CHANGELOG.md +104 -0
- package/docs/TOKEN_COSTS.md +13 -5
- package/index.js +120 -79
- package/package.json +1 -1
- package/src/adapters/bash.js +73 -0
- package/src/adapters/edit.js +249 -0
- package/src/adapters/errors.js +31 -0
- package/src/adapters/index.js +31 -0
- package/src/adapters/list.js +102 -0
- package/src/adapters/read.js +805 -0
- package/src/adapters/refs.js +41 -0
- package/src/adapters/write.js +96 -0
- package/src/bridge/catalog.js +28 -222
- package/src/bridge/host-bridge.js +113 -1668
- package/src/bridge/invoke.js +35 -0
- package/src/bridge/native-tools.js +1 -198
- package/src/context/evidence.js +140 -76
- package/src/context/fuzzy.js +42 -24
- package/src/context/ledger.js +43 -24
- package/src/context/outline.js +23 -18
- package/src/context/repo-index.js +206 -170
- package/src/context/search.js +157 -77
- package/src/context/snap.js +240 -136
- package/src/context/spans.js +2 -1
- package/src/context/surface.js +14 -5
- package/src/contract/bash.js +31 -0
- package/src/contract/edit.js +95 -0
- package/src/contract/read.js +220 -0
- package/src/fs/check.js +12 -8
- package/src/fs/diff.js +18 -7
- package/src/fs/json-read.js +66 -35
- package/src/fs/patch.js +94 -50
- package/src/fs/source-window.js +82 -0
- package/src/fs/text-ops.js +512 -0
- package/src/fs/vfs.js +205 -175
- package/src/fs/workspace.js +119 -108
- package/src/output/bottleneck.js +195 -116
- package/src/output/format.js +101 -67
- package/src/runtime/guest-deny-imports.js +34 -0
- package/src/runtime/guest-worker.js +289 -292
- package/src/runtime/parallel.js +97 -64
- package/src/runtime/program-batch.js +178 -69
- package/src/runtime/reference.js +15 -14
- package/src/runtime/runtime.js +327 -187
- package/src/shared/decode.js +58 -36
- package/src/ui/omp-frame.js +59 -42
- package/src/ui/render-measure.js +51 -29
- package/src/ui/render.js +241 -145
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { isString } from "../shared/decode.js";
|
|
2
|
+
|
|
3
|
+
/** Ordered permission rules. First decisive answer wins. */
|
|
4
|
+
export function toolIsCallable(name, env) {
|
|
5
|
+
if (name === "supernova" || env.excluded.has(name)) return false;
|
|
6
|
+
if (env.sessionInvalid()) return false;
|
|
7
|
+
if (env.nativeOwned(name)) return true;
|
|
8
|
+
if (env.hostSession) return env.evalAllows(name);
|
|
9
|
+
|
|
10
|
+
return env.listed(name);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isArgvOwned(name, args) {
|
|
14
|
+
return name === "bash" && Array.isArray(args?.args) && process.platform !== "win32"
|
|
15
|
+
&& args.args.length === Object.keys(args.args).length && args.args.every(isString);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function hostExecutor(name, env) {
|
|
19
|
+
const delegated = env.hostTool(name);
|
|
20
|
+
|
|
21
|
+
if (delegated) return { exec: delegated.execute.bind(delegated), delegated };
|
|
22
|
+
if (env.hostSession) return { exec: undefined, delegated };
|
|
23
|
+
|
|
24
|
+
return { exec: env.executors.get(name), delegated };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolveInvokeTarget(name, args, env) {
|
|
28
|
+
const argvOwned = isArgvOwned(name, args);
|
|
29
|
+
const { exec, delegated } = hostExecutor(name, env);
|
|
30
|
+
|
|
31
|
+
if (exec && !argvOwned) return { kind: "override", exec, delegated, argvOwned: false };
|
|
32
|
+
if (env.natives[name]) return { kind: "native", native: env.natives[name], argvOwned };
|
|
33
|
+
|
|
34
|
+
return { kind: "unknown" };
|
|
35
|
+
}
|
|
@@ -1,199 +1,2 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
1
|
+
/** Native command names. Host registration lives in createHostBridge. */
|
|
12
2
|
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
|
-
|
|
18
|
-
return path.resolve(cwd, value === "~" ? homedir() : value.startsWith("~/") ? path.join(homedir(), value.slice(2)) : value);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function boundText(result, maxChars) {
|
|
22
|
-
const total = result.content.reduce((sum, block) => sum + (block.type === "text" ? block.text.length : 0), 0);
|
|
23
|
-
const truncated = total > maxChars;
|
|
24
|
-
const notice = truncated ? truncateChars("\n[Truncated: read files individually or narrow the line range/question.]", maxChars, "read-result").text : "";
|
|
25
|
-
let remaining = maxChars - notice.length;
|
|
26
|
-
|
|
27
|
-
const content = result.content.map(block => {
|
|
28
|
-
if (block.type !== "text") return block; // Images remain image attachments.
|
|
29
|
-
const bounded = truncateChars(block.text, remaining, "read-result");
|
|
30
|
-
remaining -= bounded.text.length;
|
|
31
|
-
|
|
32
|
-
return { ...block, text: bounded.text };
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
if (notice) content.push({ type: "text", text: notice });
|
|
36
|
-
const details = { ...result.details };
|
|
37
|
-
|
|
38
|
-
if (truncated) details.outputTruncated = true;
|
|
39
|
-
|
|
40
|
-
return { ...result, content, details };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Familiar Pi tool definitions with Supernova's source engine and atomic VFS. */
|
|
44
|
-
export function registerNativeTools(pi, host, config = loadConfig()) {
|
|
45
|
-
let cwd = process.cwd();
|
|
46
|
-
const base = createHostBridge({ pi: null, config: { ...config, seenWindow: 0 }, getCwd: () => cwd });
|
|
47
|
-
const scheduler = createNativeScheduler();
|
|
48
|
-
|
|
49
|
-
const factories = {
|
|
50
|
-
read: host.createReadToolDefinition,
|
|
51
|
-
edit: host.createEditToolDefinition,
|
|
52
|
-
write: host.createWriteToolDefinition,
|
|
53
|
-
bash: host.createBashToolDefinition,
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
function settingsFor(ctx) {
|
|
57
|
-
try {
|
|
58
|
-
return host.SettingsManager?.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted?.() === true });
|
|
59
|
-
} catch {
|
|
60
|
-
return undefined;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function readOne(args, signal, ctx, bridge, id) {
|
|
65
|
-
const target = userPath(ctx.cwd, args.path);
|
|
66
|
-
let stat;
|
|
67
|
-
|
|
68
|
-
try { stat = await fs.stat(target); }
|
|
69
|
-
catch (error) { if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error; }
|
|
70
|
-
|
|
71
|
-
signal?.throwIfAborted();
|
|
72
|
-
const image = /\.(png|jpe?g|gif|webp|bmp)$/i.test(target);
|
|
73
|
-
|
|
74
|
-
if (stat?.isFile() && args.about === undefined && args.query === undefined && args.resolve === undefined && args.outline === undefined && args.evidence === undefined && args.json === undefined && args.complete === undefined) {
|
|
75
|
-
// Pi retains image handling, full text, line windows, truncation metadata,
|
|
76
|
-
// and actionable continuation offsets. Do not summarize or dedupe these.
|
|
77
|
-
return factories.read(ctx.cwd, { autoResizeImages: image ? settingsFor(ctx)?.getImageAutoResize() : undefined }).execute(id, { ...args, path: target }, signal, undefined, ctx);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return boundText(await bridge.natives.read({ ...args, path: stat ? target : args.path }, signal), config.maxCallResultChars);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async function execute(name, id, args, signal, onUpdate, context) {
|
|
84
|
-
const ctx = { ...context, cwd: isString(context?.cwd) && context.cwd ? context.cwd : cwd };
|
|
85
|
-
const bridge = base.fork({ getCwd: () => ctx.cwd });
|
|
86
|
-
bridge.bindCallContext(ctx, signal);
|
|
87
|
-
signal?.throwIfAborted();
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
if (name === "read") {
|
|
91
|
-
if (!Array.isArray(args.path)) return await readOne(args, signal, ctx, bridge, id);
|
|
92
|
-
|
|
93
|
-
if (args.path.some(p => !isString(p) || !p.trim())) throw new Error("read paths must be non-empty strings");
|
|
94
|
-
|
|
95
|
-
if (args.path.length > 64) throw new Error("read path arrays support at most 64 entries; use smaller batches");
|
|
96
|
-
const groups = [], errors = [];
|
|
97
|
-
|
|
98
|
-
// Bound fan-out even when a model submits a large path array.
|
|
99
|
-
for (let offset = 0; offset < args.path.length; offset += 8) {
|
|
100
|
-
const results = await Promise.all(args.path.slice(offset, offset + 8).map(async file => {
|
|
101
|
-
try {
|
|
102
|
-
const result = await readOne({ ...args, path: file }, signal, ctx, bridge, id);
|
|
103
|
-
|
|
104
|
-
return { content: [{ type: "text", text: "File: " + file }, ...result.content] };
|
|
105
|
-
} catch (error) {
|
|
106
|
-
signal?.throwIfAborted();
|
|
107
|
-
errors.push({ path: file, message: error.message });
|
|
108
|
-
|
|
109
|
-
return { content: [{ type: "text", text: "[read error: " + file + "] " + error.message }] };
|
|
110
|
-
}
|
|
111
|
-
}));
|
|
112
|
-
|
|
113
|
-
groups.push(...results);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const content = [];
|
|
117
|
-
let remaining = config.maxCallResultChars, outputTruncated = false;
|
|
118
|
-
|
|
119
|
-
for (let i = 0; i < groups.length; i++) {
|
|
120
|
-
const bounded = boundText(groups[i], Math.floor(remaining / (groups.length - i)));
|
|
121
|
-
remaining -= bounded.content.reduce((sum, block) => sum + (block.type === "text" ? block.text.length : 0), 0);
|
|
122
|
-
outputTruncated ||= bounded.details.outputTruncated === true;
|
|
123
|
-
content.push(...bounded.content);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
return { content, details: { batch: true, count: args.path.length, errors, outputTruncated } };
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (name === "bash") {
|
|
130
|
-
const settings = settingsFor(ctx);
|
|
131
|
-
|
|
132
|
-
try { return await factories.bash(ctx.cwd, { shellPath: settings?.getShellPath(), commandPrefix: settings?.getShellCommandPrefix() }).execute(id, args, signal, onUpdate, ctx); }
|
|
133
|
-
finally { bridge.invalidateFiles(); }
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
clearPathCache();
|
|
137
|
-
const target = await resolveWorkspacePath(ctx.cwd, userPath(ctx.cwd, args.path), name);
|
|
138
|
-
const ops = bridge.fileOperations;
|
|
139
|
-
let before, after;
|
|
140
|
-
|
|
141
|
-
const tool = factories[name](ctx.cwd, { operations: {
|
|
142
|
-
...ops,
|
|
143
|
-
async readFile(file) { const buffer = await ops.readFile(file); before = buffer.toString("utf8");
|
|
144
|
-
|
|
145
|
-
return buffer; },
|
|
146
|
-
async writeFile(file, content) { await ops.writeFile(file, content); after = content; },
|
|
147
|
-
} });
|
|
148
|
-
|
|
149
|
-
// Pi's edit/write implementations hold its shared canonical per-file queue
|
|
150
|
-
// over the entire mutation, including our atomic replacement operation.
|
|
151
|
-
const result = await tool.execute(id, { ...args, path: target }, signal, onUpdate, ctx);
|
|
152
|
-
|
|
153
|
-
if (name === "edit" && before !== undefined && after !== undefined && result.details?.patch) {
|
|
154
|
-
const summary = await bridge.summarizeEdit(target, before, after, buildPatchDiff(target, result.details.patch));
|
|
155
|
-
result.content = [{ type: "text", text: summary }];
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
return result;
|
|
159
|
-
} finally { bridge.close(); }
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
for (const name of NATIVE_NAMES) {
|
|
163
|
-
const definition = factories[name](cwd);
|
|
164
|
-
|
|
165
|
-
const tool = {
|
|
166
|
-
...definition,
|
|
167
|
-
execute(id, args, signal, onUpdate, ctx) {
|
|
168
|
-
return scheduler.schedule(name, () => execute(name, id, args, signal, onUpdate, ctx), signal);
|
|
169
|
-
},
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
if (name === "read") {
|
|
173
|
-
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 (at most 16 keywords). An array of up to 64 paths returns all readable files and labels individual errors. Images return as attachments up to 20 MiB each; at most 16 image attachments are returned.";
|
|
174
|
-
tool.parameters = { ...definition.parameters, properties: {
|
|
175
|
-
...definition.parameters.properties,
|
|
176
|
-
path: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" }, maxItems: 64 }], description: "File, directory, source question, or up to 64 paths to read" },
|
|
177
|
-
about: { type: "string", description: "Focus on this question or symbol (at most 16 keywords); source selection reports uncertainty instead of guessing" },
|
|
178
|
-
query: { type: "string", description: "Source question; optional path scopes the search" },
|
|
179
|
-
outline: { type: "boolean" },
|
|
180
|
-
evidence: { type: "boolean" },
|
|
181
|
-
resolve: { type: "boolean" },
|
|
182
|
-
complete: { type: "boolean" },
|
|
183
|
-
json: { anyOf: [{ type: "boolean" }, { type: "string" }, { type: "array", items: { type: "string" }, maxItems: 64 }] },
|
|
184
|
-
} };
|
|
185
|
-
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."];
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
pi.registerTool(tool);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
pi.on("session_start", (_event, ctx) => {
|
|
192
|
-
cwd = isString(ctx?.cwd) && ctx.cwd ? ctx.cwd : cwd;
|
|
193
|
-
base.invalidateFiles();
|
|
194
|
-
});
|
|
195
|
-
pi.registerCommand("supernova", {
|
|
196
|
-
description: "Show Supernova native runtime status",
|
|
197
|
-
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"),
|
|
198
|
-
});
|
|
199
|
-
}
|
package/src/context/evidence.js
CHANGED
|
@@ -87,36 +87,36 @@ function spanLines(span) {
|
|
|
87
87
|
|
|
88
88
|
// ---- eq.3 / eq.4: entity–context graph over candidate spans ----
|
|
89
89
|
|
|
90
|
-
function
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
for (
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
for (let li = span.start - 1; li < span.end; li++) {
|
|
101
|
-
for (const word of idents[li]) {
|
|
102
|
-
if (!entityNames.has(word)) continue;
|
|
103
|
-
counts.set(word, (counts.get(word) || 0) + 1);
|
|
104
|
-
total += 1;
|
|
105
|
-
}
|
|
90
|
+
function countSpanEntities(span, entityNames) {
|
|
91
|
+
const counts = new Map();
|
|
92
|
+
let total = 0;
|
|
93
|
+
const { idents } = span.lines;
|
|
94
|
+
|
|
95
|
+
for (let li = span.start - 1; li < span.end; li++) {
|
|
96
|
+
for (const word of idents[li]) {
|
|
97
|
+
if (!entityNames.has(word)) continue;
|
|
98
|
+
counts.set(word, (counts.get(word) || 0) + 1);
|
|
99
|
+
total += 1;
|
|
106
100
|
}
|
|
101
|
+
}
|
|
107
102
|
|
|
108
|
-
|
|
103
|
+
return { counts, total };
|
|
104
|
+
}
|
|
109
105
|
|
|
110
|
-
|
|
111
|
-
|
|
106
|
+
function weightsFromCounts(counts, total, entitySpans, spanId) {
|
|
107
|
+
const weights = new Map();
|
|
112
108
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
109
|
+
for (const [e, c] of counts) {
|
|
110
|
+
weights.set(e, c / total); // eq.4
|
|
116
111
|
|
|
117
|
-
|
|
112
|
+
if (!entitySpans.has(e)) entitySpans.set(e, new Set());
|
|
113
|
+
entitySpans.get(e).add(spanId);
|
|
118
114
|
}
|
|
119
115
|
|
|
116
|
+
return weights;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function hubEntities(entitySpans, spans) {
|
|
120
120
|
// Entities present in a large share of spans (isString, path, …) carry no query signal; keep them out of propagation.
|
|
121
121
|
const hubLimit = Math.max(HUB_MIN, Math.floor(spans.length * HUB_FRACTION));
|
|
122
122
|
const hubs = new Set();
|
|
@@ -125,7 +125,20 @@ function buildGraph(spans) {
|
|
|
125
125
|
if (ids.size > hubLimit) hubs.add(entity);
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
return
|
|
128
|
+
return hubs;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function buildGraph(spans) {
|
|
132
|
+
const entityNames = new Set(spans.map((s) => s.name).filter((n) => n && n.length > 2));
|
|
133
|
+
const spanEntities = new Map(); // span.id → Map(entity → w(d,e))
|
|
134
|
+
const entitySpans = new Map(); // entity → Set(span.id)
|
|
135
|
+
|
|
136
|
+
for (const span of spans) {
|
|
137
|
+
const { counts, total } = countSpanEntities(span, entityNames);
|
|
138
|
+
spanEntities.set(span.id, weightsFromCounts(counts, total, entitySpans, span.id));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { entityNames, spanEntities, entitySpans, hubs: hubEntities(entitySpans, spans), byId: new Map(spans.map((s) => [s.id, s])) };
|
|
129
142
|
}
|
|
130
143
|
|
|
131
144
|
// ---- eq.8 / eq.9: entity activation and one propagation step ----
|
|
@@ -378,7 +391,7 @@ function normalize(scores) {
|
|
|
378
391
|
|
|
379
392
|
// ---- candidate files (boundary + topology + entity hits) ----
|
|
380
393
|
|
|
381
|
-
function
|
|
394
|
+
function topologyScored(files, profile) {
|
|
382
395
|
const scored = [];
|
|
383
396
|
|
|
384
397
|
for (const f of files) {
|
|
@@ -388,27 +401,41 @@ function candidateFiles(files, profile, index, limit, overlayText) {
|
|
|
388
401
|
}
|
|
389
402
|
|
|
390
403
|
scored.sort((a, b) => b.s - a.s);
|
|
391
|
-
const chosen = new Set();
|
|
392
|
-
const anchors = (profile.subjects.length ? profile.subjects : profile.stems).map((a) => a.toLowerCase()).filter((a) => a.length > 2);
|
|
393
404
|
|
|
394
|
-
|
|
405
|
+
return scored;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function pendingAnchorHits(files, anchors, overlayText) {
|
|
409
|
+
return files.filter(file => {
|
|
395
410
|
const pending = overlayText(file);
|
|
396
411
|
|
|
397
412
|
return pending !== undefined && Buffer.byteLength(pending, "utf8") <= 512 * 1024 && anchors.some(anchor => pending.toLowerCase().includes(anchor));
|
|
398
413
|
});
|
|
414
|
+
}
|
|
399
415
|
|
|
400
|
-
|
|
401
|
-
|
|
416
|
+
function chooseByHits(hits, profile, limit, chosen) {
|
|
402
417
|
for (const f of hits) {
|
|
403
418
|
if (chosen.size >= limit) break;
|
|
404
419
|
|
|
405
420
|
if (profile.flags.wantsTest || scorePathTopology(f, profile.keywords, profile.flags) > -50) chosen.add(f);
|
|
406
421
|
}
|
|
422
|
+
}
|
|
407
423
|
|
|
424
|
+
function fillFromScored(scored, limit, chosen) {
|
|
408
425
|
for (const { f } of scored) {
|
|
409
426
|
if (chosen.size >= limit) break;
|
|
410
427
|
chosen.add(f);
|
|
411
428
|
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function candidateFiles(files, profile, index, limit, overlayText) {
|
|
432
|
+
const scored = topologyScored(files, profile);
|
|
433
|
+
const chosen = new Set();
|
|
434
|
+
const anchors = (profile.subjects.length ? profile.subjects : profile.stems).map((a) => a.toLowerCase()).filter((a) => a.length > 2);
|
|
435
|
+
const pendingHits = pendingAnchorHits(files, anchors, overlayText);
|
|
436
|
+
const hits = anchors.length ? [...new Set([...pendingHits, ...index.filesContaining(files, anchors, true)])] : [];
|
|
437
|
+
chooseByHits(hits, profile, limit, chosen);
|
|
438
|
+
fillFromScored(scored, limit, chosen);
|
|
412
439
|
|
|
413
440
|
return { files: [...chosen], fileScores: new Map(scored.map(({ f, s }) => [f, s])) };
|
|
414
441
|
}
|
|
@@ -495,77 +522,114 @@ function render(spans, picks, fused, opts, root) {
|
|
|
495
522
|
return out;
|
|
496
523
|
}
|
|
497
524
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
* @returns {{ route: string, spans: Array<{path, lines, name, kind, why, text}> }}
|
|
501
|
-
*/
|
|
502
|
-
export async function selectEvidence({ query, root, searchDir, index, overlayText = () => undefined, pendingPaths = [], options = {} }) {
|
|
503
|
-
const opts = { ...EVIDENCE_DEFAULTS, ...options };
|
|
504
|
-
const profile = profileQuery(query);
|
|
505
|
-
|
|
506
|
-
if (profile.keywords.length === 0) throw new Error("evidence requires at least one searchable concept keyword");
|
|
507
|
-
const searchRoot = path.resolve(searchDir || root);
|
|
508
|
-
|
|
509
|
-
const staged = pendingPaths.filter(file => {
|
|
525
|
+
function stagedInRoot(searchRoot, pendingPaths) {
|
|
526
|
+
return pendingPaths.filter(file => {
|
|
510
527
|
const relative = path.relative(searchRoot, file);
|
|
511
528
|
|
|
512
529
|
return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative);
|
|
513
530
|
});
|
|
531
|
+
}
|
|
514
532
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
533
|
+
function statCatch(error) {
|
|
534
|
+
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return null;
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function diskFilesAt(searchRoot, index) {
|
|
539
|
+
const rootStat = await fs.stat(searchRoot).catch(statCatch);
|
|
540
|
+
|
|
541
|
+
if (rootStat?.isFile()) return [searchRoot];
|
|
542
|
+
|
|
543
|
+
if (rootStat) return index.files(searchRoot);
|
|
544
|
+
|
|
545
|
+
return [];
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function listedFiles(root, searchDir, pendingPaths, index) {
|
|
549
|
+
const searchRoot = path.resolve(searchDir || root);
|
|
550
|
+
const files = [...new Set([...await diskFilesAt(searchRoot, index), ...stagedInRoot(searchRoot, pendingPaths)])];
|
|
521
551
|
|
|
522
552
|
if (files.length === 0) throw new Error(`no files found to search in ${searchDir || root}`);
|
|
523
553
|
|
|
524
|
-
|
|
554
|
+
return files;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function overlayEntry(f, overlayText, index) {
|
|
558
|
+
const pending = overlayText(f);
|
|
559
|
+
|
|
560
|
+
return pending === undefined
|
|
561
|
+
? index.entry(f)
|
|
562
|
+
: Buffer.byteLength(pending, "utf8") <= 512 * 1024 ? WorkspaceIndex.fromText(f, pending) : null;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function collectSpans(chosenFiles, overlayText, index, maxSpanLines) {
|
|
525
566
|
const spans = [];
|
|
526
567
|
|
|
527
568
|
for (const f of chosenFiles) {
|
|
528
|
-
const
|
|
529
|
-
const entry = pending === undefined
|
|
530
|
-
? index.entry(f)
|
|
531
|
-
: Buffer.byteLength(pending, "utf8") <= 512 * 1024 ? WorkspaceIndex.fromText(f, pending) : null;
|
|
569
|
+
const entry = overlayEntry(f, overlayText, index);
|
|
532
570
|
|
|
533
571
|
if (!entry) continue;
|
|
534
|
-
spans.push(...spansOf(entry, f,
|
|
572
|
+
spans.push(...spansOf(entry, f, maxSpanLines));
|
|
535
573
|
}
|
|
536
574
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
const graph = buildGraph(spans);
|
|
540
|
-
const hier = hierarchicalScores(spans, fileScores, profile);
|
|
541
|
-
const hierNorm = normalize(hier);
|
|
542
|
-
const eta = propagate(activateEntities(profile, graph), spans, graph, profile);
|
|
543
|
-
const pi = pageRank(spans, graph, eta, hierNorm.map((s) => s * 0.5), opts);
|
|
544
|
-
const graphNorm = normalize([...pi]);
|
|
575
|
+
return spans;
|
|
576
|
+
}
|
|
545
577
|
|
|
578
|
+
function fuseScores(profile, graphNorm, hierNorm, rho) {
|
|
546
579
|
const [primary, secondary] = profile.route === "relational" ? [graphNorm, hierNorm] : [hierNorm, graphNorm];
|
|
547
|
-
const fused = primary.map((p, i) => opts.rho * p + (1 - opts.rho) * secondary[i]); // eq.13
|
|
548
580
|
|
|
549
|
-
|
|
550
|
-
|
|
581
|
+
return primary.map((p, i) => rho * p + (1 - rho) * secondary[i]); // eq.13
|
|
582
|
+
}
|
|
551
583
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
584
|
+
function spanAdmissible(span, profile) {
|
|
585
|
+
const p = span.path;
|
|
586
|
+
const isDoc = /\.(md|mdx|rst|txt)$/i.test(p);
|
|
555
587
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
588
|
+
return span.support > 0
|
|
589
|
+
&& (profile.flags.wantsTest || !isTestPath(p))
|
|
590
|
+
&& (profile.flags.wantsDoc || !isDoc);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function compareFused(spans, fused) {
|
|
594
|
+
return (a, b) => fused[b] - fused[a] || spans[a].path.localeCompare(spans[b].path) || spans[a].start - spans[b].start;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function dampUsageDefiners(spans, profile, fused, admissible) {
|
|
598
|
+
const usage = profile.answerType === "usage";
|
|
561
599
|
|
|
562
600
|
for (const i of admissible) {
|
|
563
601
|
if (usage && profile.subjects.includes(spans[i].name)) fused[i] *= 0.5; // a usage question is answered by callers
|
|
564
602
|
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function pickEvidence(spans, fileScores, profile, opts) {
|
|
606
|
+
const graph = buildGraph(spans);
|
|
607
|
+
const hierNorm = normalize(hierarchicalScores(spans, fileScores, profile));
|
|
608
|
+
const eta = propagate(activateEntities(profile, graph), spans, graph, profile);
|
|
609
|
+
const pi = pageRank(spans, graph, eta, hierNorm.map((s) => s * 0.5), opts);
|
|
610
|
+
const fused = fuseScores(profile, normalize([...pi]), hierNorm, opts.rho);
|
|
611
|
+
// eq.15 Filter: boundary/type hard constraints and lexical support; Rank_ϕ: answer-type compatibility.
|
|
612
|
+
const admissible = spans.flatMap((span, i) => spanAdmissible(span, profile) ? [i] : []);
|
|
613
|
+
dampUsageDefiners(spans, profile, fused, admissible);
|
|
614
|
+
const main = admissible.sort(compareFused(spans, fused)).slice(0, opts.k);
|
|
615
|
+
|
|
616
|
+
return { picks: closure(main, spans, graph, fused, opts.k), fused };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* R(q): top-K provenance-bearing source spans for a concept query, selected without any model call.
|
|
621
|
+
* @returns {{ route: string, spans: Array<{path, lines, name, kind, why, text}> }}
|
|
622
|
+
*/
|
|
623
|
+
export async function selectEvidence({ query, root, searchDir, index, overlayText = () => undefined, pendingPaths = [], options = {} }) {
|
|
624
|
+
const opts = { ...EVIDENCE_DEFAULTS, ...options };
|
|
625
|
+
const profile = profileQuery(query);
|
|
565
626
|
|
|
566
|
-
|
|
567
|
-
const
|
|
568
|
-
const
|
|
627
|
+
if (profile.keywords.length === 0) throw new Error("evidence requires at least one searchable concept keyword");
|
|
628
|
+
const { files: chosenFiles, fileScores } = candidateFiles(await listedFiles(root, searchDir, pendingPaths, index), profile, index, opts.maxCandidateFiles, overlayText);
|
|
629
|
+
const spans = collectSpans(chosenFiles, overlayText, index, opts.maxSpanLines);
|
|
630
|
+
|
|
631
|
+
if (spans.length === 0) return { route: profile.route, spans: [] };
|
|
632
|
+
const { picks, fused } = pickEvidence(spans, fileScores, profile, opts);
|
|
569
633
|
|
|
570
634
|
return { route: profile.route, spans: render(spans, picks, fused, opts, root) };
|
|
571
635
|
}
|
package/src/context/fuzzy.js
CHANGED
|
@@ -122,13 +122,20 @@ function scoreAlignment(needle, nCmp, hay, hayCmp, start) {
|
|
|
122
122
|
return score;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
function considerShorter(part, typosLeft, visit, best) {
|
|
126
|
+
for (let i = 0; i < part.length; i++) {
|
|
127
|
+
const m = visit(part.slice(0, i) + part.slice(i + 1), typosLeft - 1);
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
if (!m) continue;
|
|
130
|
+
const scored = { ...m, score: m.score - 12, typos: m.typos + 1, exact: false };
|
|
130
131
|
|
|
131
|
-
|
|
132
|
+
if (!best || scored.score > best.score) best = scored;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return best;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function matchWithTypos(needle, hay, maxTypos, caseSensitive) {
|
|
132
139
|
const memo = new Map();
|
|
133
140
|
|
|
134
141
|
const visit = (part, typosLeft) => {
|
|
@@ -139,18 +146,7 @@ export function fuzzyMatch(needle, hay, { maxTypos = 0, caseSensitive = false }
|
|
|
139
146
|
|
|
140
147
|
if (best) best = { ...best, typos: 0, exact: hay.toLowerCase() === part.toLowerCase() };
|
|
141
148
|
|
|
142
|
-
if (typosLeft > 0)
|
|
143
|
-
for (let i = 0; i < part.length; i++) {
|
|
144
|
-
const shorter = part.slice(0, i) + part.slice(i + 1);
|
|
145
|
-
const m = visit(shorter, typosLeft - 1);
|
|
146
|
-
|
|
147
|
-
if (!m) continue;
|
|
148
|
-
const scored = { ...m, score: m.score - 12, typos: m.typos + 1, exact: false };
|
|
149
|
-
|
|
150
|
-
if (!best || scored.score > best.score) best = scored;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
149
|
+
if (typosLeft > 0) best = considerShorter(part, typosLeft, visit, best);
|
|
154
150
|
memo.set(key, best);
|
|
155
151
|
|
|
156
152
|
return best;
|
|
@@ -159,6 +155,17 @@ export function fuzzyMatch(needle, hay, { maxTypos = 0, caseSensitive = false }
|
|
|
159
155
|
return visit(needle, maxTypos);
|
|
160
156
|
}
|
|
161
157
|
|
|
158
|
+
/** Best match allowing up to maxTypos skipped needle characters. */
|
|
159
|
+
export function fuzzyMatch(needle, hay, { maxTypos = 0, caseSensitive = false } = {}) {
|
|
160
|
+
const direct = matchOnce(needle, hay, caseSensitive);
|
|
161
|
+
|
|
162
|
+
if (direct) return { ...direct, typos: 0, exact: hay.toLowerCase() === needle.toLowerCase() };
|
|
163
|
+
|
|
164
|
+
if (maxTypos <= 0 || needle.length < 3 || needle.length > 128) return null;
|
|
165
|
+
|
|
166
|
+
return matchWithTypos(needle, hay, maxTypos, caseSensitive);
|
|
167
|
+
}
|
|
168
|
+
|
|
162
169
|
export function smartCase(query) {
|
|
163
170
|
return /[A-Z]/.test(query);
|
|
164
171
|
}
|
|
@@ -180,23 +187,34 @@ function distancePenalty(currentDir, candidateDir) {
|
|
|
180
187
|
* Rank file paths for a query the fff way. paths are workspace-relative "/"-joined.
|
|
181
188
|
* ctx: { frecency: Frecency, mtimeOf: (path) => sec, modified: Set(path), currentFile?: string, maxTypos }
|
|
182
189
|
*/
|
|
190
|
+
function partTypos(parts, ctx) {
|
|
191
|
+
return ctx.maxTypos ?? (parts[0].length >= 6 ? 2 : parts[0].length >= 4 ? 1 : 0);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function scoredPath(rel, parts, maxTypos, caseSensitive, ctx, currentDir) {
|
|
195
|
+
const matched = matchParts(parts, rel, maxTypos, caseSensitive);
|
|
196
|
+
|
|
197
|
+
if (!matched) return null;
|
|
198
|
+
const { base, first, exact } = matched;
|
|
199
|
+
const filenameStart = rel.lastIndexOf("/") + 1;
|
|
200
|
+
const boosts = filenameBonus(base, rel, filenameStart, first, parts[0]) + contextBoost(base, rel, ctx) + distancePenalty(currentDir, rel.slice(0, filenameStart));
|
|
201
|
+
|
|
202
|
+
return { path: rel, score: base + boosts, exact, typos: first.typos };
|
|
203
|
+
}
|
|
204
|
+
|
|
183
205
|
export function rankPaths(query, paths, ctx = {}) {
|
|
184
206
|
const parts = query.trim().split(/\s+/).filter((p) => p.length >= 2);
|
|
185
207
|
|
|
186
208
|
if (parts.length === 0 || parts.length > 16) return [];
|
|
187
209
|
const caseSensitive = smartCase(query);
|
|
188
|
-
const maxTypos =
|
|
210
|
+
const maxTypos = partTypos(parts, ctx);
|
|
189
211
|
const currentDir = ctx.currentFile ? ctx.currentFile.slice(0, ctx.currentFile.lastIndexOf("/") + 1) : "";
|
|
190
212
|
const out = [];
|
|
191
213
|
|
|
192
214
|
for (const rel of paths) {
|
|
193
|
-
const
|
|
215
|
+
const scored = scoredPath(rel, parts, maxTypos, caseSensitive, ctx, currentDir);
|
|
194
216
|
|
|
195
|
-
if (
|
|
196
|
-
const { base, first, exact } = matched;
|
|
197
|
-
const filenameStart = rel.lastIndexOf("/") + 1;
|
|
198
|
-
const boosts = filenameBonus(base, rel, filenameStart, first, parts[0]) + contextBoost(base, rel, ctx) + distancePenalty(currentDir, rel.slice(0, filenameStart));
|
|
199
|
-
out.push({ path: rel, score: base + boosts, exact, typos: first.typos });
|
|
217
|
+
if (scored) out.push(scored);
|
|
200
218
|
}
|
|
201
219
|
|
|
202
220
|
out.sort((a, b) => b.score - a.score || a.path.length - b.path.length || a.path.localeCompare(b.path));
|