pi-ast-sgrep 2.1.1 → 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.
@@ -1,63 +1,57 @@
1
- import { isAbsolute } from "node:path";
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
2
3
  import { Type } from "typebox";
3
4
  import { createAsgrepConnector, runCodemode, runNativeBatch, runBatchViaStdin, CODEMODE_TYPES_FOR_MODEL, NativeSessionPool, argvFor, asEnvelope, applyQueryScope, warmCodemodeSandbox, resetCodemodeSandboxForTests, isClosedWorkerError, } from "../codemode/index.js";
4
5
  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";
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";
7
9
  import { bounded, errorDetails, failure, isFreshnessTimeout, extractInPath, report, success, } from "./results.js";
8
10
  export const DEFAULT_LIMIT = 8;
9
11
  const MAX_LIMIT = 100;
10
12
  const MAX_EXCERPT_LINES = 100;
11
13
  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" })),
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)" })),
30
25
  }, { additionalProperties: false });
31
26
  const indexParameters = Type.Object({
32
27
  force: Type.Optional(Type.Boolean({ default: false, description: "Rebuild the index from scratch" })),
33
28
  }, { additionalProperties: false });
34
- const statusParameters = Type.Object({}, { additionalProperties: false });
35
29
  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" })),
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" })),
39
33
  edits: Type.Optional(Type.Array(Type.Object({
40
34
  path: Type.Optional(Type.String({ minLength: 1, maxLength: 512 })),
41
35
  oldText: Type.String({ minLength: 1 }),
42
36
  newText: Type.String(),
43
- }), { maxItems: 64, description: "Multi-edit entries; top-level path is the default for entries that omit it" })),
37
+ }), { maxItems: 64, description: "Multi-edit entries; top-level path is the default" })),
44
38
  }, { additionalProperties: false });
45
39
  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 })),
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()),
53
47
  }, { additionalProperties: false });
54
48
  const codemodeParameters = Type.Object({
55
49
  code: Type.String({
56
50
  minLength: 1,
57
51
  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.",
52
+ description: "JavaScript: async () => { ... } or a bare body with return. The returned value is the tool result.",
59
53
  }),
60
- timeoutMs: Type.Optional(Type.Integer({ minimum: 1_000, maximum: 120_000, description: "Hard timeout in ms (default 30000)" })),
54
+ timeoutMs: Type.Optional(Type.Integer({ description: "Timeout ms (default 30000)" })),
61
55
  }, { additionalProperties: false });
62
56
  function queryForMode(query, mode) {
63
57
  if (mode === "pattern" || mode === "defs" || mode === "callers" || mode === "word" || mode === "literal" || mode === "regex" || mode === "imports") {
@@ -83,6 +77,59 @@ function withSearchLang(argv, lang) {
83
77
  const trimmed = lang?.trim();
84
78
  return trimmed ? ["--lang", trimmed, ...argv] : argv;
85
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
+ }
86
133
  export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), freshness = runtime instanceof AstSgrepRuntime
87
134
  ? new FreshnessCoordinator({ refreshIntervalMs: runtime.config.refreshIntervalMs })
88
135
  : new FreshnessCoordinator()) {
@@ -121,7 +168,96 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
121
168
  }
122
169
  poolConfigured = true;
123
170
  };
124
- const resolveRoot = async (cwd) => runtime.resolveRoot ? await runtime.resolveRoot({ cwd }) : cwd;
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
+ };
125
261
  const probeCli = (options = {}) => {
126
262
  // Test fixtures inject `run` without a resolver; production always has resolveBinaryPath.
127
263
  if (typeof runtime.resolveBinaryPath !== "function")
@@ -181,7 +317,13 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
181
317
  };
182
318
  const nativeCall = async (tool, args, context, options = {}) => {
183
319
  ensurePool();
184
- const root = await resolveRoot(context.cwd);
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);
185
327
  const sticky = await callSticky(root, tool, args, options);
