pi-ast-sgrep 1.3.2 → 2.0.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.
@@ -0,0 +1,432 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, open, realpath } from "node:fs/promises";
3
+ import { isAbsolute, relative, resolve, sep } from "node:path";
4
+ import { RuntimeError } from "./runtime.js";
5
+ const DEFAULT_LIMIT = 20;
6
+ const MAX_LIMIT = 100;
7
+ const MAX_EXCERPT_LINES = 100;
8
+ const DEFAULT_MAX_READ_CHARS = 100_000;
9
+ const MAX_READ_CHARS = 1_000_000;
10
+ const MAX_READ_REFS = 20;
11
+ const MAX_SCAN_BYTES = 64 * 1024 * 1024;
12
+ const MAX_LINE_CHARS = 2_000;
13
+ const DEVICE_PATHS = new Set([
14
+ "/dev/zero", "/dev/urandom", "/dev/random", "/dev/stdin",
15
+ "/dev/stdout", "/dev/stderr", "/dev/null", "/dev/fd/0", "/dev/fd/1", "/dev/fd/2",
16
+ ]);
17
+ function assertSafeReadPath(absolutePath) {
18
+ const normalized = absolutePath.replace(/\\/g, "/");
19
+ if (DEVICE_PATHS.has(normalized) || /^\/proc\/\d+\/fd\//.test(normalized)) {
20
+ throw new RuntimeError("READ_FORBIDDEN_PATH", `${absolutePath} is a device or process fd path and cannot be read`, { path: absolutePath });
21
+ }
22
+ }
23
+ const MAX_LINE_NUMBER = 0xffff_ffff;
24
+ const REF_PATTERN = /^(.+?)#L([1-9]\d*)-L([1-9]\d*)$/;
25
+ const KINDS = new Set(["asgrep", "def", "caller", "graph", "anchor", "import", "pattern", "embed"]);
26
+ const SIGNALS = new Set(["exact", "structural", "semantic"]);
27
+ function boundedInteger(value, fallback, minimum, maximum, name) {
28
+ if (value === undefined)
29
+ return fallback;
30
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
31
+ throw new RuntimeError("INVALID_ARGUMENT", `${name} must be an integer from ${minimum} to ${maximum}`);
32
+ }
33
+ return value;
34
+ }
35
+ function requiredText(value, name) {
36
+ if (typeof value !== "string" || value.trim().length === 0 || value.length > 4_096) {
37
+ throw new RuntimeError("INVALID_ARGUMENT", `${name} must contain 1 to 4096 characters`);
38
+ }
39
+ return value.trim();
40
+ }
41
+ function outputArgs(options) {
42
+ return [
43
+ "--json",
44
+ "--format",
45
+ "agent-capsule",
46
+ "--limit",
47
+ String(boundedInteger(options.limit, DEFAULT_LIMIT, 1, MAX_LIMIT, "limit")),
48
+ "--excerpt-lines",
49
+ String(boundedInteger(options.excerptLines, 0, 0, MAX_EXCERPT_LINES, "excerptLines")),
50
+ ];
51
+ }
52
+ function optionalTextField(field) {
53
+ return field === undefined || field === null || typeof field === "string";
54
+ }
55
+ function wireLinesValid(lines) {
56
+ return !!lines && typeof lines === "object"
57
+ && Number.isSafeInteger(lines.start)
58
+ && Number.isSafeInteger(lines.end)
59
+ && Number(lines.start) > 0
60
+ && Number(lines.end) >= Number(lines.start);
61
+ }
62
+ /** Parse wire location once: prefer branded `ref`; else derive from structured file/lines. */
63
+ function parseWireHitRef(hit) {
64
+ if (typeof hit.ref === "string") {
65
+ parseRef(hit.ref);
66
+ return hit.ref;
67
+ }
68
+ if (typeof hit.file === "string" && hit.file.length > 0 && !isAbsolute(hit.file) && wireLinesValid(hit.lines)) {
69
+ const start = Number(hit.lines.start);
70
+ const end = Number(hit.lines.end);
71
+ if (start > MAX_LINE_NUMBER || end > MAX_LINE_NUMBER) {
72
+ throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep returned an invalid search hit");
73
+ }
74
+ const ref = `${hit.file}#L${start}-L${end}`;
75
+ parseRef(ref);
76
+ return ref;
77
+ }
78
+ throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep returned an invalid search hit");
79
+ }
80
+ /** Wire hit shape gate: required protocol fields + optional text fields. Domain checks kept intact. */
81
+ function isValidHitShape(hit) {
82
+ return typeof hit.kind === "string" && KINDS.has(hit.kind)
83
+ && typeof hit.signal === "string" && SIGNALS.has(hit.signal)
84
+ && Array.isArray(hit.contributors) && hit.contributors.length > 0
85
+ && hit.contributors.every((kind) => typeof kind === "string" && KINDS.has(kind))
86
+ && typeof hit.score === "number" && Number.isFinite(hit.score)
87
+ && typeof hit.margin === "number" && Number.isFinite(hit.margin) && hit.margin >= 0
88
+ && typeof hit.preview === "string"
89
+ && optionalTextField(hit.symbol) && optionalTextField(hit.caller) && optionalTextField(hit.callee)
90
+ && optionalTextField(hit.language) && optionalTextField(hit.excerpt);
91
+ }
92
+ function parseSearchHit(candidate) {
93
+ if (!candidate || typeof candidate !== "object") {
94
+ throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep returned an invalid search hit");
95
+ }
96
+ const hit = candidate;
97
+ if (!isValidHitShape(hit)) {
98
+ throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep returned an invalid search hit");
99
+ }
100
+ const ref = parseWireHitRef(hit);
101
+ const parsed = {
102
+ kind: hit.kind,
103
+ signal: hit.signal,
104
+ contributors: hit.contributors,
105
+ score: hit.score,
106
+ margin: hit.margin,
107
+ ref,
108
+ preview: hit.preview,
109
+ ...(hit.symbol === undefined ? {} : { symbol: hit.symbol }),
110
+ ...(hit.caller === undefined ? {} : { caller: hit.caller }),
111
+ ...(hit.callee === undefined ? {} : { callee: hit.callee }),
112
+ ...(hit.language === undefined ? {} : { language: hit.language }),
113
+ ...(hit.excerpt === undefined ? {} : { excerpt: hit.excerpt }),
114
+ };
115
+ return parsed;
116
+ }
117
+ function asSearchResponse(value) {
118
+ if (value.ok !== true || !Array.isArray(value.hits)) {
119
+ throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep search response is missing hits");
120
+ }
121
+ if (value.query !== undefined && typeof value.query !== "string") {
122
+ throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep search response has an invalid query");
123
+ }
124
+ // Guard form of compound hit_count validity (same checks, less && nesting).
125
+ if (value.hit_count !== undefined) {
126
+ if (typeof value.hit_count !== "number" || !Number.isSafeInteger(value.hit_count)
127
+ || value.hit_count < 0 || value.hit_count !== value.hits.length) {
128
+ throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep search response has an invalid hit_count");
129
+ }
130
+ }
131
+ const hits = value.hits.map(parseSearchHit);
132
+ return { ...value, hits };
133
+ }
134
+ function refValue(value) {
135
+ return typeof value === "string" ? value : value.ref;
136
+ }
137
+ /** Derive file/lines from a branded ref (sole location encoding on SgrepHit). */
138
+ export function parseSgrepRef(ref) {
139
+ return parseRef(ref);
140
+ }
141
+ function parseRef(ref) {
142
+ const match = REF_PATTERN.exec(ref);
143
+ if (!match)
144
+ throw new RuntimeError("INVALID_REF", `Invalid ast-sgrep ref: ${ref}`);
145
+ const file = match[1];
146
+ const start = Number(match[2]);
147
+ const end = Number(match[3]);
148
+ if (isAbsolute(file) || !Number.isSafeInteger(start) || !Number.isSafeInteger(end)
149
+ || start > MAX_LINE_NUMBER || end > MAX_LINE_NUMBER || end < start) {
150
+ throw new RuntimeError("INVALID_REF", `Invalid ast-sgrep ref: ${ref}`);
151
+ }
152
+ return { file, start, end };
153
+ }
154
+ function inside(root, path) {
155
+ const rel = relative(root, path);
156
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
157
+ }
158
+ function checkAbort(signal) {
159
+ if (signal?.aborted)
160
+ throw new RuntimeError("CANCELLED", "ast-sgrep read was cancelled");
161
+ }
162
+ function boundedPrefix(value, maxChars) {
163
+ let chars = 0;
164
+ let end = 0;
165
+ for (const codePoint of value) {
166
+ if (chars >= maxChars)
167
+ return { text: value.slice(0, end), chars, truncated: true };
168
+ end += codePoint.length;
169
+ chars += 1;
170
+ }
171
+ return { text: value, chars, truncated: false };
172
+ }
173
+ function formatReadRef(file, start, end) {
174
+ return `${file}#L${start}-L${end}`;
175
+ }
176
+ async function readLineWindow(handle, parsed, contextLines, maxChars, signal) {
177
+ const stat = await handle.stat();
178
+ if (!stat.isFile())
179
+ throw new RuntimeError("READ_FAILED", `${parsed.file} is not a regular file`);
180
+ const wantedStart = Math.max(1, parsed.start - contextLines);
181
+ const wantedEnd = Math.min(MAX_LINE_NUMBER, parsed.end + contextLines);
182
+ const decoder = new TextDecoder("utf-8", { fatal: true });
183
+ const stream = handle.createReadStream({
184
+ autoClose: false,
185
+ highWaterMark: 64 * 1024,
186
+ ...(signal ? { signal } : {}),
187
+ });
188
+ let pending = "";
189
+ let lineNumber = 1;
190
+ let selectedStart;
191
+ let selectedEnd;
192
+ let selectedLines = 0;
193
+ let content = "";
194
+ let contentChars = 0;
195
+ let truncated = false;
196
+ let rangeComplete = false;
197
+ let scannedBytes = 0;
198
+ const consumeLine = (line) => {
199
+ if (lineNumber >= wantedStart && lineNumber <= wantedEnd) {
200
+ selectedStart ??= lineNumber;
201
+ selectedEnd = lineNumber;
202
+ if (!truncated) {
203
+ const rawLine = line.endsWith("\r") ? line.slice(0, -1) : line;
204
+ const clamped = rawLine.length > MAX_LINE_CHARS ? `${rawLine.slice(0, MAX_LINE_CHARS)}…` : rawLine;
205
+ const addition = `${selectedLines > 0 ? "\n" : ""}${clamped}`;
206
+ const bounded = boundedPrefix(addition, maxChars - contentChars);
207
+ content += bounded.text;
208
+ contentChars += bounded.chars;
209
+ truncated = bounded.truncated;
210
+ }
211
+ selectedLines += 1;
212
+ }
213
+ if (lineNumber >= wantedEnd)
214
+ rangeComplete = true;
215
+ lineNumber += 1;
216
+ };
217
+ try {
218
+ for await (const chunk of stream) {
219
+ checkAbort(signal);
220
+ const bytes = chunk;
221
+ const remainingScan = MAX_SCAN_BYTES - scannedBytes;
222
+ const scanned = bytes.length > remainingScan + 1
223
+ ? bytes.subarray(0, remainingScan + 1)
224
+ : bytes;
225
+ scannedBytes += scanned.length;
226
+ try {
227
+ pending += decoder.decode(scanned, { stream: true });
228
+ }
229
+ catch {
230
+ throw new RuntimeError("BINARY_FILE", `${parsed.file} is not valid UTF-8 text`);
231
+ }
232
+ let newline = pending.indexOf("\n");
233
+ while (newline >= 0) {
234
+ consumeLine(pending.slice(0, newline));
235
+ pending = pending.slice(newline + 1);
236
+ if (rangeComplete)
237
+ break;
238
+ newline = pending.indexOf("\n");
239
+ }
240
+ if (rangeComplete)
241
+ break;
242
+ if (scannedBytes > MAX_SCAN_BYTES || scanned.length < bytes.length) {
243
+ throw new RuntimeError("READ_SCAN_LIMIT", `${parsed.file} exceeds the ${MAX_SCAN_BYTES}-byte scan limit`);
244
+ }
245
+ if (newline < 0 && lineNumber < wantedStart)
246
+ pending = "";
247
+ }
248
+ if (!rangeComplete) {
249
+ try {
250
+ pending += decoder.decode();
251
+ }
252
+ catch {
253
+ throw new RuntimeError("BINARY_FILE", `${parsed.file} is not valid UTF-8 text`);
254
+ }
255
+ if (pending.length > 0 || lineNumber === 1)
256
+ consumeLine(pending);
257
+ }
258
+ }
259
+ catch (cause) {
260
+ if (signal?.aborted)
261
+ throw new RuntimeError("CANCELLED", "ast-sgrep read was cancelled");
262
+ throw cause;
263
+ }
264
+ finally {
265
+ stream.destroy();
266
+ }
267
+ checkAbort(signal);
268
+ const totalLines = Math.max(0, lineNumber - 1);
269
+ if (totalLines === 0) {
270
+ return {
271
+ window: null,
272
+ content: "",
273
+ truncated: false,
274
+ note: `${parsed.file} is empty`,
275
+ };
276
+ }
277
+ if (parsed.start > totalLines || parsed.end > totalLines) {
278
+ const resume = Math.max(1, totalLines);
279
+ throw new RuntimeError("RANGE_OUT_OF_BOUNDS", `Note: offset ${parsed.start} is beyond the end of ${parsed.file} (${totalLines} lines scanned). Retry with a smaller offset (e.g. start=${resume})`, { file: parsed.file, start: parsed.start, end: parsed.end, totalLines, resumeOffset: resume });
280
+ }
281
+ const endLine = selectedEnd ?? Math.max(wantedStart, totalLines);
282
+ const startLine = selectedStart ?? wantedStart;
283
+ return {
284
+ window: { file: parsed.file, start: startLine, end: endLine },
285
+ content,
286
+ truncated,
287
+ ...(truncated
288
+ ? {
289
+ resumeOffset: endLine,
290
+ note: `truncated at line ${endLine}; resume with start=${endLine}`,
291
+ }
292
+ : {}),
293
+ };
294
+ }
295
+ async function runSearch(runtime, context, command, query, options) {
296
+ const value = await runtime.run([...outputArgs(options), ...command, "--", query, "."], context, options);
297
+ return asSearchResponse(value);
298
+ }
299
+ async function resolveReadableFile(root, ref, parsed) {
300
+ const unresolved = resolve(root, parsed.file);
301
+ assertSafeReadPath(unresolved);
302
+ if (!inside(root, unresolved))
303
+ throw new RuntimeError("PATH_OUTSIDE_ROOT", `Ref escapes the project root: ${ref}`);
304
+ let filePath;
305
+ let expectedStat;
306
+ try {
307
+ filePath = await realpath(unresolved);
308
+ expectedStat = await lstat(filePath);
309
+ }
310
+ catch (cause) {
311
+ throw new RuntimeError("READ_FAILED", `Unable to resolve ${parsed.file}`, {
312
+ ref,
313
+ cause: cause instanceof Error ? cause.message : String(cause),
314
+ });
315
+ }
316
+ if (!inside(root, filePath))
317
+ throw new RuntimeError("PATH_OUTSIDE_ROOT", `Ref escapes the project root: ${ref}`);
318
+ if (!expectedStat.isFile())
319
+ throw new RuntimeError("READ_FAILED", `${parsed.file} is not a regular file`);
320
+ return { unresolved, filePath, expectedStat };
321
+ }
322
+ async function openStableHandle(root, ref, fileLabel, unresolved, filePath, expectedStat) {
323
+ let handle;
324
+ try {
325
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
326
+ handle = await open(filePath, constants.O_RDONLY | noFollow);
327
+ }
328
+ catch (cause) {
329
+ throw new RuntimeError("READ_FAILED", `Unable to open ${fileLabel}`, {
330
+ ref,
331
+ cause: cause instanceof Error ? cause.message : String(cause),
332
+ });
333
+ }
334
+ try {
335
+ const [actualStat, openedPath] = await Promise.all([handle.stat(), realpath(unresolved)]);
336
+ if (!inside(root, openedPath) || openedPath !== filePath
337
+ || actualStat.dev !== expectedStat.dev || actualStat.ino !== expectedStat.ino) {
338
+ throw new RuntimeError("PATH_CHANGED", `Ref changed while opening: ${ref}`);
339
+ }
340
+ return handle;
341
+ }
342
+ catch (cause) {
343
+ await handle.close();
344
+ throw cause;
345
+ }
346
+ }
347
+ export class SgrepCodeMode {
348
+ runtime;
349
+ context;
350
+ #api;
351
+ constructor(runtime, context) {
352
+ this.runtime = runtime;
353
+ this.context = context;
354
+ this.#api = Object.freeze({
355
+ keywordSearch: this.keywordSearch.bind(this),
356
+ astSearch: this.astSearch.bind(this),
357
+ semanticSearch: this.semanticSearch.bind(this),
358
+ codeRead: this.codeRead.bind(this),
359
+ find: this.find.bind(this),
360
+ astFind: this.astFind.bind(this),
361
+ semantic: this.semantic.bind(this),
362
+ read: this.read.bind(this),
363
+ });
364
+ }
365
+ async execute(plan) {
366
+ if (typeof plan !== "function")
367
+ throw new RuntimeError("INVALID_PLAN", "Code Mode plan must be a function");
368
+ return await plan(this.#api);
369
+ }
370
+ async keywordSearch(query, options = {}) {
371
+ return runSearch(this.runtime, this.context, ["keyword"], requiredText(query, "query"), options);
372
+ }
373
+ async astSearch(pattern, options = {}) {
374
+ return runSearch(this.runtime, this.context, [], `pattern: ${requiredText(pattern, "pattern")}`, options);
375
+ }
376
+ async semanticSearch(query, options = {}) {
377
+ return runSearch(this.runtime, this.context, ["semantic"], requiredText(query, "query"), options);
378
+ }
379
+ async find(query, options) {
380
+ return this.keywordSearch(query, options);
381
+ }
382
+ async astFind(pattern, options) {
383
+ return this.astSearch(pattern, options);
384
+ }
385
+ async semantic(query, options) {
386
+ return this.semanticSearch(query, options);
387
+ }
388
+ async codeRead(ids, options = {}) {
389
+ const values = Array.isArray(ids) ? ids : [ids];
390
+ if (values.length === 0 || values.length > MAX_READ_REFS) {
391
+ throw new RuntimeError("INVALID_ARGUMENT", `read requires 1 to ${MAX_READ_REFS} refs`);
392
+ }
393
+ const contextLines = boundedInteger(options.contextLines, 0, 0, 100, "contextLines");
394
+ const maxChars = boundedInteger(options.maxChars, DEFAULT_MAX_READ_CHARS, 1, MAX_READ_CHARS, "maxChars");
395
+ const perRefChars = Math.floor(maxChars / values.length);
396
+ const remainder = maxChars % values.length;
397
+ checkAbort(options.signal);
398
+ const root = await realpath(await this.runtime.resolveRoot(this.context));
399
+ const results = [];
400
+ for (const [index, value] of values.entries()) {
401
+ checkAbort(options.signal);
402
+ const ref = refValue(value);
403
+ const parsed = parseRef(ref);
404
+ const { unresolved, filePath, expectedStat } = await resolveReadableFile(root, ref, parsed);
405
+ const handle = await openStableHandle(root, ref, parsed.file, unresolved, filePath, expectedStat);
406
+ try {
407
+ const budget = perRefChars + (index < remainder ? 1 : 0);
408
+ const payload = await readLineWindow(handle, parsed, contextLines, budget, options.signal);
409
+ const windowRef = payload.window
410
+ ? formatReadRef(payload.window.file, payload.window.start, payload.window.end)
411
+ : ref;
412
+ results.push({
413
+ ref: windowRef,
414
+ content: payload.content,
415
+ truncated: payload.truncated,
416
+ ...(payload.resumeOffset === undefined ? {} : { resumeOffset: payload.resumeOffset }),
417
+ ...(payload.note === undefined ? {} : { note: payload.note }),
418
+ });
419
+ }
420
+ finally {
421
+ await handle.close();
422
+ }
423
+ }
424
+ return results;
425
+ }
426
+ async read(ids, options) {
427
+ return await this.codeRead(ids, options);
428
+ }
429
+ }
430
+ export function createSgrepCodeMode(runtime, context) {
431
+ return new SgrepCodeMode(runtime, context);
432
+ }
@@ -0,0 +1,92 @@
1
+ import type { MachineEnvelope } from "../runtime.js";
2
+ import type { ChainArgs, SearchArgs } from "./types.js";
3
+ import { type BatchCapableHost, type DispatchStats } from "./dispatch.js";
4
+ /**
5
+ * Spawn/CLI transport. Hosts provide argv `run` only — never a typed twin.
6
+ * Typed entry lives solely on {@link DispatchSurface} (dispatcher output).
7
+ */
8
+ export type ConnectorHost = {
9
+ run(args: readonly string[], context: {
10
+ cwd: string;
11
+ }, options?: {
12
+ signal?: AbortSignal;
13
+ }): Promise<MachineEnvelope>;
14
+ };
15
+ /**
16
+ * Trusted typed dispatch after coalescing. `call` is required; no argv peer
17
+ * that can disagree with tool+args.
18
+ */
19
+ export type DispatchSurface = {
20
+ call(tool: string, args: Record<string, unknown>, context: {
21
+ cwd: string;
22
+ }, options?: {
23
+ signal?: AbortSignal;
24
+ }): Promise<MachineEnvelope>;
25
+ };
26
+ export type AsgrepConnector = {
27
+ search(input: SearchArgs, options?: {
28
+ signal?: AbortSignal;
29
+ }): Promise<MachineEnvelope>;
30
+ semantic(input: SearchArgs, options?: {
31
+ signal?: AbortSignal;
32
+ }): Promise<MachineEnvelope>;
33
+ chain(input: ChainArgs, options?: {
34
+ signal?: AbortSignal;
35
+ }): Promise<MachineEnvelope>;
36
+ defs(input: {
37
+ symbol: string;
38
+ limit?: number;
39
+ excerptLines?: number;
40
+ }, options?: {
41
+ signal?: AbortSignal;
42
+ }): Promise<MachineEnvelope>;
43
+ callers(input: {
44
+ symbol: string;
45
+ limit?: number;
46
+ excerptLines?: number;
47
+ }, options?: {
48
+ signal?: AbortSignal;
49
+ }): Promise<MachineEnvelope>;
50
+ imports(input: {
51
+ module: string;
52
+ limit?: number;
53
+ excerptLines?: number;
54
+ }, options?: {
55
+ signal?: AbortSignal;
56
+ }): Promise<MachineEnvelope>;
57
+ indexStatus(options?: {
58
+ signal?: AbortSignal;
59
+ }): Promise<MachineEnvelope>;
60
+ indexRepo(input?: {
61
+ force?: boolean;
62
+ }, options?: {
63
+ signal?: AbortSignal;
64
+ }): Promise<MachineEnvelope>;
65
+ /** Progressive discovery (like deferred tools) — list/filter available asgrep tools. */
66
+ catalogSearch(input: {
67
+ query: string;
68
+ }, options?: {
69
+ signal?: AbortSignal;
70
+ }): Promise<MachineEnvelope>;
71
+ catalogDescribe(input: {
72
+ name: string;
73
+ }, options?: {
74
+ signal?: AbortSignal;
75
+ }): Promise<MachineEnvelope>;
76
+ };
77
+ export type ConnectorBundle = {
78
+ asgrep: AsgrepConnector;
79
+ stats: () => DispatchStats;
80
+ resetStats: () => void;
81
+ };
82
+ /**
83
+ * Host-side connector: typed methods the Code Mode program calls.
84
+ *
85
+ * Same-tick calls (Promise.all) are coalesced by CodemodeDispatcher so N
86
+ * lookups share sticky serve / one warm batch process when available.
87
+ */
88
+ export declare function createAsgrepConnector(host: BatchCapableHost, context: {
89
+ cwd: string;
90
+ }, options?: {
91
+ signal?: AbortSignal;
92
+ }): ConnectorBundle;
@@ -0,0 +1,79 @@
1
+ import { createCodemodeDispatcher, } from "./dispatch.js";
2
+ const DEFAULT_LIMIT = 8;
3
+ function clampLimit(limit) {
4
+ if (limit === undefined)
5
+ return DEFAULT_LIMIT;
6
+ return Math.min(100, Math.max(1, Math.trunc(limit)));
7
+ }
8
+ function clampExcerpt(excerptLines) {
9
+ if (excerptLines === undefined)
10
+ return 0;
11
+ return Math.min(100, Math.max(0, Math.trunc(excerptLines)));
12
+ }
13
+ /**
14
+ * Host-side connector: typed methods the Code Mode program calls.
15
+ *
16
+ * Same-tick calls (Promise.all) are coalesced by CodemodeDispatcher so N
17
+ * lookups share sticky serve / one warm batch process when available.
18
+ */
19
+ export function createAsgrepConnector(host, context, options = {}) {
20
+ const dispatcher = createCodemodeDispatcher(host);
21
+ const combinedSignals = new WeakMap();
22
+ const callOptions = (signal) => {
23
+ if (!options.signal)
24
+ return signal ? { signal } : {};
25
+ if (!signal || signal === options.signal)
26
+ return { signal: options.signal };
27
+ let combined = combinedSignals.get(signal);
28
+ if (!combined) {
29
+ combined = AbortSignal.any([options.signal, signal]);
30
+ combinedSignals.set(signal, combined);
31
+ }
32
+ return { signal: combined };
33
+ };
34
+ const call = (tool, args, signal) => dispatcher.host.call(tool, args, context, callOptions(signal));
35
+ // Bound function properties (not methods) so vm call sites cannot lose `this`.
36
+ const asgrep = {
37
+ search: (input, callOptions) => call("search", {
38
+ query: input.query,
39
+ limit: clampLimit(input.limit),
40
+ excerpt_lines: clampExcerpt(input.excerptLines),
41
+ format: input.format === "agent" ? "agent" : "capsule",
42
+ }, callOptions?.signal),
43
+ semantic: (input, callOptions) => call("semantic", {
44
+ query: input.query,
45
+ limit: clampLimit(input.limit),
46
+ excerpt_lines: clampExcerpt(input.excerptLines),
47
+ format: input.format === "agent" ? "agent" : "capsule",
48
+ }, callOptions?.signal),
49
+ chain: (input, callOptions) => call("chain", {
50
+ query: input.query,
51
+ limit: clampLimit(input.limit),
52
+ top_n: 20,
53
+ }, callOptions?.signal),
54
+ defs: (input, callOptions) => call("defs", {
55
+ symbol: input.symbol,
56
+ limit: clampLimit(input.limit),
57
+ excerpt_lines: clampExcerpt(input.excerptLines),
58
+ }, callOptions?.signal),
59
+ callers: (input, callOptions) => call("callers", {
60
+ symbol: input.symbol,
61
+ limit: clampLimit(input.limit),
62
+ excerpt_lines: clampExcerpt(input.excerptLines),
63
+ }, callOptions?.signal),
64
+ imports: (input, callOptions) => call("imports", {
65
+ module: input.module,
66
+ limit: clampLimit(input.limit),
67
+ excerpt_lines: clampExcerpt(input.excerptLines),
68
+ }, callOptions?.signal),
69
+ indexStatus: (callOptions) => call("index_status", {}, callOptions?.signal),
70
+ indexRepo: (input = {}, callOptions) => call("index_repo", { force: input.force === true }, callOptions?.signal),
71
+ catalogSearch: (input, callOptions) => call("catalog_search", { query: input.query }, callOptions?.signal),
72
+ catalogDescribe: (input, callOptions) => call("catalog_describe", { name: input.name }, callOptions?.signal),
73
+ };
74
+ return {
75
+ asgrep,
76
+ stats: dispatcher.stats,
77
+ resetStats: dispatcher.resetStats,
78
+ };
79
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Same-tick call coalescing + typed batch / sticky-serve dispatch.
3
+ *
4
+ * Amdahl: serial cost is process spawn + SQLite open. Sticky serve kills spawn
5
+ * for the whole Code Mode program; batch coalescing kills it per Promise.all wave.
6
+ */
7
+ import type { MachineEnvelope } from "../runtime.js";
8
+ import type { ConnectorHost, DispatchSurface } from "./connector.js";
9
+ export type CodemodeToolCall = {
10
+ tool: string;
11
+ args: Record<string, unknown>;
12
+ };
13
+ export type DispatchStats = {
14
+ waves: number;
15
+ calls: number;
16
+ batchedCalls: number;
17
+ parallelSpawnCalls: number;
18
+ stickyCalls: number;
19
+ wallMs: number;
20
+ };
21
+ export type BatchResult = {
22
+ results: Array<{
23
+ id: string;
24
+ ok: boolean;
25
+ value?: unknown;
26
+ error?: string;
27
+ }>;
28
+ mode?: string;
29
+ wall_ms?: number;
30
+ all_ok?: boolean;
31
+ };
32
+ export type StickyWorker = {
33
+ call(tool: string, args: Record<string, unknown>, options?: {
34
+ signal?: AbortSignal;
35
+ }): Promise<MachineEnvelope>;
36
+ batch(calls: Array<{
37
+ id: string;
38
+ tool: string;
39
+ args: Record<string, unknown>;
40
+ }>, options?: {
41
+ signal?: AbortSignal;
42
+ }): Promise<BatchResult>;
43
+ end(): Promise<void>;
44
+ };
45
+ export type BatchCapableHost = ConnectorHost & {
46
+ /** One-shot warm batch (codemode-batch). */
47
+ runBatch?(calls: Array<{
48
+ id: string;
49
+ tool: string;
50
+ args: Record<string, unknown>;
51
+ }>, context: {
52
+ cwd: string;
53
+ }, options?: {
54
+ signal?: AbortSignal;
55
+ }): Promise<BatchResult>;
56
+ /** Sticky NDJSON worker for the whole Code Mode program (preferred). */
57
+ sticky?: StickyWorker | null;
58
+ };
59
+ /**
60
+ * Wraps a host so Promise.all([asgrep.search, asgrep.defs, …]) collapses into
61
+ * one microtask wave. Prefers sticky serve → one-shot batch → overlapped spawn.
62
+ */
63
+ export declare function createCodemodeDispatcher(host: BatchCapableHost): {
64
+ host: DispatchSurface;
65
+ stats: () => DispatchStats;
66
+ resetStats: () => void;
67
+ };
68
+ export declare function argvFor(tool: string, args: Record<string, unknown>): string[];
69
+ export declare function asEnvelope(value: unknown, command?: string): MachineEnvelope;
70
+ /** One-shot batch via stdin (no tempfile) when spawn-with-stdin is available. */
71
+ export declare function runNativeBatch(run: ConnectorHost["run"], calls: Array<{
72
+ id: string;
73
+ tool: string;
74
+ args: Record<string, unknown>;
75
+ }>, context: {
76
+ cwd: string;
77
+ }, options?: {
78
+ signal?: AbortSignal;
79
+ }, writeBatch?: (body: string, context: {
80
+ cwd: string;
81
+ }, options?: {
82
+ signal?: AbortSignal;
83
+ }) => Promise<MachineEnvelope>): Promise<BatchResult>;