pi-ast-sgrep 2.2.0 → 2.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import type { MachineEnvelope } from "../runtime/runtime.js";
2
- import type { ChainArgs, EditArgs, FindArgs, ReadArgs, SearchArgs } from "./types.js";
3
2
  import { type BatchCapableHost, type DispatchCall, type DispatchStats } from "./dispatch.js";
3
+ import type { ChainArgs, EditArgs, FindArgs, ReadArgs, SearchArgs } from "./types.js";
4
4
  /**
5
5
  * Spawn/CLI transport. Hosts provide argv `run` only — never a typed twin.
6
6
  * Typed entry lives solely on {@link DispatchSurface} (dispatcher output).
@@ -1,7 +1,15 @@
1
+ import { argvFor, asEnvelope, createCodemodeDispatcher, } from "./dispatch.js";
2
+ import { editFilesFallback, readWindowsFallback } from "./fallback.js";
1
3
  import { coerceHostArgs } from "./guest-api.js";
2
4
  import { defined } from "./types.js";
3
- import { createCodemodeDispatcher, } from "./dispatch.js";
4
5
  const DEFAULT_LIMIT = 8;
6
+ /** Native tools the extension serves itself when the launcher predates them. */
7
+ const NATIVE_FALLBACK_TOOLS = new Set(["read", "edit", "find"]);
8
+ /** Stable unknown-tool prefix, byte-identical in every native generation. */
9
+ function isUnknownToolError(cause) {
10
+ const message = cause instanceof Error ? cause.message : String(cause);
11
+ return message.includes("unknown tool:");
12
+ }
5
13
  function clampLimit(limit) {
6
14
  if (limit === undefined)
7
15
  return DEFAULT_LIMIT;
@@ -58,7 +66,35 @@ export function createAsgrepConnector(host, context, options = {}) {
58
66
  }
59
67
  return { signal: combined };
60
68
  };
