pi-ast-sgrep 2.0.2 → 2.2.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 (53) hide show
  1. package/README.md +16 -19
  2. package/dist/code-mode.d.ts +1 -1
  3. package/dist/code-mode.js +1 -1
  4. package/dist/codemode/connector.d.ts +18 -3
  5. package/dist/codemode/connector.js +85 -31
  6. package/dist/codemode/dispatch.d.ts +13 -1
  7. package/dist/codemode/dispatch.js +87 -24
  8. package/dist/codemode/guest-api.d.ts +16 -0
  9. package/dist/codemode/guest-api.js +194 -0
  10. package/dist/codemode/guest-worker.mjs +287 -0
  11. package/dist/codemode/index.d.ts +4 -3
  12. package/dist/codemode/index.js +4 -3
  13. package/dist/codemode/native.d.ts +1 -1
  14. package/dist/codemode/native.js +1 -1
  15. package/dist/codemode/runner.d.ts +13 -9
  16. package/dist/codemode/runner.js +411 -213
  17. package/dist/codemode/session-pool.d.ts +6 -1
  18. package/dist/codemode/session-pool.js +125 -32
  19. package/dist/codemode/types.d.ts +42 -2
  20. package/dist/codemode/types.js +40 -15
  21. package/dist/codemode/worker.d.ts +1 -1
  22. package/dist/codemode/worker.js +25 -2
  23. package/dist/host/commands.d.ts +6 -0
  24. package/dist/host/commands.js +49 -0
  25. package/dist/host/results.d.ts +123 -0
  26. package/dist/host/results.js +126 -0
  27. package/dist/host/tools.d.ts +28 -0
  28. package/dist/host/tools.js +802 -0
  29. package/dist/index.d.ts +7 -34
  30. package/dist/index.js +5 -543
  31. package/dist/runtime/config.d.ts +36 -0
  32. package/dist/runtime/config.js +98 -0
  33. package/dist/runtime/freshness.d.ts +43 -0
  34. package/dist/runtime/freshness.js +446 -0
  35. package/dist/runtime/index-health.d.ts +16 -0
  36. package/dist/runtime/index-health.js +111 -0
  37. package/dist/runtime/runtime.d.ts +48 -0
  38. package/dist/runtime/runtime.js +265 -0
  39. package/dist/runtime/sqlite.d.ts +15 -0
  40. package/dist/runtime/sqlite.js +63 -0
  41. package/dist/runtime/types.d.ts +55 -0
  42. package/dist/runtime/types.js +25 -0
  43. package/dist/ui/card.d.ts +66 -0
  44. package/dist/ui/card.js +375 -0
  45. package/dist/ui/present.d.ts +89 -0
  46. package/dist/ui/present.js +391 -0
  47. package/package.json +8 -7
  48. package/dist/codemode/sandbox-worker.d.ts +0 -1
  49. package/dist/codemode/sandbox-worker.js +0 -204
  50. package/dist/present.d.ts +0 -70
  51. package/dist/present.js +0 -260
  52. package/dist/runtime.d.ts +0 -137
  53. package/dist/runtime.js +0 -799