186
328
  if (sticky)
187
329
  return asEnvelope(sticky);
@@ -191,7 +333,7 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
191
333
  // Freshness + tools share the same warm in-process Searcher as Code Mode.
192
334
  const warmRuntime = {
193
335
  run: (args, context, options) => runtime.run(args, context, options),
194
- resolveRoot: (context) => resolveRoot(context.cwd),
336
+ resolveRoot: (context) => resolveRoot(context),
195
337
  nativeCall,
196
338
  };
197
339
  // Optional runtime capabilities pass through when present — bound, since
@@ -205,38 +347,76 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
205
347
  }
206
348
  if (runtime.rebuildIncompatibleIndex) {
207
349
  warmRuntime.rebuildIncompatibleIndex = async (context, options) => {
208
- const root = await resolveRoot(context.cwd);
350
+ const root = await resolveRoot(context);
209
351
  await pool.invalidate(root);
210
352
  return runtime.rebuildIncompatibleIndex(context, options);
211
353
  };
212
354
  }
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. */
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
+ */
215
365
  const freshRoot = async (cwd, signal, scopedPath) => {
216
366
  const options = signal ? { signal } : {};
217
- if (scopedPath) {
218
- const root = await resolveRoot(cwd);
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) {
219
372
  try {
220
- await nativeCall("index_repo", { paths: [scopedPath] }, { cwd }, options);
373
+ await nativeCall("index_repo", { paths: [target] }, rootedAt(anchor.root), options);
221
374
  }
222
375
  catch (cause) {
223
376
  if (!isFreshnessTimeout(cause, signal))
224
377
  throw cause;
225
- await pool.invalidate(root).catch(() => undefined);
378
+ return { root: anchor.root, ...(scope ? { scope } : {}), freshness: "stale" };
226
379
  }
227
- return root;
380
+ return { root: anchor.root, ...(scope ? { scope } : {}) };
228
381
  }
229
382
  try {
230
- return await freshness.ensureFresh(warmRuntime, { cwd }, options);
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 } : {}) };
231
388
  }
232
389
  catch (cause) {
233
390
  if (!isFreshnessTimeout(cause, signal))
234
391
  throw cause;
235
- const root = await resolveRoot(cwd);
236
- await pool.invalidate(root).catch(() => undefined);
237
- return root;
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;
238
417
  }
239
418
  };
419
+ const zeroHitResponse = (response) => response.ok !== false && Array.isArray(response.hits) && response.hits.length === 0;
240
420
  /** Typed sticky call first, argv fallback when no session — the shape every
241
421
  * one-shot tool shares. */
242
422
  const machineCall = async (root, tool, stickyArgs, argv, context, options) => (await callSticky(root, tool, stickyArgs, options)) ?? await runCli(argv, context, options);
@@ -301,13 +481,24 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
301
481
  });
