pi-supernova 0.3.2 → 0.4.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 +134 -15
- package/docs/CHANGELOG.md +28 -0
- package/docs/TOKEN_COSTS.md +171 -0
- package/index.js +116 -38
- package/package.json +3 -1
- package/src/bridge/catalog.js +35 -1
- package/src/bridge/host-bridge.js +271 -6
- package/src/bridge/native-tools.js +35 -1
- package/src/config/config.default.json +1 -1
- package/src/config/config.js +19 -0
- package/src/context/evidence.js +119 -5
- package/src/context/fuzzy.js +37 -0
- package/src/context/ledger.js +128 -6
- package/src/context/outline.js +24 -0
- package/src/context/repo-index.js +62 -0
- package/src/context/search.js +45 -0
- package/src/context/snap.js +80 -4
- package/src/context/surface.js +25 -0
- package/src/fs/check.js +50 -0
- package/src/fs/diff.js +16 -0
- package/src/fs/json-read.js +87 -0
- package/src/fs/patch.js +26 -0
- package/src/fs/vfs.js +99 -5
- package/src/fs/workspace.js +35 -0
- package/src/output/bottleneck.js +46 -0
- package/src/output/format.js +117 -17
- package/src/runtime/guest-worker.js +136 -13
- package/src/runtime/parallel.js +33 -0
- package/src/runtime/program-batch.js +112 -0
- package/src/runtime/program-file.js +40 -0
- package/src/runtime/reference.js +25 -0
- package/src/runtime/runtime.js +90 -6
- package/src/shared/decode.js +29 -0
- package/src/ui/omp-frame.js +30 -1
- package/src/ui/render-measure.js +24 -0
- package/src/ui/render.js +96 -2
package/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
+
import { runProgramBatch } from "./src/runtime/program-batch.js";
|
|
3
|
+
import { REFERENCE } from "./src/runtime/reference.js";
|
|
2
4
|
import { isString, isFunction } from "./src/shared/decode.js";
|
|
3
5
|
import { loadConfig } from "./src/config/config.js";
|
|
4
6
|
import { createHostBridge } from "./src/bridge/host-bridge.js";
|
|
@@ -10,13 +12,17 @@ export { renderSupernovaCall, renderSupernovaResult };
|
|
|
10
12
|
|
|
11
13
|
// Sync only, never top-level await. Dynamic import of host/deps hung OMP plugin load.
|
|
12
14
|
const require = createRequire(import.meta.url);
|
|
15
|
+
|
|
13
16
|
let Type;
|
|
17
|
+
|
|
14
18
|
try {
|
|
15
19
|
Type = require("typebox").Type;
|
|
16
20
|
} catch {
|
|
17
21
|
Type = {
|
|
18
22
|
Object: (props, opts) => ({ type: "object", properties: props || {}, additionalProperties: false, ...opts }),
|
|
19
23
|
String: (opts) => ({ type: "string", ...opts }),
|
|
24
|
+
Unknown: (opts) => ({ ...opts }),
|
|
25
|
+
Array: (items, opts) => ({ type: "array", items, ...opts }),
|
|
20
26
|
Integer: (opts) => ({ type: "integer", ...opts }),
|
|
21
27
|
Optional: (s) => ({ ...s }),
|
|
22
28
|
};
|
|
@@ -38,30 +44,38 @@ export function progressEmitter(onUpdate) {
|
|
|
38
44
|
let pending = null;
|
|
39
45
|
let timer = null;
|
|
40
46
|
let lastSent = -Infinity;
|
|
47
|
+
|
|
41
48
|
const send = () => {
|
|
42
49
|
timer = null;
|
|
50
|
+
|
|
43
51
|
if (pending === null) return;
|
|
44
52
|
// Snapshot only at emission, not on every tool event. Completed records must
|
|
45
53
|
// not mutate a previously emitted frame while Pi is still consuming it.
|
|
46
54
|
const trace = pending.map(record => ({ ...record }));
|
|
47
55
|
pending = null;
|
|
48
56
|
lastSent = performance.now();
|
|
57
|
+
|
|
49
58
|
try {
|
|
50
59
|
onUpdate({ content: [{ type: "text", text: "" }], details: { trace, running: true } });
|
|
51
60
|
} catch {}
|
|
52
61
|
};
|
|
62
|
+
|
|
53
63
|
const emit = (trace) => {
|
|
54
64
|
pending = trace;
|
|
65
|
+
|
|
55
66
|
if (timer !== null) return;
|
|
56
67
|
const wait = PROGRESS_FRAME_MS - (performance.now() - lastSent);
|
|
68
|
+
|
|
57
69
|
if (wait <= 0) send();
|
|
58
70
|
else timer = setTimeout(send, wait);
|
|
59
71
|
};
|
|
72
|
+
|
|
60
73
|
emit.flush = () => {
|
|
61
74
|
if (timer !== null) clearTimeout(timer);
|
|
62
75
|
pending = null;
|
|
63
76
|
timer = null;
|
|
64
77
|
};
|
|
78
|
+
|
|
65
79
|
return emit;
|
|
66
80
|
}
|
|
67
81
|
|
|
@@ -70,36 +84,42 @@ function sessionStats({ programs, returnedChars }) {
|
|
|
70
84
|
}
|
|
71
85
|
|
|
72
86
|
function logsBlock(outcome, tail = "") {
|
|
73
|
-
|
|
87
|
+
if (!outcome.logs?.length && !outcome.logTruncated) return "";
|
|
88
|
+
|
|
89
|
+
return `\n--- logs${outcome.logTruncated ? " [logs truncated]" : ""}\n${outcome.logs?.join("\n") ?? ""}${tail}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function mutationText(outcome) {
|
|
93
|
+
const m = outcome.mutations;
|
|
94
|
+
|
|
95
|
+
if (!m) return "";
|
|
96
|
+
const external = m.external ? "; external calls attempted=" + m.external + ", their side effects cannot be rolled back" : "";
|
|
97
|
+
const uncertain = m.pendingCommits || m.recoveryFailed ? "; filesystem outcome uncertain: inspect disk and any recovery backups before retrying" : "";
|
|
98
|
+
|
|
99
|
+
return "\nmutations: committed=" + m.committed + " rolledBack=" + m.rolledBack + " (file versions)" + external + uncertain;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Corrective hint, emitted only when a turn actually split. Independent work
|
|
103
|
+
// belongs in one program: a split cannot use the single prewarmed worker and pays
|
|
104
|
+
// one extra spawn per sibling. Costs nothing until it fires, so it needs no room in
|
|
105
|
+
// the tool definition.
|
|
106
|
+
function splitTurnHint(outcome) {
|
|
107
|
+
return outcome.overlappedTurn ? ` (${outcome.overlappedTurn} supernova calls ran at once; independent work belongs in one program)` : "";
|
|
74
108
|
}
|
|
75
109
|
|
|
76
110
|
function errorText(outcome, call) {
|
|
77
|
-
return `error #${call} ${outcome.wallMs}ms: ${outcome
|
|
111
|
+
return `error #${call} ${outcome.wallMs}ms${outcome.returnTruncated ? " [output truncated]" : ""}${mutationText(outcome)}${splitTurnHint(outcome)}
|
|
112
|
+
error: ${outcome.error}${logsBlock(outcome)}`;
|
|
78
113
|
}
|
|
79
114
|
|
|
80
115
|
function successText(outcome, call) {
|
|
81
116
|
const truncated = outcome.returnTruncated ? " [return truncated]" : "";
|
|
82
117
|
const hint = outcome.undefinedReturn ? " (no return statement; add `return` to get a value)" : "";
|
|
83
|
-
|
|
118
|
+
|
|
119
|
+
return `ok #${call} ${outcome.wallMs}ms${truncated}${outcome.mutations?.committed || outcome.mutations?.rolledBack || outcome.mutations?.external ? mutationText(outcome) : ""}${splitTurnHint(outcome)}${logsBlock(outcome, "\n--- result")}\n${outcome.resultText}${hint}`;
|
|
84
120
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
Native commands (async):
|
|
88
|
-
read(path|paths, offset?, limit?) → file text or text[]; read(directory) → directory entries
|
|
89
|
-
read("symbol or question") → locate and open source in one call, without an index; selected file text stays raw
|
|
90
|
-
read({query, resolve:true}) → {status,path,line,lines,text,complete,nextOffset?} for a direct resolve→edit handoff
|
|
91
|
-
read(path, {about: question}) → relevant file bodies, or source selection inside a directory
|
|
92
|
-
read({query, evidence:true}) → ranked evidence; read({path, outline:true}) → structural declarations
|
|
93
|
-
write(path, text) → write a file; write({path,content,append:true}) appends a chunk without a bounded read
|
|
94
|
-
edit(path, oldText, newText) → post-edit lines, checks, and references
|
|
95
|
-
edit(async () => {...}) → filesystem checkpoint: commit on success, rollback on throw; no shell commands, nesting, or concurrent outside commands
|
|
96
|
-
bash(command, {cwd?, timeoutMs?}) → bounded output; throws on non-zero exit
|
|
97
|
-
bash({command, args:[...]}) → literal argv without shell expansion of arguments
|
|
98
|
-
|
|
99
|
-
Only found selects and opens a file. Uncertain reads return ambiguous, not_found, or incomplete with no selected path. Use resolve:true for structured status checks; narrow the directory with path+about when uncertain.
|
|
100
|
-
For read-modify-write, use read({path,complete:true}); it rejects partial output. Prefer edit for large files. Array reads reject failures; use Promise.allSettled for per-path outcomes.
|
|
101
|
-
Object arguments also work: read({path, offset?, limit?, about?, outline?, evidence?, resolve?, complete?}), edit({path, edits:[{oldText,newText}]}), edit({path,patch}), write({path,content}), bash({command,timeoutMs?}).
|
|
102
|
-
Independent read starts batch automatically. Mutations preserve submission order. Plain reads remain self-contained; oversized reads provide continuation offsets. Return only what the model needs. console.log is captured.`;
|
|
121
|
+
|
|
122
|
+
const TOOL_DESCRIPTION = REFERENCE;
|
|
103
123
|
|
|
104
124
|
export default function piSupernova(pi) {
|
|
105
125
|
registerCodeMode(pi);
|
|
@@ -107,12 +127,20 @@ export default function piSupernova(pi) {
|
|
|
107
127
|
|
|
108
128
|
// Shared entry used by both host adapters and direct engine integration.
|
|
109
129
|
export function registerCodeMode(pi) {
|
|
110
|
-
//
|
|
111
|
-
|
|
130
|
+
// Citation elision is experimental and disabled by default. A context event is
|
|
131
|
+
// not the final provider payload: hidden details or later transforms can invalidate
|
|
132
|
+
// a citation. A positive seenWindow explicitly opts in despite those limitations.
|
|
133
|
+
const config = loadConfig();
|
|
112
134
|
let cwd = process.cwd();
|
|
113
135
|
let programSeq = 0;
|
|
114
136
|
let stopped = false;
|
|
115
137
|
let warmTimer;
|
|
138
|
+
// Program runs currently executing. Only one pristine worker is ever prewarmed,
|
|
139
|
+
// so concurrent invocations cannot share it and each sibling pays a fresh spawn.
|
|
140
|
+
// Counting them lets a result say so without adding standing guidance to the
|
|
141
|
+
// tool definition, which is resent on every request.
|
|
142
|
+
let inFlight = 0;
|
|
143
|
+
|
|
116
144
|
function cancelWarmTimer() {
|
|
117
145
|
if (warmTimer !== undefined) clearImmediate(warmTimer);
|
|
118
146
|
warmTimer = undefined;
|
|
@@ -148,28 +176,34 @@ export function registerCodeMode(pi) {
|
|
|
148
176
|
name: "supernova",
|
|
149
177
|
label: "Supernova",
|
|
150
178
|
description: TOOL_DESCRIPTION,
|
|
151
|
-
promptSnippet: "
|
|
152
|
-
promptGuidelines: [
|
|
153
|
-
"Use read, write, edit, and bash inside supernova. Start with read(question), or read(directory, {about: question}) for scoped source selection. A source question already opens the selected file; do not issue a redundant read. Use read({query,resolve:true}) and check status before editing its path. Explicit about/outline/evidence reads remain available when needed. Return a compact value.",
|
|
154
|
-
],
|
|
179
|
+
promptSnippet: "JavaScript with read, write, edit, and bash",
|
|
155
180
|
parameters: Type.Object({
|
|
156
|
-
code: Type.String({ maxLength: config.maxCodeChars ?? 48000
|
|
181
|
+
code: Type.Optional(Type.String({ maxLength: config.maxCodeChars ?? 48000 })),
|
|
182
|
+
file: Type.Optional(Type.String({ minLength: 1 })),
|
|
183
|
+
data: Type.Optional(Type.Unknown({ description: "Literal JSON input available as data in the program; put Markdown, scripts or argv here instead of nesting JavaScript quoting. JSON-encoded size is limited to the code character budget." })),
|
|
157
184
|
timeoutMs: Type.Optional(Type.Integer({ minimum: 1000, description: "Hard timeout in ms." })),
|
|
158
|
-
|
|
185
|
+
programs: Type.Optional(Type.Array(Type.Object({
|
|
186
|
+
code: Type.Optional(Type.String({ maxLength: config.maxCodeChars ?? 48000 })),
|
|
187
|
+
file: Type.Optional(Type.String({ minLength: 1 })),
|
|
188
|
+
data: Type.Optional(Type.Unknown()),
|
|
189
|
+
}, {additionalProperties:false}), {minItems:1,maxItems:32,description:"Instead of top-level code/file/data. JSON-encoded array shares the code character cap."})),
|
|
190
|
+
}),
|
|
159
191
|
// One self-owned result frame is shared by Pi and OMP; renderCall stays empty
|
|
160
192
|
// so separate call/result slots cannot duplicate the lifecycle card.
|
|
161
193
|
renderShell: "self",
|
|
162
194
|
mergeCallAndResult: true,
|
|
163
195
|
renderCall: renderSupernovaCall,
|
|
164
196
|
renderResult: renderSupernovaResult,
|
|
165
|
-
async execute(_id, params, signal, onUpdate, ctx) {
|
|
197
|
+
execute: async function execute(_id, params, signal, onUpdate, ctx, budget) {
|
|
198
|
+
if (params?.programs !== undefined) return runProgramBatch(_id,params,signal,onUpdate,ctx,config,execute);
|
|
166
199
|
cancelWarmTimer();
|
|
167
200
|
const runCwd = ctx?.cwd || cwd;
|
|
168
201
|
const runController = new AbortController();
|
|
169
202
|
const abortRun = () => runController.abort(signal?.reason);
|
|
203
|
+
|
|
170
204
|
if (signal?.aborted) abortRun();
|
|
171
205
|
else signal?.addEventListener("abort", abortRun, { once: true });
|
|
172
|
-
const runBridge = bridge.fork({ getCwd: () => runCwd });
|
|
206
|
+
const runBridge = bridge.fork({ getCwd: () => runCwd, budget });
|
|
173
207
|
runBridge.bindCallContext(ctx, runController.signal);
|
|
174
208
|
runBridge.resetCallBudget();
|
|
175
209
|
|
|
@@ -180,61 +214,103 @@ export function registerCodeMode(pi) {
|
|
|
180
214
|
emitProgress([]);
|
|
181
215
|
const started = performance.now();
|
|
182
216
|
let outcome;
|
|
217
|
+
const overlappedTurn = inFlight > 0 ? inFlight + 1 : 0;
|
|
218
|
+
inFlight += 1;
|
|
219
|
+
|
|
183
220
|
try {
|
|
184
221
|
refreshCatalog(runBridge);
|
|
185
222
|
runBridge.beginSpeculation();
|
|
186
223
|
outcome = await runGuestProgram({
|
|
187
224
|
code: params?.code,
|
|
225
|
+
file: params?.file,
|
|
226
|
+
cwd: runCwd,
|
|
227
|
+
data: params?.data,
|
|
188
228
|
nova: makeNovaApi(runBridge, abortRun),
|
|
189
|
-
config: { ...config, timeoutMs: Number.isInteger(params?.timeoutMs) ? params.timeoutMs : config.timeoutMs },
|
|
229
|
+
config: { ...config, maxLogLines: Math.max(0,config.maxLogLines-(budget?.logLines ?? 0)), timeoutMs: Number.isInteger(params?.timeoutMs) ? params.timeoutMs : config.timeoutMs },
|
|
190
230
|
signal: runController.signal,
|
|
191
231
|
onTimeout: abortRun,
|
|
192
232
|
});
|
|
193
233
|
runBridge.close();
|
|
234
|
+
|
|
194
235
|
if (outcome.ok) {
|
|
195
236
|
if (runBridge.getOverlayDepth() !== 1) throw new Error("program ended with an unfinished edit checkpoint; await it before returning");
|
|
196
237
|
await runBridge.commitSpeculation();
|
|
197
238
|
}
|
|
198
|
-
else runBridge.rollbackSpeculation();
|
|
239
|
+
else while (runBridge.getOverlayDepth()) runBridge.rollbackSpeculation();
|
|
199
240
|
} catch (error) {
|
|
200
241
|
abortRun();
|
|
201
242
|
runBridge.close();
|
|
202
|
-
|
|
243
|
+
|
|
244
|
+
while (runBridge.getOverlayDepth()) runBridge.rollbackSpeculation();
|
|
203
245
|
outcome = { ok: false, error: error instanceof Error ? error.message : String(error), logs: outcome?.logs ?? [], wallMs: Math.round(performance.now() - started) };
|
|
204
246
|
} finally {
|
|
247
|
+
inFlight -= 1;
|
|
205
248
|
runBridge.setCallListener(null);
|
|
206
249
|
emitProgress.flush();
|
|
207
250
|
signal?.removeEventListener("abort", abortRun);
|
|
208
251
|
// Prepare one pristine worker during the model's next decision. Never
|
|
209
252
|
// recycle a worker that has executed arbitrary guest JavaScript.
|
|
210
253
|
cancelWarmTimer();
|
|
254
|
+
|
|
211
255
|
if (!stopped && !runController.signal.aborted) {
|
|
212
256
|
// Deliver the result before paying for another Worker constructor.
|
|
213
257
|
warmTimer = setImmediate(() => {
|
|
214
258
|
warmTimer = undefined;
|
|
259
|
+
|
|
215
260
|
if (!stopped && !runController.signal.aborted) warmGuestWorker(config).catch(() => {});
|
|
216
261
|
});
|
|
217
262
|
warmTimer.unref?.();
|
|
218
263
|
}
|
|
219
264
|
}
|
|
265
|
+
|
|
266
|
+
if (budget) budget.logLines += outcome.logs?.length ?? 0;
|
|
267
|
+
// inFlight has dropped by now, so a non-zero value means a sibling is still
|
|
268
|
+
// running: report the overlap from either side so the hint does not depend on
|
|
269
|
+
// which invocation happened to start first.
|
|
270
|
+
outcome.overlappedTurn = overlappedTurn || (inFlight > 0 ? inFlight + 1 : 0);
|
|
271
|
+
outcome.mutations = runBridge.getMutations();
|
|
220
272
|
const trace = runBridge.getTrace();
|
|
221
|
-
const
|
|
273
|
+
const format = outcome.ok ? successText : errorText;
|
|
274
|
+
let text = format(outcome, call);
|
|
275
|
+
|
|
276
|
+
if (text.length > config.maxReturnChars) {
|
|
277
|
+
outcome.returnTruncated = true;
|
|
278
|
+
text = format(outcome, call);
|
|
279
|
+
}
|
|
280
|
+
|
|
222
281
|
const bounded = truncateChars(text, config.maxReturnChars, "output").text;
|
|
223
282
|
const visible = runBridge.ledger.dedupe(bounded, call);
|
|
224
|
-
|
|
283
|
+
|
|
225
284
|
const response = result(visible, {
|
|
226
285
|
ok: outcome.ok, error: outcome.error, wallMs: outcome.wallMs,
|
|
227
286
|
returnTruncated: outcome.returnTruncated, logTruncated: outcome.logTruncated,
|
|
228
|
-
logs: outcome.logs, result: outcome.result, trace,
|
|
287
|
+
logs: outcome.logs, result: outcome.result, trace, mutations: outcome.mutations,
|
|
229
288
|
});
|
|
289
|
+
|
|
230
290
|
if (outcome.images?.length) response.content.push(...outcome.images);
|
|
291
|
+
|
|
292
|
+
if (!outcome.ok) {
|
|
293
|
+
const error = new Error(visible);
|
|
294
|
+
Object.defineProperty(error,"supernovaResult",{value:response});
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
|
|
231
298
|
return response;
|
|
232
299
|
},
|
|
233
300
|
});
|
|
234
301
|
|
|
235
|
-
|
|
302
|
+
// This is a pre-conversion observation, not a final-payload retention proof.
|
|
303
|
+
// With the shipping seenWindow:0 default, observe is a no-op.
|
|
304
|
+
pi.on("context", event => {
|
|
305
|
+
try { bridge.ledger.observe(event?.messages); } catch {}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
pi.on("session_shutdown", () => { stopped = true; cancelWarmTimer();
|
|
309
|
+
|
|
310
|
+
return stopWarmGuestWorker(); });
|
|
236
311
|
pi.on("session_start", (_event, ctx) => {
|
|
237
312
|
stopped = false;
|
|
313
|
+
|
|
238
314
|
if (ctx && isString(ctx.cwd) && ctx.cwd) cwd = ctx.cwd;
|
|
239
315
|
// A new session is a new model context: nothing has been seen yet.
|
|
240
316
|
bridge.bindCallContext(ctx);
|
|
@@ -250,11 +326,13 @@ export function registerCodeMode(pi) {
|
|
|
250
326
|
bridge.bindCallContext(ctx);
|
|
251
327
|
refreshCatalog();
|
|
252
328
|
const commands = ["read", "edit", "write", "bash"].filter(bridge.isCallable);
|
|
329
|
+
|
|
253
330
|
const lines = [
|
|
254
331
|
`Supernova CodeMode: ${commands.join(", ")}`,
|
|
255
332
|
`timeoutMs=${config.timeoutMs} maxCallResultChars=${config.maxCallResultChars} maxReturnChars=${config.maxReturnChars} maxBridgeCalls=${config.maxBridgeCalls} maxHeapMb=${config.maxHeapMb}`,
|
|
256
333
|
sessionStats(bridge.ledger.stats),
|
|
257
334
|
];
|
|
335
|
+
|
|
258
336
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
259
337
|
},
|
|
260
338
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-supernova",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "One CodeMode invocation for Pi and OMP, with four guest commands, automatic read batching and source context.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "AdityaVG13",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"test": "node --test tests/*/*.test.mjs",
|
|
34
34
|
"test:hosts": "node tests/hosts/verify.mjs",
|
|
35
35
|
"measure": "node tests/efficiency/measure.mjs",
|
|
36
|
+
"test:tokens": "node tests/efficiency/tokens.mjs",
|
|
36
37
|
"prepublishOnly": "npm test && node ../../scripts/preflight.mjs"
|
|
37
38
|
},
|
|
38
39
|
"pi": {
|
|
@@ -58,6 +59,7 @@
|
|
|
58
59
|
}
|
|
59
60
|
},
|
|
60
61
|
"devDependencies": {
|
|
62
|
+
"js-tiktoken": "1.0.21",
|
|
61
63
|
"typebox": "^1.0.0"
|
|
62
64
|
},
|
|
63
65
|
"publishConfig": {
|
package/src/bridge/catalog.js
CHANGED
|
@@ -4,7 +4,7 @@ import { isString, isObject } from "../shared/decode.js";
|
|
|
4
4
|
const NATIVE_TOOL_DEFINITIONS = [
|
|
5
5
|
{
|
|
6
6
|
name: "read",
|
|
7
|
-
description: "Read files or directories. Source questions locate and open source directly; resolve returns structured source/status without guessing.",
|
|
7
|
+
description: "Read files, images or directories. JSON selectors project full documents within output budgets. Source questions locate and open source directly; resolve returns structured source/status without guessing.",
|
|
8
8
|
parameters: { type: "object", properties: {
|
|
9
9
|
path: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], description: "Workspace-relative file or directory, source question, or array of paths" },
|
|
10
10
|
target: { anyOf: [{ type: "string" }, { type: "array" }], description: "File path/query or array of paths" },
|
|
@@ -14,6 +14,7 @@ const NATIVE_TOOL_DEFINITIONS = [
|
|
|
14
14
|
query: { type: "string", description: "Source question; optional path scopes the search directory" },
|
|
15
15
|
resolve: { type: "boolean", description: "Return structured source/status for a direct resolve-to-edit handoff" },
|
|
16
16
|
complete: { type: "boolean", description: "Fail unless the entire requested file fits without clipping" },
|
|
17
|
+
json: { anyOf: [{ type: "boolean" }, { type: "string" }, { type: "array", items: { type: "string" } }], description: "Parse the complete JSON input (up to 16 MiB), then select .field, .items[0:3], or quoted keys. A selector array returns an array of values; true selects the root. Oversized selections fail, never clip." },
|
|
17
18
|
} },
|
|
18
19
|
},
|
|
19
20
|
{
|
|
@@ -85,28 +86,36 @@ export function mergeNativeToolDefinitions(tools, capturedNames = []) {
|
|
|
85
86
|
merged.push(fallback && !captured.has(tool.name)
|
|
86
87
|
? { ...tool, ...fallback, sourceInfo: { path: "<native:" + tool.name + ">" } }
|
|
87
88
|
: tool);
|
|
89
|
+
|
|
88
90
|
if (tool?.name) seen.add(tool.name);
|
|
89
91
|
}
|
|
92
|
+
|
|
90
93
|
for (const fallback of NATIVE_TOOL_DEFINITIONS) {
|
|
91
94
|
if (!seen.has(fallback.name)) {
|
|
92
95
|
merged.push({ ...fallback, sourceInfo: { path: "<native:" + fallback.name + ">" } });
|
|
93
96
|
}
|
|
94
97
|
}
|
|
98
|
+
|
|
95
99
|
return merged;
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
function sourcePathOf(tool) {
|
|
99
103
|
if (tool.sourceInfo && isString(tool.sourceInfo.path)) return tool.sourceInfo.path;
|
|
104
|
+
|
|
100
105
|
if (isString(tool.extensionPath)) return tool.extensionPath;
|
|
106
|
+
|
|
101
107
|
if (isString(tool.sourcePath)) return tool.sourcePath;
|
|
108
|
+
|
|
102
109
|
return undefined;
|
|
103
110
|
}
|
|
104
111
|
|
|
105
112
|
function normalizeTool(tool) {
|
|
106
113
|
if (!tool || !isObject(tool)) return null;
|
|
107
114
|
const name = isString(tool.name) ? tool.name : "";
|
|
115
|
+
|
|
108
116
|
if (!name) return null;
|
|
109
117
|
const description = isString(tool.description) ? tool.description : "";
|
|
118
|
+
|
|
110
119
|
return {
|
|
111
120
|
name,
|
|
112
121
|
nameLower: name.toLowerCase(),
|
|
@@ -121,12 +130,16 @@ function normalizeTool(tool) {
|
|
|
121
130
|
export function buildCatalog(tools, excludeNames = []) {
|
|
122
131
|
const exclude = new Set(excludeNames);
|
|
123
132
|
const rows = [];
|
|
133
|
+
|
|
124
134
|
for (const tool of tools || []) {
|
|
125
135
|
const row = normalizeTool(tool);
|
|
136
|
+
|
|
126
137
|
if (!row || exclude.has(row.name)) continue;
|
|
127
138
|
rows.push(row);
|
|
128
139
|
}
|
|
140
|
+
|
|
129
141
|
rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
142
|
+
|
|
130
143
|
return rows;
|
|
131
144
|
}
|
|
132
145
|
|
|
@@ -142,74 +155,94 @@ function scoreRow(row, tokens) {
|
|
|
142
155
|
const name = row.nameLower || row.name.toLowerCase();
|
|
143
156
|
const desc = row.descLower || row.description.toLowerCase();
|
|
144
157
|
let score = 0;
|
|
158
|
+
|
|
145
159
|
for (const token of tokens) {
|
|
146
160
|
if (name === token) score += 10;
|
|
147
161
|
else if (name.includes(token)) score += 5;
|
|
148
162
|
else if (desc.includes(token)) score += 2;
|
|
149
163
|
}
|
|
164
|
+
|
|
150
165
|
return score;
|
|
151
166
|
}
|
|
152
167
|
|
|
153
168
|
export function searchCatalog(catalog, query, limit = 12) {
|
|
154
169
|
const tokens = tokenize(query);
|
|
155
170
|
const scored = [];
|
|
171
|
+
|
|
156
172
|
for (const row of catalog) {
|
|
157
173
|
const score = scoreRow(row, tokens);
|
|
174
|
+
|
|
158
175
|
if (score <= 0 && tokens.length > 0) continue;
|
|
159
176
|
scored.push({ name: row.name, description: row.description.slice(0, 160), score });
|
|
160
177
|
}
|
|
178
|
+
|
|
161
179
|
scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
|
180
|
+
|
|
162
181
|
return scored.slice(0, Math.max(1, limit)).map(({ score: _s, ...hit }) => hit);
|
|
163
182
|
}
|
|
164
183
|
|
|
165
184
|
/** Optimal string alignment distance: insert/delete/substitute/adjacent-transpose cost 1. */
|
|
166
185
|
function editDistance(a, b) {
|
|
167
186
|
const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array.from({ length: b.length }, () => 0)]);
|
|
187
|
+
|
|
168
188
|
for (let j = 1; j <= b.length; j++) rows[0][j] = j;
|
|
189
|
+
|
|
169
190
|
for (let i = 1; i <= a.length; i++) {
|
|
170
191
|
for (let j = 1; j <= b.length; j++) {
|
|
171
192
|
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
172
193
|
let best = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
|
|
194
|
+
|
|
173
195
|
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) best = Math.min(best, rows[i - 2][j - 2] + 1);
|
|
174
196
|
rows[i][j] = best;
|
|
175
197
|
}
|
|
176
198
|
}
|
|
199
|
+
|
|
177
200
|
return rows[a.length][b.length];
|
|
178
201
|
}
|
|
179
202
|
|
|
180
203
|
/** Closest tool names for a mistyped name: substring hits first, then a length-scaled edit distance. */
|
|
181
204
|
function suggestNames(name, candidates, limit = 3) {
|
|
182
205
|
const needle = String(name || "").toLowerCase();
|
|
206
|
+
|
|
183
207
|
if (!needle) return [];
|
|
184
208
|
const maxDistance = Math.max(1, Math.floor(needle.length / 3));
|
|
185
209
|
const scored = [];
|
|
210
|
+
|
|
186
211
|
for (const candidate of candidates) {
|
|
187
212
|
const lower = candidate.toLowerCase();
|
|
213
|
+
|
|
188
214
|
if (lower === needle) continue;
|
|
189
215
|
const distance = lower.includes(needle) || needle.includes(lower) ? 1 : editDistance(needle, lower);
|
|
216
|
+
|
|
190
217
|
if (distance <= maxDistance) scored.push({ candidate, distance });
|
|
191
218
|
}
|
|
219
|
+
|
|
192
220
|
scored.sort(
|
|
193
221
|
(a, b) =>
|
|
194
222
|
a.distance - b.distance ||
|
|
195
223
|
Math.abs(a.candidate.length - needle.length) - Math.abs(b.candidate.length - needle.length) ||
|
|
196
224
|
a.candidate.localeCompare(b.candidate),
|
|
197
225
|
);
|
|
226
|
+
|
|
198
227
|
return scored.slice(0, limit).map((s) => s.candidate);
|
|
199
228
|
}
|
|
200
229
|
|
|
201
230
|
export function unknownToolMessage(name, candidates) {
|
|
202
231
|
const close = suggestNames(name, candidates);
|
|
203
232
|
const hint = close.length ? ` Did you mean ${close.map((c) => JSON.stringify(c)).join(", ")}?` : "";
|
|
233
|
+
|
|
204
234
|
return `unknown tool "${name}".${hint} Check the command name and configured tool exclusions.`;
|
|
205
235
|
}
|
|
206
236
|
|
|
207
237
|
export function describeTool(catalog, name) {
|
|
208
238
|
const row = catalog.find((t) => t.name === name);
|
|
239
|
+
|
|
209
240
|
if (!row) {
|
|
210
241
|
return { ok: false, error: unknownToolMessage(name, catalog.map((t) => t.name)) };
|
|
211
242
|
}
|
|
243
|
+
|
|
212
244
|
if (!isObject(row.parameters)) return { ok: false, name, error: "tool schema unavailable: " + (row.schemaError ?? name) };
|
|
245
|
+
|
|
213
246
|
if (!row._described) {
|
|
214
247
|
row._described = {
|
|
215
248
|
ok: true,
|
|
@@ -219,5 +252,6 @@ export function describeTool(catalog, name) {
|
|
|
219
252
|
sourcePath: row.sourcePath,
|
|
220
253
|
};
|
|
221
254
|
}
|
|
255
|
+
|
|
222
256
|
return row._described;
|
|
223
257
|
}
|