pi-ast-sgrep 2.0.2 → 2.1.1

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 (49) hide show
  1. package/README.md +15 -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 +17 -3
  5. package/dist/codemode/connector.js +59 -31
  6. package/dist/codemode/dispatch.d.ts +13 -1
  7. package/dist/codemode/dispatch.js +83 -23
  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/runner.d.ts +13 -9
  14. package/dist/codemode/runner.js +411 -213
  15. package/dist/codemode/session-pool.d.ts +6 -1
  16. package/dist/codemode/session-pool.js +125 -32
  17. package/dist/codemode/types.d.ts +33 -2
  18. package/dist/codemode/types.js +39 -13
  19. package/dist/codemode/worker.d.ts +1 -1
  20. package/dist/codemode/worker.js +25 -2
  21. package/dist/host/commands.d.ts +6 -0
  22. package/dist/host/commands.js +49 -0
  23. package/dist/host/results.d.ts +115 -0
  24. package/dist/host/results.js +80 -0
  25. package/dist/host/tools.d.ts +9 -0
  26. package/dist/host/tools.js +610 -0
  27. package/dist/index.d.ts +7 -34
  28. package/dist/index.js +5 -543
  29. package/dist/runtime/config.d.ts +36 -0
  30. package/dist/runtime/config.js +98 -0
  31. package/dist/runtime/freshness.d.ts +43 -0
  32. package/dist/runtime/freshness.js +413 -0
  33. package/dist/runtime/index-health.d.ts +16 -0
  34. package/dist/runtime/index-health.js +109 -0
  35. package/dist/runtime/runtime.d.ts +48 -0
  36. package/dist/runtime/runtime.js +265 -0
  37. package/dist/runtime/sqlite.d.ts +15 -0
  38. package/dist/runtime/sqlite.js +63 -0
  39. package/dist/runtime/types.d.ts +55 -0
  40. package/dist/runtime/types.js +25 -0
  41. package/dist/ui/card.d.ts +63 -0
  42. package/dist/ui/card.js +316 -0
  43. package/dist/{present.d.ts → ui/present.d.ts} +20 -2
  44. package/dist/{present.js → ui/present.js} +68 -9
  45. package/package.json +6 -6
  46. package/dist/codemode/sandbox-worker.d.ts +0 -1
  47. package/dist/codemode/sandbox-worker.js +0 -204
  48. package/dist/runtime.d.ts +0 -137
  49. package/dist/runtime.js +0 -799