302
482
  pi.on("session_start", (_event, ctx) => {
303
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);
304
488
  // Warm the in-process Searcher at session start so the first asgrep
305
489
  // search does not pay NAPI/SQLite open on the user's first lookup.
306
490
  void (async () => {
307
491
  try {
308
492
  ensurePool();
309
- const root = await resolveRoot(ctx.cwd);
493
+ const root = await resolveRoot({ cwd: ctx.cwd });
310
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);
311
502
  }
312
503
  catch {
313
504
  // Doctor reports backend errors; a failed warmup must not block the session.
@@ -329,27 +520,17 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
329
520
  promptSnippet: ASGREP_PROMPT_SNIPPET,
330
521
  promptGuidelines: [...ASGREP_PROMPT_GUIDELINES],
331
522
  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
- "",
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.",
336
525
  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
- "}",
526
+ "Example: async () => (await asgrep.search(\"auth\", { limit: 5 })).hits",
349
527
  ].join("\n"),
350
528
  parameters: codemodeParameters,
351
- renderCall(args, theme, context) {
352
- return presentText(formatCodemodeCall(args.code, theme), context.lastComponent);
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;
353
534
  },
354
535
  renderResult(result, options, theme, context) {
355
536
  return renderAsgrepResult(result, options, theme, context);
@@ -367,11 +548,11 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
367
548
  : timeoutSignal;
368
549
  const options = { signal: operationSignal };
369
550
  ensurePool();
370
- const root = await freshRoot(ctx.cwd, signal);
551
+ const { root, scope, freshness: fresh } = await freshRoot(ctx.cwd, signal);
371
552
  const { env, binary } = nativeLaunch();
372
553
  // In-process NAPI first; CLI sticky only if addon missing.
373
554
  const sticky = await pool.acquire(root);
374
- const bundle = createAsgrepConnector(buildBatchHost(sticky, env, binary), { cwd: ctx.cwd }, options);
555
+ const bundle = createAsgrepConnector(buildBatchHost(sticky, env, binary), rootedAt(root), { ...options, ...(scope ? { scope } : {}) });
375
556
  bundle.resetStats();
376
557
  const codemodeOptions = { stats: bundle.stats };
377
558
  codemodeOptions.timeoutMs = Math.max(1, deadline - Date.now());
@@ -413,6 +594,7 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
413
594
  wallMs: outcome.wallMs,
414
595
  activationMs,
415
596
  backend: pool.backend(),
597
+ ...(fresh ? { freshness: fresh } : {}),
416
598
  },
417
599
  };
418
600
  }
@@ -426,11 +608,12 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
426
608
  pi.registerTool({
427
609
  name: "asgrep_search",
428
610
  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.",
611
+ promptSnippet: "One-shot asgrep search",
612
+ description: "One-shot search. Use asgrep (Code Mode) for anything multi-step, parallel, or filtered.",
431
613
  parameters: searchParameters,
432
- renderCall(args, theme, context) {
433
- return presentText(formatSearchCall(args, theme), context.lastComponent);
614
+ renderShell: "self",
615
+ renderCall() {
616
+ return EMPTY_CALL;
434
617
  },
435
618
  renderResult(result, options, theme, context) {
436
619
  return renderAsgrepResult(result, options, theme, context);
@@ -442,17 +625,43 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
442
625
  try {
443
626
  ensurePool();
444
627
  const scopedPath = (typeof params.in === "string" ? params.in : undefined)
445
- ?? (typeof params.fileFilter === "string" ? params.fileFilter : undefined)
446
628
  ?? 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);
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
+ }
450
653
  report(onUpdate, "search", "completed");
451
654
  return success("search", response, {
452
655
  query: params.query,
453
656
  mode: params.mode ?? "natural",
454
657
  activationMs: performance.now() - started,
455
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 } : {}),
456
665
  });
457
666
  }