@@ -0,0 +1,802 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
3
+ import { Type } from "typebox";
4
+ import { createAsgrepConnector, runCodemode, runNativeBatch, runBatchViaStdin, CODEMODE_TYPES_FOR_MODEL, NativeSessionPool, argvFor, asEnvelope, applyQueryScope, warmCodemodeSandbox, resetCodemodeSandboxForTests, isClosedWorkerError, } from "../codemode/index.js";
5
+ import { AstSgrepRuntime, FreshnessCoordinator, RuntimeError } from "../runtime/runtime.js";
6
+ import { RESOLVED_ROOT } from "../runtime/types.js";
7
+ import { ASGREP_PROMPT_GUIDELINES, ASGREP_PROMPT_SNIPPET, formatCodemodeResult, } from "../ui/present.js";
8
+ import { EMPTY_CALL, renderAsgrepResult } from "../ui/card.js";
9
+ import { bounded, errorDetails, failure, isFreshnessTimeout, extractInPath, report, success, } from "./results.js";
10
+ export const DEFAULT_LIMIT = 8;
11
+ const MAX_LIMIT = 100;
12
+ const MAX_EXCERPT_LINES = 100;
13
+ const searchParameters = Type.Object({
14
+ query: Type.String({ maxLength: 4_096, description: "Query, symbol, or pattern" }),
15
+ mode: Type.Optional(Type.Unsafe({
16
+ type: "string",
17
+ enum: ["natural", "pattern", "defs", "callers", "chain", "semantic", "word", "literal", "regex", "imports"],
18
+ default: "natural",
19
+ description: "Search strategy",
20
+ })),
21
+ limit: Type.Optional(Type.Integer({ default: DEFAULT_LIMIT })),
22
+ excerptLines: Type.Optional(Type.Integer({ default: 0, description: "Inline N excerpt lines per hit" })),
23
+ in: Type.Optional(Type.String({ maxLength: 512, description: "Bound to a directory or glob" })),
24
+ lang: Type.Optional(Type.String({ maxLength: 32, description: "Language filter (rs, ts, py)" })),
25
+ }, { additionalProperties: false });
26
+ const indexParameters = Type.Object({
27
+ force: Type.Optional(Type.Boolean({ default: false, description: "Rebuild the index from scratch" })),
28
+ }, { additionalProperties: false });
29
+ const editParameters = Type.Object({
30
+ path: Type.Optional(Type.String({ maxLength: 512, description: "File to edit" })),
31
+ oldText: Type.Optional(Type.String({ description: "Exact text to replace (must match once)" })),
32
+ newText: Type.Optional(Type.String({ description: "Replacement" })),
33
+ edits: Type.Optional(Type.Array(Type.Object({
34
+ path: Type.Optional(Type.String({ minLength: 1, maxLength: 512 })),
35
+ oldText: Type.String({ minLength: 1 }),
36
+ newText: Type.String(),
37
+ }), { maxItems: 64, description: "Multi-edit entries; top-level path is the default" })),
38
+ }, { additionalProperties: false });
39
+ const readParameters = Type.Object({
40
+ path: Type.Optional(Type.String({ maxLength: 512, description: "File to read" })),
41
+ ref: Type.Optional(Type.String({ description: "Hit ref path#L12-L40" })),
42
+ refs: Type.Optional(Type.Array(Type.String(), { maxItems: 24, description: "Several refs in one call" })),
43
+ start: Type.Optional(Type.Integer()),
44
+ end: Type.Optional(Type.Integer()),
45
+ contextLines: Type.Optional(Type.Integer()),
46
+ maxChars: Type.Optional(Type.Integer()),
47
+ }, { additionalProperties: false });
48
+ const codemodeParameters = Type.Object({
49
+ code: Type.String({
50
+ minLength: 1,
51
+ maxLength: 32_000,
52
+ description: "JavaScript: async () => { ... } or a bare body with return. The returned value is the tool result.",
53
+ }),
54
+ timeoutMs: Type.Optional(Type.Integer({ description: "Timeout ms (default 30000)" })),
55
+ }, { additionalProperties: false });
56
+ function queryForMode(query, mode) {
57
+ if (mode === "pattern" || mode === "defs" || mode === "callers" || mode === "word" || mode === "literal" || mode === "regex" || mode === "imports") {
58
+ return `${mode}: ${query}`;
59
+ }
60
+ return query;
61
+ }
62
+ function searchArgs(params) {
63
+ const mode = params.mode ?? "natural";
64
+ const query = queryForMode(scopedSearchQuery(params), mode);
65
+ const output = withSearchLang(["--json", "--format", "agent-capsule", "--limit", String(params.limit ?? DEFAULT_LIMIT), "--excerpt-lines", String(params.excerptLines ?? 0)], params.lang);
66
+ return mode === "chain" || mode === "semantic"
67
+ ? [mode, query, ".", ...output]
68
+ : [...output, query, "."];
69
+ }
70
+ function scopedSearchQuery(params) {
71
+ return applyQueryScope(params.query, {
72
+ ...(typeof params.in === "string" ? { in: params.in } : {}),
73
+ ...(typeof params.fileFilter === "string" ? { fileFilter: params.fileFilter } : {}),
74
+ }) ?? params.query;
75
+ }
76
+ function withSearchLang(argv, lang) {
77
+ const trimmed = lang?.trim();
78
+ return trimmed ? ["--lang", trimmed, ...argv] : argv;
79
+ }
80
+ /**
81
+ * pi ships `read`, `edit`, `write`, `bash`, `grep`, `find`, `ls` built in, so on
82
+ * a normal Pi host our one-shot file tools would be paid for twice and never
83
+ * needed. They stay REGISTERED — an MCP-style host, a `--no-builtin-tools`
84
+ * session, or a host that drops the built-ins still gets them — but they are
85
+ * left out of the active set when the host already provides read+edit. Pi only
86
+ * sends ACTIVE tools (schema, snippet, guidelines) to the model, so this is the
87
+ * difference between ~296 tokens per request and nothing.
88
+ *
89
+ * ASGREP_KEEP_FILE_TOOLS=1 pins them active regardless.
90
+ */
91
+ /**
92
+ * Tools that MUTATE the index never ride the warm session: its calls are
93
+ * serialized, so a write there blocks every read queued behind it.
94
+ *
95
+ * Exported so the routing contract is testable without a live session.
96
+ */
97
+ export function writesOffSession(tool) {
98
+ return tool === "index_repo";
99
+ }
100
+ export function hostProvidesFileTools(pi, env = process.env) {
101
+ if (env.ASGREP_KEEP_FILE_TOOLS === "1")
102
+ return false;
103
+ const api = pi;
104
+ try {
105
+ if (typeof api.getActiveTools !== "function")
106
+ return false;
107
+ // Active by name, whatever supplies it: pi's built-ins, a wrapped host tool,
108
+ // or another extension. Our own tools are named asgrep_read/asgrep_edit, so
109
+ // this can only be somebody else's reader/editor.
110
+ const active = api.getActiveTools();
111
+ return active.includes("read") && active.includes("edit");
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ }
117
+ /** Drop our file tools from the active set; capability stays registered. */
118
+ function deactivateRedundantFileTools(pi) {
119
+ const api = pi;
120
+ try {
121
+ if (typeof api.getActiveTools !== "function" || typeof api.setActiveTools !== "function")
122
+ return;
123
+ const active = api.getActiveTools();
124
+ const redundant = new Set(["asgrep_read", "asgrep_edit"]);
125
+ const next = active.filter((name) => !redundant.has(name));
126
+ if (next.length !== active.length)
127
+ api.setActiveTools(next);
128
+ }
129
+ catch {
130
+ // A host without tool-set control keeps today's behaviour.
131
+ }
132
+ }
133
+ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), freshness = runtime instanceof AstSgrepRuntime
134
+ ? new FreshnessCoordinator({ refreshIntervalMs: runtime.config.refreshIntervalMs })
135
+ : new FreshnessCoordinator()) {
136
+ const pool = new NativeSessionPool();
137
+ let poolConfigured = false;
138
+ // Prefer a registration-local pool so tests / multi-agent hosts do not share
139
+ // sticky state. sharedNativePool remains for advanced single-session reuse.
140
+ const ensurePool = () => {
141
+ if (poolConfigured)
142
+ return;
143
+ try {
144
+ const env = runtime.nativeEnv?.() ?? { NO_COLOR: "1" };
145
+ let binary;
146
+ try {
147
+ binary = runtime.resolveBinaryPath?.({ env });
148
+ }
149
+ catch {
150
+ binary = undefined;
151
+ }
152
+ const opts = { env };
153
+ if (binary)
154
+ opts.binary = binary;
155
+ if (runtime.config?.timeoutMs !== undefined)
156
+ opts.timeoutMs = runtime.config.timeoutMs;
157
+ if (runtime.config?.maxOutputBytes !== undefined)
158
+ opts.maxOutputBytes = runtime.config.maxOutputBytes;
159
+ if (typeof env.ASGREP_NO_EMBED === "string") {
160
+ opts.useEmbed = env.ASGREP_NO_EMBED !== "1" && env.ASGREP_NO_EMBED !== "true";
161
+ }
162
+ if (typeof env.ASGREP_INDEX_PATH === "string")
163
+ opts.indexPath = env.ASGREP_INDEX_PATH;
164
+ pool.configure(opts);
165
+ }
166
+ catch {
167
+ pool.configure({});
168
+ }
169
+ poolConfigured = true;
170
+ };
171
+ /**
172
+ * Context for a follow-up call at an already-resolved root. The marker keeps
173
+ * a configured root from being re-applied against it: that re-resolution is
174
+ * how a subdirectory anchor silently turned back into the subdirectory.
175
+ */
176
+ const rootedAt = (root) => ({ cwd: root, [RESOLVED_ROOT]: true });
177
+ /**
178
+ * Cold checkout: build the index in the background at session start.
179
+ *
180
+ * Returns immediately when the index file already exists (the common case),
181
+ * so a warm session pays one stat while a cold one gets its first search
182
+ * answered from a warm index instead of waiting behind the build.
183
+ */
184
+ const warmColdIndex = async (root) => {
185
+ // Opt out on very large checkouts where the startup walk is not worth it:
186
+ // ASGREP_NO_WARM_INDEX=1.
187
+ if ((runtime.nativeEnv?.() ?? {}).ASGREP_NO_WARM_INDEX === "1")
188
+ return;
189
+ const indexPathFor = runtime.resolveIndexPath;
190
+ if (typeof indexPathFor !== "function")
191
+ return;
192
+ try {
193
+ if (existsSync(indexPathFor.call(runtime, root)))
194
+ return;
195
+ }
196
+ catch {
197
+ return;
198
+ }
199
+ await runCli(["index", ".", "--json", "--no-embed"], rootedAt(root));
200
+ };
201
+ const resolveRoot = async (context) => runtime.resolveRoot ? await runtime.resolveRoot(context) : context.cwd;
202
+ /**
203
+ * One index per checkout. Pi hands us the session cwd; when that cwd sits
204
+ * inside a checkout that already owns an index, that index serves it —
205
+ * scoped to the cwd — instead of a second multi-hundred-MB `.asgrep` growing
206
+ * beside it. An explicit ASGREP_INDEX_PATH already shares one index across
207
+ * every root, so it is left alone.
208
+ */
209
+ const anchorRoot = async (cwd) => {
210
+ const root = await resolveRoot({ cwd });
211
+ const resolveIndexPath = runtime.resolveIndexPath;
212
+ if (typeof resolveIndexPath !== "function")
213
+ return { root };
214
+ const env = runtime.nativeEnv?.() ?? {};
215
+ const configured = env.ASGREP_INDEX_PATH;
216
+ if (typeof configured === "string" && configured !== "")
217
+ return { root };
218
+ const indexAt = (dir) => {
219
+ try {
220
+ return existsSync(resolveIndexPath.call(runtime, dir));
221
+ }
222
+ catch {
223
+ return false;
224
+ }
225
+ };
226
+ if (indexAt(root))
227
+ return { root };
228
+ // Scope the walk to this checkout: an index that merely lives above the git
229
+ // work tree root (a home directory, a shared scratch tree) belongs to no
230
+ // project here and must not capture this session's searches.
231
+ let workTree;
232
+ for (let dir = root;; dir = dirname(dir)) {
233
+ if (existsSync(join(dir, ".git"))) {
234
+ workTree = dir;
235
+ break;
236
+ }
237
+ const parent = dirname(dir);
238
+ if (dir === parent)
239
+ break;
240
+ }
241
+ const within = (dir) => workTree === undefined || dir === workTree || dir.startsWith(workTree + sep);
242
+ for (let dir = dirname(root);; dir = dirname(dir)) {
243
+ const parent = dirname(dir);
244
+ if (dir === parent)
245
+ break;
246
+ if (!within(dir))
247
+ break;
248
+ if (!indexAt(dir))
249
+ continue;
250
+ const scope = relative(dir, root).split(sep).join("/");
251
+ return scope !== "" && scope !== "." ? { root: dir, scope } : { root: dir };
252
+ }
253
+ // Nothing indexed in this checkout yet: the index belongs at its root, not
254
+ // in whichever subdirectory this session happens to sit in.
255
+ if (workTree !== undefined && workTree !== root) {
256
+ const scope = relative(workTree, root).split(sep).join("/");
257
+ return scope !== "" && scope !== "." ? { root: workTree, scope } : { root: workTree };
258
+ }
259
+ return { root };
260
+ };
261
+ const probeCli = (options = {}) => {
262
+ // Test fixtures inject `run` without a resolver; production always has resolveBinaryPath.
263
+ if (typeof runtime.resolveBinaryPath !== "function")
264
+ return { kind: "cli" };
265
+ try {
266
+ const base = runtime.nativeEnv?.() ?? {};
267
+ if (options.env) {
268
+ runtime.resolveBinaryPath({ env: { ...base, ...options.env } });
269
+ }
270
+ else {
271
+ runtime.resolveBinaryPath({ env: base });
272
+ }
273
+ return { kind: "cli" };
274
+ }
275
+ catch (cause) {
276
+ return {
277
+ kind: "unavailable",
278
+ cause: cause instanceof Error ? cause.message : String(cause),
279
+ };
280
+ }
281
+ };
282
+ const requireBackend = (availability, context) => {
283
+ if (availability.kind !== "unavailable")
284
+ return;
285
+ throw new RuntimeError("BACKEND_UNAVAILABLE", "ast-sgrep backend unavailable (no NAPI session and no CLI binary)", {
286
+ backend: "unavailable",
287
+ // Agent-facing mirrors of the closed unavailable variant (not an open product).
288
+ napi: false,
289
+ cli: false,
290
+ cwd: context.cwd,
291
+ hint: "Install @ast-sgrep/<platform> or run npm run build:native in packages/pi/extension",
292
+ ...(availability.cause ? { cause: availability.cause } : {}),
293
+ });
294
+ };
295
+ const runCli = async (args, context, options = {}) => {
296
+ requireBackend(probeCli(options), context);
297
+ return runtime.run(args, context, options);
298
+ };
299
+ /** Typed sticky call that degrades to null when no backend is available or
300
+ * the session is crash-looping — the caller's CLI fallback owns the error
301
+ * fidelity when the binary itself is also broken. pool.call owns the
302
+ * respawn-on-closed retry; we only translate its outcomes. */
303
+ const callSticky = async (root, tool, args, options = {}) => {
304
+ try {
305
+ return await pool.call(root, tool, args, options.signal ? { signal: options.signal } : {});
306
+ }
307
+ catch (cause) {
308
+ // Aborted: return null so the CLI fallback surfaces the cancellation with
309
+ // its own semantics (runCli -> runtime.run maps abort to CANCELLED).
310
+ if (options.signal?.aborted)
311
+ return null;
312
+ const message = cause instanceof Error ? cause.message : String(cause);
313
+ if (isClosedWorkerError(cause) || message.includes("backend unavailable"))
314
+ return null;
315
+ throw cause;
316
+ }
317
+ };
318
+ const nativeCall = async (tool, args, context, options = {}) => {
319
+ ensurePool();
320
+ const root = await resolveRoot(context);
321
+ // Writes never ride the warm session. Its calls are serialized, so an index
322
+ // running there blocks every read queued behind it (measured p100: a search
323
+ // waited 9.2s for a background reindex). Index work goes out of process;
324
+ // SQLite WAL lets readers keep their own snapshot meanwhile.
325
+ if (writesOffSession(tool))
326
+ return runCli(argvFor(tool, args), context, options);
327
+ const sticky = await callSticky(root, tool, args, options);
328
+ if (sticky)
329
+ return asEnvelope(sticky);
330
+ // Cold CLI only when a real binary resolves -- never remap missing natives to BINARY_RESOLUTION_FAILED.
331
+ return runCli(argvFor(tool, args), context, options);
332
+ };
333
+ // Freshness + tools share the same warm in-process Searcher as Code Mode.
334
+ const warmRuntime = {
335
+ run: (args, context, options) => runtime.run(args, context, options),
336
+ resolveRoot: (context) => resolveRoot(context),
337
+ nativeCall,
338
+ };
339
+ // Optional runtime capabilities pass through when present — bound, since
340
+ // they are class methods whose private fields live on the runtime instance.
341
+ for (const key of ["watchExternalChanges", "resolveIndexPath", "inspectIndexCompatibility"]) {
342
+ const member = runtime[key];
343
+ if (member !== undefined) {
344
+ warmRuntime[key] =
345
+ typeof member === "function" ? member.bind(runtime) : member;
346
+ }
347
+ }
348
+ if (runtime.rebuildIncompatibleIndex) {
349
+ warmRuntime.rebuildIncompatibleIndex = async (context, options) => {
350
+ const root = await resolveRoot(context);
351
+ await pool.invalidate(root);
352
+ return runtime.rebuildIncompatibleIndex(context, options);
353
+ };
354
+ }
355
+ /**
356
+ * Freshness gate shared by the one-shot tools: ensureFresh with a bounded
357
+ * timeout fallback, or a scoped-path index when the query carries in:/fileFilter.
358
+ *
359
+ * Bounded means serve-stale, not fail: a caller that ran out of freshness
360
+ * budget still queries the current index and is told the result may be stale.
361
+ * The session is never torn down here — the shared refresh runs on it, so
362
+ * invalidating would kill the index work the caller just stopped waiting for
363
+ * and leave the root permanently stale.
364
+ */
365
+ const freshRoot = async (cwd, signal, scopedPath) => {
366
+ const options = signal ? { signal } : {};
367
+ const anchor = await anchorRoot(cwd);
368
+ const scope = anchor.scope;
369
+ // A subtree refresh still lands in the checkout's own index.
370
+ const target = scopedPath ? (scope ? `${scope}/${scopedPath}` : scopedPath) : undefined;
371
+ if (target) {
372
+ try {
373
+ await nativeCall("index_repo", { paths: [target] }, rootedAt(anchor.root), options);
374
+ }
375
+ catch (cause) {
376
+ if (!isFreshnessTimeout(cause, signal))
377
+ throw cause;
378
+ return { root: anchor.root, ...(scope ? { scope } : {}), freshness: "stale" };
379
+ }
380
+ return { root: anchor.root, ...(scope ? { scope } : {}) };
381
+ }
382
+ try {
383
+ const resolved = await freshness.ensureFresh(warmRuntime, rootedAt(anchor.root), options);
384
+ // The contract is a root string; a host/test double that returns nothing
385
+ // must not hand an undefined cwd to the runtime.
386
+ const root = typeof resolved === "string" && resolved !== "" ? resolved : anchor.root;
387
+ return { root, ...(scope ? { scope } : {}) };
388
+ }
389
+ catch (cause) {
390
+ if (!isFreshnessTimeout(cause, signal))
391
+ throw cause;
392
+ return { root: anchor.root, ...(scope ? { scope } : {}), freshness: "stale" };
393
+ }
394
+ };
395
+ /** Anchor the caller's own in:/fileFilter scope under the checkout root. */
396
+ const withAnchorScope = (params, scope) => {
397
+ if (!scope)
398
+ return params;
399
+ const nested = params.in ?? params.fileFilter;
400
+ const combined = typeof nested === "string" && nested.trim() ? `${scope}/${nested.replace(/^(?:\.\/)+/u, "")}` : scope;
401
+ return { ...params, in: combined };
402
+ };
403
+ /** An index with no files is not a no-match: it answers nothing at all. */
404
+ const probeIndexState = async (root, context, options) => {
405
+ try {
406
+ const status = await machineCall(root, "index_status", {}, ["status", ".", "--json"], context, options);
407
+ if (typeof status.file_count !== "number")
408
+ return undefined;
409
+ const probe = { files: status.file_count };
410
+ if (typeof status.semantic_chunk_count === "number")
411
+ probe.semanticChunks = status.semantic_chunk_count;
412
+ return probe;
413
+ }
414
+ catch {
415
+ // Coverage is a note on an answer, never a failure of its own.
416
+ return undefined;
417
+ }
418
+ };
419
+ const zeroHitResponse = (response) => response.ok !== false && Array.isArray(response.hits) && response.hits.length === 0;
420
+ /** Typed sticky call first, argv fallback when no session — the shape every
421
+ * one-shot tool shares. */
422
+ const machineCall = async (root, tool, stickyArgs, argv, context, options) => (await callSticky(root, tool, stickyArgs, options)) ?? await runCli(argv, context, options);
423
+ /** Native launch env + resolved binary for the codemode batch host. */
424
+ const nativeLaunch = () => {
425
+ const env = runtime.nativeEnv?.() ?? { NO_COLOR: "1" };
426
+ let binary = null;
427
+ try {
428
+ binary = runtime.resolveBinaryPath?.({ env }) ?? null;
429
+ }
430
+ catch {
431
+ binary = null;
432
+ }
433
+ return { env, binary };
434
+ };
435
+ /** Host surface for codemode: argv run + optional sticky + stdin batch. */
436
+ const buildBatchHost = (sticky, env, binary) => {
437
+ const host = {
438
+ run: (args, context, runOptions) => runtime.run(args, context, runOptions ?? {}),
439
+ sticky,
440
+ };
441
+ if (binary) {
442
+ host.runBatch = (calls, context, runOptions) => runNativeBatch((a, c, o) => runtime.run(a, c, o ?? {}), calls, context, runOptions, (body, c, o) => {
443
+ const stdinOpts = { binary, cwd: c.cwd, body, env };
444
+ if (o?.signal)
445
+ stdinOpts.signal = o.signal;
446
+ if (runtime.config?.timeoutMs !== undefined)
447
+ stdinOpts.timeoutMs = runtime.config.timeoutMs;
448
+ if (runtime.config?.maxOutputBytes !== undefined)
449
+ stdinOpts.maxOutputBytes = runtime.config.maxOutputBytes;
450
+ return runBatchViaStdin(stdinOpts);
451
+ });
452
+ }
453
+ return host;
454
+ };
455
+ let stopWorkspaceEvents;
456
+ function watchWorkspaceChanges() {
457
+ stopWorkspaceEvents ??= pi.events?.on("workspace:changed", (data) => {
458
+ if (!data || typeof data !== "object")
459
+ return;
460
+ const event = data;
461
+ if (event.version !== 1 || typeof event.cwd !== "string" || !isAbsolute(event.cwd))
462
+ return;
463
+ if (event.paths === null) {
464
+ freshness.markRootDirty?.(event.cwd);
465
+ }
466
+ else if (Array.isArray(event.paths) && event.paths.every(p => typeof p === "string" && isAbsolute(p))) {
467
+ for (const file of event.paths)
468
+ freshness.markAffectedPath(file, event.cwd);
469
+ }
470
+ });
471
+ }
472
+ watchWorkspaceChanges();
473
+ pi.on("tool_result", (event, ctx) => {
474
+ if (event.isError)
475
+ return;
476
+ if (event.toolName !== "write" && event.toolName !== "edit")
477
+ return;
478
+ const path = event.input.path;
479
+ if (typeof path === "string")
480
+ freshness.markAffectedPath(path, ctx.cwd);
481
+ });
482
+ pi.on("session_start", (_event, ctx) => {
483
+ watchWorkspaceChanges();
484
+ // Built-in read/edit present and active: keep ours registered (other hosts
485
+ // need them) but off the model's tool list.
486
+ if (hostProvidesFileTools(pi))
487
+ deactivateRedundantFileTools(pi);
488
+ // Warm the in-process Searcher at session start so the first asgrep
489
+ // search does not pay NAPI/SQLite open on the user's first lookup.
490
+ void (async () => {
491
+ try {
492
+ ensurePool();
493
+ const root = await resolveRoot({ cwd: ctx.cwd });
494
+ await Promise.all([pool.acquire(root), warmCodemodeSandbox()]);
495
+ // Cold checkout: build the index now, in the background, out of process.
496
+ // Session start (system prompt, first model turn) is a second or two of
497
+ // free time, and a lexical/AST index of a few thousand files takes a few
498
+ // hundred ms — so the first search answers from a warm index instead of
499
+ // waiting behind a build (measured cold first search: 271ms and rising
500
+ // with repo size). Failures stay silent: the search path owns recovery.
501
+ await warmColdIndex(root);
502
+ }
503
+ catch {
504
+ // Doctor reports backend errors; a failed warmup must not block the session.
505
+ }
506
+ })();
507
+ });
508
+ pi.on("session_shutdown", () => {
509
+ stopWorkspaceEvents?.();
510
+ stopWorkspaceEvents = undefined;
511
+ freshness.shutdown?.();
512
+ void pool.shutdown();
513
+ void resetCodemodeSandboxForTests();
514
+ });
515
+ // Primary surface: Code Mode -- in-process NAPI (MCP-class), compose in JS.
516
+ // Sibling to MCP: pick one surface; both link core, never each other.
517
+ pi.registerTool({
518
+ name: "asgrep",
519
+ label: "asgrep",
520
+ promptSnippet: ASGREP_PROMPT_SNIPPET,
521
+ promptGuidelines: [...ASGREP_PROMPT_GUIDELINES],
522
+ description: [
523
+ "Code search (in-process, warm Searcher): use it for any code lookup instead of grep.",
524
+ "Write JavaScript; the returned value is your result.",
525
+ CODEMODE_TYPES_FOR_MODEL,
526
+ "Example: async () => (await asgrep.search(\"auth\", { limit: 5 })).hits",
527
+ ].join("\n"),
528
+ parameters: codemodeParameters,
529
+ // The card owns its own frame: no host Box padding/background around it,
530
+ // and no duplicate title line above it.
531
+ renderShell: "self",
532
+ renderCall() {
533
+ return EMPTY_CALL;
534
+ },
535
+ renderResult(result, options, theme, context) {
536
+ return renderAsgrepResult(result, options, theme, context);
537
+ },
538
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
539
+ report(onUpdate, "codemode", "started");
540
+ try {
541
+ const timeoutMs = typeof params.timeoutMs === "number"
542
+ ? params.timeoutMs
543
+ : runtime.config?.timeoutMs ?? 30_000;
544
+ const deadline = Date.now() + timeoutMs;
545
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
546
+ const operationSignal = signal
547
+ ? AbortSignal.any([signal, timeoutSignal])
548
+ : timeoutSignal;
549
+ const options = { signal: operationSignal };
550
+ ensurePool();
551
+ const { root, scope, freshness: fresh } = await freshRoot(ctx.cwd, signal);
552
+ const { env, binary } = nativeLaunch();
553
+ // In-process NAPI first; CLI sticky only if addon missing.
554
+ const sticky = await pool.acquire(root);
555
+ const bundle = createAsgrepConnector(buildBatchHost(sticky, env, binary), rootedAt(root), { ...options, ...(scope ? { scope } : {}) });
556
+ bundle.resetStats();
557
+ const codemodeOptions = { stats: bundle.stats };
558
+ codemodeOptions.timeoutMs = Math.max(1, deadline - Date.now());
559
+ codemodeOptions.signal = operationSignal;
560
+ await warmCodemodeSandbox().catch(() => undefined);
561
+ const outcome = await runCodemode(params.code, bundle.asgrep, codemodeOptions);
562
+ report(onUpdate, "codemode", "completed");
563
+ if (!outcome.ok) {
564
+ return {
565
+ content: [{ type: "text", text: bounded(`codemode failed: ${outcome.error}`) }],
566
+ details: {
567
+ ok: false,
568
+ command: "codemode",
569
+ error: { code: "CODEMODE_ERROR", message: outcome.error, details: { logs: outcome.logs, stats: outcome.stats } },
570
+ code: outcome.code,
571
+ stats: outcome.stats,
572
+ trace: bundle.trace(),
573
+ wallMs: outcome.wallMs,
574
+ backend: pool.backend(),
575
+ },
576
+ };
577
+ }
578
+ const rendered = formatCodemodeResult(outcome.result, {
579
+ ...(outcome.stats ? { stats: outcome.stats } : {}),
580
+ wallMs: outcome.wallMs,
581
+ backend: pool.backend(),
582
+ });
583
+ const activationMs = outcome.wallMs;
584
+ return {
585
+ content: [{ type: "text", text: bounded(rendered) }],
586
+ details: {
587
+ ok: true,
588
+ command: "codemode",
589
+ result: outcome.result,
590
+ logs: outcome.logs,
591
+ rendered,
592
+ stats: outcome.stats,
593
+ trace: bundle.trace(),
594
+ wallMs: outcome.wallMs,
595
+ activationMs,
596
+ backend: pool.backend(),
597
+ ...(fresh ? { freshness: fresh } : {}),
598
+ },
599
+ };
600
+ }
601
+ catch (cause) {
602
+ return failure("codemode", cause, signal);
603
+ }
604
+ },
605
+ });
606
+ // Escape hatches: one-shot tools for simple lookups. Prefer asgrep.
607
+ // They ride the same session sticky pool when available (no cold spawn).
608
+ pi.registerTool({
609
+ name: "asgrep_search",
610
+ label: "asgrep search",
611
+ promptSnippet: "One-shot asgrep search",
612
+ description: "One-shot search. Use asgrep (Code Mode) for anything multi-step, parallel, or filtered.",
613
+ parameters: searchParameters,
614
+ renderShell: "self",
615
+ renderCall() {
616
+ return EMPTY_CALL;
617
+ },
618
+ renderResult(result, options, theme, context) {
619
+ return renderAsgrepResult(result, options, theme, context);
620
+ },
621
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
622
+ const options = signal ? { signal } : {};
623
+ const started = performance.now();
624
+ report(onUpdate, "search", "started");
625
+ try {
626
+ ensurePool();
627
+ const scopedPath = (typeof params.in === "string" ? params.in : undefined)
628
+ ?? extractInPath(params.query);
629
+ const fresh = await freshRoot(ctx.cwd, signal, scopedPath);
630
+ // The checkout owns the index; the caller's scope rides under it.
631
+ const anchored = withAnchorScope(params, fresh.scope);
632
+ const [tool, args] = searchToolCall(anchored);
633
+ const response = await machineCall(fresh.root, tool, args, searchArgs(anchored), rootedAt(fresh.root), options);
634
+ const notes = [];
635
+ if (fresh.freshness === "stale") {
636
+ notes.push("index refresh is still running; this answer came from the current index and may be stale");
637
+ }
638
+ let indexState;
639
+ if (zeroHitResponse(response)) {
640
+ const probe = await probeIndexState(fresh.root, rootedAt(fresh.root), options);
641
+ if (probe) {
642
+ indexState = probe.files === 0 ? "empty" : "ready";
643
+ if (indexState === "empty") {
644
+ notes.push("index has 0 files: this repository is not indexed -- run /asgrep-index (or asgrep.indexRepo()) and retry");
645
+ }
646
+ else if ((params.mode ?? "natural") === "semantic" && probe.semanticChunks === 0) {
647
+ // Freshness refreshes index lexical/AST only, so a semantic query
648
+ // on a cold repo has nothing to rank yet.
649
+ notes.push("no embeddings yet: this index was built lexical-only -- run /asgrep-index (or asgrep.indexRepo()) to build vectors");
650
+ }
651
+ }
652
+ }
653
+ report(onUpdate, "search", "completed");
654
+ return success("search", response, {
655
+ query: params.query,
656
+ mode: params.mode ?? "natural",
657
+ activationMs: performance.now() - started,
658
+ backend: pool.backend(),
659
+ // Drives excerpt rendering in the model-facing text: capsules carry
660
+ // body text whether or not it was asked for.
661
+ excerptLines: params.excerptLines ?? 0,
662
+ ...(fresh.freshness ? { freshness: fresh.freshness } : {}),
663
+ ...(indexState ? { indexState } : {}),
664
+ ...(notes.length > 0 ? { notes } : {}),
665
+ });
666
+ }
667
+ catch (cause) {
668
+ return failure("search", cause, signal);
669
+ }
670
+ },
671
+ });
672
+ // Trained-priorty escape hatches: direct edit/read without writing JS.
673
+ // Both ride the connector's arg plumbing and the same sticky pool.
674
+ pi.registerTool({
675
+ name: "asgrep_edit",
676
+ label: "asgrep edit",
677
+ promptSnippet: "Exact-string edit",
678
+ description: "Edit by exact-string replace; oldText must match once. edits[] applies many atomically.",
679
+ parameters: editParameters,
680
+ renderShell: "self",
681
+ renderCall() {
682
+ return EMPTY_CALL;
683
+ },
684
+ renderResult(result, options, theme, context) {
685
+ return renderAsgrepResult(result, options, theme, context);
686
+ },
687
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
688
+ report(onUpdate, "edit", "started");
689
+ try {
690
+ ensurePool();
691
+ const options = signal ? { signal } : {};
692
+ const { root, scope, freshness: fresh } = await freshRoot(ctx.cwd, signal);
693
+ const sticky = await pool.acquire(root);
694
+ const bundle = createAsgrepConnector({ run: (a, c, o) => runtime.run(a, c, o), sticky }, rootedAt(root), { ...options, ...(scope ? { scope } : {}) });
695
+ const response = await bundle.asgrep.edit(params);
696
+ report(onUpdate, "edit", "completed");
697
+ return success("edit", response, { backend: pool.backend(), ...(fresh ? { freshness: fresh } : {}) });
698
+ }
699
+ catch (cause) {
700
+ return failure("edit", cause, signal);
701
+ }
702
+ },
703
+ });
704
+ pi.registerTool({
705
+ name: "asgrep_read",
706
+ label: "asgrep read",
707
+ promptSnippet: "Read file window or hit ref",
708
+ description: "Read a file window or resolve hit refs (path#L1-L40).",
709
+ parameters: readParameters,
710
+ renderShell: "self",
711
+ renderCall() {
712
+ return EMPTY_CALL;
713
+ },
714
+ renderResult(result, options, theme, context) {
715
+ return renderAsgrepResult(result, options, theme, context);
716
+ },
717
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
718
+ report(onUpdate, "read", "started");
719
+ try {
720
+ ensurePool();
721
+ const options = signal ? { signal } : {};
722
+ const { root, scope, freshness: fresh } = await freshRoot(ctx.cwd, signal);
723
+ const sticky = await pool.acquire(root);
724
+ const bundle = createAsgrepConnector({ run: (a, c, o) => runtime.run(a, c, o), sticky }, rootedAt(root), { ...options, ...(scope ? { scope } : {}) });
725
+ const response = await bundle.asgrep.read(params);
726
+ report(onUpdate, "read", "completed");
727
+ return success("read", response, { backend: pool.backend(), ...(fresh ? { freshness: fresh } : {}) });
728
+ }
729
+ catch (cause) {
730
+ return failure("read", cause, signal);
731
+ }
732
+ },
733
+ });
734
+ pi.registerTool({
735
+ name: "asgrep_index",
736
+ label: "asgrep index",
737
+ promptSnippet: "Build or rebuild the index",
738
+ description: "Build or rebuild the index (embeddings included).",
739
+ parameters: indexParameters,
740
+ renderShell: "self",
741
+ renderCall() {
742
+ return EMPTY_CALL;
743
+ },
744
+ renderResult(result, options, theme, context) {
745
+ return renderAsgrepResult(result, options, theme, context);
746
+ },
747
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
748
+ const force = params.force === true;
749
+ const command = force ? "reindex" : "index";
750
+ report(onUpdate, command, "started");
751
+ try {
752
+ ensurePool();
753
+ const options = signal ? { signal } : {};
754
+ const { root, freshness: fresh } = await freshRoot(ctx.cwd, signal);
755
+ // Always out of process: an index inside the warm session would block
756
+ // every read queued behind it (measured 9.2s p100 for a search during a
757
+ // reindex). The explicit path keeps embeddings; implicit refreshes skip
758
+ // them (see runtime/freshness.ts).
759
+ const response = await runCli([command, ".", "--json"], rootedAt(root), options);
760
+ report(onUpdate, command, "completed");
761
+ return success(command, response, { ...(fresh ? { freshness: fresh } : {}) });
762
+ }
763
+ catch (cause) {
764
+ return failure(command, cause, signal);
765
+ }
766
+ },
767
+ });
768
+ // No asgrep_status tool: index/runtime status is a diagnostic, not a lookup.
769
+ // The model reads it in Code Mode (asgrep.indexStatus()) and humans have
770
+ // /asgrep-status, so the schema does not carry it on every request.
771
+ }
772
+ const SEARCH_CALL_SPEC = {
773
+ semantic: { tool: "semantic" },
774
+ chain: { tool: "chain" },
775
+ defs: { tool: "defs", key: "symbol" },
776
+ callers: { tool: "callers", key: "symbol" },
777
+ imports: { tool: "imports", key: "module" },
778
+ pattern: { tool: "search", prefix: "pattern" },
779
+ word: { tool: "search", prefix: "word" },
780
+ literal: { tool: "search", prefix: "literal" },
781
+ regex: { tool: "search", prefix: "regex" },
782
+ natural: { tool: "search" },
783
+ };
784
+ function searchToolCall(params) {
785
+ const mode = params.mode ?? "natural";
786
+ const limit = params.limit ?? DEFAULT_LIMIT;
787
+ const excerpt_lines = params.excerptLines ?? 0;
788
+ const query = scopedSearchQuery(params);
789
+ const spec = SEARCH_CALL_SPEC[mode];
790
+ const lang = typeof params.lang === "string" ? params.lang.trim() : "";
791
+ if (spec.tool === "semantic") {
792
+ return ["semantic", { query, limit, excerpt_lines, format: "capsule", ...(lang ? { lang } : {}) }];
793
+ }
794
+ if (spec.tool === "chain") {
795
+ return ["chain", { query, limit, top_n: 20 }];
796
+ }
797
+ if (spec.tool === "search") {
798
+ const prefixed = spec.prefix ? `${spec.prefix}: ${query}` : query;
799
+ return ["search", { query: prefixed, limit, excerpt_lines, format: "capsule", ...(lang ? { lang } : {}) }];
800
+ }
801
+ return [spec.tool, { [spec.key]: params.query, limit, excerpt_lines, ...(lang ? { lang } : {}) }];
802
+ }