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 +3 -1
- package/dist/code-mode.js +18 -15
- package/dist/codemode/connector.d.ts +11 -15
- package/dist/codemode/connector.js +37 -24
- package/dist/codemode/dispatch.d.ts +1 -0
- package/dist/codemode/dispatch.js +1 -1
- package/dist/codemode/fallback.d.ts +3 -5
- package/dist/codemode/fallback.js +51 -20
- package/dist/codemode/native.d.ts +1 -1
- package/dist/codemode/native.js +2 -2
- package/dist/codemode/session-pool.js +2 -2
- package/dist/host/commands.js +13 -5
- package/dist/host/results.d.ts +9 -2
- package/dist/host/results.js +11 -7
- package/dist/host/tools.js +32 -34
- package/dist/runtime/index-health.d.ts +2 -0
- package/dist/runtime/index-health.js +10 -2
- package/dist/runtime/runtime.d.ts +3 -0
- package/dist/runtime/runtime.js +63 -10
- package/dist/runtime/types.d.ts +1 -1
- package/dist/runtime/types.js +1 -1
- package/dist/ui/card.js +1 -7
- package/dist/ui/present.d.ts +4 -1
- package/dist/ui/present.js +64 -26
- package/package.json +2 -2
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.
|
|
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
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
212
|
+
selectedLines += 1;
|
|
210
213
|
}
|
|
211
|
-
|
|
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
|
|
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 ||
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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:
|
|
119
|
-
refs: Array.isArray(input.refs) ? input.refs.map(
|
|
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
|
-
|
|
142
|
-
|
|
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: (
|
|
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.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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,
|
|
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) =>
|
|
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
|
-
|
|
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).
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
187
|
+
const bytes = await readFile(abs, { signal }).catch(() => {
|
|
188
|
+
signal?.throwIfAborted();
|
|
182
189
|
throw new Error(`cannot read ${display}`);
|
|
183
190
|
});
|
|
184
|
-
if (
|
|
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
|
-
|
|
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.
|
|
16
|
+
export declare const CODEMODE_BINDING_VERSION = "2.5.4";
|
|
17
17
|
export type NativeSessionConfig = {
|
|
18
18
|
root?: string;
|
|
19
19
|
indexPath?: string;
|
package/dist/codemode/native.js
CHANGED
|
@@ -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.
|
|
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);
|
package/dist/host/commands.js
CHANGED
|
@@ -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
|
}
|
package/dist/host/results.d.ts
CHANGED
|
@@ -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;
|
package/dist/host/results.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/dist/host/tools.js
CHANGED
|
@@ -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,
|
|
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:
|
|
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",
|
|
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
|
-
*
|
|
356
|
-
*
|
|
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
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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
|
|
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: "
|
|
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
|
-
|
|
715
|
+
// Reading source must remain available when the binary or index is broken.
|
|
720
716
|
const options = signal ? { signal } : {};
|
|
721
|
-
const { root, scope
|
|
722
|
-
const
|
|
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:
|
|
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 {
|
|
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(
|
|
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;
|
package/dist/runtime/runtime.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
37
|
-
|
|
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 =
|
|
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
|
-
|
|
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 = {}) {
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -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.
|
|
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). */
|
package/dist/runtime/types.js
CHANGED
|
@@ -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.
|
|
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;
|
package/dist/ui/present.d.ts
CHANGED
|
@@ -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;
|
package/dist/ui/present.js
CHANGED
|
@@ -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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
|
394
|
-
//
|
|
395
|
-
const rows =
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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", ` ${
|
|
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
|
+
"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.
|
|
66
|
+
"ast-sgrep": ">=2.5.4 <3",
|
|
67
67
|
"typebox": "^1.0.0"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|