pi-ast-sgrep 2.5.2 → 2.5.4

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 CHANGED
@@ -6,7 +6,9 @@ Native Code Mode, structural, graph, and semantic code search for [Pi](https://g
6
6
 
7
7
  `pi-ast-sgrep` gives Pi a warm, project-aware search engine for understanding code. It finds behavior by intent, resolves definitions and callers, traces relationships, matches syntax-aware patterns, joins two indexed channels, and searches local semantic embeddings. The primary `asgrep` tool lets Pi compose several searches in one JavaScript program instead of spending one model round trip per lookup.
8
8
 
9
- **v2.0.0** · 13 languages · local-first semantic · critic + two-channel `AND` · **Code Mode** (on by default, no API key)
9
+ **v2.5.3** (native engine 2.5.2) · 15 languages · local-first semantic · critic + two-channel `AND` · **Code Mode** (on by default, no API key)
10
+
11
+ **2.5.3 recovery fix:** Missing `ASGREP_BIN`/`AST_SGREP_BINARY` developer builds now fall back to the verified bundled binary with a warning. Explicit `binaryPath` settings and invalid binaries remain strict. `asgrep_read` works without a search backend or index, `/asgrep-doctor` reports component versions, and the launcher dependency now requires at least 2.5.2. Update with `pi update npm:pi-ast-sgrep`, then reload Pi.
10
12
 
11
13
  **Upgrading to 2.0:** this is a breaking semver release because the cloud/Ollama embedding backends were removed. Update the Pi package normally; local hashed semantic search remains the default, optional neural embeddings remain in-process, and indexes that still store `embed_backend=cloud|ollama` fail closed until `/asgrep-reindex`. One-shot tools and Code Mode now put bounded hits in `content` so the model sees them, not only display-only `details`.
12
14
 
package/dist/code-mode.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { constants } from "node:fs";
2
- import { lstat, open, realpath } from "node:fs/promises";
2
+ import { lstat, open } from "node:fs/promises";
3
+ import { realpathUtf8 as realpath } from "./runtime/index-health.js";
3
4
  import { isAbsolute, relative, resolve, sep } from "node:path";
4
5
  import { RuntimeError } from "./runtime/runtime.js";
5
6
  const DEFAULT_LIMIT = 20;
@@ -179,7 +180,7 @@ async function readLineWindow(handle, parsed, contextLines, maxChars, signal) {
179
180
  throw new RuntimeError("READ_FAILED", `${parsed.file} is not a regular file`);
180
181
  const wantedStart = Math.max(1, parsed.start - contextLines);
181
182
  const wantedEnd = Math.min(MAX_LINE_NUMBER, parsed.end + contextLines);
182
- const decoder = new TextDecoder("utf-8", { fatal: true });
183
+ const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
183
184
  const stream = handle.createReadStream({
184
185
  autoClose: false,
185
186
  highWaterMark: 64 * 1024,
@@ -195,20 +196,22 @@ async function readLineWindow(handle, parsed, contextLines, maxChars, signal) {
195
196
  let truncated = false;
196
197
  let rangeComplete = false;
197
198
  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);
199
+ const consumeLine = (line, terminated = false) => {
200
+ if (lineNumber >= wantedStart && lineNumber <= wantedEnd && !truncated) {
201
+ const rawLine = terminated && line.endsWith("\r") ? line.slice(0, -1) : line;
202
+ const clamped = boundedPrefix(rawLine, MAX_LINE_CHARS);
203
+ const addition = `${selectedLines > 0 ? "\n" : ""}${clamped.text}`;
204
+ const bounded = boundedPrefix(addition, maxChars - contentChars);
205
+ // Continue scanning to validate the requested range, but do not advance
206
+ // the returned ref/resume offset over text the caller never received.
207
+ if (bounded.text.length > 0 || addition.length === 0) {
208
+ selectedStart ??= lineNumber;
209
+ selectedEnd = lineNumber;
207
210
  content += bounded.text;
208
211
  contentChars += bounded.chars;
209
- truncated = bounded.truncated;
212
+ selectedLines += 1;
210
213
  }
211
- selectedLines += 1;
214
+ truncated = clamped.truncated || bounded.truncated;
212
215
  }
213
216
  if (lineNumber >= wantedEnd)
214
217
  rangeComplete = true;
@@ -231,7 +234,7 @@ async function readLineWindow(handle, parsed, contextLines, maxChars, signal) {
231
234
  }
232
235
  let newline = pending.indexOf("\n");
233
236
  while (newline >= 0) {
234
- consumeLine(pending.slice(0, newline));
237
+ consumeLine(pending.slice(0, newline), true);
235
238
  pending = pending.slice(newline + 1);
236
239
  if (rangeComplete)
237
240
  break;
@@ -252,7 +255,7 @@ async function readLineWindow(handle, parsed, contextLines, maxChars, signal) {
252
255
  catch {
253
256
  throw new RuntimeError("BINARY_FILE", `${parsed.file} is not valid UTF-8 text`);
254
257
  }
255
- if (pending.length > 0 || lineNumber === 1)
258
+ if (pending.length > 0)
256
259
  consumeLine(pending);
257
260
  }
258
261
  }
@@ -23,6 +23,12 @@ export type DispatchSurface = {
23
23
  signal?: AbortSignal;
24
24
  }): Promise<MachineEnvelope>;
25
25
  };
26
+ type SymbolArgs = Omit<SearchArgs, "query"> & {
27
+ symbol: string;
28
+ };
29
+ type ImportArgs = Omit<SearchArgs, "query"> & {
30
+ module: string;
31
+ };
26
32
  export type AsgrepConnector = {
27
33
  search(input: SearchArgs, options?: {
28
34
  signal?: AbortSignal;
@@ -42,25 +48,13 @@ export type AsgrepConnector = {
42
48
  chain(input: ChainArgs, options?: {
43
49
  signal?: AbortSignal;
44
50
  }): Promise<MachineEnvelope>;
45
- defs(input: {
46
- symbol: string;
47
- limit?: number;
48
- excerptLines?: number;
49
- }, options?: {
51
+ defs(input: SymbolArgs, options?: {
50
52
  signal?: AbortSignal;
51
53
  }): Promise<MachineEnvelope>;
52
- callers(input: {
53
- symbol: string;
54
- limit?: number;
55
- excerptLines?: number;
56
- }, options?: {
54
+ callers(input: SymbolArgs, options?: {
57
55
  signal?: AbortSignal;
58
56
  }): Promise<MachineEnvelope>;
59
- imports(input: {
60
- module: string;
61
- limit?: number;
62
- excerptLines?: number;
63
- }, options?: {
57
+ imports(input: ImportArgs, options?: {
64
58
  signal?: AbortSignal;
65
59
  }): Promise<MachineEnvelope>;
66
60
  indexStatus(options?: {
@@ -104,4 +98,6 @@ export declare function createAsgrepConnector(host: BatchCapableHost, context: {
104
98
  }, options?: {
105
99
  signal?: AbortSignal;
106
100
  scope?: string;
101
+ localReads?: boolean;
107
102
  }): ConnectorBundle;
103
+ export {};
@@ -1,3 +1,4 @@
1
+ import { isAbsolute } from "node:path";
1
2
  import { argvFor, asEnvelope, createCodemodeDispatcher, } from "./dispatch.js";
2
3
  import { editFilesFallback, readWindowsFallback } from "./fallback.js";
3
4
  import { coerceHostArgs } from "./guest-api.js";
@@ -36,14 +37,25 @@ export function createAsgrepConnector(host, context, options = {}) {
36
37
  ? options.scope.replace(/^(?:\.\/)+/u, "").replace(/\/+$/u, "")
37
38
  : undefined;
38
39
  const rebasePath = (path) => {
39
- if (!scope || path.startsWith("/") || path.startsWith("~"))
40
+ if (!scope || isAbsolute(path))
40
41
  return path;
41
42
  const clean = path.replace(/^(?:\.\/)+/u, "");
43
+ if (clean === scope || clean.startsWith(`${scope}/`))
44
+ return clean;
42
45
  return clean === "" || clean === "." ? scope : `${scope}/${clean}`;
43
46
  };
44
- const rebaseRef = (ref) => {
45
- const hash = ref.indexOf("#");
46
- return hash === -1 ? rebasePath(ref) : rebasePath(ref.slice(0, hash)) + ref.slice(hash);
47
+ const rebaseReadSpec = (value) => {
48
+ // Refs emitted by search/read are already checkout-relative. Path-form
49
+ // requests may be cwd-relative, including objects inside refs[].
50
+ if (!value || typeof value !== "object" || Array.isArray(value))
51
+ return value;
52
+ const spec = value;
53
+ if (typeof spec.ref === "string")
54
+ return spec;
55
+ return { ...spec,
56
+ ...(typeof spec.path === "string" ? { path: rebasePath(spec.path) } : {}),
57
+ ...(typeof spec.file === "string" ? { file: rebasePath(spec.file) } : {}),
58
+ };
47
59
  };
48
60
  /** Directories below the checkout combine with the anchor scope, never replace it. */
49
61
  const withScope = (input) => {
@@ -80,6 +92,8 @@ export function createAsgrepConnector(host, context, options = {}) {
80
92
  };
81
93
  const call = async (tool, args, signal) => {
82
94
  const opts = callOptions(signal);
95
+ if (tool === "read" && options.localReads)
96
+ return fallbackCall(tool, args, opts.signal);
83
97
  if (NATIVE_FALLBACK_TOOLS.has(tool) && nativeMissing.has(tool))
84
98
  return fallbackCall(tool, args, opts.signal);
85
99
  try {
@@ -107,6 +121,15 @@ export function createAsgrepConnector(host, context, options = {}) {
107
121
  payload.lang = scoped.lang.trim();
108
122
  return payload;
109
123
  };
124
+ const symbolCall = (tool, input, signal) => {
125
+ const args = coerceHostArgs(tool, input);
126
+ const key = tool === "imports" ? "module" : "symbol";
127
+ if (scope || ["in", "fileFilter", "file_filter", "lang"].some(key => typeof args[key] === "string" && args[key].trim())) {
128
+ return call("search", searchPayload("search", { ...args, query: `${tool}:${args[key] ?? ""}` }), signal);
129
+ }
130
+ return call(tool, defined({ [key]: args[key], limit: clampLimit(args.limit),
131
+ excerpt_lines: clampExcerpt(args.excerptLines) }), signal);
132
+ };
110
133
  // Bound function properties (not methods) so vm call sites cannot lose `this`.
111
134
  const asgrep = {
112
135
  search: (input, callOptions) => call("search", searchPayload("search", input), callOptions?.signal),
@@ -115,8 +138,8 @@ export function createAsgrepConnector(host, context, options = {}) {
115
138
  path: typeof input.path === "string" ? rebasePath(input.path) : input.path,
116
139
  start: input.start,
117
140
  end: input.end,
118
- ref: typeof input.ref === "string" ? rebaseRef(input.ref) : input.ref,
119
- refs: Array.isArray(input.refs) ? input.refs.map((ref) => (typeof ref === "string" ? rebaseRef(ref) : ref)) : input.refs,
141
+ ref: input.ref,
142
+ refs: Array.isArray(input.refs) ? input.refs.map(rebaseReadSpec) : input.refs,
120
143
  context_lines: input.contextLines,
121
144
  max_chars: input.maxChars,
122
145
  }), callOptions?.signal),
@@ -137,28 +160,18 @@ export function createAsgrepConnector(host, context, options = {}) {
137
160
  },
138
161
  semantic: (input, callOptions) => call("semantic", searchPayload("semantic", input), callOptions?.signal),
139
162
  chain: (input, callOptions) => call("chain", { query: input.query, limit: clampLimit(input.limit), top_n: 20 }, callOptions?.signal),
140
- defs: (input, callOptions) => {
141
- const scoped = coerceHostArgs("defs", { ...input });
142
- return call("defs", {
143
- symbol: scoped.symbol,
144
- limit: clampLimit(input.limit),
145
- excerpt_lines: clampExcerpt(input.excerptLines),
146
- }, callOptions?.signal);
147
- },
148
- callers: (input, callOptions) => {
149
- const scoped = coerceHostArgs("callers", { ...input });
150
- return call("callers", {
151
- symbol: scoped.symbol,
152
- limit: clampLimit(input.limit),
153
- excerpt_lines: clampExcerpt(input.excerptLines),
154
- }, callOptions?.signal);
155
- },
156
- imports: (input, callOptions) => call("imports", defined({ module: input.module, limit: clampLimit(input.limit), excerpt_lines: clampExcerpt(input.excerptLines) }), callOptions?.signal),
163
+ defs: (input, callOptions) => symbolCall("defs", input, callOptions?.signal),
164
+ callers: (input, callOptions) => symbolCall("callers", input, callOptions?.signal),
165
+ imports: (input, callOptions) => symbolCall("imports", input, callOptions?.signal),
157
166
  indexStatus: (callOptions) => call("index_status", {}, callOptions?.signal),
158
167
  indexRepo: (input = {}, callOptions) => call("index_repo", { force: input.force === true }, callOptions?.signal),
159
168
  catalogSearch: (input, callOptions) => call("catalog_search", { query: input.query }, callOptions?.signal),
160
169
  catalogDescribe: (input, callOptions) => call("catalog_describe", { name: input.name }, callOptions?.signal),
161
- doctor: (callOptions) => host.run(["doctor", ".", "--json"], context, callOptions),
170
+ doctor: async (perCall) => {
171
+ const opts = callOptions(perCall?.signal);
172
+ opts.signal?.throwIfAborted();
173
+ return host.run(["doctor", ".", "--json"], context, opts);
174
+ },
162
175
  };
163
176
  return {
164
177
  asgrep,
@@ -67,6 +67,7 @@ export type BatchCapableHost = ConnectorHost & {
67
67
  /** Sticky NDJSON worker for the whole Code Mode program (preferred). */
68
68
  sticky?: StickyWorker | null;
69
69
  };
70
+ export declare const MUTATING_TOOLS: ReadonlySet<string>;
70
71
  /**
71
72
  * Wraps a host so Promise.all([asgrep.search, asgrep.defs, …]) collapses into
72
73
  * one microtask wave. Prefers sticky serve → one-shot batch → overlapped spawn.
@@ -16,7 +16,7 @@ function callTarget(args) {
16
16
  return text.length > 60 ? text.slice(0, 59) + "…" : text;
17
17
  }
18
18
  const MAX_WAVE = 32;
19
- const MUTATING_TOOLS = new Set(["index_repo", "edit"]);
19
+ export const MUTATING_TOOLS = new Set(["index_repo", "edit"]);
20
20
  const abortError = () => Object.assign(new Error("codemode aborted"), { name: "AbortError" });
21
21
  function rejectWave(wave, cause) {
22
22
  for (const item of wave)
@@ -3,11 +3,9 @@
3
3
  *
4
4
  * Severed-lane contract: when native answers `unknown tool: <name>` for
5
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.
6
+ * an extension tracking main keeps working on an official launcher. Shapes,
7
+ * defaults, caps, and confinement follow crates/ast-sgrep-codemode/src/io.rs.
8
+ * Errors are plain Errors; adapter-specific filesystem messages can differ.
11
9
  *
12
10
  * Deliberate divergences from native, both strictly safer:
13
11
  * - Reads come from disk, never the index (the extension has no row reader
@@ -3,11 +3,9 @@
3
3
  *
4
4
  * Severed-lane contract: when native answers `unknown tool: <name>` for
5
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.
6
+ * an extension tracking main keeps working on an official launcher. Shapes,
7
+ * defaults, caps, and confinement follow crates/ast-sgrep-codemode/src/io.rs.
8
+ * Errors are plain Errors; adapter-specific filesystem messages can differ.
11
9
  *
12
10
  * Deliberate divergences from native, both strictly safer:
13
11
  * - Reads come from disk, never the index (the extension has no row reader
@@ -15,9 +13,9 @@
15
13
  * - Edits skip the native targeted reindex. A fallback edit is exactly like
16
14
  * an external editor write, which the freshness layer already reconciles.
17
15
  */
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";
16
+ import { readFile, writeFile } from "node:fs/promises";
17
+ import { isAbsolute, join, relative, sep } from "node:path";
18
+ import { pathContained, realpathUtf8 as realpath } from "../runtime/index-health.js";
21
19
  // Native caps (crates/ast-sgrep-codemode/src/io.rs + core limits).
22
20
  const MAX_READ_REFS = 32;
23
21
  const MAX_READ_CHARS = 100_000;
@@ -26,6 +24,11 @@ const MAX_LINE_CHARS = 2_000;
26
24
  const MAX_INDEX_FILE_BYTES = 64 * 1024 * 1024;
27
25
  const MAX_EXCERPT_LINES = 100;
28
26
  const U32_MAX = 4_294_967_295;
27
+ const editTails = new Map();
28
+ function assertUnicode(text) {
29
+ if (Buffer.from(text, "utf8").toString("utf8") !== text)
30
+ throw new Error("invalid Unicode: unpaired UTF-16 surrogate");
31
+ }
29
32
  /** Mirror serde as_u64 (integers only, then wrapping `as u32`). */
30
33
  function u32(value) {
31
34
  if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > Number.MAX_SAFE_INTEGER)
@@ -49,7 +52,7 @@ function present(args, ...keys) {
49
52
  function rustLines(text) {
50
53
  if (text === "")
51
54
  return [];
52
- const lines = text.split("\n").map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
55
+ const lines = text.split("\n").map((line, index, all) => index < all.length - 1 && line.endsWith("\r") ? line.slice(0, -1) : line);
53
56
  if (text.endsWith("\n"))
54
57
  lines.pop();
55
58
  return lines;
@@ -70,6 +73,7 @@ async function jailRoot(configuredCwd, args) {
70
73
  const raw = args.root;
71
74
  if (typeof raw !== "string")
72
75
  return configured;
76
+ assertUnicode(raw);
73
77
  const candidate = isAbsolute(raw) ? raw : join(configured, raw);
74
78
  const resolved = await realpath(candidate).catch(() => {
75
79
  throw new Error(`cannot resolve requested root: ${candidate}`);
@@ -81,7 +85,8 @@ async function jailRoot(configuredCwd, args) {
81
85
  }
82
86
  /** Mirror jail_rel_path: explicit '..' rejection, canonicalize, containment. */
83
87
  async function jailPath(root, raw) {
84
- if (raw.split(/[\\/]/u).includes(".."))
88
+ assertUnicode(raw);
89
+ if (raw.split(sep === "\\" ? /[\\/]/u : "/").includes(".."))
85
90
  throw new Error("path must not contain '..'");
86
91
  const candidate = isAbsolute(raw) ? raw : join(root, raw);
87
92
  const abs = await realpath(candidate).catch(() => {
@@ -89,7 +94,7 @@ async function jailPath(root, raw) {
89
94
  });
90
95
  if (!pathContained(root, abs))
91
96
  throw new Error(`path escapes session root: ${raw}`);
92
- return { abs, display: relative(root, abs).replace(/\\/g, "/") };
97
+ return { abs, display: relative(root, abs).split(sep).join("/") };
93
98
  }
94
99
  function parseU32Strict(text) {
95
100
  const digits = /^(?:\+)?(\d+)$/.exec(text)?.[1];
@@ -127,9 +132,9 @@ function parseRefValue(value) {
127
132
  const path = present(obj, "path", "file");
128
133
  if (typeof path !== "string")
129
134
  throw new Error("ref.path is required");
130
- const start = u32(present(obj, "start", "line_start")) ?? 1;
135
+ const start = Math.max(1, u32(present(obj, "start", "line_start")) ?? 1);
131
136
  const end = u32(present(obj, "end", "line_end")) ?? start;
132
- return { path, start: Math.max(1, start), end: Math.max(start, end) };
137
+ return { path, start, end: Math.max(start, end) };
133
138
  }
134
139
  function collectRefs(args) {
135
140
  if (Array.isArray(args.refs))
@@ -139,9 +144,9 @@ function collectRefs(args) {
139
144
  const path = present(args, "path", "file");
140
145
  if (typeof path !== "string")
141
146
  throw new Error("path is required");
142
- const start = u32(present(args, "start", "line_start")) ?? 1;
147
+ const start = Math.max(1, u32(present(args, "start", "line_start")) ?? 1);
143
148
  const end = u32(present(args, "end", "line_end")) ?? start;
144
- return [{ path, start: Math.max(1, start), end: Math.max(start, end) }];
149
+ return [{ path, start, end: Math.max(start, end) }];
145
150
  }
146
151
  function sliceWindow(numbered, start, end, maxChars) {
147
152
  let out = "";
@@ -167,24 +172,33 @@ function sliceWindow(numbered, start, end, maxChars) {
167
172
  actualStart = no;
168
173
  first = false;
169
174
  }
170
- if (out !== "")
175
+ else {
171
176
  out += "\n";
177
+ }
172
178
  out += line;
173
179
  actualEnd = no;
174
180
  chars += add;
175
181
  }
176
182
  if (first)
177
- return { text: "", actualStart: start, actualEnd: start, truncated: false };
183
+ return { text: "", actualStart: start, actualEnd: start, truncated };
178
184
  return { text: out, actualStart, actualEnd, truncated };
179
185
  }
180
186
  async function readFileCapped(abs, display, signal) {
181
- const text = await readFile(abs, "utf8").catch(() => {
187
+ const bytes = await readFile(abs, { signal }).catch(() => {
188
+ signal?.throwIfAborted();
182
189
  throw new Error(`cannot read ${display}`);
183
190
  });
184
- if (Buffer.byteLength(text) > MAX_INDEX_FILE_BYTES)
191
+ if (bytes.length > MAX_INDEX_FILE_BYTES)
185
192
  throw new Error(`${display} exceeds max ${MAX_INDEX_FILE_BYTES} bytes`);
186
193
  signal?.throwIfAborted();
187
- return text;
194
+ try {
195
+ // Match Rust's strict UTF-8 reads. Replacement decoding would corrupt bytes
196
+ // outside the requested edit; ignoreBOM retains a literal source BOM.
197
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
198
+ }
199
+ catch {
200
+ throw new Error(`cannot read ${display}: invalid UTF-8`);
201
+ }
188
202
  }
189
203
  /** Mirror read_windows (disk-backed; see module note). */
190
204
  export async function readWindowsFallback(configuredCwd, args, signal) {
@@ -232,6 +246,8 @@ function parseEditValue(value) {
232
246
  throw new Error("newText is required");
233
247
  if (oldText === "")
234
248
  throw new Error("oldText must not be empty");
249
+ assertUnicode(oldText);
250
+ assertUnicode(newText);
235
251
  return { path, oldText, newText };
236
252
  }
237
253
  function collectEdits(args) {
@@ -252,6 +268,21 @@ function matchLine(buffer, offset) {
252
268
  export async function editFilesFallback(configuredCwd, args, signal) {
253
269
  signal?.throwIfAborted();
254
270
  const root = await jailRoot(configuredCwd, args);
271
+ // Native mutations serialize per session. Fallbacks can be reached through
272
+ // different connectors, so their read/modify/write queue belongs here.
273
+ const operation = (editTails.get(root) ?? Promise.resolve()).then(() => applyEdits(root, args, signal));
274
+ const tail = operation.then(() => undefined, () => undefined);
275
+ editTails.set(root, tail);
276
+ try {
277
+ return await operation;
278
+ }
279
+ finally {
280
+ if (editTails.get(root) === tail)
281
+ editTails.delete(root);
282
+ }
283
+ }
284
+ async function applyEdits(root, args, signal) {
285
+ signal?.throwIfAborted();
255
286
  const edits = collectEdits(args);
256
287
  if (edits.length === 0)
257
288
  throw new Error("edit requires path+oldText+newText or edits[]");
@@ -13,7 +13,7 @@
13
13
  * generation (an ABI mismatch risks crashes, not errors). Older launchers
14
14
  * serve Code Mode over CLI sticky/batch/spawn instead — same tools, slower.
15
15
  */
16
- export declare const CODEMODE_BINDING_VERSION = "2.5.2";
16
+ export declare const CODEMODE_BINDING_VERSION = "2.5.4";
17
17
  export type NativeSessionConfig = {
18
18
  root?: string;
19
19
  indexPath?: string;
@@ -17,7 +17,7 @@ import { createRequire } from "node:module";
17
17
  import { existsSync } from "node:fs";
18
18
  import { dirname, join } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
- export const CODEMODE_BINDING_VERSION = "2.5.2";
20
+ export const CODEMODE_BINDING_VERSION = "2.5.4";
21
21
  let cached;
22
22
  function platformTriple() {
23
23
  const { platform, arch } = process;
@@ -60,7 +60,7 @@ function candidatePaths() {
60
60
  join(here, "..", "..", "native"),
61
61
  join(here, "..", "native"),
62
62
  // Workspace release output
63
- join(here, "..", "..", "..", "..", "target", "release"),
63
+ join(here, "..", "..", "..", "..", "..", "target", "release"),
64
64
  ];
65
65
  const cargoTarget = process.env.CARGO_TARGET_DIR;
66
66
  if (cargoTarget)
@@ -7,7 +7,7 @@
7
7
  * Fallback: sticky `codemode-serve` child only when the `.node` addon is missing
8
8
  * (unsupported host / incomplete install). Doctor reports that as degraded.
9
9
  */
10
- import { asEnvelope } from "./dispatch.js";
10
+ import { asEnvelope, MUTATING_TOOLS } from "./dispatch.js";
11
11
  import { loadCodemodeNative } from "./native.js";
12
12
  import { defined } from "./types.js";
13
13
  import { startStickyWorker } from "./worker.js";
@@ -200,7 +200,7 @@ export class NativeSessionPool {
200
200
  return await worker.call(tool, args, options);
201
201
  }
202
202
  catch (cause) {
203
- if (options?.signal?.aborted || !isClosedWorkerError(cause))
203
+ if (options?.signal?.aborted || MUTATING_TOOLS.has(tool) || !isClosedWorkerError(cause))
204
204
  throw cause;
205
205
  await this.invalidate(root);
206
206
  const retry = await this.acquire(root);
@@ -14,17 +14,25 @@ async function runCommand(runtime, command, ctx, args) {
14
14
  error: { code: "INVALID_ARGUMENTS", message: `/${command} does not accept arguments`, details: { args } },
15
15
  };
16
16
  }
17
+ let diagnostics;
17
18
  try {
19
+ if (command === "asgrep-doctor")
20
+ diagnostics = await runtime.diagnostics?.({ cwd: ctx.cwd });
18
21
  const response = await runtime.run([command.slice("asgrep-".length), ".", "--json"], { cwd: ctx.cwd });
19
- return { ok: true, command, response };
22
+ return { ok: true, command, response, ...(diagnostics ? { diagnostics } : {}) };
20
23
  }
21
24
  catch (cause) {
22
- return { ok: false, command, error: errorDetails(cause) };
25
+ return { ok: false, command, error: errorDetails(cause), ...(diagnostics ? { diagnostics } : {}) };
23
26
  }
24
27
  }
25
28
  function compactCommandResult(result) {
29
+ const diagnostics = result.diagnostics;
30
+ const versions = diagnostics
31
+ ? `extension=${diagnostics.extension ?? "unknown"} launcher=${diagnostics.launcher ?? "unknown"} native=${diagnostics.native ?? "unavailable"}`
32
+ : "";
33
+ const warning = typeof diagnostics?.warning === "string" ? diagnostics.warning : "";
26
34
  if (!result.ok)
27
- return `${result.command} failed [${result.error.code}]: ${result.error.message}`;
35
+ return bounded([`${result.command} failed [${result.error.code}]: ${result.error.message}`, versions, warning].filter(Boolean).join(" · "));
28
36
  const response = result.response;
29
37
  const counts = response.counts && typeof response.counts === "object"
30
38
  ? Object.entries(response.counts).map(([key, value]) => `${key}=${String(value)}`).join(" ")
@@ -32,7 +40,7 @@ function compactCommandResult(result) {
32
40
  const state = typeof response.status === "string" ? response.status
33
41
  : typeof response.index_status === "string" ? response.index_status
34
42
  : response.ok ? "healthy" : "failed";
35
- return bounded([`${result.command}: ${state}`, counts].filter(Boolean).join(" · "));
43
+ return bounded([`${result.command}: ${state}`, counts, versions, warning].filter(Boolean).join(" · "));
36
44
  }
37
45
  export function registerAstSgrepCommands(pi, runtime = new AstSgrepRuntime(pi)) {
38
46
  for (const [name, description] of COMMANDS) {
@@ -42,7 +50,7 @@ export function registerAstSgrepCommands(pi, runtime = new AstSgrepRuntime(pi))
42
50
  const ctx = context;
43
51
  const result = await runCommand(runtime, name, ctx, args);
44
52
  const output = ctx.hasUI ? compactCommandResult(result) : JSON.stringify(result);
45
- ctx.ui.notify(output, result.ok ? "info" : "error");
53
+ ctx.ui.notify(output, result.ok ? result.diagnostics?.warning ? "warning" : "info" : "error");
46
54
  },
47
55
  });
48
56
  }
@@ -14,6 +14,10 @@ export type RuntimeLike = {
14
14
  nativeEnv?(options?: {
15
15
  env?: NodeJS.ProcessEnv;
16
16
  }): NodeJS.ProcessEnv;
17
+ binaryWarning?(): string | undefined;
18
+ diagnostics?(context: {
19
+ cwd: string;
20
+ }): Promise<Record<string, unknown>>;
17
21
  config?: {
18
22
  timeoutMs?: number;
19
23
  maxOutputBytes?: number;
@@ -42,6 +46,8 @@ export type CommandContext = ToolContext & {
42
46
  };
43
47
  };
44
48
  export type CommandResult = {
49
+ diagnostics?: Record<string, unknown>;
50
+ } & ({
45
51
  ok: true;
46
52
  command: string;
47
53
  response: MachineEnvelope;
@@ -53,7 +59,7 @@ export type CommandResult = {
53
59
  message: string;
54
60
  details: Readonly<Record<string, unknown>>;
55
61
  };
56
- };
62
+ });
57
63
  export type Update = (result: {
58
64
  content: Array<{
59
65
  type: "text";
@@ -61,7 +67,8 @@ export type Update = (result: {
61
67
  }>;
62
68
  details: Record<string, unknown>;
63
69
  }) => void;
64
- export declare function bounded(text: string): string;
70
+ export declare function bounded(text: string, maxChars?: number): string;
71
+ export declare function withNotes(text: string, notes?: string[]): string;
65
72
  export declare function success(command: string, response: MachineEnvelope, extra?: {
66
73
  query?: string;
67
74
  mode?: string;
@@ -4,12 +4,18 @@
4
4
  */
5
5
  import { isClosedWorkerError } from "../codemode/index.js";
6
6
  import { RuntimeError } from "../runtime/types.js";
7
- import { formatEditResult, formatIndexResult, formatReadResult, formatSearchResult, formatStatusResult } from "../ui/present.js";
7
+ import { boundedText, formatEditResult, formatIndexResult, formatReadResult, formatSearchResult, formatStatusResult } from "../ui/present.js";
8
8
  export const MAX_CONTENT_CHARS = 8_000;
9
- export function bounded(text) {
10
- return text.length <= MAX_CONTENT_CHARS ? text : `${text.slice(0, MAX_CONTENT_CHARS - 1)}…`;
9
+ export function bounded(text, maxChars = MAX_CONTENT_CHARS) {
10
+ return boundedText(text, maxChars);
11
+ }
12
+ const noteText = (notes = []) => bounded(notes.map(note => ` ! ${note}`).join("\n"), 2000);
13
+ export function withNotes(text, notes = []) {
14
+ const prefix = noteText(notes);
15
+ return prefix ? `${prefix}\n${bounded(text, MAX_CONTENT_CHARS - prefix.length - 1)}` : bounded(text);
11
16
  }
12
17
  export function success(command, response, extra = {}) {
18
+ const notes = noteText(extra.notes);
13
19
  const text = command === "status"
14
20
  ? formatStatusResult(response)
15
21
  : command === "index" || command === "reindex"
@@ -17,13 +23,11 @@ export function success(command, response, extra = {}) {
17
23
  : command === "edit"
18
24
  ? formatEditResult(response)
19
25
  : command === "read"
20
- ? formatReadResult(response)
26
+ ? formatReadResult(response, undefined, MAX_CONTENT_CHARS - (notes ? notes.length + 1 : 0))
21
27
  : formatSearchResult(response, { command, ...extra });
22
28
  // Notes qualify the answer the agent is about to trust (stale index, empty
23
29
  // index): they stay in the model-visible text, not only in the details bag.
24
- const body = (extra.notes ?? []).length > 0
25
- ? `${text}\n${(extra.notes ?? []).map((note) => ` ! ${note}`).join("\n")}`
26
- : text;
30
+ const body = withNotes(text, extra.notes);
27
31
  return {
28
32
  content: [{ type: "text", text: bounded(body) }],
29
33
  // The tool execute owns its machine command: normalize the envelope's
@@ -6,7 +6,7 @@ import { AstSgrepRuntime, FreshnessCoordinator, RuntimeError } from "../runtime/
6
6
  import { RESOLVED_ROOT } from "../runtime/types.js";
7
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
- import { bounded, errorDetails, failure, isFreshnessTimeout, extractInPath, report, success, } from "./results.js";
9
+ import { bounded, errorDetails, failure, isFreshnessTimeout, report, success, withNotes, } from "./results.js";
10
10
  export const DEFAULT_LIMIT = 8;
11
11
  const MAX_LIMIT = 100;
12
12
  const MAX_EXCERPT_LINES = 100;
@@ -33,7 +33,7 @@ const editParameters = Type.Object({
33
33
  path: Type.Optional(Type.String({ minLength: 1, maxLength: 512 })),
34
34
  oldText: Type.String({ minLength: 1 }),
35
35
  newText: Type.String(),
36
- }), { maxItems: 64, description: "Multi-edit entries; top-level path is the default" })),
36
+ }), { maxItems: 16, description: "Multi-edit entries; top-level path is the default" })),
37
37
  }, { additionalProperties: false });
38
38
  const readParameters = Type.Object({
39
39
  path: Type.Optional(Type.String({ maxLength: 512 })),
@@ -281,7 +281,7 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
281
281
  const requireBackend = (availability, context) => {
282
282
  if (availability.kind !== "unavailable")
283
283
  return;
284
- throw new RuntimeError("BACKEND_UNAVAILABLE", "ast-sgrep backend unavailable (no NAPI session and no CLI binary)", {
284
+ throw new RuntimeError("BACKEND_UNAVAILABLE", `ast-sgrep backend unavailable (no NAPI session and no CLI binary)${availability.cause ? `: ${availability.cause}` : ""}. Run /asgrep-doctor for recovery details.`, {
285
285
  backend: "unavailable",
286
286
  // Agent-facing mirrors of the closed unavailable variant (not an open product).
287
287
  napi: false,
@@ -352,8 +352,8 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
352
352
  };
353
353
  }
354
354
  /**
355
- * Freshness gate shared by the one-shot tools: ensureFresh with a bounded
356
- * timeout fallback, or a scoped-path index when the query carries in:/fileFilter.
355
+ * Shared freshness gate. Query scopes (including directories and globs) filter
356
+ * retrieval; they are not file-only index --path mutation requests.
357
357
  *
358
358
  * Bounded means serve-stale, not fail: a caller that ran out of freshness
359
359
  * budget still queries the current index and is told the result may be stale.
@@ -361,23 +361,10 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
361
361
  * invalidating would kill the index work the caller just stopped waiting for
362
362
  * and leave the root permanently stale.
363
363
  */
364
- const freshRoot = async (cwd, signal, scopedPath) => {
364
+ const freshRoot = async (cwd, signal) => {
365
365
  const options = signal ? { signal } : {};
366
366
  const anchor = await anchorRoot(cwd);
367
367
  const scope = anchor.scope;
368
- // A subtree refresh still lands in the checkout's own index.
369
- const target = scopedPath ? (scope ? `${scope}/${scopedPath}` : scopedPath) : undefined;
370
- if (target) {
371
- try {
372
- await nativeCall("index_repo", { paths: [target] }, rootedAt(anchor.root), options);
373
- }
374
- catch (cause) {
375
- if (!isFreshnessTimeout(cause, signal))
376
- throw cause;
377
- return { root: anchor.root, ...(scope ? { scope } : {}), freshness: "stale" };
378
- }
379
- return { root: anchor.root, ...(scope ? { scope } : {}) };
380
- }
381
368
  try {
382
369
  const resolved = await freshness.ensureFresh(warmRuntime, rootedAt(anchor.root), options);
383
370
  // The contract is a root string; a host/test double that returns nothing
@@ -502,6 +489,11 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
502
489
  catch {
503
490
  // Doctor reports backend errors; a failed warmup must not block the session.
504
491
  }
492
+ finally {
493
+ const warning = runtime.binaryWarning?.();
494
+ if (warning && ctx.hasUI)
495
+ ctx.ui.notify(warning, "warning");
496
+ }
505
497
  })();
506
498
  });
507
499
  pi.on("session_shutdown", () => {
@@ -536,18 +528,19 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
536
528
  },
537
529
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
538
530
  report(onUpdate, "codemode", "started");
531
+ const timeoutMs = typeof params.timeoutMs === "number"
532
+ ? params.timeoutMs
533
+ : runtime.config?.timeoutMs ?? 30_000;
534
+ let timeoutSignal;
539
535
  try {
540
- const timeoutMs = typeof params.timeoutMs === "number"
541
- ? params.timeoutMs
542
- : runtime.config?.timeoutMs ?? 30_000;
543
536
  const deadline = Date.now() + timeoutMs;
544
- const timeoutSignal = AbortSignal.timeout(timeoutMs);
537
+ timeoutSignal = AbortSignal.timeout(timeoutMs);
545
538
  const operationSignal = signal
546
539
  ? AbortSignal.any([signal, timeoutSignal])
547
540
  : timeoutSignal;
548
541
  const options = { signal: operationSignal };
549
542
  ensurePool();
550
- const { root, scope, freshness: fresh } = await freshRoot(ctx.cwd, signal);
543
+ const { root, scope, freshness: fresh } = await freshRoot(ctx.cwd, operationSignal);
551
544
  const { env, binary } = nativeLaunch();
552
545
  // In-process NAPI first; CLI sticky only if addon missing.
553
546
  const sticky = await pool.acquire(root);
@@ -580,14 +573,16 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
580
573
  backend: pool.backend(),
581
574
  });
582
575
  const activationMs = outcome.wallMs;
576
+ const notes = fresh ? ["index refresh is still running; this answer came from the current index and may be stale"] : [];
583
577
  return {
584
- content: [{ type: "text", text: bounded(rendered) }],
578
+ content: [{ type: "text", text: withNotes(rendered, notes) }],
585
579
  details: {
586
580
  ok: true,
587
581
  command: "codemode",
588
582
  result: outcome.result,
589
583
  logs: outcome.logs,
590
584
  rendered,
585
+ notes,
591
586
  stats: outcome.stats,
592
587
  trace: bundle.trace(),
593
588
  wallMs: outcome.wallMs,
@@ -598,6 +593,9 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
598
593
  };
599
594
  }
600
595
  catch (cause) {
596
+ if (timeoutSignal?.aborted && !signal?.aborted) {
597
+ return failure("codemode", new RuntimeError("TIMEOUT", `codemode timed out after ${timeoutMs}ms`));
598
+ }
601
599
  return failure("codemode", cause, signal);
602
600
  }
603
601
  },
@@ -623,9 +621,7 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
623
621
  report(onUpdate, "search", "started");
624
622
  try {
625
623
  ensurePool();
626
- const scopedPath = (typeof params.in === "string" ? params.in : undefined)
627
- ?? extractInPath(params.query);
628
- const fresh = await freshRoot(ctx.cwd, signal, scopedPath);
624
+ const fresh = await freshRoot(ctx.cwd, signal);
629
625
  // The checkout owns the index; the caller's scope rides under it.
630
626
  const anchored = withAnchorScope(params, fresh.scope);
631
627
  const [tool, args] = searchToolCall(anchored);
@@ -674,7 +670,7 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
674
670
  name: "asgrep_edit",
675
671
  label: "asgrep edit",
676
672
  promptSnippet: "Exact-string edit",
677
- description: "Edit by exact-string replace. edits[] applies many atomically.",
673
+ description: "Exact-string edits, validated together before writing.",
678
674
  parameters: editParameters,
679
675
  renderShell: "self",
680
676
  renderCall() {
@@ -716,14 +712,13 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
716
712
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
717
713
  report(onUpdate, "read", "started");
718
714
  try {
719
- ensurePool();
715
+ // Reading source must remain available when the binary or index is broken.
720
716
  const options = signal ? { signal } : {};
721
- const { root, scope, freshness: fresh } = await freshRoot(ctx.cwd, signal);
722
- const sticky = await pool.acquire(root);
723
- const bundle = createAsgrepConnector({ run: (a, c, o) => runtime.run(a, c, o), sticky }, rootedAt(root), { ...options, ...(scope ? { scope } : {}) });
717
+ const { root, scope } = await anchorRoot(ctx.cwd);
718
+ const bundle = createAsgrepConnector({ run: (a, c, o) => runtime.run(a, c, o) }, rootedAt(root), { ...options, localReads: true, ...(scope ? { scope } : {}) });
724
719
  const response = await bundle.asgrep.read(params);
725
720
  report(onUpdate, "read", "completed");
726
- return success("read", response, { backend: pool.backend(), ...(fresh ? { freshness: fresh } : {}) });
721
+ return success("read", response, { backend: "filesystem" });
727
722
  }
728
723
  catch (cause) {
729
724
  return failure("read", cause, signal);
@@ -797,5 +792,8 @@ function searchToolCall(params) {
797
792
  const prefixed = spec.prefix ? `${spec.prefix}: ${query}` : query;
798
793
  return ["search", { query: prefixed, limit, excerpt_lines, format: "capsule", ...(lang ? { lang } : {}) }];
799
794
  }
795
+ if (/(?:^|\s)in:/u.test(query)) {
796
+ return ["search", { query: queryForMode(query, mode), limit, excerpt_lines, format: "capsule", ...(lang ? { lang } : {}) }];
797
+ }
800
798
  return [spec.tool, { [spec.key]: params.query, limit, excerpt_lines, ...(lang ? { lang } : {}) }];
801
799
  }
@@ -1,5 +1,7 @@
1
1
  import { type MachineEnvelope } from "./types.js";
2
2
  export type IndexHealth = "ready" | "missing" | "incompatible";
3
+ /** Never turn a canonical byte path into another file's replacement-character name. */
4
+ export declare function realpathUtf8(path: string): Promise<string>;
3
5
  export declare function pathContained(parent: string, child: string): boolean;
4
6
  export declare function record(value: unknown): Record<string, unknown> | undefined;
5
7
  export declare function indexHealth(status: MachineEnvelope): IndexHealth;
@@ -3,12 +3,20 @@
3
3
  * rebuild-failure reporting, containment helper. Imports types + sqlite.
4
4
  */
5
5
  import { existsSync, readdirSync } from "node:fs";
6
- import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
6
+ import { realpath } from "node:fs/promises";
7
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
8
  import { INDEX_FORMAT_VERSION, RuntimeError } from "./types.js";
8
9
  import { openIndexDatabase } from "./sqlite.js";
10
+ /** Never turn a canonical byte path into another file's replacement-character name. */
11
+ export async function realpathUtf8(path) {
12
+ if (Buffer.from(path, "utf8").toString("utf8") !== path)
13
+ throw new Error("invalid Unicode path");
14
+ const bytes = await realpath(path, { encoding: "buffer" });
15
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
16
+ }
9
17
  export function pathContained(parent, child) {
10
18
  const rel = relative(parent, child);
11
- const first = rel.split(/[\\/]/u, 1)[0];
19
+ const first = rel.split(sep, 1)[0];
12
20
  return rel === "" || (!isAbsolute(rel) && first !== "..");
13
21
  }
14
22
  export function record(value) {
@@ -40,6 +40,9 @@ export declare class AstSgrepRuntime {
40
40
  resolveBinaryPath(options?: {
41
41
  env?: NodeJS.ProcessEnv;
42
42
  }): string;
43
+ binaryWarning(): string | undefined;
44
+ /** Report installed layers even if the project's index cannot be opened. */
45
+ diagnostics(context: RuntimeContext): Promise<Record<string, unknown>>;
43
46
  /** Merged process env for native Code Mode workers. */
44
47
  nativeEnv(options?: {
45
48
  env?: NodeJS.ProcessEnv;
@@ -1,10 +1,10 @@
1
- import { realpath } from "node:fs/promises";
2
- import { constants, accessSync } from "node:fs";
3
- import { isAbsolute, resolve } from "node:path";
1
+ import { constants, accessSync, readFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { createRequire } from "node:module";
4
4
  import { resolveBinary } from "ast-sgrep";
5
5
  import { CONFIG_SCHEMA_VERSION, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_REFRESH_INTERVAL_MS, DEFAULT_TIMEOUT_MS, INDEX_FORMAT_VERSION, MACHINE_SCHEMA_VERSION, RUNTIME_VERSION, RuntimeError, RESOLVED_ROOT, } from "./types.js";
6
6
  import { finitePositive, migrateConfig, resolveConfig, rollbackConfig, } from "./config.js";
7
- import { indexCompletion, indexPathFor, indexQuarantines, inspectIndexFile, pathContained, record, throwIndexRebuildFailed, } from "./index-health.js";
7
+ import { indexCompletion, indexPathFor, indexQuarantines, inspectIndexFile, pathContained, realpathUtf8 as realpath, record, throwIndexRebuildFailed, } from "./index-health.js";
8
8
  // The public surface of ./runtime is a contract (package.json exports +
9
9
  // tests): re-export the symbols that moved to their own modules.
10
10
  export { CONFIG_SCHEMA_VERSION, DEFAULT_FRESHNESS_WAIT_MS, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_REFRESH_INTERVAL_MS, DEFAULT_TIMEOUT_MS, INDEX_FORMAT_VERSION, MACHINE_SCHEMA_VERSION, RUNTIME_VERSION, RuntimeError, } from "./types.js";
@@ -25,18 +25,37 @@ export async function resolveRuntimeRoot(projectCwd, requestedRoot, allowOutside
25
25
  }
26
26
  return candidate;
27
27
  }
28
- function getBinary(config, env, resolver) {
28
+ function getBinary(config, env, resolver, onRecovery) {
29
29
  let binary;
30
+ let warning;
30
31
  try {
31
32
  const options = config.binaryPath ? { binaryPath: config.binaryPath, env } : { env };
32
33
  binary = resolver(options);
33
34
  }
34
35
  catch (cause) {
35
36
  const message = cause instanceof Error ? cause.message : String(cause);
36
- if (config.binaryPath) {
37
- throw new RuntimeError("BINARY_NOT_FOUND", `Configured ast-sgrep binary is unavailable: ${config.binaryPath}`, { binaryPath: config.binaryPath, cause: message });
37
+ const failure = cause;
38
+ // Missing environment-only dev builds may recover, never explicit settings,
39
+ // permissions, empty artifacts, checksum failures, PATH, or downloads.
40
+ if (onRecovery && (env.ASGREP_BIN || env.AST_SGREP_BINARY) && failure?.code === "ASGREP_EXECUTABLE_MISSING" &&
41
+ (failure.cause?.code === "ENOENT" || failure.cause?.code === "ENOTDIR")) {
42
+ const variable = env.ASGREP_BIN ? "ASGREP_BIN" : "AST_SGREP_BINARY";
43
+ try {
44
+ binary = resolver({ env: { ...env, ASGREP_BIN: undefined, AST_SGREP_BINARY: undefined } });
45
+ }
46
+ catch (fallbackCause) {
47
+ const fallback = fallbackCause instanceof Error ? fallbackCause.message : String(fallbackCause);
48
+ throw new RuntimeError("BINARY_RESOLUTION_FAILED", `Missing ${variable} override (${config.binaryPath ?? env[variable]}); bundled binary unavailable: ${fallback}. Run /asgrep-doctor and reinstall pi-ast-sgrep with optional dependencies enabled.`, { cause: message, fallbackCause: fallback });
49
+ }
50
+ warning = `Ignored missing ${variable}=${config.binaryPath ?? env[variable]}; using bundled binary ${binary}. Remove the stale export from your shell configuration.`;
51
+ }
52
+ else {
53
+ const hint = "Correct binaryPath or unset ASGREP_BIN/AST_SGREP_BINARY; run /asgrep-doctor.";
54
+ if (config.binaryPath) {
55
+ throw new RuntimeError("BINARY_NOT_FOUND", `Configured ast-sgrep binary is unavailable: ${config.binaryPath}. ${message}. ${hint}`, { binaryPath: config.binaryPath, cause: message, hint });
56
+ }
57
+ throw new RuntimeError("BINARY_RESOLUTION_FAILED", `Unable to resolve an ast-sgrep binary for this platform: ${message}. Run /asgrep-doctor.`, { cause: message });
38
58
  }
39
- throw new RuntimeError("BINARY_RESOLUTION_FAILED", "Unable to resolve an ast-sgrep binary for this platform", { cause: message });
40
59
  }
41
60
  try {
42
61
  accessSync(binary, constants.X_OK);
@@ -44,6 +63,8 @@ function getBinary(config, env, resolver) {
44
63
  catch (cause) {
45
64
  throw new RuntimeError("BINARY_NOT_EXECUTABLE", `ast-sgrep binary is not executable: ${binary}`, { binaryPath: binary, cause: cause instanceof Error ? cause.message : String(cause) });
46
65
  }
66
+ if (warning)
67
+ onRecovery?.(warning);
47
68
  return binary;
48
69
  }
49
70
  function byteLength(value) { return Buffer.byteLength(value, "utf8"); }
@@ -153,11 +174,15 @@ export class AstSgrepRuntime {
153
174
  config;
154
175
  #resolver;
155
176
  #environment;
177
+ #recoverMissingOverride;
178
+ #binaryWarning;
156
179
  constructor(pi, sources = {}, dependencies = {}) {
157
180
  this.pi = pi;
158
181
  this.#environment = sources.environment ?? process.env;
159
182
  this.config = resolveConfig({ ...sources, environment: this.#environment });
160
183
  this.#resolver = dependencies.resolveBinary ?? resolveBinary;
184
+ this.#recoverMissingOverride = ![sources.explicitProjectConfig, sources.projectSettings, sources.globalSettings, sources.defaults]
185
+ .some(source => source?.binaryPath !== undefined);
161
186
  }
162
187
  async resolveRoot(context) {
163
188
  return context[RESOLVED_ROOT]
@@ -249,7 +274,7 @@ export class AstSgrepRuntime {
249
274
  const root = await this.resolveRoot(context);
250
275
  const timeout = finitePositive(options.timeoutMs, this.config.timeoutMs, "timeoutMs");
251
276
  const env = this.#mergedEnv(options.env);
252
- const binary = getBinary(this.config, env, this.#resolver);
277
+ const binary = this.resolveBinaryPath({ env });
253
278
  try {
254
279
  const execOptions = { cwd: root, env, timeout };
255
280
  if (options.signal)
@@ -267,7 +292,35 @@ export class AstSgrepRuntime {
267
292
  }
268
293
  /** Absolute path to the native binary (for sticky serve / stdin batch spawn). */
269
294
  resolveBinaryPath(options = {}) {
270
- return getBinary(this.config, this.#mergedEnv(options.env), this.#resolver);
295
+ this.#binaryWarning = undefined;
296
+ return getBinary(this.config, this.#mergedEnv(options.env), this.#resolver, this.#recoverMissingOverride ? warning => { this.#binaryWarning = warning; } : undefined);
297
+ }
298
+ binaryWarning() { return this.#binaryWarning; }
299
+ /** Report installed layers even if the project's index cannot be opened. */
300
+ async diagnostics(context) {
301
+ const versionAt = (path) => {
302
+ try {
303
+ return JSON.parse(readFileSync(path, "utf8")).version ?? "unknown";
304
+ }
305
+ catch {
306
+ return "unknown";
307
+ }
308
+ };
309
+ const result = {
310
+ extension: versionAt(new URL("../../package.json", import.meta.url)),
311
+ };
312
+ try {
313
+ const launcher = createRequire(import.meta.url).resolve("ast-sgrep");
314
+ result.launcher = versionAt(join(dirname(launcher), "..", "package.json"));
315
+ result.binaryPath = this.resolveBinaryPath();
316
+ result.native = (await this.checkCompatibility(context)).version;
317
+ }
318
+ catch (cause) {
319
+ result.error = cause instanceof Error ? cause.message : String(cause);
320
+ }
321
+ if (this.#binaryWarning)
322
+ result.warning = this.#binaryWarning;
323
+ return result;
271
324
  }
272
325
  /** Merged process env for native Code Mode workers. */
273
326
  nativeEnv(options = {}) {
@@ -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.5.2";
5
+ export declare const RUNTIME_VERSION = "2.5.4";
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.5.2";
5
+ export const RUNTIME_VERSION = "2.5.4";
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). */
package/dist/ui/card.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /** Pi TUI result card — supernova-style: the call slot is empty; one card
2
2
  * owns the whole lifecycle (running → ops ledger → result → error). Rows are
3
3
  * fixed-column and theme-painted; nothing here writes to the model channel. */
4
- import { displayWidth, hitLabel, hitLocation, paint, sanitizeContent, summarizeValue, truncateToWidth, visibleWidth, } from "./present.js";
4
+ import { displayWidth, hitLabel, hitLocation, hitsOf, paint, sanitizeContent, summarizeValue, truncateToWidth, visibleWidth, } from "./present.js";
5
5
  /** Header text that rides the top border: "asgrep search · 4 hits · 12ms · napi". */
6
6
  function frameLabel(model) {
7
7
  const bits = ["asgrep", model.command, ...model.title.filter((b) => Boolean(b))];
@@ -250,12 +250,6 @@ function editsOf(value) {
250
250
  }
251
251
  return undefined;
252
252
  }
253
- function hitsOf(value) {
254
- if (value && typeof value === "object" && Array.isArray(value.hits)) {
255
- return value.hits;
256
- }
257
- return undefined;
258
- }
259
253
  function resultPreviewLines(value) {
260
254
  if (value === undefined || value === null)
261
255
  return undefined;
@@ -39,10 +39,12 @@ export declare const ASGREP_PROMPT_GUIDELINES: readonly ["Any code lookup: call
39
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."];
40
40
  export declare function paint(theme: PresentTheme | undefined, role: string, text: string, bold?: boolean): string;
41
41
  export declare function hitLocation(hit: HitLike): string;
42
+ /** A caller may return arbitrary data named `hits`; only locations are search hits. */
43
+ export declare function hitsOf(value: unknown): HitLike[] | undefined;
42
44
  export declare function hitLabel(hit: HitLike): string;
43
45
  export declare function formatEditResult(response: EnvelopeLike, theme?: PresentTheme): string;
44
46
  /** Model-visible text for a read envelope: the window contents themselves. */
45
- export declare function formatReadResult(response: EnvelopeLike, theme?: PresentTheme): string;
47
+ export declare function formatReadResult(response: EnvelopeLike, theme?: PresentTheme, maxChars?: number): string;
46
48
  /**
47
49
  * Model-facing result text is deliberately lean: the tool call already carries
48
50
  * the query/mode, the TUI card renders timing and backend for the human, and
@@ -84,6 +86,7 @@ export declare function truncateToWidth(text: string, maxWidth: number, ellipsis
84
86
  * value compacted. Transport fields are dropped rather than rendered.
85
87
  */
86
88
  export declare function summarizeValue(value: unknown, limit?: number): string[];
89
+ export declare function boundedText(text: string, maxChars?: number, notice?: string): string;
87
90
  export declare function formatCodemodeResult(value: unknown, meta?: {
88
91
  stats?: {
89
92
  calls: number;
@@ -30,10 +30,18 @@ export function hitLocation(hit) {
30
30
  return sanitizeContent(hit.ref);
31
31
  return file || "?";
32
32
  }
33
+ /** A caller may return arbitrary data named `hits`; only locations are search hits. */
34
+ export function hitsOf(value) {
35
+ const hits = value && typeof value === "object" ? value.hits : undefined;
36
+ return Array.isArray(hits) && hits.every(hit => hit && typeof hit === "object" &&
37
+ (typeof hit.file === "string" || typeof hit.path === "string" || typeof hit.ref === "string"))
38
+ ? hits : undefined;
39
+ }
33
40
  /** Hard caps for one result: rows shown, excerpt lines per hit, longest preview. */
34
41
  const MAX_HIT_ROWS = 24;
35
42
  const MAX_EXCERPT_LINES_OUT = 12;
36
43
  const MAX_PREVIEW_CHARS = 96;
44
+ const MAX_RESULT_CHARS = 8_000;
37
45
  export function hitLabel(hit) {
38
46
  const symbol = typeof hit.symbol === "string" ? sanitizeContent(hit.symbol) : "";
39
47
  const kind = typeof hit.kind === "string" ? sanitizeContent(hit.kind) : "";
@@ -55,22 +63,38 @@ export function formatEditResult(response, theme) {
55
63
  return rows.join("\n");
56
64
  }
57
65
  /** Model-visible text for a read envelope: the window contents themselves. */
58
- export function formatReadResult(response, theme) {
66
+ export function formatReadResult(response, theme, maxChars = MAX_RESULT_CHARS) {
59
67
  const windows = Array.isArray(response.windows) ? response.windows : [];
60
68
  if (windows.length === 0)
61
69
  return "read: 0 windows";
62
70
  const out = [];
71
+ let displayed = 0;
72
+ const moreWindows = (count) => count > 0 ? [`… ${count} more windows`] : [];
63
73
  for (const w of windows.slice(0, 8)) {
64
74
  const path = sanitizeContent(typeof w.path === "string" ? w.path : "?");
65
- // The window's own path+range is the line the model needs to cite back.
66
- out.push(path + "#L" + (w.start ?? 1) + "-L" + (w.end ?? ""));
67
75
  const text = sanitizeContent(typeof w.text === "string" ? w.text : "");
68
- for (const line of text.split("\n").slice(0, 80))
69
- out.push(line);
76
+ const lines = text.split("\n");
77
+ const start = w.start ?? 1;
78
+ const render = (count) => {
79
+ const end = count < lines.length && typeof start === "number" ? start + Math.max(0, count - 1) : (w.end ?? "");
80
+ return [path + "#L" + start + "-L" + end, ...lines.slice(0, count),
81
+ ...(count < lines.length || w.truncated === true ? ["… (truncated; read a smaller window)"] : [])].join("\n");
82
+ };
83
+ // Reserve the omission notice and fit whole lines before finalizing refs;
84
+ // a later character clip would otherwise overstate the displayed range.
85
+ const suffix = moreWindows(windows.length - displayed - 1);
86
+ let count = Math.min(lines.length, 80);
87
+ const fits = () => [...out, render(count), ...suffix].join("\n").length <= maxChars;
88
+ while (count > 0 && !fits())
89
+ count--;
90
+ if (!fits())
91
+ break;
92
+ out.push(render(count));
93
+ displayed++;
94
+ if (count < Math.min(lines.length, 80))
95
+ break;
70
96
  }
71
- if (windows.length > 8)
72
- out.push("… " + (windows.length - 8) + " more windows");
73
- return out.join("\n");
97
+ return boundedText([...out, ...moreWindows(windows.length - displayed)].join("\n"), maxChars);
74
98
  }
75
99
  /**
76
100
  * Model-facing result text is deliberately lean: the tool call already carries
@@ -324,7 +348,7 @@ export function summarizeValue(value, limit = 4) {
324
348
  }
325
349
  const recordValue = value;
326
350
  const rows = [];
327
- const hits = Array.isArray(recordValue.hits) ? recordValue.hits : undefined;
351
+ const hits = hitsOf(value);
328
352
  const windows = Array.isArray(recordValue.windows)
329
353
  ? recordValue.windows
330
354
  : undefined;
@@ -361,19 +385,34 @@ function compactValue(value) {
361
385
  const json = JSON.stringify(value);
362
386
  return json.length <= 80 ? json : `${json.slice(0, 79)}…`;
363
387
  }
388
+ /** Model output is data, not the TUI's lossy summary. Keep a visible size bound. */
389
+ function codemodePayload(value) {
390
+ return sanitizeContent(typeof value === "string" ? value : JSON.stringify(value, null, 2) ?? String(value));
391
+ }
392
+ export function boundedText(text, maxChars = MAX_RESULT_CHARS, notice = "\n… (truncated)") {
393
+ if (text.length <= maxChars)
394
+ return text;
395
+ if (maxChars <= notice.length)
396
+ return notice.slice(0, Math.max(0, maxChars));
397
+ let end = maxChars - notice.length;
398
+ const last = text.charCodeAt(end - 1);
399
+ if (last >= 0xd800 && last <= 0xdbff)
400
+ end--;
401
+ return text.slice(0, end) + notice;
402
+ }
403
+ function boundedCodemode(text) {
404
+ return boundedText(text, MAX_RESULT_CHARS, "\n… (truncated; return fewer items or smaller windows)");
405
+ }
364
406
  export function formatCodemodeResult(value, meta = {}, theme) {
365
- if (value && typeof value === "object" && Array.isArray(value.hits)) {
366
- const searchMeta = { command: "codemode" };
367
- if (meta.wallMs !== undefined)
368
- searchMeta.activationMs = meta.wallMs;
369
- if (meta.backend !== undefined)
370
- searchMeta.backend = meta.backend;
371
- return formatSearchResult(value, searchMeta, theme);
372
- }
407
+ const result = value && typeof value === "object" ? value : undefined;
408
+ const hits = hitsOf(value);
409
+ const envelope = result?.tool === "asgrep" && typeof result.schema_version === "string" && typeof result.ok === "boolean";
373
410
  const bits = [];
411
+ if (hits)
412
+ bits.push(`${hits.length} hit${hits.length === 1 ? "" : "s"}`);
374
413
  if (value && typeof value === "object") {
375
414
  const record = value;
376
- if (typeof record.hit_count === "number")
415
+ if (!hits && typeof record.hit_count === "number")
377
416
  bits.push(`${record.hit_count} hit${record.hit_count === 1 ? "" : "s"}`);
378
417
  else if (typeof record.node_count === "number")
379
418
  bits.push(`${record.node_count} node${record.node_count === 1 ? "" : "s"}`);
@@ -390,13 +429,12 @@ export function formatCodemodeResult(value, meta = {}, theme) {
390
429
  return `${title}\n${paint(theme, "muted", " (no return statement; add `return` to send a value to the model)")}`;
391
430
  }
392
431
  if (value && typeof value === "object" && !Array.isArray(value)) {
393
- // The program's own return shape is a deliberate choice: summarize it
394
- // wider than a hit preview, or the model has to re-run to see its value.
395
- const rows = summarizeValue(value, 12).map((row) => paint(theme, "toolOutput", ` ${row}`));
396
- return [title, ...rows].join("\n");
397
- }
398
- if (Array.isArray(value)) {
399
- return [title, paint(theme, "toolOutput", ` ${value.length} value${value.length === 1 ? "" : "s"}`)].join("\n");
432
+ // The chosen return value is data. Lossy hit summaries belong only to the
433
+ // TUI or one-shot search, never this explicit model-facing return channel.
434
+ const rows = Object.entries(value)
435
+ .filter(([key]) => !envelope || !TRANSPORT_KEYS.has(key))
436
+ .map(([key, entry]) => paint(theme, "toolOutput", ` ${sanitizeContent(key)}: ${codemodePayload(entry)}`));
437
+ return boundedCodemode([title, ...rows].join("\n"));
400
438
  }
401
- return `${title}\n${paint(theme, "toolOutput", ` ${compactValue(value)}`)}`;
439
+ return boundedCodemode(`${title}\n${paint(theme, "toolOutput", ` ${codemodePayload(value)}`)}`);
402
440
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ast-sgrep",
3
- "version": "2.5.2",
3
+ "version": "2.5.4",
4
4
  "description": "Native Code Mode, structural, graph, and semantic code search for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -63,7 +63,7 @@
63
63
  "node": ">=22.19.0"
64
64
  },
65
65
  "dependencies": {
66
- "ast-sgrep": ">=2.0.0 <3",
66
+ "ast-sgrep": ">=2.5.4 <3",
67
67
  "typebox": "^1.0.0"
68
68
  },
69
69
  "peerDependencies": {