458
667
  catch (cause) {
@@ -465,11 +674,12 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
465
674
  pi.registerTool({
466
675
  name: "asgrep_edit",
467
676
  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.",
677
+ promptSnippet: "Exact-string edit",
678
+ description: "Edit by exact-string replace; oldText must match once. edits[] applies many atomically.",
470
679
  parameters: editParameters,
471
- renderCall(args, theme, context) {
472
- return presentText(formatEditCall(args, theme), context.lastComponent);
680
+ renderShell: "self",
681
+ renderCall() {
682
+ return EMPTY_CALL;
473
683
  },
474
684
  renderResult(result, options, theme, context) {
475
685
  return renderAsgrepResult(result, options, theme, context);
@@ -479,12 +689,12 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
479
689
  try {
480
690
  ensurePool();
481
691
  const options = signal ? { signal } : {};
482
- const root = await freshRoot(ctx.cwd, signal);
692
+ const { root, scope, freshness: fresh } = await freshRoot(ctx.cwd, signal);
483
693
  const sticky = await pool.acquire(root);
484
- const bundle = createAsgrepConnector({ run: (a, c, o) => runtime.run(a, c, o), sticky }, { cwd: ctx.cwd }, options);
694
+ const bundle = createAsgrepConnector({ run: (a, c, o) => runtime.run(a, c, o), sticky }, rootedAt(root), { ...options, ...(scope ? { scope } : {}) });
485
695
  const response = await bundle.asgrep.edit(params);
486
696
  report(onUpdate, "edit", "completed");
487
- return success("edit", response, { backend: pool.backend() });
697
+ return success("edit", response, { backend: pool.backend(), ...(fresh ? { freshness: fresh } : {}) });
488
698
  }
489
699
  catch (cause) {
490
700
  return failure("edit", cause, signal);
@@ -494,11 +704,12 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
494
704
  pi.registerTool({
495
705
  name: "asgrep_read",
496
706
  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.",
707
+ promptSnippet: "Read file window or hit ref",
708
+ description: "Read a file window or resolve hit refs (path#L1-L40).",
499
709
  parameters: readParameters,
500
- renderCall(args, theme, context) {
501
- return presentText(formatReadCall(args, theme), context.lastComponent);
710
+ renderShell: "self",
711
+ renderCall() {
712
+ return EMPTY_CALL;
502
713
  },
503
714
  renderResult(result, options, theme, context) {
504
715
  return renderAsgrepResult(result, options, theme, context);
@@ -508,12 +719,12 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
508
719
  try {
509
720
  ensurePool();
510
721
  const options = signal ? { signal } : {};
511
- const root = await freshRoot(ctx.cwd, signal);
722
+ const { root, scope, freshness: fresh } = await freshRoot(ctx.cwd, signal);
512
723
  const sticky = await pool.acquire(root);
513
- const bundle = createAsgrepConnector({ run: (a, c, o) => runtime.run(a, c, o), sticky }, { cwd: ctx.cwd }, options);
724
+ const bundle = createAsgrepConnector({ run: (a, c, o) => runtime.run(a, c, o), sticky }, rootedAt(root), { ...options, ...(scope ? { scope } : {}) });
514
725
  const response = await bundle.asgrep.read(params);
515
726
  report(onUpdate, "read", "completed");
516
- return success("read", response, { backend: pool.backend() });
727
+ return success("read", response, { backend: pool.backend(), ...(fresh ? { freshness: fresh } : {}) });
517
728
  }
518
729
  catch (cause) {
519
730
  return failure("read", cause, signal);
@@ -523,11 +734,12 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
523
734
  pi.registerTool({
524
735
  name: "asgrep_index",
525
736
  label: "asgrep index",
526
- promptSnippet: "Build or rebuild the asgrep index",
527
- description: "Build or rebuild the index. Prefer asgrep.indexRepo inside asgrep.",
737
+ promptSnippet: "Build or rebuild the index",
738
+ description: "Build or rebuild the index (embeddings included).",
528
739
  parameters: indexParameters,
529
- renderCall(args, theme, context) {
530
- return presentText(formatIndexCall(args.force === true, theme), context.lastComponent);
740
+ renderShell: "self",
741
+ renderCall() {
742
+ return EMPTY_CALL;
531
743
  },
532
744
  renderResult(result, options, theme, context) {
533
745
  return renderAsgrepResult(result, options, theme, context);
@@ -539,43 +751,23 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
539
751
  try {
540
752
  ensurePool();
541
753
  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);
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);
544
760
  report(onUpdate, command, "completed");
545
- return success(command, response);
761
+ return success(command, response, { ...(fresh ? { freshness: fresh } : {}) });
546
762
  }
547
763
  catch (cause) {
548
764
  return failure(command, cause, signal);
549
765
  }
550
766
  },
551
767
  });
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
- });
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.
579
771
  }
580
772
  const SEARCH_CALL_SPEC = {
581
773
  semantic: { tool: "semantic" },