pi-supernova 0.10.2 → 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.
Files changed (81) hide show
  1. package/README.md +365 -90
  2. package/docs/CHANGELOG.md +89 -5
  3. package/docs/TOKEN_COSTS.md +149 -324
  4. package/index.js +40 -11
  5. package/package.json +4 -3
  6. package/src/adapters/bash.js +40 -10
  7. package/src/adapters/edit.js +77 -23
  8. package/src/adapters/errors.js +4 -0
  9. package/src/adapters/index.js +1 -0
  10. package/src/adapters/list.js +6 -3
  11. package/src/adapters/read-focus.js +6 -0
  12. package/src/adapters/read-image.js +17 -9
  13. package/src/adapters/read-indexed.js +72 -0
  14. package/src/adapters/read-text.js +19 -2
  15. package/src/adapters/read.js +57 -13
  16. package/src/adapters/refs.js +17 -7
  17. package/src/adapters/write.js +14 -21
  18. package/src/bridge/host-bridge.js +152 -39
  19. package/src/bridge/invoke.js +3 -1
  20. package/src/bridge/tool-registry.js +132 -16
  21. package/src/context/evidence-graph.js +1 -0
  22. package/src/context/evidence-rank.js +1 -0
  23. package/src/context/evidence.js +4 -2
  24. package/src/context/fuzzy.js +31 -8
  25. package/src/context/outline.js +33 -9
  26. package/src/context/query.js +7 -2
  27. package/src/context/repo-index.js +8 -3
  28. package/src/context/search-files.js +20 -0
  29. package/src/context/search.js +25 -10
  30. package/src/context/snap-search.js +28 -6
  31. package/src/context/snap.js +8 -3
  32. package/src/context/source-entry.js +64 -28
  33. package/src/context/spans.js +1 -0
  34. package/src/contract/bash.js +16 -7
  35. package/src/contract/edit.js +26 -7
  36. package/src/contract/program.js +2 -1
  37. package/src/contract/read.js +46 -46
  38. package/src/fs/background.js +10 -2
  39. package/src/fs/check.js +844 -46
  40. package/src/fs/commit.js +89 -15
  41. package/src/fs/diff.js +12 -9
  42. package/src/fs/directory.js +14 -4
  43. package/src/fs/file-io.js +28 -7
  44. package/src/fs/glob.js +93 -11
  45. package/src/fs/json-read.js +3 -2
  46. package/src/fs/json-size.js +2 -1
  47. package/src/fs/lines.js +49 -2
  48. package/src/fs/patch.js +26 -10
  49. package/src/fs/process-tree.js +19 -1
  50. package/src/fs/read-window.js +36 -20
  51. package/src/fs/source-window.js +5 -10
  52. package/src/fs/text-ops.js +38 -18
  53. package/src/fs/vfs.js +296 -41
  54. package/src/fs/workspace.js +24 -6
  55. package/src/output/bottleneck.js +50 -25
  56. package/src/output/final.js +20 -0
  57. package/src/output/format.js +97 -36
  58. package/src/output/outcome.js +16 -4
  59. package/src/runtime/batch-input.js +17 -3
  60. package/src/runtime/guest-api.js +32 -7
  61. package/src/runtime/guest-worker.js +22 -4
  62. package/src/runtime/parallel.js +9 -49
  63. package/src/runtime/program-batch.js +53 -28
  64. package/src/runtime/program-file.js +2 -1
  65. package/src/runtime/program.js +94 -41
  66. package/src/runtime/reference.js +10 -9
  67. package/src/runtime/runtime.js +69 -18
  68. package/src/runtime/worker-pool.js +10 -1
  69. package/src/shared/decode.js +9 -8
  70. package/src/shared/image-worker.js +7 -0
  71. package/src/shared/image.js +58 -15
  72. package/src/shared/png.js +18 -0
  73. package/src/shared/result.js +30 -9
  74. package/src/shared/syntax-context.js +7 -1
  75. package/src/shared/utf8.js +11 -13
  76. package/src/ui/host-render.js +4 -0
  77. package/src/ui/omp-frame.js +2 -0
  78. package/src/ui/progress.js +2 -1
  79. package/src/ui/render-measure.js +11 -0
  80. package/src/ui/render.js +92 -31
  81. 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, runParallelWave, createNativeScheduler } from "../runtime/parallel.js";
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 event, not a tool or a transaction participant. Consumers
46
- // invalidate synchronously; failures must never affect committed bytes.
47
- function notifyWorkspaceChanged(paths = null) {
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("workspace:changed", event)?.catch?.(() => {}); } catch {}
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 deliver = onItem ? (index, raw) => onItem(index, packageHostResult(raw, config)) : undefined;
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
- return scheduler.schedule(kind, invoke, activeSignal, fileMutationKey(name, args));
267
- }
360
+ const deliver = onItem ? async (index, raw) => {
361
+ const result = packageResult(raw);
268
362
 
269
- async function callMany(calls) {
270
- if (!Array.isArray(calls)) throw new TypeError("nova.callMany requires an array");
271
- const list = calls;
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
- if (list.some(item => !isString(item?.name) || !item.name)) throw new TypeError("nova.callMany entries require a tool name");
368
+ const invoke = () => vfs.withPathScope(async () => {
369
+ const raw = await invokeRaw(name, args, deliver);
370
+ const result = packageResult(raw);
274
371
 
275
- const thunks = list.map((item) => {
276
- const n = item?.name;
277
- const a = item?.args;
372
+ if (name === "read") await rememberOpenedPaths(args, result);
373
+ rememberReads(raw, result);
278
374
 
279
- return () => call(n, a);
375
+ return result;
280
376
  });
281
377
 
282
- const names = list.map((item) => item?.name).filter((n) => isString(n));
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 results;
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
- return await vfs.commit();
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
  }
@@ -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 (Object.hasOwn(natives, name) && metadata?.sourceInfo?.source === "builtin") return undefined;
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: () => hostSession && (hostSession.isDisposed || hostSession.sessionManager?.getSessionId?.() !== boundSessionId),
47
- nativeOwned: name => Object.hasOwn(natives, name) && !executors.has(name)
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
- function refreshTools() {
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
- const tools = Array.isArray(listed) ? listed : [];
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?.sessionManager?.getSessionId?.();
156
+ const sessionId = sessionIdOf(ctx);
92
157
  boundSessionId = sessionId;
93
- const registry = pi?.pi?.AgentRegistry?.global?.();
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
- if (!Array.isArray(sessions)) sessions = [];
98
- hostSession = sessionId
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
- return {refreshTools,isCallable,externalNames,hostTool,evalToolNames,bindSession,get session(){return hostSession;}};
218
+
219
+ return {refreshTools,isCallable,externalNames,hostTool,evalToolNames,bindSession,get modern(){return invocationContext !== null;},get session(){return hostSession;}};
104
220
  }
@@ -246,4 +246,5 @@ function pageRank(spans, graph, eta, prior, { gamma, pprIterations }) {
246
246
 
247
247
  return pi;
248
248
  }
249
+
249
250
  export { splitIdentifier, spanLines, buildGraph, activateEntities, propagate, pageRank };
@@ -150,4 +150,5 @@ function pickEvidence(spans, fileScores, profile, opts) {
150
150
 
151
151
  return { picks: closure(main, spans, graph, fused, opts.k), fused };
152
152
  }
153
+
153
154
  export { pickEvidence };
@@ -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 { files: chosenFiles, fileScores } = candidateFiles(await listedFiles(root, searchDir, pendingPaths, index), profile, index, opts.maxCandidateFiles, overlayText);
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: [] };
@@ -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.toLowerCase();
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.toLowerCase() : hayLowerOrNull;
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.toLowerCase(), hay, hay, null, maxTypos);
239
+ if (caseSensitive) return matchPart(needle, needle, foldCase(needle), hay, hay, null, maxTypos);
218
240
 
219
- const needleLower = needle.toLowerCase();
220
- const hayLower = hay.toLowerCase();
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.toLowerCase();
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((p) => p.toLowerCase());
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).toLowerCase() === needleLower ? Math.floor((base * 2) / 5) : Math.floor(base / 5);
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;