pi-ast-sgrep 2.5.3 → 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/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?: {
@@ -106,3 +100,4 @@ export declare function createAsgrepConnector(host: BatchCapableHost, context: {
106
100
  scope?: string;
107
101
  localReads?: boolean;
108
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) => {
@@ -109,6 +121,15 @@ export function createAsgrepConnector(host, context, options = {}) {
109
121
  payload.lang = scoped.lang.trim();
110
122
  return payload;
111
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
+ };
112
133
  // Bound function properties (not methods) so vm call sites cannot lose `this`.
113
134
  const asgrep = {
114
135
  search: (input, callOptions) => call("search", searchPayload("search", input), callOptions?.signal),
@@ -117,8 +138,8 @@ export function createAsgrepConnector(host, context, options = {}) {
117
138
  path: typeof input.path === "string" ? rebasePath(input.path) : input.path,
118
139
  start: input.start,
119
140
  end: input.end,
120
- ref: typeof input.ref === "string" ? rebaseRef(input.ref) : input.ref,
121
- 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,
122
143
  context_lines: input.contextLines,
123
144
  max_chars: input.maxChars,
124
145
  }), callOptions?.signal),
@@ -139,28 +160,18 @@ export function createAsgrepConnector(host, context, options = {}) {
139
160
  },
140
161
  semantic: (input, callOptions) => call("semantic", searchPayload("semantic", input), callOptions?.signal),
141
162
  chain: (input, callOptions) => call("chain", { query: input.query, limit: clampLimit(input.limit), top_n: 20 }, callOptions?.signal),
142
- defs: (input, callOptions) => {
143
- const scoped = coerceHostArgs("defs", { ...input });
144
- return call("defs", {
145
- symbol: scoped.symbol,
146
- limit: clampLimit(input.limit),
147
- excerpt_lines: clampExcerpt(input.excerptLines),
148
- }, callOptions?.signal);
149
- },
150
- callers: (input, callOptions) => {
151
- const scoped = coerceHostArgs("callers", { ...input });
152
- return call("callers", {
153
- symbol: scoped.symbol,
154
- limit: clampLimit(input.limit),
155
- excerpt_lines: clampExcerpt(input.excerptLines),
156
- }, callOptions?.signal);
157
- },
158
- 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),
159
166
  indexStatus: (callOptions) => call("index_status", {}, callOptions?.signal),
160
167
  indexRepo: (input = {}, callOptions) => call("index_repo", { force: input.force === true }, callOptions?.signal),
161
168
  catalogSearch: (input, callOptions) => call("catalog_search", { query: input.query }, callOptions?.signal),
162
169
  catalogDescribe: (input, callOptions) => call("catalog_describe", { name: input.name }, callOptions?.signal),
163
- 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
+ },
164
175
  };
165
176
  return {
166
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);
@@ -67,7 +67,8 @@ export type Update = (result: {
67
67
  }>;
68
68
  details: Record<string, unknown>;
69
69
  }) => void;
70
- 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;
71
72
  export declare function success(command: string, response: MachineEnvelope, extra?: {
72
73
  query?: string;
73
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 })),
@@ -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
@@ -541,18 +528,19 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
541
528
  },
542
529
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
543
530
  report(onUpdate, "codemode", "started");
531
+ const timeoutMs = typeof params.timeoutMs === "number"
532
+ ? params.timeoutMs
533
+ : runtime.config?.timeoutMs ?? 30_000;
534
+ let timeoutSignal;
544
535
  try {
545
- const timeoutMs = typeof params.timeoutMs === "number"
546
- ? params.timeoutMs
547
- : runtime.config?.timeoutMs ?? 30_000;
548
536
  const deadline = Date.now() + timeoutMs;
549
- const timeoutSignal = AbortSignal.timeout(timeoutMs);
537
+ timeoutSignal = AbortSignal.timeout(timeoutMs);
550
538
  const operationSignal = signal
551
539
  ? AbortSignal.any([signal, timeoutSignal])
552
540
  : timeoutSignal;
553
541
  const options = { signal: operationSignal };
554
542
  ensurePool();
555
- const { root, scope, freshness: fresh } = await freshRoot(ctx.cwd, signal);
543
+ const { root, scope, freshness: fresh } = await freshRoot(ctx.cwd, operationSignal);
556
544
  const { env, binary } = nativeLaunch();
557
545
  // In-process NAPI first; CLI sticky only if addon missing.
558
546
  const sticky = await pool.acquire(root);
@@ -585,14 +573,16 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
585
573
  backend: pool.backend(),
586
574
  });
