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.
- package/README.md +1 -0
- package/dist/codemode/connector.d.ts +1 -0
- package/dist/codemode/connector.js +32 -6
- package/dist/codemode/dispatch.js +4 -1
- package/dist/codemode/native.d.ts +1 -1
- package/dist/codemode/native.js +1 -1
- package/dist/codemode/types.d.ts +9 -0
- package/dist/codemode/types.js +12 -13
- package/dist/host/results.d.ts +8 -0
- package/dist/host/results.js +47 -1
- package/dist/host/tools.d.ts +19 -0
- package/dist/host/tools.js +319 -127
- package/dist/runtime/freshness.js +40 -7
- package/dist/runtime/index-health.d.ts +1 -1
- package/dist/runtime/index-health.js +4 -2
- package/dist/runtime/types.d.ts +1 -1
- package/dist/runtime/types.js +1 -1
- package/dist/ui/card.d.ts +4 -1
- package/dist/ui/card.js +125 -66
- package/dist/ui/present.d.ts +37 -36
- package/dist/ui/present.js +226 -154
- package/package.json +5 -4
package/dist/host/tools.js
CHANGED
|
@@ -1,63 +1,57 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
6
|
-
import {
|
|
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({
|
|
13
|
-
mode: Type.Optional(Type.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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({
|
|
37
|
-
oldText: Type.Optional(Type.String({ description: "Exact text to replace
|
|
38
|
-
newText: Type.Optional(Type.String({ description: "Replacement
|
|
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
|
|
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({
|
|
47
|
-
ref: Type.Optional(Type.String({
|
|
48
|
-
refs: Type.Optional(Type.Array(Type.String(
|
|
49
|
-
start: Type.Optional(Type.Integer(
|
|
50
|
-
end: Type.Optional(Type.Integer(
|
|
51
|
-
contextLines: Type.Optional(Type.Integer(
|
|
52
|
-
maxChars: Type.Optional(Type.Integer(
|
|
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.
|
|
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({
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
350
|
+
const root = await resolveRoot(context);
|
|
209
351
|
await pool.invalidate(root);
|
|
210
352
|
return runtime.rebuildIncompatibleIndex(context, options);
|
|
211
353
|
};
|
|
212
354
|
}
|
|
213
|
-
/**
|
|
214
|
-
*
|
|
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
|
-
|
|
218
|
-
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
"
|
|
333
|
-
"Write JavaScript
|
|
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
|
-
|
|
352
|
-
|
|
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), {
|
|
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
|
|
430
|
-
description: "One-shot search.
|
|
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
|
-
|
|
433
|
-
|
|
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
|
|
448
|
-
|
|
449
|
-
const
|
|
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: "
|
|
469
|
-
description: "Edit
|
|
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
|
-
|
|
472
|
-
|
|
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 }, {
|
|
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
|
|
498
|
-
description: "Read a file window or resolve hit refs (path#L1-L40)
|
|
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
|
-
|
|
501
|
-
|
|
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 }, {
|
|
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
|
|
527
|
-
description: "Build or rebuild the index
|
|
737
|
+
promptSnippet: "Build or rebuild the index",
|
|
738
|
+
description: "Build or rebuild the index (embeddings included).",
|
|
528
739
|
parameters: indexParameters,
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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" },
|