@@ -0,0 +1,610 @@
1
+ import { isAbsolute } from "node:path";
2
+ import { Type } from "typebox";
3
+ import { createAsgrepConnector, runCodemode, runNativeBatch, runBatchViaStdin, CODEMODE_TYPES_FOR_MODEL, NativeSessionPool, argvFor, asEnvelope, applyQueryScope, warmCodemodeSandbox, resetCodemodeSandboxForTests, isClosedWorkerError, } from "../codemode/index.js";
4
+ import { AstSgrepRuntime, FreshnessCoordinator, RuntimeError } from "../runtime/runtime.js";
5
+ import { ASGREP_PROMPT_GUIDELINES, ASGREP_PROMPT_SNIPPET, formatCodemodeCall, formatCodemodeResult, formatEditCall, formatIndexCall, formatReadCall, formatSearchCall, formatStatusCall, presentText, } from "../ui/present.js";
6
+ import { renderAsgrepResult } from "../ui/card.js";
7
+ import { bounded, errorDetails, failure, isFreshnessTimeout, extractInPath, report, success, } from "./results.js";
8
+ export const DEFAULT_LIMIT = 8;
9
+ const MAX_LIMIT = 100;
10
+ const MAX_EXCERPT_LINES = 100;
11
+ const searchParameters = Type.Object({
12
+ query: Type.String({ minLength: 1, maxLength: 4_096, description: "Natural-language query, symbol, or structural pattern" }),
13
+ mode: Type.Optional(Type.Union([
14
+ Type.Literal("natural"),
15
+ Type.Literal("pattern"),
16
+ Type.Literal("defs"),
17
+ Type.Literal("callers"),
18
+ Type.Literal("chain"),
19
+ Type.Literal("semantic"),
20
+ Type.Literal("word"),
21
+ Type.Literal("literal"),
22
+ Type.Literal("regex"),
23
+ Type.Literal("imports"),
24
+ ], { default: "natural", description: "Search strategy (CLI-aligned modes)" })),
25
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_LIMIT, default: DEFAULT_LIMIT })),
26
+ excerptLines: Type.Optional(Type.Integer({ minimum: 0, maximum: MAX_EXCERPT_LINES, default: 0, description: "Opt in to excerpt body lines" })),
27
+ in: Type.Optional(Type.String({ minLength: 1, maxLength: 512, description: "Directory or glob to bound the search (in:path)" })),
28
+ lang: Type.Optional(Type.String({ minLength: 1, maxLength: 32, description: "Language id or extension (rs, ts, py)" })),
29
+ fileFilter: Type.Optional(Type.String({ minLength: 1, maxLength: 512, description: "Repository-relative glob; alias of in" })),
30
+ }, { additionalProperties: false });
31
+ const indexParameters = Type.Object({
32
+ force: Type.Optional(Type.Boolean({ default: false, description: "Rebuild the index from scratch" })),
33
+ }, { additionalProperties: false });
34
+ const statusParameters = Type.Object({}, { additionalProperties: false });
35
+ const editParameters = Type.Object({
36
+ path: Type.Optional(Type.String({ minLength: 1, maxLength: 512, description: "Repository-relative file to edit" })),
37
+ oldText: Type.Optional(Type.String({ description: "Exact text to replace — must match exactly once in the file" })),
38
+ newText: Type.Optional(Type.String({ description: "Replacement text" })),
39
+ edits: Type.Optional(Type.Array(Type.Object({
40
+ path: Type.Optional(Type.String({ minLength: 1, maxLength: 512 })),
41
+ oldText: Type.String({ minLength: 1 }),
42
+ newText: Type.String(),
43
+ }), { maxItems: 64, description: "Multi-edit entries; top-level path is the default for entries that omit it" })),
44
+ }, { additionalProperties: false });
45
+ const readParameters = Type.Object({
46
+ path: Type.Optional(Type.String({ minLength: 1, maxLength: 512, description: "Repository-relative file path" })),
47
+ ref: Type.Optional(Type.String({ minLength: 1, description: "Hit ref (path#L12-L40) from a search result" })),
48
+ refs: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { maxItems: 24, description: "Multiple refs to read in one call" })),
49
+ start: Type.Optional(Type.Integer({ minimum: 1 })),
50
+ end: Type.Optional(Type.Integer({ minimum: 1 })),
51
+ contextLines: Type.Optional(Type.Integer({ minimum: 0, maximum: 40 })),
52
+ maxChars: Type.Optional(Type.Integer({ minimum: 64, maximum: 64_000 })),
53
+ }, { additionalProperties: false });
54
+ const codemodeParameters = Type.Object({
55
+ code: Type.String({
56
+ minLength: 1,
57
+ maxLength: 32_000,
58
+ description: "JavaScript: async () => { ... } or a bare body with return. Call asgrep.search(\"query\"), asgrep.defs(\"Symbol\"). Prefer Promise.all. Return only the shaped final value.",
59
+ }),
60
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 1_000, maximum: 120_000, description: "Hard timeout in ms (default 30000)" })),
61
+ }, { additionalProperties: false });
62
+ function queryForMode(query, mode) {
63
+ if (mode === "pattern" || mode === "defs" || mode === "callers" || mode === "word" || mode === "literal" || mode === "regex" || mode === "imports") {
64
+ return `${mode}: ${query}`;
65
+ }
66
+ return query;
67
+ }
68
+ function searchArgs(params) {
69
+ const mode = params.mode ?? "natural";
70
+ const query = queryForMode(scopedSearchQuery(params), mode);
71
+ const output = withSearchLang(["--json", "--format", "agent-capsule", "--limit", String(params.limit ?? DEFAULT_LIMIT), "--excerpt-lines", String(params.excerptLines ?? 0)], params.lang);
72
+ return mode === "chain" || mode === "semantic"
73
+ ? [mode, query, ".", ...output]
74
+ : [...output, query, "."];
75
+ }
76
+ function scopedSearchQuery(params) {
77
+ return applyQueryScope(params.query, {
78
+ ...(typeof params.in === "string" ? { in: params.in } : {}),
79
+ ...(typeof params.fileFilter === "string" ? { fileFilter: params.fileFilter } : {}),
80
+ }) ?? params.query;
81
+ }
82
+ function withSearchLang(argv, lang) {
83
+ const trimmed = lang?.trim();
84
+ return trimmed ? ["--lang", trimmed, ...argv] : argv;
85
+ }
86
+ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), freshness = runtime instanceof AstSgrepRuntime
87
+ ? new FreshnessCoordinator({ refreshIntervalMs: runtime.config.refreshIntervalMs })
88
+ : new FreshnessCoordinator()) {
89
+ const pool = new NativeSessionPool();
90
+ let poolConfigured = false;
91
+ // Prefer a registration-local pool so tests / multi-agent hosts do not share
92
+ // sticky state. sharedNativePool remains for advanced single-session reuse.
93
+ const ensurePool = () => {
94
+ if (poolConfigured)
95
+ return;
96
+ try {
97
+ const env = runtime.nativeEnv?.() ?? { NO_COLOR: "1" };
98
+ let binary;
99
+ try {
100
+ binary = runtime.resolveBinaryPath?.({ env });
101
+ }
102
+ catch {
103
+ binary = undefined;
104
+ }
105
+ const opts = { env };
106
+ if (binary)
107
+ opts.binary = binary;
108
+ if (runtime.config?.timeoutMs !== undefined)
109
+ opts.timeoutMs = runtime.config.timeoutMs;
110
+ if (runtime.config?.maxOutputBytes !== undefined)
111
+ opts.maxOutputBytes = runtime.config.maxOutputBytes;
112
+ if (typeof env.ASGREP_NO_EMBED === "string") {
113
+ opts.useEmbed = env.ASGREP_NO_EMBED !== "1" && env.ASGREP_NO_EMBED !== "true";
114
+ }
115
+ if (typeof env.ASGREP_INDEX_PATH === "string")
116
+ opts.indexPath = env.ASGREP_INDEX_PATH;
117
+ pool.configure(opts);
118
+ }
119
+ catch {
120
+ pool.configure({});
121
+ }
122
+ poolConfigured = true;
123
+ };
124
+ const resolveRoot = async (cwd) => runtime.resolveRoot ? await runtime.resolveRoot({ cwd }) : cwd;
125
+ const probeCli = (options = {}) => {
126
+ // Test fixtures inject `run` without a resolver; production always has resolveBinaryPath.
127
+ if (typeof runtime.resolveBinaryPath !== "function")
128
+ return { kind: "cli" };
129
+ try {
130
+ const base = runtime.nativeEnv?.() ?? {};
131
+ if (options.env) {
132
+ runtime.resolveBinaryPath({ env: { ...base, ...options.env } });
133
+ }
134
+ else {
135
+ runtime.resolveBinaryPath({ env: base });
136
+ }
137
+ return { kind: "cli" };
138
+ }
139
+ catch (cause) {
140
+ return {
141
+ kind: "unavailable",
142
+ cause: cause instanceof Error ? cause.message : String(cause),
143
+ };
144
+ }
145
+ };
146
+ const requireBackend = (availability, context) => {
147
+ if (availability.kind !== "unavailable")
148
+ return;
149
+ throw new RuntimeError("BACKEND_UNAVAILABLE", "ast-sgrep backend unavailable (no NAPI session and no CLI binary)", {
150
+ backend: "unavailable",
151
+ // Agent-facing mirrors of the closed unavailable variant (not an open product).
152
+ napi: false,
153
+ cli: false,
154
+ cwd: context.cwd,
155
+ hint: "Install @ast-sgrep/<platform> or run npm run build:native in packages/pi/extension",
156
+ ...(availability.cause ? { cause: availability.cause } : {}),
157
+ });
158
+ };
159
+ const runCli = async (args, context, options = {}) => {
160
+ requireBackend(probeCli(options), context);
161
+ return runtime.run(args, context, options);
162
+ };
163
+ /** Typed sticky call that degrades to null when no backend is available or
164
+ * the session is crash-looping — the caller's CLI fallback owns the error
165
+ * fidelity when the binary itself is also broken. pool.call owns the
166
+ * respawn-on-closed retry; we only translate its outcomes. */
167
+ const callSticky = async (root, tool, args, options = {}) => {
168
+ try {
169
+ return await pool.call(root, tool, args, options.signal ? { signal: options.signal } : {});
170
+ }
171
+ catch (cause) {
172
+ // Aborted: return null so the CLI fallback surfaces the cancellation with
173
+ // its own semantics (runCli -> runtime.run maps abort to CANCELLED).
174
+ if (options.signal?.aborted)
175
+ return null;
176
+ const message = cause instanceof Error ? cause.message : String(cause);
177
+ if (isClosedWorkerError(cause) || message.includes("backend unavailable"))
178
+ return null;
179
+ throw cause;
180
+ }
181
+ };
182
+ const nativeCall = async (tool, args, context, options = {}) => {
183
+ ensurePool();
184
+ const root = await resolveRoot(context.cwd);
185
+ const sticky = await callSticky(root, tool, args, options);
186
+ if (sticky)
187
+ return asEnvelope(sticky);
188
+ // Cold CLI only when a real binary resolves -- never remap missing natives to BINARY_RESOLUTION_FAILED.
189
+ return runCli(argvFor(tool, args), context, options);
190
+ };
191
+ // Freshness + tools share the same warm in-process Searcher as Code Mode.
192
+ const warmRuntime = {
193
+ run: (args, context, options) => runtime.run(args, context, options),
194
+ resolveRoot: (context) => resolveRoot(context.cwd),
195
+ nativeCall,
196
+ };
197
+ // Optional runtime capabilities pass through when present — bound, since
198
+ // they are class methods whose private fields live on the runtime instance.
199
+ for (const key of ["watchExternalChanges", "resolveIndexPath", "inspectIndexCompatibility"]) {
200
+ const member = runtime[key];
201
+ if (member !== undefined) {
202
+ warmRuntime[key] =
203
+ typeof member === "function" ? member.bind(runtime) : member;
204
+ }
205
+ }
206
+ if (runtime.rebuildIncompatibleIndex) {
207
+ warmRuntime.rebuildIncompatibleIndex = async (context, options) => {
208
+ const root = await resolveRoot(context.cwd);
209
+ await pool.invalidate(root);
210
+ return runtime.rebuildIncompatibleIndex(context, options);
211
+ };
212
+ }
213
+ /** Freshness gate shared by the one-shot tools: ensureFresh with a bounded
214
+ * timeout fallback, or a scoped-path index when the query carries in:/fileFilter. */
215
+ const freshRoot = async (cwd, signal, scopedPath) => {
216
+ const options = signal ? { signal } : {};
217
+ if (scopedPath) {
218
+ const root = await resolveRoot(cwd);
219
+ try {
220
+ await nativeCall("index_repo", { paths: [scopedPath] }, { cwd }, options);
221
+ }
222
+ catch (cause) {
223
+ if (!isFreshnessTimeout(cause, signal))
224
+ throw cause;
225
+ await pool.invalidate(root).catch(() => undefined);
226
+ }
227
+ return root;
228
+ }
229
+ try {
230
+ return await freshness.ensureFresh(warmRuntime, { cwd }, options);
231
+ }
232
+ catch (cause) {
233
+ if (!isFreshnessTimeout(cause, signal))
234
+ throw cause;
235
+ const root = await resolveRoot(cwd);
236
+ await pool.invalidate(root).catch(() => undefined);
237
+ return root;
238
+ }
239
+ };
240
+ /** Typed sticky call first, argv fallback when no session — the shape every
241
+ * one-shot tool shares. */
242
+ const machineCall = async (root, tool, stickyArgs, argv, context, options) => (await callSticky(root, tool, stickyArgs, options)) ?? await runCli(argv, context, options);
243
+ /** Native launch env + resolved binary for the codemode batch host. */
244
+ const nativeLaunch = () => {
245
+ const env = runtime.nativeEnv?.() ?? { NO_COLOR: "1" };
246
+ let binary = null;
247
+ try {
248
+ binary = runtime.resolveBinaryPath?.({ env }) ?? null;
249
+ }
250
+ catch {
251
+ binary = null;
252
+ }
253
+ return { env, binary };
254
+ };
255
+ /** Host surface for codemode: argv run + optional sticky + stdin batch. */
256
+ const buildBatchHost = (sticky, env, binary) => {
257
+ const host = {
258
+ run: (args, context, runOptions) => runtime.run(args, context, runOptions ?? {}),
259
+ sticky,
260
+ };
261
+ if (binary) {
262
+ host.runBatch = (calls, context, runOptions) => runNativeBatch((a, c, o) => runtime.run(a, c, o ?? {}), calls, context, runOptions, (body, c, o) => {
263
+ const stdinOpts = { binary, cwd: c.cwd, body, env };
264
+ if (o?.signal)
265
+ stdinOpts.signal = o.signal;
266
+ if (runtime.config?.timeoutMs !== undefined)
267
+ stdinOpts.timeoutMs = runtime.config.timeoutMs;
268
+ if (runtime.config?.maxOutputBytes !== undefined)
269
+ stdinOpts.maxOutputBytes = runtime.config.maxOutputBytes;
270
+ return runBatchViaStdin(stdinOpts);
271
+ });
272
+ }
273
+ return host;
274
+ };
275
+ let stopWorkspaceEvents;
276
+ function watchWorkspaceChanges() {
277
+ stopWorkspaceEvents ??= pi.events?.on("workspace:changed", (data) => {
278
+ if (!data || typeof data !== "object")
279
+ return;
280
+ const event = data;
281
+ if (event.version !== 1 || typeof event.cwd !== "string" || !isAbsolute(event.cwd))
282
+ return;
283
+ if (event.paths === null) {
284
+ freshness.markRootDirty?.(event.cwd);
285
+ }
286
+ else if (Array.isArray(event.paths) && event.paths.every(p => typeof p === "string" && isAbsolute(p))) {
287
+ for (const file of event.paths)
288
+ freshness.markAffectedPath(file, event.cwd);
289
+ }
290
+ });
291
+ }
292
+ watchWorkspaceChanges();
293
+ pi.on("tool_result", (event, ctx) => {
294
+ if (event.isError)
295
+ return;
296
+ if (event.toolName !== "write" && event.toolName !== "edit")
297
+ return;
298
+ const path = event.input.path;
299
+ if (typeof path === "string")
300
+ freshness.markAffectedPath(path, ctx.cwd);
301
+ });
302
+ pi.on("session_start", (_event, ctx) => {
303
+ watchWorkspaceChanges();
304
+ // Warm the in-process Searcher at session start so the first asgrep
305
+ // search does not pay NAPI/SQLite open on the user's first lookup.
306
+ void (async () => {
307
+ try {
308
+ ensurePool();
309
+ const root = await resolveRoot(ctx.cwd);
310
+ await Promise.all([pool.acquire(root), warmCodemodeSandbox()]);
311
+ }
312
+ catch {
313
+ // Doctor reports backend errors; a failed warmup must not block the session.
314
+ }
315
+ })();
316
+ });
317
+ pi.on("session_shutdown", () => {
318
+ stopWorkspaceEvents?.();
319
+ stopWorkspaceEvents = undefined;
320
+ freshness.shutdown?.();
321
+ void pool.shutdown();
322
+ void resetCodemodeSandboxForTests();
323
+ });
324
+ // Primary surface: Code Mode -- in-process NAPI (MCP-class), compose in JS.
325
+ // Sibling to MCP: pick one surface; both link core, never each other.
326
+ pi.registerTool({
327
+ name: "asgrep",
328
+ label: "asgrep",
329
+ promptSnippet: ASGREP_PROMPT_SNIPPET,
330
+ promptGuidelines: [...ASGREP_PROMPT_GUIDELINES],
331
+ description: [
332
+ "Primary code-search tool for this project. Call it whenever you need to find, trace, or understand code — do not wait for the user to mention asgrep.",
333
+ "Write JavaScript that calls asgrep.search, asgrep.defs, asgrep.callers, asgrep.read, and asgrep.edit. Positional args work: search(\"auth\"), defs(\"Foo\"). Compose with await / Promise.all, filter in code, return only the shaped final value.",
334
+ "Runs in-process (native addon) with a warm Searcher for the Pi session.",
335
+ "",
336
+ CODEMODE_TYPES_FOR_MODEL,
337
+ "",
338
+ "Example:",
339
+ "async () => {",
340
+ " const seed = await asgrep.search('auth refresh', { limit: 5 });",
341
+ " const hit = seed.hits?.[0];",
342
+ " if (!hit?.symbol) return { seed, next: seed.suggested_next };",
343
+ " const [defs, window] = await Promise.all([",
344
+ " asgrep.defs(hit.symbol, { limit: 5 }),",
345
+ " asgrep.read({ refs: [hit.ref] }),",
346
+ " ]);",
347
+ " return { symbol: hit.symbol, defs: defs.hits, window };",
348
+ "}",
349
+ ].join("\n"),
350
+ parameters: codemodeParameters,
351
+ renderCall(args, theme, context) {
352
+ return presentText(formatCodemodeCall(args.code, theme), context.lastComponent);
353
+ },
354
+ renderResult(result, options, theme, context) {
355
+ return renderAsgrepResult(result, options, theme, context);
356
+ },
357
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
358
+ report(onUpdate, "codemode", "started");
359
+ try {
360
+ const timeoutMs = typeof params.timeoutMs === "number"
361
+ ? params.timeoutMs
362
+ : runtime.config?.timeoutMs ?? 30_000;
363
+ const deadline = Date.now() + timeoutMs;
364
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
365
+ const operationSignal = signal
366
+ ? AbortSignal.any([signal, timeoutSignal])
367
+ : timeoutSignal;
368
+ const options = { signal: operationSignal };
369
+ ensurePool();
370
+ const root = await freshRoot(ctx.cwd, signal);
371
+ const { env, binary } = nativeLaunch();
372
+ // In-process NAPI first; CLI sticky only if addon missing.
373
+ const sticky = await pool.acquire(root);
374
+ const bundle = createAsgrepConnector(buildBatchHost(sticky, env, binary), { cwd: ctx.cwd }, options);
375
+ bundle.resetStats();
376
+ const codemodeOptions = { stats: bundle.stats };
377
+ codemodeOptions.timeoutMs = Math.max(1, deadline - Date.now());
378
+ codemodeOptions.signal = operationSignal;
379
+ await warmCodemodeSandbox().catch(() => undefined);
380
+ const outcome = await runCodemode(params.code, bundle.asgrep, codemodeOptions);
381
+ report(onUpdate, "codemode", "completed");
382
+ if (!outcome.ok) {
383
+ return {
384
+ content: [{ type: "text", text: bounded(`codemode failed: ${outcome.error}`) }],
385
+ details: {
386
+ ok: false,
387
+ command: "codemode",
388
+ error: { code: "CODEMODE_ERROR", message: outcome.error, details: { logs: outcome.logs, stats: outcome.stats } },
389
+ code: outcome.code,
390
+ stats: outcome.stats,
391
+ trace: bundle.trace(),
392
+ wallMs: outcome.wallMs,
393
+ backend: pool.backend(),
394
+ },
395
+ };
396
+ }
397
+ const rendered = formatCodemodeResult(outcome.result, {
398
+ ...(outcome.stats ? { stats: outcome.stats } : {}),
399
+ wallMs: outcome.wallMs,
400
+ backend: pool.backend(),
401
+ });
402
+ const activationMs = outcome.wallMs;
403
+ return {
404
+ content: [{ type: "text", text: bounded(rendered) }],
405
+ details: {
406
+ ok: true,
407
+ command: "codemode",
408
+ result: outcome.result,
409
+ logs: outcome.logs,
410
+ rendered,
411
+ stats: outcome.stats,
412
+ trace: bundle.trace(),
413
+ wallMs: outcome.wallMs,
414
+ activationMs,
415
+ backend: pool.backend(),
416
+ },
417
+ };
418
+ }
419
+ catch (cause) {
420
+ return failure("codemode", cause, signal);
421
+ }
422
+ },
423
+ });
424
+ // Escape hatches: one-shot tools for simple lookups. Prefer asgrep.
425
+ // They ride the same session sticky pool when available (no cold spawn).
426
+ pi.registerTool({
427
+ name: "asgrep_search",
428
+ label: "asgrep search",
429
+ promptSnippet: "One-shot asgrep search (natural, defs, callers, pattern, chain, semantic)",
430
+ description: "One-shot search. Prefer asgrep for anything multi-step, parallel, or filtered. Call this on your own whenever a single lookup is enough.",
431
+ parameters: searchParameters,
432
+ renderCall(args, theme, context) {
433
+ return presentText(formatSearchCall(args, theme), context.lastComponent);
434
+ },
435
+ renderResult(result, options, theme, context) {
436
+ return renderAsgrepResult(result, options, theme, context);
437
+ },
438
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
439
+ const options = signal ? { signal } : {};
440
+ const started = performance.now();
441
+ report(onUpdate, "search", "started");
442
+ try {
443
+ ensurePool();
444
+ const scopedPath = (typeof params.in === "string" ? params.in : undefined)
445
+ ?? (typeof params.fileFilter === "string" ? params.fileFilter : undefined)
446
+ ?? extractInPath(params.query);
447
+ const root = await freshRoot(ctx.cwd, signal, scopedPath);
448
+ const [tool, args] = searchToolCall(params);
449
+ const response = await machineCall(root, tool, args, searchArgs(params), { cwd: ctx.cwd }, options);
450
+ report(onUpdate, "search", "completed");
451
+ return success("search", response, {
452
+ query: params.query,
453
+ mode: params.mode ?? "natural",
454
+ activationMs: performance.now() - started,
455
+ backend: pool.backend(),
456
+ });
457
+ }
458
+ catch (cause) {
459
+ return failure("search", cause, signal);
460
+ }
461
+ },
462
+ });
463
+ // Trained-priorty escape hatches: direct edit/read without writing JS.
464
+ // Both ride the connector's arg plumbing and the same sticky pool.
465
+ pi.registerTool({
466
+ name: "asgrep_edit",
467
+ label: "asgrep edit",
468
+ promptSnippet: "Edit a file by exact-string replace (asgrep_edit; use asgrep for multi-step)",
469
+ description: "Edit a file by exact-string replace — oldText must match exactly once. edits[] applies many edits atomically. Prefer the asgrep tool for anything multi-step or filtered.",
470
+ parameters: editParameters,
471
+ renderCall(args, theme, context) {
472
+ return presentText(formatEditCall(args, theme), context.lastComponent);
473
+ },
474
+ renderResult(result, options, theme, context) {
475
+ return renderAsgrepResult(result, options, theme, context);
476
+ },
477
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
478
+ report(onUpdate, "edit", "started");
479
+ try {
480
+ ensurePool();
481
+ const options = signal ? { signal } : {};
482
+ const root = await freshRoot(ctx.cwd, signal);
483
+ const sticky = await pool.acquire(root);
484
+ const bundle = createAsgrepConnector({ run: (a, c, o) => runtime.run(a, c, o), sticky }, { cwd: ctx.cwd }, options);
485
+ const response = await bundle.asgrep.edit(params);
486
+ report(onUpdate, "edit", "completed");
487
+ return success("edit", response, { backend: pool.backend() });
488
+ }
489
+ catch (cause) {
490
+ return failure("edit", cause, signal);
491
+ }
492
+ },
493
+ });
494
+ pi.registerTool({
495
+ name: "asgrep_read",
496
+ label: "asgrep read",
497
+ promptSnippet: "Read file windows or hit refs (asgrep_read; use asgrep for multi-step)",
498
+ description: "Read a file window or resolve hit refs (path#L1-L40) into content. Prefer the asgrep tool for composed lookups.",
499
+ parameters: readParameters,
500
+ renderCall(args, theme, context) {
501
+ return presentText(formatReadCall(args, theme), context.lastComponent);
502
+ },
503
+ renderResult(result, options, theme, context) {
504
+ return renderAsgrepResult(result, options, theme, context);
505
+ },
506
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
507
+ report(onUpdate, "read", "started");
508
+ try {
509
+ ensurePool();
510
+ const options = signal ? { signal } : {};
511
+ const root = await freshRoot(ctx.cwd, signal);
512
+ const sticky = await pool.acquire(root);
513
+ const bundle = createAsgrepConnector({ run: (a, c, o) => runtime.run(a, c, o), sticky }, { cwd: ctx.cwd }, options);
514
+ const response = await bundle.asgrep.read(params);
515
+ report(onUpdate, "read", "completed");
516
+ return success("read", response, { backend: pool.backend() });
517
+ }
518
+ catch (cause) {
519
+ return failure("read", cause, signal);
520
+ }
521
+ },
522
+ });
523
+ pi.registerTool({
524
+ name: "asgrep_index",
525
+ label: "asgrep index",
526
+ promptSnippet: "Build or rebuild the asgrep index",
527
+ description: "Build or rebuild the index. Prefer asgrep.indexRepo inside asgrep.",
528
+ parameters: indexParameters,
529
+ renderCall(args, theme, context) {
530
+ return presentText(formatIndexCall(args.force === true, theme), context.lastComponent);
531
+ },
532
+ renderResult(result, options, theme, context) {
533
+ return renderAsgrepResult(result, options, theme, context);
534
+ },
535
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
536
+ const force = params.force === true;
537
+ const command = force ? "reindex" : "index";
538
+ report(onUpdate, command, "started");
539
+ try {
540
+ ensurePool();
541
+ const options = signal ? { signal } : {};
542
+ const root = await freshRoot(ctx.cwd, signal);
543
+ const response = await machineCall(root, "index_repo", { force }, [command, ".", "--json"], { cwd: ctx.cwd }, options);
544
+ report(onUpdate, command, "completed");
545
+ return success(command, response);
546
+ }
547
+ catch (cause) {
548
+ return failure(command, cause, signal);
549
+ }
550
+ },
551
+ });
552
+ pi.registerTool({
553
+ name: "asgrep_status",
554
+ label: "asgrep status",
555
+ promptSnippet: "asgrep index and backend status",
556
+ description: "Index/runtime status. Prefer asgrep.indexStatus inside asgrep.",
557
+ parameters: statusParameters,
558
+ renderCall(_args, theme, context) {
559
+ return presentText(formatStatusCall(theme), context.lastComponent);
560
+ },
561
+ renderResult(result, options, theme, context) {
562
+ return renderAsgrepResult(result, options, theme, context);
563
+ },
564
+ async execute(_toolCallId, _params, signal, onUpdate, ctx) {
565
+ report(onUpdate, "status", "started");
566
+ try {
567
+ ensurePool();
568
+ const options = signal ? { signal } : {};
569
+ const root = await freshRoot(ctx.cwd, signal);
570
+ const response = await machineCall(root, "index_status", {}, ["status", ".", "--json"], { cwd: ctx.cwd }, options);
571
+ report(onUpdate, "status", "completed");
572
+ return success("status", response);
573
+ }
574
+ catch (cause) {
575
+ return failure("status", cause, signal);
576
+ }
577
+ },
578
+ });
579
+ }
580
+ const SEARCH_CALL_SPEC = {
581
+ semantic: { tool: "semantic" },
582
+ chain: { tool: "chain" },
583
+ defs: { tool: "defs", key: "symbol" },
584
+ callers: { tool: "callers", key: "symbol" },
585
+ imports: { tool: "imports", key: "module" },
586
+ pattern: { tool: "search", prefix: "pattern" },
587
+ word: { tool: "search", prefix: "word" },
588
+ literal: { tool: "search", prefix: "literal" },
589
+ regex: { tool: "search", prefix: "regex" },
590
+ natural: { tool: "search" },
591
+ };
592
+ function searchToolCall(params) {
593
+ const mode = params.mode ?? "natural";
594
+ const limit = params.limit ?? DEFAULT_LIMIT;
595
+ const excerpt_lines = params.excerptLines ?? 0;
596
+ const query = scopedSearchQuery(params);
597
+ const spec = SEARCH_CALL_SPEC[mode];
598
+ const lang = typeof params.lang === "string" ? params.lang.trim() : "";
599
+ if (spec.tool === "semantic") {
600
+ return ["semantic", { query, limit, excerpt_lines, format: "capsule", ...(lang ? { lang } : {}) }];
601
+ }
602
+ if (spec.tool === "chain") {
603
+ return ["chain", { query, limit, top_n: 20 }];
604
+ }
605
+ if (spec.tool === "search") {
606
+ const prefixed = spec.prefix ? `${spec.prefix}: ${query}` : query;
607
+ return ["search", { query: prefixed, limit, excerpt_lines, format: "capsule", ...(lang ? { lang } : {}) }];
608
+ }
609
+ return [spec.tool, { [spec.key]: params.query, limit, excerpt_lines, ...(lang ? { lang } : {}) }];
610
+ }
package/dist/index.d.ts CHANGED
@@ -1,36 +1,9 @@
1
+ /**
2
+ * pi-ast-sgrep extension entry: constructs the runtime + freshness
3
+ * coordinator and registers tools + commands. Implementation lives in
4
+ * tools.ts / commands.ts; result plumbing in envelope.ts.
5
+ */
1
6
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { FreshnessCoordinator, type MachineEnvelope, type RunOptions } from "./runtime.js";
3
- type RuntimeLike = {
4
- run(args: readonly string[], context: {
5
- cwd: string;
6
- }, options?: RunOptions): Promise<MachineEnvelope>;
7
- resolveRoot?(context: {
8
- cwd: string;
9
- }): Promise<string>;
10
- resolveBinaryPath?(options?: {
11
- env?: NodeJS.ProcessEnv;
12
- }): string;
13
- nativeEnv?(options?: {
14
- env?: NodeJS.ProcessEnv;
15
- }): NodeJS.ProcessEnv;
16
- config?: {
17
- timeoutMs?: number;
18
- maxOutputBytes?: number;
19
- refreshIntervalMs?: number;
20
- };
21
- inspectIndexCompatibility?(context: {
22
- cwd: string;
23
- }): Promise<"ready" | "missing" | "incompatible">;
24
- rebuildIncompatibleIndex?(context: {
25
- cwd: string;
26
- }, options?: RunOptions): Promise<MachineEnvelope>;
27
- resolveIndexPath?(root: string): string;
28
- watchExternalChanges?: boolean;
29
- };
30
- type FreshnessLike = Pick<FreshnessCoordinator, "ensureFresh" | "markAffectedPath"> & {
31
- shutdown?(): void;
32
- };
33
- export declare function registerAstSgrepTools(pi: ExtensionAPI, runtime?: RuntimeLike, freshness?: FreshnessLike): void;
34
- export declare function registerAstSgrepCommands(pi: ExtensionAPI, runtime?: RuntimeLike): void;
7
+ export { registerAstSgrepTools } from "./host/tools.js";
8
+ export { registerAstSgrepCommands } from "./host/commands.js";
35
9
  export default function astSgrepExtension(pi: ExtensionAPI): void;
36
- export {};