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