587
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"] : [];
588
577
  return {
589
- content: [{ type: "text", text: bounded(rendered) }],
578
+ content: [{ type: "text", text: withNotes(rendered, notes) }],
590
579
  details: {
591
580
  ok: true,
592
581
  command: "codemode",
593
582
  result: outcome.result,
594
583
  logs: outcome.logs,
595
584
  rendered,
585
+ notes,
596
586
  stats: outcome.stats,
597
587
  trace: bundle.trace(),
598
588
  wallMs: outcome.wallMs,
@@ -603,6 +593,9 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
603
593
  };
604
594
  }
605
595
  catch (cause) {
596
+ if (timeoutSignal?.aborted && !signal?.aborted) {
597
+ return failure("codemode", new RuntimeError("TIMEOUT", `codemode timed out after ${timeoutMs}ms`));
598
+ }
606
599
  return failure("codemode", cause, signal);
607
600
  }
608
601
  },
@@ -628,9 +621,7 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
628
621
  report(onUpdate, "search", "started");
629
622
  try {
630
623
  ensurePool();
631
- const scopedPath = (typeof params.in === "string" ? params.in : undefined)
632
- ?? extractInPath(params.query);
633
- const fresh = await freshRoot(ctx.cwd, signal, scopedPath);
624
+ const fresh = await freshRoot(ctx.cwd, signal);
634
625
  // The checkout owns the index; the caller's scope rides under it.
635
626
  const anchored = withAnchorScope(params, fresh.scope);
636
627
  const [tool, args] = searchToolCall(anchored);
@@ -679,7 +670,7 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
679
670
  name: "asgrep_edit",
680
671
  label: "asgrep edit",
681
672
  promptSnippet: "Exact-string edit",
682
- description: "Edit by exact-string replace. edits[] applies many atomically.",
673
+ description: "Exact-string edits, validated together before writing.",
683
674
  parameters: editParameters,
684
675
  renderShell: "self",
685
676
  renderCall() {
@@ -801,5 +792,8 @@ function searchToolCall(params) {
801
792
  const prefixed = spec.prefix ? `${spec.prefix}: ${query}` : query;
802
793
  return ["search", { query: prefixed, limit, excerpt_lines, format: "capsule", ...(lang ? { lang } : {}) }];
803
794
  }
795
+ if (/(?:^|\s)in:/u.test(query)) {
796
+ return ["search", { query: queryForMode(query, mode), limit, excerpt_lines, format: "capsule", ...(lang ? { lang } : {}) }];
797
+ }
804
798
  return [spec.tool, { [spec.key]: params.query, limit, excerpt_lines, ...(lang ? { lang } : {}) }];
805
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) {
@@ -1,11 +1,10 @@
1
- import { realpath } from "node:fs/promises";
2
1
  import { constants, accessSync, readFileSync } from "node:fs";
3
2
  import { dirname, isAbsolute, join, resolve } from "node:path";
4
3
  import { createRequire } from "node:module";
5
4
  import { resolveBinary } from "ast-sgrep";
6
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";
7
6
  import { finitePositive, migrateConfig, resolveConfig, rollbackConfig, } from "./config.js";
8
- 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";
9
8
  // The public surface of ./runtime is a contract (package.json exports +
10
9
  // tests): re-export the symbols that moved to their own modules.
11
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";
@@ -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.3",
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.5.2 <3",
66
+ "ast-sgrep": ">=2.5.4 <3",
67
67
  "typebox": "^1.0.0"
68
68
  },
69
69
  "peerDependencies": {