61
- const call = (tool, args, signal) => dispatcher.host.call(tool, args, context, callOptions(signal));
69
+ // Unknown-tool fallback engages once per tool: after the first miss the
70
+ // native attempt is skipped for this bundle (a backend cannot gain tools
71
+ // mid-session). Any other error still propagates untouched, unmemoized.
72
+ const nativeMissing = new Set();
73
+ const fallbackCall = async (tool, args, signal) => {
74
+ if (tool === "find")
75
+ return host.run(argvFor("find", args), context, signal === undefined ? {} : { signal });
76
+ const value = tool === "read"
77
+ ? await readWindowsFallback(context.cwd, args, signal)
78
+ : await editFilesFallback(context.cwd, args, signal);
79
+ return asEnvelope(value, tool);
80
+ };
81
+ const call = async (tool, args, signal) => {
82
+ const opts = callOptions(signal);
83
+ if (NATIVE_FALLBACK_TOOLS.has(tool) && nativeMissing.has(tool))
84
+ return fallbackCall(tool, args, opts.signal);
85
+ try {
86
+ return await dispatcher.host.call(tool, args, context, opts);
87
+ }
88
+ catch (cause) {
89
+ // No memo check here: concurrent same-tool misses must all fall back —
90
+ // the memo only skips future native attempts, never a fresh miss.
91
+ if (NATIVE_FALLBACK_TOOLS.has(tool) && isUnknownToolError(cause)) {
92
+ nativeMissing.add(tool);
93
+ return fallbackCall(tool, args, opts.signal);
94
+ }
95
+ throw cause;
96
+ }
97
+ };
62
98
  const searchPayload = (method, input) => {
63
99
  const scoped = coerceHostArgs(method, withScope({ ...input }));
64
100
  const payload = {
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Extension-side read/edit for launchers that predate the native tools.
3
+ *
4
+ * Severed-lane contract: when native answers `unknown tool: <name>` for
5
+ * read/edit/find, the connector serves the call here instead of failing, so
6
+ * an extension tracking main keeps working on an official launcher. Every
7
+ * shape, default, cap, jail rule, and error string mirrors
8
+ * crates/ast-sgrep-codemode/src/io.rs exactly — callers cannot distinguish
9
+ * this lane from a native one. Errors are plain Errors carrying the native
10
+ * text (the native lane surfaces them the same way); no new error codes.
11
+ *
12
+ * Deliberate divergences from native, both strictly safer:
13
+ * - Reads come from disk, never the index (the extension has no row reader
14
+ * here). Disk is fresher than a possibly stale index row.
15
+ * - Edits skip the native targeted reindex. A fallback edit is exactly like
16
+ * an external editor write, which the freshness layer already reconciles.
17
+ */
18
+ /** Mirror read_windows (disk-backed; see module note). */
19
+ export declare function readWindowsFallback(configuredCwd: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<{
20
+ ok: true;
21
+ count: number;
22
+ windows: Array<{
23
+ path: string;
24
+ ref: string;
25
+ start: number;
26
+ end: number;
27
+ truncated: boolean;
28
+ text: string;
29
+ }>;
30
+ }>;
31
+ /** Mirror edit_files (validate-all-then-write; no targeted reindex — freshness covers). */
32
+ export declare function editFilesFallback(configuredCwd: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<{
33
+ ok: true;
34
+ changed: number;
35
+ edits: Array<{
36
+ path: string;
37
+ changed: boolean;
38
+ line: number;
39
+ removed: string[];
40
+ added: string[];
41
+ truncated?: true;
42
+ }>;
43
+ }>;
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Extension-side read/edit for launchers that predate the native tools.
3
+ *
4
+ * Severed-lane contract: when native answers `unknown tool: <name>` for
5
+ * read/edit/find, the connector serves the call here instead of failing, so
6
+ * an extension tracking main keeps working on an official launcher. Every
7
+ * shape, default, cap, jail rule, and error string mirrors
8
+ * crates/ast-sgrep-codemode/src/io.rs exactly — callers cannot distinguish
9
+ * this lane from a native one. Errors are plain Errors carrying the native
10
+ * text (the native lane surfaces them the same way); no new error codes.
11
+ *
12
+ * Deliberate divergences from native, both strictly safer:
13
+ * - Reads come from disk, never the index (the extension has no row reader
14
+ * here). Disk is fresher than a possibly stale index row.
15
+ * - Edits skip the native targeted reindex. A fallback edit is exactly like
16
+ * an external editor write, which the freshness layer already reconciles.
17
+ */
18
+ import { readFile, realpath, writeFile } from "node:fs/promises";
19
+ import { isAbsolute, join, relative } from "node:path";
20
+ import { pathContained } from "../runtime/index-health.js";
21
+ // Native caps (crates/ast-sgrep-codemode/src/io.rs + core limits).
22
+ const MAX_READ_REFS = 32;
23
+ const MAX_READ_CHARS = 100_000;
24
+ const MAX_EDITS = 16;
25
+ const MAX_LINE_CHARS = 2_000;
26
+ const MAX_INDEX_FILE_BYTES = 64 * 1024 * 1024;
27
+ const MAX_EXCERPT_LINES = 100;
28
+ const U32_MAX = 4_294_967_295;
29
+ /** Mirror serde as_u64 (integers only, then wrapping `as u32`). */
30
+ function u32(value) {
31
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > Number.MAX_SAFE_INTEGER)
32
+ return undefined;
33
+ return value % (U32_MAX + 1);
34
+ }
35
+ /** Mirror serde as_u64 without narrowing (context/max budgets). */
36
+ function u64(value) {
37
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > Number.MAX_SAFE_INTEGER)
38
+ return undefined;
39
+ return value;
40
+ }
41
+ /** First present key wins even when its value is unusable (or_else chain). */
42
+ function present(args, ...keys) {
43
+ for (const key of keys)
44
+ if (key in args)
45
+ return args[key];
46
+ return undefined;
47
+ }
48
+ /** Mirror str::lines: \n or \r\n separators, no phantom trailing line. */
49
+ function rustLines(text) {
50
+ if (text === "")
51
+ return [];
52
+ const lines = text.split("\n").map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
53
+ if (text.endsWith("\n"))
54
+ lines.pop();
55
+ return lines;
56
+ }
57
+ function charCount(text) {
58
+ return [...text].length;
59
+ }
60
+ function clampLine(line) {
61
+ if (charCount(line) <= MAX_LINE_CHARS)
62
+ return { line, clamped: false };
63
+ return { line: [...line].slice(0, MAX_LINE_CHARS).join(""), clamped: true };
64
+ }
65
+ /** Mirror session root_arg: configured root, optional contained override. */
66
+ async function jailRoot(configuredCwd, args) {
67
+ const configured = await realpath(configuredCwd).catch(() => {
68
+ throw new Error(`cannot resolve session root: ${configuredCwd}`);
69
+ });
70
+ const raw = args.root;
71
+ if (typeof raw !== "string")
72
+ return configured;
73
+ const candidate = isAbsolute(raw) ? raw : join(configured, raw);
74
+ const resolved = await realpath(candidate).catch(() => {
75
+ throw new Error(`cannot resolve requested root: ${candidate}`);
76
+ });
77
+ if (!pathContained(configured, resolved)) {
78
+ throw new Error(`requested root is outside the configured session root: ${resolved}`);
79
+ }
80
+ return resolved;
81
+ }
82
+ /** Mirror jail_rel_path: explicit '..' rejection, canonicalize, containment. */
83
+ async function jailPath(root, raw) {
84
+ if (raw.split(/[\\/]/u).includes(".."))
85
+ throw new Error("path must not contain '..'");
86
+ const candidate = isAbsolute(raw) ? raw : join(root, raw);
87
+ const abs = await realpath(candidate).catch(() => {
88
+ throw new Error(`cannot resolve path ${raw}`);
89
+ });
90
+ if (!pathContained(root, abs))
91
+ throw new Error(`path escapes session root: ${raw}`);
92
+ return { abs, display: relative(root, abs).replace(/\\/g, "/") };
93
+ }
94
+ function parseU32Strict(text) {
95
+ const digits = /^(?:\+)?(\d+)$/.exec(text)?.[1];
96
+ if (digits === undefined)
97
+ return undefined;
98
+ const value = Number(digits);
99
+ return Number.isSafeInteger(value) && value <= U32_MAX ? value : undefined;
100
+ }
101
+ function parseRefString(raw) {
102
+ const hash = raw.lastIndexOf("#L");
103
+ if (hash === -1)
104
+ return { path: raw, start: 1, end: 40 };
105
+ const rest = raw.slice(hash + 2).trim();
106
+ const dash = rest.indexOf("-L");
107
+ const startText = dash === -1 ? rest : rest.slice(0, dash);
108
+ const endText = dash === -1 ? rest : rest.slice(dash + 2);
109
+ const start = parseU32Strict(startText);
110
+ if (start === undefined)
111
+ throw new Error(`invalid ref start in ${raw}`);
112
+ const end = parseU32Strict(endText);
113
+ if (end === undefined)
114
+ throw new Error(`invalid ref end in ${raw}`);
115
+ if (start === 0 || end < start)
116
+ throw new Error(`invalid ref range in ${raw}`);
117
+ return { path: raw.slice(0, hash), start, end };
118
+ }
119
+ function parseRefValue(value) {
120
+ if (typeof value === "string")
121
+ return parseRefString(value);
122
+ if (value === null || typeof value !== "object" || Array.isArray(value))
123
+ throw new Error("ref must be a string or object");
124
+ const obj = value;
125
+ if (typeof obj.ref === "string")
126
+ return parseRefString(obj.ref);
127
+ const path = present(obj, "path", "file");
128
+ if (typeof path !== "string")
129
+ throw new Error("ref.path is required");
130
+ const start = u32(present(obj, "start", "line_start")) ?? 1;
131
+ const end = u32(present(obj, "end", "line_end")) ?? start;
132
+ return { path, start: Math.max(1, start), end: Math.max(start, end) };
133
+ }
134
+ function collectRefs(args) {
135
+ if (Array.isArray(args.refs))
136
+ return args.refs.map(parseRefValue);
137
+ if (args.ref !== undefined)
138
+ return [parseRefValue(args.ref)];
139
+ const path = present(args, "path", "file");
140
+ if (typeof path !== "string")
141
+ throw new Error("path is required");
142
+ const start = u32(present(args, "start", "line_start")) ?? 1;
143
+ const end = u32(present(args, "end", "line_end")) ?? start;
144
+ return [{ path, start: Math.max(1, start), end: Math.max(start, end) }];
145
+ }
146
+ function sliceWindow(numbered, start, end, maxChars) {
147
+ let out = "";
148
+ let actualStart = start;
149
+ let actualEnd = start;
150
+ let first = true;
151
+ let truncated = false;
152
+ let chars = 0;
153
+ for (const [no, content] of numbered) {
154
+ if (no < start)
155
+ continue;
156
+ if (no > end)
157
+ break;
158
+ const { line, clamped } = clampLine(content);
159
+ if (clamped)
160
+ truncated = true;
161
+ const add = (first ? 0 : 1) + charCount(line);
162
+ if (chars + add > maxChars) {
163
+ truncated = true;
164
+ break;
165
+ }
166
+ if (first) {
167
+ actualStart = no;
168
+ first = false;
169
+ }
170
+ if (out !== "")
171
+ out += "\n";
172
+ out += line;
173
+ actualEnd = no;
174
+ chars += add;
175
+ }
176
+ if (first)
177
+ return { text: "", actualStart: start, actualEnd: start, truncated: false };
178
+ return { text: out, actualStart, actualEnd, truncated };
179
+ }
180
+ async function readFileCapped(abs, display, signal) {
181
+ const text = await readFile(abs, "utf8").catch(() => {
182
+ throw new Error(`cannot read ${display}`);
183
+ });
184
+ if (Buffer.byteLength(text) > MAX_INDEX_FILE_BYTES)
185
+ throw new Error(`${display} exceeds max ${MAX_INDEX_FILE_BYTES} bytes`);
186
+ signal?.throwIfAborted();
187
+ return text;
188
+ }
189
+ /** Mirror read_windows (disk-backed; see module note). */
190
+ export async function readWindowsFallback(configuredCwd, args, signal) {
191
+ signal?.throwIfAborted();
192
+ const root = await jailRoot(configuredCwd, args);
193
+ const contextLines = Math.min(u64(present(args, "context_lines", "contextLines")) ?? 0, MAX_EXCERPT_LINES);
194
+ const maxChars = Math.min(Math.max(u64(present(args, "max_chars", "maxChars")) ?? MAX_READ_CHARS, 1), MAX_READ_CHARS);
195
+ const refs = collectRefs(args);
196
+ if (refs.length === 0)
197
+ throw new Error("read requires path, ref, or refs");
198
+ if (refs.length > MAX_READ_REFS)
199
+ throw new Error(`read exceeds max ${MAX_READ_REFS} windows`);
200
+ const windows = [];
201
+ for (const spec of refs) {
202
+ signal?.throwIfAborted();
203
+ const { abs, display } = await jailPath(root, spec.path);
204
+ const text = await readFileCapped(abs, display, signal);
205
+ const numbered = rustLines(text).map((line, index) => [index + 1, line]);
206
+ const start = Math.max(1, spec.start - contextLines);
207
+ const end = Math.min(spec.end + contextLines, U32_MAX);
208
+ const slice = sliceWindow(numbered, start, end, maxChars);
209
+ windows.push({
210
+ path: display,
211
+ ref: `${display}#L${slice.actualStart}-L${slice.actualEnd}`,
212
+ start: slice.actualStart,
213
+ end: slice.actualEnd,
214
+ truncated: slice.truncated,
215
+ text: slice.text,
216
+ });
217
+ }
218
+ return { ok: true, count: windows.length, windows };
219
+ }
220
+ function parseEditValue(value) {
221
+ if (value === null || typeof value !== "object" || Array.isArray(value))
222
+ throw new Error("edit must be an object");
223
+ const obj = value;
224
+ const path = present(obj, "path", "file");
225
+ if (typeof path !== "string")
226
+ throw new Error("path is required");
227
+ const oldText = present(obj, "oldText", "old_string", "old");
228
+ if (typeof oldText !== "string")
229
+ throw new Error("oldText is required");
230
+ const newText = present(obj, "newText", "new_string", "new");
231
+ if (typeof newText !== "string")
232
+ throw new Error("newText is required");
233
+ if (oldText === "")
234
+ throw new Error("oldText must not be empty");
235
+ return { path, oldText, newText };
236
+ }
237
+ function collectEdits(args) {
238
+ if (Array.isArray(args.edits))
239
+ return args.edits.map(parseEditValue);
240
+ if (typeof args.path === "string")
241
+ return [parseEditValue(args)];
242
+ return [];
243
+ }
244
+ function diffLines(text) {
245
+ const all = rustLines(text);
246
+ return { lines: all.slice(0, 24), more: all.length > 24 };
247
+ }
248
+ function matchLine(buffer, offset) {
249
+ return buffer.slice(0, offset).split("\n").length;
250
+ }
251
+ /** Mirror edit_files (validate-all-then-write; no targeted reindex — freshness covers). */
252
+ export async function editFilesFallback(configuredCwd, args, signal) {
253
+ signal?.throwIfAborted();
254
+ const root = await jailRoot(configuredCwd, args);
255
+ const edits = collectEdits(args);
256
+ if (edits.length === 0)
257
+ throw new Error("edit requires path+oldText+newText or edits[]");
258
+ if (edits.length > MAX_EDITS)
259
+ throw new Error(`edit exceeds max ${MAX_EDITS} replacements`);
260
+ // Phase 1: resolve, read, and compute every rewrite before any write.
261
+ const plan = new Map();
262
+ const applied = [];
263
+ for (const edit of edits) {
264
+ signal?.throwIfAborted();
265
+ const { abs, display } = await jailPath(root, edit.path);
266
+ let entry = plan.get(display);
267
+ if (!entry) {
268
+ const original = await readFileCapped(abs, display, signal);
269
+ entry = { abs, display, original, rewritten: original };
270
+ plan.set(display, entry);
271
+ }
272
+ const first = entry.rewritten.indexOf(edit.oldText);
273
+ if (first === -1)
274
+ throw new Error("oldText must match exactly once (found 0)");
275
+ if (entry.rewritten.indexOf(edit.oldText, first + edit.oldText.length) !== -1) {
276
+ throw new Error("oldText must match exactly once (found 2+)");
277
+ }
278
+ const line = matchLine(entry.rewritten, first);
279
+ const next = entry.rewritten.slice(0, first) + edit.newText + entry.rewritten.slice(first + edit.oldText.length);
280
+ const changed = next !== entry.rewritten;
281
+ entry.rewritten = next;
282
+ const removed = diffLines(edit.oldText);
283
+ const added = diffLines(edit.newText);
284
+ applied.push({
285
+ path: display,
286
+ changed,
287
+ line,
288
+ removed: removed.lines,
289
+ added: added.lines,
290
+ ...(removed.more || added.more ? { truncated: true } : {}),
291
+ });
292
+ }
293
+ // Phase 2: every edit validated — write each touched file once.
294
+ for (const entry of plan.values()) {
295
+ if (entry.rewritten === entry.original)
296
+ continue;
297
+ signal?.throwIfAborted();
298
+ await writeFile(entry.abs, entry.rewritten).catch(() => {
299
+ throw new Error(`cannot write ${entry.display}`);
300
+ });
301
+ }
302
+ return { ok: true, changed: applied.filter((row) => row.changed).length, edits: applied };
303
+ }
@@ -8,8 +8,12 @@
8
8
  * 1. `ASGREP_CODEMODE_NAPI_PATH` (dev override)
9
9
  * 2. `@ast-sgrep/<platform>/ast-sgrep-codemode.node` via launcher (release install)
10
10
  * 3. Local `extension/native/` / cargo `target/release` (dev builds)
11
+ *
12
+ * Severed-lane policy: the in-process binding requires an exact native
13
+ * generation (an ABI mismatch risks crashes, not errors). Older launchers
14
+ * serve Code Mode over CLI sticky/batch/spawn instead — same tools, slower.
11
15
  */
12
- export declare const CODEMODE_BINDING_VERSION = "2.1.0";
16
+ export declare const CODEMODE_BINDING_VERSION = "2.5.2";
13
17
  export type NativeSessionConfig = {
14
18
  root?: string;
15
19
  indexPath?: string;
@@ -8,12 +8,16 @@
8
8
  * 1. `ASGREP_CODEMODE_NAPI_PATH` (dev override)
9
9
  * 2. `@ast-sgrep/<platform>/ast-sgrep-codemode.node` via launcher (release install)
10
10
  * 3. Local `extension/native/` / cargo `target/release` (dev builds)
11
+ *
12
+ * Severed-lane policy: the in-process binding requires an exact native
13
+ * generation (an ABI mismatch risks crashes, not errors). Older launchers
14
+ * serve Code Mode over CLI sticky/batch/spawn instead — same tools, slower.
11
15
  */
12
16
  import { createRequire } from "node:module";
13
17
  import { existsSync } from "node:fs";
14
18
  import { dirname, join } from "node:path";
15
19
  import { fileURLToPath } from "node:url";
16
- export const CODEMODE_BINDING_VERSION = "2.1.0";
20
+ export const CODEMODE_BINDING_VERSION = "2.5.2";
17
21
  let cached;
18
22
  function platformTriple() {
19
23
  const { platform, arch } = process;
@@ -13,7 +13,7 @@ export declare const DEFAULT_LIMIT = 8;
13
13
  * session, or a host that drops the built-ins still gets them — but they are
14
14
  * left out of the active set when the host already provides read+edit. Pi only
15
15
  * sends ACTIVE tools (schema, snippet, guidelines) to the model, so this is the
16
- * difference between ~296 tokens per request and nothing.
16
+ * difference between ~271 tokens per request and nothing.
17
17
  *
18
18
  * ASGREP_KEEP_FILE_TOOLS=1 pins them active regardless.
19
19
  */
@@ -4,22 +4,21 @@ import { Type } from "typebox";
4
4
  import { createAsgrepConnector, runCodemode, runNativeBatch, runBatchViaStdin, CODEMODE_TYPES_FOR_MODEL, NativeSessionPool, argvFor, asEnvelope, applyQueryScope, warmCodemodeSandbox, resetCodemodeSandboxForTests, isClosedWorkerError, } from "../codemode/index.js";
5
5
  import { AstSgrepRuntime, FreshnessCoordinator, RuntimeError } from "../runtime/runtime.js";
6
6
  import { RESOLVED_ROOT } from "../runtime/types.js";
7
- import { ASGREP_PROMPT_GUIDELINES, ASGREP_PROMPT_SNIPPET, formatCodemodeResult, } from "../ui/present.js";
7
+ import { ASGREP_PROMPT_GUIDELINES, ASGREP_PROMPT_GUIDELINES_HOST_FILES, ASGREP_PROMPT_SNIPPET, formatCodemodeResult, } from "../ui/present.js";
8
8
  import { EMPTY_CALL, renderAsgrepResult } from "../ui/card.js";
9
9
  import { bounded, errorDetails, failure, isFreshnessTimeout, extractInPath, report, success, } from "./results.js";
10
10
  export const DEFAULT_LIMIT = 8;
11
11
  const MAX_LIMIT = 100;
12
12
  const MAX_EXCERPT_LINES = 100;
13
13
  const searchParameters = Type.Object({
14
- query: Type.String({ maxLength: 4_096, description: "Query, symbol, or pattern" }),
14
+ query: Type.String({ maxLength: 4_096 }),
15
15
  mode: Type.Optional(Type.Unsafe({
16
16
  type: "string",
17
17
  enum: ["natural", "pattern", "defs", "callers", "chain", "semantic", "word", "literal", "regex", "imports"],
18
18
  default: "natural",
19
- description: "Search strategy",
20
19
  })),
21
20
  limit: Type.Optional(Type.Integer({ default: DEFAULT_LIMIT })),
22
- excerptLines: Type.Optional(Type.Integer({ default: 0, description: "Inline N excerpt lines per hit" })),
21
+ excerptLines: Type.Optional(Type.Integer({ default: 0 })),
23
22
  in: Type.Optional(Type.String({ maxLength: 512, description: "Bound to a directory or glob" })),
24
23
  lang: Type.Optional(Type.String({ maxLength: 32, description: "Language filter (rs, ts, py)" })),
25
24
  }, { additionalProperties: false });
@@ -27,9 +26,9 @@ const indexParameters = Type.Object({
27
26
  force: Type.Optional(Type.Boolean({ default: false, description: "Rebuild the index from scratch" })),
28
27
  }, { additionalProperties: false });
29
28
  const editParameters = Type.Object({
30
- path: Type.Optional(Type.String({ maxLength: 512, description: "File to edit" })),
29
+ path: Type.Optional(Type.String({ maxLength: 512 })),
31
30
  oldText: Type.Optional(Type.String({ description: "Exact text to replace (must match once)" })),
32
- newText: Type.Optional(Type.String({ description: "Replacement" })),
31
+ newText: Type.Optional(Type.String()),
33
32
  edits: Type.Optional(Type.Array(Type.Object({
34
33
  path: Type.Optional(Type.String({ minLength: 1, maxLength: 512 })),
35
34
  oldText: Type.String({ minLength: 1 }),
@@ -37,7 +36,7 @@ const editParameters = Type.Object({
37
36
  }), { maxItems: 64, description: "Multi-edit entries; top-level path is the default" })),
38
37
  }, { additionalProperties: false });
39
38
  const readParameters = Type.Object({
40
- path: Type.Optional(Type.String({ maxLength: 512, description: "File to read" })),
39
+ path: Type.Optional(Type.String({ maxLength: 512 })),
41
40
  ref: Type.Optional(Type.String({ description: "Hit ref path#L12-L40" })),
42
41
  refs: Type.Optional(Type.Array(Type.String(), { maxItems: 24, description: "Several refs in one call" })),
43
42
  start: Type.Optional(Type.Integer()),
@@ -49,7 +48,7 @@ const codemodeParameters = Type.Object({
49
48
  code: Type.String({
50
49
  minLength: 1,
51
50
  maxLength: 32_000,
52
- description: "JavaScript: async () => { ... } or a bare body with return. The returned value is the tool result.",
51
+ description: "JavaScript: async () => { ... } or a bare body with return.",
53
52
  }),
54
53
  timeoutMs: Type.Optional(Type.Integer({ description: "Timeout ms (default 30000)" })),
55
54
  }, { additionalProperties: false });
@@ -84,7 +83,7 @@ function withSearchLang(argv, lang) {
84
83
  * session, or a host that drops the built-ins still gets them — but they are
85
84
  * left out of the active set when the host already provides read+edit. Pi only
86
85
  * sends ACTIVE tools (schema, snippet, guidelines) to the model, so this is the
87
- * difference between ~296 tokens per request and nothing.
86
+ * difference between ~271 tokens per request and nothing.
88
87
  *
89
88
  * ASGREP_KEEP_FILE_TOOLS=1 pins them active regardless.
90
89
  */
@@ -518,10 +517,10 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
518
517
  name: "asgrep",
519
518
  label: "asgrep",
520
519
  promptSnippet: ASGREP_PROMPT_SNIPPET,
521
- promptGuidelines: [...ASGREP_PROMPT_GUIDELINES],
520
+ promptGuidelines: [...(hostProvidesFileTools(pi) ? ASGREP_PROMPT_GUIDELINES_HOST_FILES : ASGREP_PROMPT_GUIDELINES)],
522
521
  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.",
522
+ "Code search: use it for any code lookup instead of grep.",
523
+ "The returned value is your result.",
525
524
  CODEMODE_TYPES_FOR_MODEL,
526
525
  "Example: async () => (await asgrep.search(\"auth\", { limit: 5 })).hits",
527
526
  ].join("\n"),
@@ -608,7 +607,7 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
608
607
  pi.registerTool({
609
608
  name: "asgrep_search",
610
609
  label: "asgrep search",
611
- promptSnippet: "One-shot asgrep search",
610
+ promptSnippet: "One-shot search",
612
611
  description: "One-shot search. Use asgrep (Code Mode) for anything multi-step, parallel, or filtered.",
613
612
  parameters: searchParameters,
614
613
  renderShell: "self",
@@ -675,7 +674,7 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
675
674
  name: "asgrep_edit",
676
675
  label: "asgrep edit",
677
676
  promptSnippet: "Exact-string edit",
678
- description: "Edit by exact-string replace; oldText must match once. edits[] applies many atomically.",
677
+ description: "Edit by exact-string replace. edits[] applies many atomically.",
679
678
  parameters: editParameters,
680
679
  renderShell: "self",
681
680
  renderCall() {
@@ -735,7 +734,7 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
735
734
  name: "asgrep_index",
736
735
  label: "asgrep index",
737
736
  promptSnippet: "Build or rebuild the index",
738
- description: "Build or rebuild the index (embeddings included).",
737
+ description: "Build or rebuild the index.",
739
738
  parameters: indexParameters,
740
739
  renderShell: "self",
741
740
  renderCall() {
@@ -47,10 +47,26 @@ function getBinary(config, env, resolver) {
47
47
  return binary;
48
48
  }
49
49
  function byteLength(value) { return Buffer.byteLength(value, "utf8"); }
50
- /** Present-field version identity checks. Pass `requireIdentity` for version --json. */
50
+ /**
51
+ * Binary-generation check. The extension tracks main ahead of official
52
+ * launcher releases (severed lane), so any binary of the same major is
53
+ * accepted: features gate on envelope content and the index negotiates its
54
+ * schema with the binary. A different major or an unparseable version still
55
+ * fails closed, as does a missing version when identity is required.
56
+ */
57
+ function binaryMajor(version) {
58
+ if (typeof version !== "string")
59
+ return undefined;
60
+ const major = version.match(/^(\d+)\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/)?.[1];
61
+ return major === undefined ? undefined : Number(major);
62
+ }
63
+ // RUNTIME_VERSION is contract-pinned semver, so this is total. A malformed
64
+ // constant (-1) would reject every binary — fail closed.
65
+ const RUNTIME_MAJOR = binaryMajor(RUNTIME_VERSION) ?? -1;
66
+ /** Present-field version checks. Pass `requireIdentity` for version --json. */
51
67
  function assertVersionTriple(envelope, requireIdentity = false) {
52
68
  // Compound guards (same short-circuit as nested if): check only when required or field present.
53
- if ((requireIdentity || envelope.version !== undefined) && envelope.version !== RUNTIME_VERSION) {
69
+ if ((requireIdentity || envelope.version !== undefined) && binaryMajor(envelope.version) !== RUNTIME_MAJOR) {
54
70
  throw new RuntimeError("VERSION_MISMATCH", "ast-sgrep binary version does not match the extension", { expected: RUNTIME_VERSION, actual: envelope.version });
55
71
  }
56
72
  if ((requireIdentity || envelope.machine_schema_version !== undefined) && envelope.machine_schema_version !== MACHINE_SCHEMA_VERSION) {
@@ -2,7 +2,7 @@
2
2
  * Shared leaf module: version constants, wire types, RuntimeError.
3
3
  * Imports nothing from sibling modules — any file may depend on it.
4
4
  */
5
- export declare const RUNTIME_VERSION = "2.1.0";
5
+ export declare const RUNTIME_VERSION = "2.5.2";
6
6
  export declare const MACHINE_SCHEMA_VERSION = "1.0.0";
7
7
  export declare const CONFIG_SCHEMA_VERSION: 1;
8
8
  /** Index format this release ships. Must equal INDEX_SCHEMA_VERSION in crates/ast-sgrep-core (check-contract gates it). */
@@ -2,7 +2,7 @@
2
2
  * Shared leaf module: version constants, wire types, RuntimeError.
3
3
  * Imports nothing from sibling modules — any file may depend on it.
4
4
  */
5
- export const RUNTIME_VERSION = "2.1.0";
5
+ export const RUNTIME_VERSION = "2.5.2";
6
6
  export const MACHINE_SCHEMA_VERSION = "1.0.0";
7
7
  export const CONFIG_SCHEMA_VERSION = 1;
8
8
  /** Index format this release ships. Must equal INDEX_SCHEMA_VERSION in crates/ast-sgrep-core (check-contract gates it). */
@@ -27,8 +27,16 @@ export type EnvelopeLike = {
27
27
  backend?: unknown;
28
28
  [key: string]: unknown;
29
29
  };
30
- export declare const ASGREP_PROMPT_SNIPPET = "Code search by intent, symbol, defs, callers, pattern (asgrep; use without being asked)";
31
- export declare const ASGREP_PROMPT_GUIDELINES: readonly ["Any code lookup (function, def, caller, intent, pattern): call asgrep first.", "Compose in Code Mode (search/defs/read with Promise.all, return a small shaped value); grep only for exact strings or filenames. Bound with in: \"path\"; on 0 hits use suggested_next."];
30
+ export declare const ASGREP_PROMPT_SNIPPET = "Code search by intent, symbol, defs, callers, pattern (use without being asked)";
31
+ export declare const ASGREP_PROMPT_GUIDELINES: readonly ["Any code lookup: call asgrep first; read code with asgrep_read, change it with asgrep_edit.", "Compose in Code Mode (search/defs/read, Promise.all, small shaped value); grep only exact strings/filenames. Bound with in:\"path\"; on 0 hits use suggested_next."];
32
+ /**
33
+ * Guideline variant for hosts that already ship read/edit built in: our
34
+ * one-shot file tools are inactive there, so naming them would point the
35
+ * model at tools it cannot see. The host's own builtins describe
36
+ * themselves; this keeps only the asgrep-first routing plus the shared
37
+ * composition guideline.
38
+ */
39
+ export declare const ASGREP_PROMPT_GUIDELINES_HOST_FILES: readonly ["Any code lookup: call asgrep first.", "Compose in Code Mode (search/defs/read, Promise.all, small shaped value); grep only exact strings/filenames. Bound with in:\"path\"; on 0 hits use suggested_next."];
32
40
  export declare function paint(theme: PresentTheme | undefined, role: string, text: string, bold?: boolean): string;
33
41
  export declare function hitLocation(hit: HitLike): string;
34
42
  export declare function hitLabel(hit: HitLike): string;
@@ -1,8 +1,19 @@
1
1
  /** Neat asgrep tool chrome for the Pi TUI and the model-visible content. */
2
- export const ASGREP_PROMPT_SNIPPET = "Code search by intent, symbol, defs, callers, pattern (asgrep; use without being asked)";
2
+ export const ASGREP_PROMPT_SNIPPET = "Code search by intent, symbol, defs, callers, pattern (use without being asked)";
3
3
  export const ASGREP_PROMPT_GUIDELINES = [
4
- "Any code lookup (function, def, caller, intent, pattern): call asgrep first.",
5
- "Compose in Code Mode (search/defs/read with Promise.all, return a small shaped value); grep only for exact strings or filenames. Bound with in: \"path\"; on 0 hits use suggested_next.",
4
+ "Any code lookup: call asgrep first; read code with asgrep_read, change it with asgrep_edit.",
5
+ "Compose in Code Mode (search/defs/read, Promise.all, small shaped value); grep only exact strings/filenames. Bound with in:\"path\"; on 0 hits use suggested_next.",
6
+ ];
7
+ /**
8
+ * Guideline variant for hosts that already ship read/edit built in: our
9
+ * one-shot file tools are inactive there, so naming them would point the
10
+ * model at tools it cannot see. The host's own builtins describe
11
+ * themselves; this keeps only the asgrep-first routing plus the shared
12
+ * composition guideline.
13
+ */
14
+ export const ASGREP_PROMPT_GUIDELINES_HOST_FILES = [
15
+ "Any code lookup: call asgrep first.",
16
+ ASGREP_PROMPT_GUIDELINES[1],
6
17
  ];
7
18
  export function paint(theme, role, text, bold = false) {
8
19
  const body = bold && theme ? theme.bold(text) : text;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ast-sgrep",
3
- "version": "2.2.0",
3
+ "version": "2.5.2",
4
4
  "description": "Native Code Mode, structural, graph, and semantic code search for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -53,7 +53,7 @@
53
53
  "scripts": {
54
54
  "build": "tsc -p tsconfig.json && node scripts/copy-guest-worker.mjs",
55
55
  "build:native": "cargo build -p ast-sgrep-codemode-napi --release && node ./scripts/copy-native.mjs",
56
- "test": "ASGREP_CODEMODE_BACKEND=cli node --import tsx --test ../../../tests/pi/extension/code-mode.test.ts ../../../tests/pi/extension/codemode.test.ts ../../../tests/pi/extension/commands.test.ts ../../../tests/pi/extension/present.test.ts ../../../tests/pi/extension/runtime.test.ts ../../../tests/pi/extension/security.test.ts ../../../tests/pi/extension/session-pool.test.ts ../../../tests/pi/extension/skill-workflow.test.ts ../../../tests/pi/extension/sqlite.test.ts ../../../tests/pi/extension/../../../tests/pi/extension/token-budget.test.ts ../../../tests/pi/extension/tools.test.ts",
56
+ "test": "ASGREP_CODEMODE_BACKEND=cli node --import tsx --test ../../../tests/pi/extension/code-mode.test.ts ../../../tests/pi/extension/codemode.test.ts ../../../tests/pi/extension/codemode-fallback.test.ts ../../../tests/pi/extension/commands.test.ts ../../../tests/pi/extension/present.test.ts ../../../tests/pi/extension/runtime.test.ts ../../../tests/pi/extension/security.test.ts ../../../tests/pi/extension/session-pool.test.ts ../../../tests/pi/extension/skill-workflow.test.ts ../../../tests/pi/extension/sqlite.test.ts ../../../tests/pi/extension/../../../tests/pi/extension/token-budget.test.ts ../../../tests/pi/extension/tools.test.ts",
57
57
  "test:native": "node --import tsx --test ../../../tests/pi/extension/native-inprocess.test.ts",
58
58
  "test:all": "npm test && npm run test:native",
59
59
  "prepack": "npm run build",
@@ -63,7 +63,7 @@
63
63
  "node": ">=22.19.0"
64
64
  },
65
65
  "dependencies": {
66
- "ast-sgrep": ">=2.1.0 <3",
66
+ "ast-sgrep": ">=2.0.0 <3",
67
67
  "typebox": "^1.0.0"
68
68
  },
69
69
  "peerDependencies": {