pi-supernova 0.10.1 → 0.11.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 +376 -221
- package/docs/CHANGELOG.md +107 -5
- package/docs/TOKEN_COSTS.md +149 -324
- package/index.js +40 -11
- package/package.json +4 -3
- package/src/adapters/bash.js +40 -10
- package/src/adapters/edit.js +77 -23
- package/src/adapters/errors.js +4 -0
- package/src/adapters/index.js +1 -0
- package/src/adapters/list.js +6 -3
- package/src/adapters/read-focus.js +6 -0
- package/src/adapters/read-image.js +17 -9
- package/src/adapters/read-indexed.js +72 -0
- package/src/adapters/read-text.js +19 -2
- package/src/adapters/read.js +57 -13
- package/src/adapters/refs.js +17 -7
- package/src/adapters/write.js +14 -21
- package/src/bridge/host-bridge.js +152 -39
- package/src/bridge/invoke.js +3 -1
- package/src/bridge/tool-registry.js +132 -16
- package/src/context/evidence-graph.js +1 -0
- package/src/context/evidence-rank.js +1 -0
- package/src/context/evidence.js +4 -2
- package/src/context/fuzzy.js +31 -8
- package/src/context/outline.js +33 -9
- package/src/context/query.js +7 -2
- package/src/context/repo-index.js +8 -3
- package/src/context/search-files.js +20 -0
- package/src/context/search.js +25 -10
- package/src/context/snap-search.js +28 -6
- package/src/context/snap.js +8 -3
- package/src/context/source-entry.js +64 -28
- package/src/context/spans.js +1 -0
- package/src/contract/bash.js +16 -7
- package/src/contract/edit.js +26 -7
- package/src/contract/program.js +2 -1
- package/src/contract/read.js +46 -46
- package/src/fs/background.js +10 -2
- package/src/fs/check.js +844 -46
- package/src/fs/commit.js +89 -15
- package/src/fs/diff.js +12 -9
- package/src/fs/directory.js +14 -4
- package/src/fs/file-io.js +28 -7
- package/src/fs/glob.js +93 -11
- package/src/fs/json-read.js +3 -2
- package/src/fs/json-size.js +2 -1
- package/src/fs/lines.js +49 -2
- package/src/fs/patch.js +26 -10
- package/src/fs/process-tree.js +19 -1
- package/src/fs/read-window.js +36 -20
- package/src/fs/source-window.js +5 -10
- package/src/fs/text-ops.js +39 -19
- package/src/fs/vfs.js +296 -41
- package/src/fs/workspace.js +24 -6
- package/src/output/bottleneck.js +50 -25
- package/src/output/final.js +20 -0
- package/src/output/format.js +97 -36
- package/src/output/outcome.js +25 -9
- package/src/runtime/batch-input.js +17 -3
- package/src/runtime/guest-api.js +32 -7
- package/src/runtime/guest-worker.js +22 -4
- package/src/runtime/parallel.js +9 -49
- package/src/runtime/program-batch.js +53 -28
- package/src/runtime/program-file.js +2 -1
- package/src/runtime/program.js +94 -41
- package/src/runtime/reference.js +10 -9
- package/src/runtime/runtime.js +86 -17
- package/src/runtime/worker-pool.js +10 -1
- package/src/shared/decode.js +9 -8
- package/src/shared/image-worker.js +7 -0
- package/src/shared/image.js +58 -15
- package/src/shared/png.js +18 -0
- package/src/shared/result.js +30 -9
- package/src/shared/syntax-context.js +7 -1
- package/src/shared/utf8.js +11 -13
- package/src/ui/host-render.js +4 -0
- package/src/ui/omp-frame.js +2 -0
- package/src/ui/progress.js +2 -1
- package/src/ui/render-measure.js +11 -0
- package/src/ui/render.js +92 -31
- package/src/ui/trace.js +8 -5
|
@@ -4,9 +4,10 @@ import {traceArgs,finishRecord} from './trace.js';
|
|
|
4
4
|
import * as fs from "node:fs/promises";
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import { packageHostResult } from "../output/bottleneck.js";
|
|
7
|
+
import { READ_FILES } from "../shared/result.js";
|
|
7
8
|
|
|
8
9
|
import { errorMessage, isString, isFunction } from "../shared/decode.js";
|
|
9
|
-
import { isMutatingTool,
|
|
10
|
+
import { isMutatingTool, createNativeScheduler } from "../runtime/parallel.js";
|
|
10
11
|
import { unknownToolMessage } from "./catalog.js";
|
|
11
12
|
import { resolveInvokeTarget } from "./invoke.js";
|
|
12
13
|
import { buildWriteDiff } from "../fs/diff.js";
|
|
@@ -15,7 +16,7 @@ import { SeenLedger } from "../context/ledger.js";
|
|
|
15
16
|
import { CausalVfs, resolveCommitTarget } from "../fs/vfs.js";
|
|
16
17
|
import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash } from "../fs/workspace.js";
|
|
17
18
|
import { createNativeAdapters } from "../adapters/index.js";
|
|
18
|
-
import { resultDiff, boundedWriteDiff, writeSnapshot } from "../fs/text-ops.js";
|
|
19
|
+
import { resultDiff, boundedWriteDiff, writeSnapshot, resolveReadPath } from "../fs/text-ops.js";
|
|
19
20
|
|
|
20
21
|
export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger, budget, terminalIdentity }) {
|
|
21
22
|
const index = registry?.index ?? new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
|
|
@@ -26,6 +27,8 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
26
27
|
notifyWorkspaceChanged(paths);
|
|
27
28
|
}, target => resolveWorkspacePath(getCwd(), target, "commit", false, true), assertSession);
|
|
28
29
|
|
|
30
|
+
vfs.bestEffort = true;
|
|
31
|
+
|
|
29
32
|
const executors = registry?.executors ?? new Map();
|
|
30
33
|
const definitions = registry?.definitions ?? new Map();
|
|
31
34
|
const sharedRegistry = registry ?? { executors, definitions, index, callSeq: 0 };
|
|
@@ -39,12 +42,15 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
39
42
|
let activeCtx = null;
|
|
40
43
|
let activeSignal = undefined;
|
|
41
44
|
let trace = [];
|
|
45
|
+
const readFiles = new Set();
|
|
46
|
+
// Write protection is independent of optional, bounded disk-read receipts.
|
|
47
|
+
const openedPaths = new Set();
|
|
42
48
|
let callListener = null;
|
|
43
49
|
const scheduler = createNativeScheduler();
|
|
44
50
|
|
|
45
|
-
// Advisory host
|
|
46
|
-
//
|
|
47
|
-
function
|
|
51
|
+
// Advisory host events, not transaction participants. Observer failures
|
|
52
|
+
// cannot fail reads or change committed bytes.
|
|
53
|
+
function notifyWorkspace(name, paths) {
|
|
48
54
|
if (!isFunction(pi?.events?.emit)) return;
|
|
49
55
|
|
|
50
56
|
const event = Object.freeze({
|
|
@@ -52,7 +58,80 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
52
58
|
paths: paths === null ? null : Object.freeze([...new Set(paths)]),
|
|
53
59
|
});
|
|
54
60
|
|
|
55
|
-
try { pi.events.emit(
|
|
61
|
+
try { pi.events.emit(name, event)?.catch?.(() => {}); } catch {}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function notifyWorkspaceChanged(paths = null) { notifyWorkspace("workspace:changed", paths); }
|
|
65
|
+
|
|
66
|
+
function rememberReads(raw, result) {
|
|
67
|
+
if (!result.ok || !isFunction(pi?.events?.emit)) return;
|
|
68
|
+
|
|
69
|
+
for (const file of raw?.[READ_FILES] ?? []) {
|
|
70
|
+
if (readFiles.size === 256) break;
|
|
71
|
+
readFiles.add(file);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function fileIdentities(input, existingOnly = false) {
|
|
76
|
+
if (!isString(input)) return [];
|
|
77
|
+
let logical;
|
|
78
|
+
|
|
79
|
+
try { logical = resolveReadPath(getCwd(), input); }
|
|
80
|
+
catch { return existingOnly ? [] : [input]; }
|
|
81
|
+
|
|
82
|
+
let canonical;
|
|
83
|
+
|
|
84
|
+
try { canonical = await vfs.resolvePath(logical, false); }
|
|
85
|
+
catch {
|
|
86
|
+
if (existingOnly && await vfs.getOverlay(logical) === undefined) return [];
|
|
87
|
+
canonical = await vfs.resolvePath(logical).catch(() => logical);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Captured tools can serve virtual paths; retain lexical identity too.
|
|
91
|
+
// This is path-based protection, not an inode lease or cross-process lock.
|
|
92
|
+
return logical === canonical ? [logical] : [logical, canonical];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readPaths(args, result) {
|
|
96
|
+
const paths = [];
|
|
97
|
+
|
|
98
|
+
// A resolved missing-file probe must not forbid creation. Actual source
|
|
99
|
+
// provenance also covers staged files that do not exist on disk yet.
|
|
100
|
+
if (args?.query === undefined && args?.evidence !== true && isString(args?.path)) paths.push({path:args.path,existingOnly:args.resolve===true});
|
|
101
|
+
|
|
102
|
+
if (isString(result.sourcePath)) paths.push({path:result.sourcePath,existingOnly:false});
|
|
103
|
+
|
|
104
|
+
return paths;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function rememberOpenedPaths(args, result) {
|
|
108
|
+
if (result.streamed) return;
|
|
109
|
+
|
|
110
|
+
const paths = Array.isArray(args?.path) && Array.isArray(result.items)
|
|
111
|
+
? args.path.flatMap((input, i) => result.itemErrors?.[i] ? [] : readPaths({...args,path:input},{sourcePath:result.sourcePaths?.[i]}))
|
|
112
|
+
: result.ok ? readPaths(args, result) : [];
|
|
113
|
+
|
|
114
|
+
const unique = new Map();
|
|
115
|
+
|
|
116
|
+
for (const entry of paths) {
|
|
117
|
+
if (!unique.has(entry.path) || !entry.existingOnly) unique.set(entry.path, entry);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const entries = [...unique.values()];
|
|
121
|
+
|
|
122
|
+
for (let i = 0; i < entries.length; i += 8) {
|
|
123
|
+
await Promise.all(entries.slice(i, i + 8).map(async entry => {
|
|
124
|
+
for (const identity of await fileIdentities(entry.path, entry.existingOnly)) openedPaths.add(identity);
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function assertUnreadWrite(args) {
|
|
130
|
+
if (args?.replace === true || args?.append === true || !openedPaths.size) return;
|
|
131
|
+
|
|
132
|
+
if ((await fileIdentities(args?.path)).some(identity => openedPaths.has(identity))) {
|
|
133
|
+
throw new Error("file was already read this program; use edit(oldText, newText) or edit(view, ...). write({path,content,replace:true}) replaces anyway");
|
|
134
|
+
}
|
|
56
135
|
}
|
|
57
136
|
|
|
58
137
|
hooks.terminalOwner = () => terminalOwner;
|
|
@@ -99,6 +178,8 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
99
178
|
vfs.closed = false;
|
|
100
179
|
callCount = 0;
|
|
101
180
|
trace = [];
|
|
181
|
+
readFiles.clear();
|
|
182
|
+
openedPaths.clear();
|
|
102
183
|
// Files may change between programs (editor, git); never serve a stale run.
|
|
103
184
|
vfs.invalidateObserved();
|
|
104
185
|
clearPathCache();
|
|
@@ -168,6 +249,8 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
168
249
|
}
|
|
169
250
|
|
|
170
251
|
function assertOwnedOverride(name, args) {
|
|
252
|
+
if (name === "read" && args?.indexed === true) throw new Error("indexed source retrieval requires the Supernova-owned read adapter");
|
|
253
|
+
|
|
171
254
|
if (name === "bash" && (args?.background === true || args?.action !== undefined)) throw new Error("background terminals require the Supernova-owned bash adapter, not an external override");
|
|
172
255
|
|
|
173
256
|
if (name === "read" && (args?.json !== undefined || /^(agent|artifact):\/\/.*\?/i.test(String(args?.path)))) throw new Error("JSON projection requires the Supernova-owned read adapter, not an external override");
|
|
@@ -188,7 +271,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
188
271
|
assertSession();
|
|
189
272
|
|
|
190
273
|
try {
|
|
191
|
-
const res = await target.exec(`supernova:${name}:${callId}`, args || {}, activeSignal, undefined, target.delegated
|
|
274
|
+
const res = await target.exec(`supernova:${name}:${callId}`, args || {}, activeSignal, undefined, target.delegated && tools.session
|
|
192
275
|
? { ...activeCtx, settings: tools.session.settings, toolNames: evalToolNames(), autoApprove: false }
|
|
193
276
|
: activeCtx);
|
|
194
277
|
|
|
@@ -202,6 +285,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
202
285
|
|
|
203
286
|
async function invokeNative(target, args, record, onItem) {
|
|
204
287
|
assertSession();
|
|
288
|
+
vfs.bindMutationRecord(record);
|
|
205
289
|
const res = await target.native(target.argvOwned ? { ...args, args: args.args.map(String) } : args || {}, activeSignal, onItem);
|
|
206
290
|
completeRecord(record, res);
|
|
207
291
|
|
|
@@ -221,7 +305,9 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
221
305
|
assertCallableTarget(name);
|
|
222
306
|
|
|
223
307
|
if (!isCallable(name)) throw new Error(unknownToolMessage(name, [...definitions.keys(), ...Object.keys(natives)].filter(isCallable)));
|
|
308
|
+
|
|
224
309
|
// Refused calls are free: only charge the budget once a target will run.
|
|
310
|
+
if (name === "write") await assertUnreadWrite(args);
|
|
225
311
|
chargeCallBudget();
|
|
226
312
|
|
|
227
313
|
const command = { apply_patch: "edit", surface: "read", evidence: "read", snap: "read" }[name] ?? name;
|
|
@@ -230,7 +316,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
230
316
|
notifyCall(record);
|
|
231
317
|
|
|
232
318
|
try {
|
|
233
|
-
const target = resolveInvokeTarget(name, args, { hostTool, hostSession: tools.session, executors, natives });
|
|
319
|
+
const target = resolveInvokeTarget(name, args, { hostTool, hostSession: tools.session, modern: tools.modern, executors, natives });
|
|
234
320
|
|
|
235
321
|
if (target.kind === "override") return await invokeOverride(target, name, args, record, callId);
|
|
236
322
|
|
|
@@ -243,11 +329,21 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
243
329
|
}
|
|
244
330
|
}
|
|
245
331
|
|
|
332
|
+
// Candidate discovery uses the existing permission, trace and budget boundary.
|
|
333
|
+
// It cannot grant a read observation or invoke a mutating provider.
|
|
334
|
+
hooks.indexedSearch = async args => {
|
|
335
|
+
refreshTools();
|
|
336
|
+
|
|
337
|
+
if (!isCallable("isearch") || isMutatingTool("isearch", config, args, definitions.get("isearch"))) throw new Error("indexed read requires a callable read-only isearch; enable/promote isearch or omit indexed:true");
|
|
338
|
+
|
|
339
|
+
return invokeRaw("isearch", args);
|
|
340
|
+
};
|
|
341
|
+
|
|
246
342
|
function fileMutationKey(name, args) {
|
|
247
343
|
if (!["edit", "write", "apply_patch"].includes(name) || !isString(args?.path)) return;
|
|
248
344
|
|
|
249
345
|
// Overrides can mutate more than their declared path: keep them global.
|
|
250
|
-
if (hostTool(name) || executors.has(name)) return;
|
|
346
|
+
if (hostTool(name) || (!tools.modern && executors.has(name))) return;
|
|
251
347
|
|
|
252
348
|
return async () => {
|
|
253
349
|
const target = await resolveWorkspacePath(getCwd(), args.path, name, false, true);
|
|
@@ -259,40 +355,32 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
259
355
|
|
|
260
356
|
async function call(name, args, onItem) {
|
|
261
357
|
if (!isString(name) || !name) throw new Error("nova.call requires a tool name");
|
|
262
|
-
const
|
|
263
|
-
const invoke = async () => packageHostResult(await invokeRaw(name, args, deliver), config);
|
|
264
|
-
const kind = isMutatingTool(name, config, args, definitions.get(name)) ? "write" : "read";
|
|
358
|
+
const packageResult = raw => packageHostResult(raw, config, definitions.get(name)?.outputSchema !== undefined);
|
|
265
359
|
|
|
266
|
-
|
|
267
|
-
|
|
360
|
+
const deliver = onItem ? async (index, raw) => {
|
|
361
|
+
const result = packageResult(raw);
|
|
268
362
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
363
|
+
if (name === "read") await rememberOpenedPaths({...args,path:args.path[index]}, result);
|
|
364
|
+
await onItem(index, result);
|
|
365
|
+
rememberReads(raw, result);
|
|
366
|
+
} : undefined;
|
|
272
367
|
|
|
273
|
-
|
|
368
|
+
const invoke = () => vfs.withPathScope(async () => {
|
|
369
|
+
const raw = await invokeRaw(name, args, deliver);
|
|
370
|
+
const result = packageResult(raw);
|
|
274
371
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const a = item?.args;
|
|
372
|
+
if (name === "read") await rememberOpenedPaths(args, result);
|
|
373
|
+
rememberReads(raw, result);
|
|
278
374
|
|
|
279
|
-
return
|
|
375
|
+
return result;
|
|
280
376
|
});
|
|
281
377
|
|
|
282
|
-
const
|
|
283
|
-
const wave = await runParallelWave(thunks, { names, calls: list, definitions: names.map(name => definitions.get(name)) }, { mode: "auto", config });
|
|
284
|
-
// Return a results array that also carries .mode/.reason, and is directly
|
|
285
|
-
// iterable so `for (const r of await nova.callMany([...]))` works.
|
|
286
|
-
const results = Array.isArray(wave.results) ? wave.results.slice() : [];
|
|
287
|
-
Object.defineProperties(results, {
|
|
288
|
-
mode: { value: wave.mode, enumerable: false },
|
|
289
|
-
reason: { value: wave.reason, enumerable: false },
|
|
290
|
-
results: { value: results, enumerable: false },
|
|
291
|
-
});
|
|
378
|
+
const kind = isMutatingTool(name, config, args, definitions.get(name)) ? "write" : "read";
|
|
292
379
|
|
|
293
|
-
return
|
|
380
|
+
return scheduler.schedule(kind, invoke, activeSignal, fileMutationKey(name, args));
|
|
294
381
|
}
|
|
295
382
|
|
|
383
|
+
|
|
296
384
|
return {
|
|
297
385
|
executors,
|
|
298
386
|
definitions,
|
|
@@ -300,9 +388,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
300
388
|
refreshTools,
|
|
301
389
|
isCallable,
|
|
302
390
|
externalNames,
|
|
303
|
-
supportsBatchRead: () => !hostTool("read") && !executors.has("read"),
|
|
304
|
-
// Windows command shims need shell handling; preserve the existing route there.
|
|
305
|
-
supportsNativeArgv: () => process.platform !== "win32" && !hostTool("bash") && !executors.has("bash"),
|
|
391
|
+
supportsBatchRead: () => !hostTool("read") && (tools.modern || !executors.has("read")),
|
|
306
392
|
summarizeEdit: (target, before, after, diff) => hooks.summarizeEdit(getCwd(), target, before, after, diff),
|
|
307
393
|
invalidateFiles() { vfs.invalidateObserved(); index.invalidate(); clearPathCache(); },
|
|
308
394
|
describeMemory() {
|
|
@@ -348,12 +434,39 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
348
434
|
async commitSpeculation() {
|
|
349
435
|
assertSession();
|
|
350
436
|
|
|
351
|
-
|
|
437
|
+
const depth = vfs.getOverlayDepth();
|
|
438
|
+
const result = await vfs.commit();
|
|
439
|
+
|
|
440
|
+
for (const record of trace) {
|
|
441
|
+
if (record.mutationState !== "pending" || record.mutationDepth < depth) continue;
|
|
442
|
+
record.mutationState = result.depth <= 1 ? "saved" : "pending";
|
|
443
|
+
record.mutationDepth = result.depth;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// The outer success commit follows guest completion/delivery. Checkpoint
|
|
447
|
+
// merges and failed programs must not publish provisional read credit.
|
|
448
|
+
if (vfs.getOverlayDepth() === 0 && readFiles.size) {
|
|
449
|
+
const paths = [...readFiles];
|
|
450
|
+
readFiles.clear();
|
|
451
|
+
notifyWorkspace("workspace:read", paths);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return result;
|
|
455
|
+
},
|
|
456
|
+
rollbackSpeculation() {
|
|
457
|
+
const depth = vfs.getOverlayDepth();
|
|
458
|
+
const result = vfs.rollback();
|
|
459
|
+
|
|
460
|
+
for (const record of trace) {
|
|
461
|
+
if (record.mutationState === "pending" && record.mutationDepth >= depth) record.mutationState = vfs.mutations.recoveryFailed ? "uncertain" : "rolled back";
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (vfs.getOverlayDepth() === 0) readFiles.clear();
|
|
465
|
+
|
|
466
|
+
return result;
|
|
352
467
|
},
|
|
353
|
-
rollbackSpeculation: () => vfs.rollback(),
|
|
354
468
|
getOverlayDepth: () => vfs.getOverlayDepth(),
|
|
355
469
|
call,
|
|
356
|
-
callMany,
|
|
357
470
|
ledger,
|
|
358
471
|
};
|
|
359
472
|
}
|
package/src/bridge/invoke.js
CHANGED
|
@@ -8,6 +8,8 @@ export function toolIsCallable(name, env) {
|
|
|
8
8
|
|
|
9
9
|
if (env.nativeOwned(name)) return true;
|
|
10
10
|
|
|
11
|
+
if (env.modern) return env.modernAllows(name);
|
|
12
|
+
|
|
11
13
|
if (env.hostSession) return env.evalAllows(name);
|
|
12
14
|
|
|
13
15
|
return env.listed(name);
|
|
@@ -23,7 +25,7 @@ function hostExecutor(name, env) {
|
|
|
23
25
|
|
|
24
26
|
if (delegated) return { exec: delegated.execute.bind(delegated), delegated };
|
|
25
27
|
|
|
26
|
-
if (env.hostSession) return { exec: undefined, delegated };
|
|
28
|
+
if (env.modern || env.hostSession) return { exec: undefined, delegated };
|
|
27
29
|
|
|
28
30
|
return { exec: env.executors.get(name), delegated };
|
|
29
31
|
}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import {isString,isFunction} from '../shared/decode.js';
|
|
2
2
|
import {toolIsCallable} from './invoke.js';
|
|
3
3
|
|
|
4
|
+
// Executor maps outlive individual program bridges.
|
|
5
|
+
const indexerExecutors = new WeakMap();
|
|
6
|
+
|
|
4
7
|
export function createToolRegistry({pi,config,registry,natives,executors,definitions}) {
|
|
5
|
-
let hostSession = null, boundSessionId;
|
|
8
|
+
let hostSession = null, boundSessionId, invocationContext = null;
|
|
9
|
+
|
|
6
10
|
function captureHostTool(tool, excluded) {
|
|
7
11
|
return tool && isString(tool.name) && isFunction(tool.execute) && tool.name !== "supernova" && !excluded.has(tool.name);
|
|
8
12
|
}
|
|
@@ -26,12 +30,34 @@ function evalToolNames() {
|
|
|
26
30
|
catch { return []; }
|
|
27
31
|
}
|
|
28
32
|
|
|
33
|
+
function invocationTools() {
|
|
34
|
+
if (!invocationContext) return [];
|
|
35
|
+
|
|
36
|
+
try { return Array.isArray(invocationContext.tools) ? invocationContext.tools : []; }
|
|
37
|
+
catch { return []; }
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
function hostTool(name) {
|
|
30
|
-
if (!hostSession) return undefined;
|
|
31
41
|
const metadata = definitions.get(name);
|
|
32
42
|
|
|
43
|
+
if (invocationContext) {
|
|
44
|
+
if (nativeAdapterOwns(name, metadata, true)) return undefined;
|
|
45
|
+
|
|
46
|
+
if (!invocationTools().some(tool => tool.name === name)) return undefined;
|
|
47
|
+
|
|
48
|
+
// Never call an AgentTool executor directly: the invocation owns validation,
|
|
49
|
+
// exposure, permissions, cancellation, nested receipts and usage accounting.
|
|
50
|
+
return {async execute(_id, args, signal, onUpdate) {
|
|
51
|
+
const outcome = await invocationContext.executeTool(name, args, {signal, onUpdate});
|
|
52
|
+
|
|
53
|
+
return {...outcome.result, isError:outcome.isError};
|
|
54
|
+
}};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!hostSession) return undefined;
|
|
58
|
+
|
|
33
59
|
// Keep Supernova's transactional adapters for ordinary built-ins. Respect overrides.
|
|
34
|
-
if (
|
|
60
|
+
if (nativeAdapterOwns(name, metadata, false)) return undefined;
|
|
35
61
|
|
|
36
62
|
try { return hostSession.getToolForEvalBridge?.(name); }
|
|
37
63
|
catch { return undefined; }
|
|
@@ -41,11 +67,12 @@ function callableEnv() {
|
|
|
41
67
|
return {
|
|
42
68
|
excluded: new Set(config.excludeTools || []),
|
|
43
69
|
hostSession,
|
|
70
|
+
modern: invocationContext !== null,
|
|
71
|
+
modernAllows: name => invocationTools().some(tool => tool.name === name),
|
|
44
72
|
natives,
|
|
45
73
|
executors,
|
|
46
|
-
sessionInvalid
|
|
47
|
-
nativeOwned
|
|
48
|
-
&& (!hostSession || !definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin"),
|
|
74
|
+
sessionInvalid,
|
|
75
|
+
nativeOwned,
|
|
49
76
|
evalAllows: name => {
|
|
50
77
|
if (!evalToolNames().includes(name) && definitions.has(name)) return false;
|
|
51
78
|
|
|
@@ -68,37 +95,126 @@ function isCallable(name) {
|
|
|
68
95
|
return toolIsCallable(name, callableEnv());
|
|
69
96
|
}
|
|
70
97
|
|
|
71
|
-
|
|
98
|
+
// Pi's public catalog intentionally omits executors. The optional indexer
|
|
99
|
+
// handshake is synchronous and read-only, never a fallback around a host
|
|
100
|
+
// session's eval/approval gate. Withdrawn or ambiguous capabilities fail closed.
|
|
101
|
+
function withdrawIndexer() {
|
|
102
|
+
const previous = indexerExecutors.get(executors);
|
|
103
|
+
|
|
104
|
+
if (previous && executors.get('isearch') === previous) executors.delete('isearch');
|
|
105
|
+
indexerExecutors.delete(executors);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function discoverIndexer(tools) {
|
|
109
|
+
withdrawIndexer();
|
|
110
|
+
const metadata = tools.find(tool=>tool?.name==='isearch');
|
|
111
|
+
|
|
112
|
+
if (invocationContext || hostSession || executors.has('isearch') || !indexerMetadataAllowed(metadata)) return;
|
|
113
|
+
const providers = [];
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
if (!pi.getActiveTools?.().includes('isearch')) return;
|
|
117
|
+
pi.events.emit('pi-indexer:readonly-provider',{version:1,parameters:metadata.parameters,sessionId:boundSessionId,accept(provider) {
|
|
118
|
+
if (provider?.name === 'isearch' && provider.parameters === metadata.parameters && provider.annotations?.readOnlyHint === true && isFunction(provider.execute)) providers.push(provider);
|
|
119
|
+
}});
|
|
120
|
+
} catch { return; }
|
|
121
|
+
|
|
122
|
+
if (providers.length !== 1) return;
|
|
123
|
+
definitions.set('isearch',{...definitions.get('isearch'),...providers[0]});
|
|
124
|
+
indexerExecutors.set(executors,providers[0].execute);
|
|
125
|
+
executors.set('isearch',providers[0].execute);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function listedTools() {
|
|
72
129
|
let listed = [];
|
|
73
130
|
|
|
74
131
|
try { listed = pi?.getAllTools?.() ?? []; } catch {}
|
|
75
|
-
|
|
132
|
+
|
|
133
|
+
return Array.isArray(listed) ? listed : [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function refreshTools() {
|
|
137
|
+
const tools = [...listedTools(), ...invocationTools()];
|
|
76
138
|
|
|
77
139
|
for (const tool of tools) {
|
|
78
140
|
if (!isString(tool?.name)) continue;
|
|
79
141
|
definitions.set(tool.name, { ...definitions.get(tool.name), ...tool });
|
|
80
142
|
|
|
81
|
-
if (!hostSession && isFunction(tool.execute)) executors.set(tool.name, tool.execute.bind(tool));
|
|
143
|
+
if (!invocationContext && !hostSession && isFunction(tool.execute)) executors.set(tool.name, tool.execute.bind(tool));
|
|
82
144
|
}
|
|
83
145
|
|
|
146
|
+
discoverIndexer(tools);
|
|
147
|
+
|
|
84
148
|
return [...definitions.values()].filter(tool => isCallable(tool.name));
|
|
85
149
|
}
|
|
86
150
|
|
|
87
151
|
function externalNames() {
|
|
88
152
|
return [...definitions.keys()].filter(name => !!hostTool(name) || executors.has(name));
|
|
89
153
|
}
|
|
154
|
+
|
|
90
155
|
function bindSession(ctx) {
|
|
91
|
-
const sessionId = ctx
|
|
156
|
+
const sessionId = sessionIdOf(ctx);
|
|
92
157
|
boundSessionId = sessionId;
|
|
93
|
-
|
|
158
|
+
invocationContext = isFunction(ctx?.executeTool) ? ctx : null;
|
|
159
|
+
|
|
160
|
+
if (invocationContext) {
|
|
161
|
+
hostSession = null;
|
|
162
|
+
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
hostSession = findSession(sessionId);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function nativeAdapterOwns(name, metadata, allowMissing) {
|
|
170
|
+
return Object.hasOwn(natives, name) && ((allowMissing && !metadata) || metadata?.sourceInfo?.source === "builtin");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function knownBuiltin(name) {
|
|
174
|
+
return !definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function nativeOwned(name) {
|
|
178
|
+
if (!Object.hasOwn(natives,name)) return false;
|
|
179
|
+
|
|
180
|
+
if (invocationContext) return knownBuiltin(name);
|
|
181
|
+
|
|
182
|
+
return !executors.has(name) && (!hostSession || knownBuiltin(name));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function sessionIdOf(ctx) {
|
|
186
|
+
return ctx?.sessionManager?.getSessionId?.();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function sessionInvalid() {
|
|
190
|
+
return invocationContext ? sessionIdOf(invocationContext) !== boundSessionId
|
|
191
|
+
: hostSession && (hostSession.isDisposed || sessionIdOf(hostSession) !== boundSessionId);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function indexerMetadataAllowed(metadata) {
|
|
195
|
+
return metadata && metadata.exposure === undefined && metadata.annotations?.readOnlyHint !== false && isFunction(pi?.events?.emit);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function agentRegistry() {
|
|
199
|
+
return pi?.pi?.AgentRegistry?.global?.();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function registrySessions() {
|
|
203
|
+
const registry = agentRegistry();
|
|
94
204
|
let sessions = [];
|
|
95
205
|
|
|
96
206
|
try { sessions = registry?.list?.() ?? []; } catch {}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
? sessions.map(ref => ref.session).find(session => !session?.isDisposed && session?.sessionManager?.getSessionId?.() === sessionId) ?? null
|
|
100
|
-
: null;
|
|
207
|
+
|
|
208
|
+
return Array.isArray(sessions) ? sessions : [];
|
|
101
209
|
}
|
|
210
|
+
|
|
211
|
+
function findSession(sessionId) {
|
|
212
|
+
const sessions = registrySessions();
|
|
213
|
+
|
|
214
|
+
return sessionId ? sessions.map(ref => ref.session).find(session => !session?.isDisposed && sessionIdOf(session) === sessionId) ?? null : null;
|
|
215
|
+
}
|
|
216
|
+
|
|
102
217
|
wrapHostRegister();
|
|
103
|
-
|
|
218
|
+
|
|
219
|
+
return {refreshTools,isCallable,externalNames,hostTool,evalToolNames,bindSession,get modern(){return invocationContext !== null;},get session(){return hostSession;}};
|
|
104
220
|
}
|
package/src/context/evidence.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {pickEvidence} from "./evidence-rank.js";
|
|
2
|
-
import {pendingInScope,overlaySearchEntry} from './search-files.js';
|
|
2
|
+
import {pendingInScope,overlaySearchEntry,overlaySnapshot} from './search-files.js';
|
|
3
3
|
import * as fs from "node:fs/promises";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { WorkspaceIndex } from "./repo-index.js";
|
|
@@ -237,7 +237,9 @@ export async function selectEvidence({ query, root, searchDir, index, overlayTex
|
|
|
237
237
|
const profile = profileQuery(query);
|
|
238
238
|
|
|
239
239
|
if (profile.keywords.length === 0) throw new Error("evidence requires at least one searchable concept keyword");
|
|
240
|
-
const
|
|
240
|
+
const files = await listedFiles(root, searchDir, pendingPaths, index);
|
|
241
|
+
overlayText = await overlaySnapshot(files, overlayText);
|
|
242
|
+
const { files: chosenFiles, fileScores } = candidateFiles(files, profile, index, opts.maxCandidateFiles, overlayText);
|
|
241
243
|
const spans = usageSpans(collectSpans(chosenFiles, overlayText, index, opts.maxSpanLines), profile, opts.maxSpanLines);
|
|
242
244
|
|
|
243
245
|
if (spans.length === 0) return { route: profile.route, spans: [] };
|
package/src/context/fuzzy.js
CHANGED
|
@@ -15,6 +15,7 @@ const AI_DECAY = Math.LN2 / 3; // per day
|
|
|
15
15
|
const AI_MAX_HISTORY_DAYS = 7;
|
|
16
16
|
|
|
17
17
|
const MAX_TIMESTAMPS_PER_FILE = 128;
|
|
18
|
+
|
|
18
19
|
const MAX_FRECENCY_FILES = 10000;
|
|
19
20
|
|
|
20
21
|
const AI_MODIFICATION_THRESHOLDS = [[16, 30], [8, 300], [4, 900], [2, 3600], [1, 14400]]; // [boost, seconds]
|
|
@@ -31,6 +32,10 @@ export class Frecency {
|
|
|
31
32
|
if (this.access.size >= MAX_FRECENCY_FILES) this.access.delete(this.access.keys().next().value);
|
|
32
33
|
this.access.set(filePath, (list = []));
|
|
33
34
|
}
|
|
35
|
+
|
|
36
|
+
// Existing keys must move to the newest position before capacity eviction.
|
|
37
|
+
this.access.delete(filePath);
|
|
38
|
+
this.access.set(filePath, list);
|
|
34
39
|
list.push(at);
|
|
35
40
|
|
|
36
41
|
if (list.length > MAX_TIMESTAMPS_PER_FILE) list.splice(0, list.length - MAX_TIMESTAMPS_PER_FILE);
|
|
@@ -64,6 +69,23 @@ export class Frecency {
|
|
|
64
69
|
}
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
// Fuzzy offsets index the original UTF-16 string. Keep lowercase expansion
|
|
73
|
+
// marks out of the positional projection (not full Unicode normalization).
|
|
74
|
+
function foldCase(text) {
|
|
75
|
+
const lower = text.toLowerCase();
|
|
76
|
+
|
|
77
|
+
if (lower.length === text.length) return lower;
|
|
78
|
+
|
|
79
|
+
let out = "", offset = 0;
|
|
80
|
+
|
|
81
|
+
for (const char of text) {
|
|
82
|
+
out += lower.slice(offset, offset + char.length);
|
|
83
|
+
offset += char.toLowerCase().length;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
67
89
|
const SEPARATORS = new Set(["/", "\\", "_", "-", ".", " "]);
|
|
68
90
|
|
|
69
91
|
function isBoundary(hay, i) {
|
|
@@ -177,7 +199,7 @@ function matchWithTypos(part, pCmp, partLower, hay, hayCmp, hayLowerOrNull, maxT
|
|
|
177
199
|
start = hayCmp.lastIndexOf(subCmp[ni], start - 1);
|
|
178
200
|
}
|
|
179
201
|
|
|
180
|
-
if (hayLower === null) hayLower = hay
|
|
202
|
+
if (hayLower === null) hayLower = foldCase(hay);
|
|
181
203
|
best = {
|
|
182
204
|
score: scoreAlignment(sub, subCmp, hay, hayCmp, start),
|
|
183
205
|
start,
|
|
@@ -202,7 +224,7 @@ function matchPart(part, pCmp, partLower, hay, hayCmp, hayLowerOrNull, maxTypos)
|
|
|
202
224
|
const direct = matchOnce(part, pCmp, hay, hayCmp);
|
|
203
225
|
|
|
204
226
|
if (direct) {
|
|
205
|
-
const hayLower = hayLowerOrNull === null ? hay
|
|
227
|
+
const hayLower = hayLowerOrNull === null ? foldCase(hay) : hayLowerOrNull;
|
|
206
228
|
|
|
207
229
|
return { ...direct, typos: 0, exact: hayLower === partLower };
|
|
208
230
|
}
|
|
@@ -214,10 +236,10 @@ function matchPart(part, pCmp, partLower, hay, hayCmp, hayLowerOrNull, maxTypos)
|
|
|
214
236
|
|
|
215
237
|
/** Best match allowing up to maxTypos skipped needle characters. */
|
|
216
238
|
export function fuzzyMatch(needle, hay, { maxTypos = 0, caseSensitive = false } = {}) {
|
|
217
|
-
if (caseSensitive) return matchPart(needle, needle, needle
|
|
239
|
+
if (caseSensitive) return matchPart(needle, needle, foldCase(needle), hay, hay, null, maxTypos);
|
|
218
240
|
|
|
219
|
-
const needleLower = needle
|
|
220
|
-
const hayLower = hay
|
|
241
|
+
const needleLower = foldCase(needle);
|
|
242
|
+
const hayLower = foldCase(hay);
|
|
221
243
|
|
|
222
244
|
return matchPart(needle, needleLower, needleLower, hay, hayLower, hayLower, maxTypos);
|
|
223
245
|
}
|
|
@@ -258,7 +280,7 @@ function partTypos(parts, ctx) {
|
|
|
258
280
|
}
|
|
259
281
|
|
|
260
282
|
function scoredPath(rel, parts, partLower, maxTypos, caseSensitive, ctx, currentSegs, dirCache) {
|
|
261
|
-
const hayCmp = caseSensitive ? rel : rel
|
|
283
|
+
const hayCmp = caseSensitive ? rel : foldCase(rel);
|
|
262
284
|
const matched = matchParts(parts, partLower, rel, hayCmp, caseSensitive ? null : hayCmp, maxTypos, caseSensitive);
|
|
263
285
|
|
|
264
286
|
if (!matched) return null;
|
|
@@ -278,7 +300,7 @@ export function rankPaths(query, paths, ctx = {}) {
|
|
|
278
300
|
// Per-query hoists: lowered parts once (not once per path per part), the
|
|
279
301
|
// current directory split once (not once per candidate), plus a
|
|
280
302
|
// per-call cache for candidate directory segments (paths share dirs).
|
|
281
|
-
const partLower = parts.map(
|
|
303
|
+
const partLower = parts.map(foldCase);
|
|
282
304
|
const currentDir = ctx.currentFile ? ctx.currentFile.slice(0, ctx.currentFile.lastIndexOf("/") + 1) : "";
|
|
283
305
|
const currentSegs = currentDir ? splitDirSegs(currentDir) : null;
|
|
284
306
|
const dirCache = new Map();
|
|
@@ -317,7 +339,7 @@ function matchParts(parts, partLower, rel, hayCmp, hayLowerOrNull, maxTypos, cas
|
|
|
317
339
|
function filenameBonus(base, rel, filenameStart, first, needleLower) {
|
|
318
340
|
if (first.start < filenameStart) return 0;
|
|
319
341
|
|
|
320
|
-
return rel.slice(filenameStart)
|
|
342
|
+
return foldCase(rel.slice(filenameStart)) === needleLower ? Math.floor((base * 2) / 5) : Math.floor(base / 5);
|
|
321
343
|
}
|
|
322
344
|
|
|
323
345
|
/** fff: frecency boost base·f/100 and +15% for git-modified files. */
|
|
@@ -325,6 +347,7 @@ function contextBoost(base, rel, ctx) {
|
|
|
325
347
|
let frecency = 0;
|
|
326
348
|
|
|
327
349
|
try { frecency = ctx.frecency ? ctx.frecency.score(rel, ctx.mtimeOf?.(rel)) : 0; } catch {}
|
|
350
|
+
|
|
328
351
|
const gitBoost = ctx.modified?.has(rel) ? Math.floor((base * 15) / 100) : 0;
|
|
329
352
|
|
|
330
353
|
return Math.floor((base * frecency) / 100) + gitBoost;
|