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 +18 -15
- package/dist/codemode/connector.d.ts +10 -15
- package/dist/codemode/connector.js +35 -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/results.d.ts +2 -1
- package/dist/host/results.js +11 -7
- package/dist/host/tools.js +22 -28
- package/dist/runtime/index-health.d.ts +2 -0
- package/dist/runtime/index-health.js +10 -2
- package/dist/runtime/runtime.js +1 -2
- 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/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?: {
|
|
@@ -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 ||
|
|
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) => {
|
|
@@ -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:
|
|
121
|
-
refs: Array.isArray(input.refs) ? input.refs.map(
|
|
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
|
-
|
|
144
|
-
|
|
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: (
|
|
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.
|
|
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/results.d.ts
CHANGED
|
@@ -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;
|
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 })),
|
|
@@ -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
|
|
@@ -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
|
-
|
|
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,
|
|
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:
|
|
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
|
|
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: "
|
|
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 {
|
|
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) {
|
package/dist/runtime/runtime.js
CHANGED
|
@@ -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";
|
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.5.
|
|
66
|
+
"ast-sgrep": ">=2.5.4 <3",
|
|
67
67
|
"typebox": "^1.0.0"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|