pi-ast-sgrep 2.0.2 → 2.2.0
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 +16 -19
- package/dist/code-mode.d.ts +1 -1
- package/dist/code-mode.js +1 -1
- package/dist/codemode/connector.d.ts +18 -3
- package/dist/codemode/connector.js +85 -31
- package/dist/codemode/dispatch.d.ts +13 -1
- package/dist/codemode/dispatch.js +87 -24
- package/dist/codemode/guest-api.d.ts +16 -0
- package/dist/codemode/guest-api.js +194 -0
- package/dist/codemode/guest-worker.mjs +287 -0
- package/dist/codemode/index.d.ts +4 -3
- package/dist/codemode/index.js +4 -3
- package/dist/codemode/native.d.ts +1 -1
- package/dist/codemode/native.js +1 -1
- package/dist/codemode/runner.d.ts +13 -9
- package/dist/codemode/runner.js +411 -213
- package/dist/codemode/session-pool.d.ts +6 -1
- package/dist/codemode/session-pool.js +125 -32
- package/dist/codemode/types.d.ts +42 -2
- package/dist/codemode/types.js +40 -15
- package/dist/codemode/worker.d.ts +1 -1
- package/dist/codemode/worker.js +25 -2
- package/dist/host/commands.d.ts +6 -0
- package/dist/host/commands.js +49 -0
- package/dist/host/results.d.ts +123 -0
- package/dist/host/results.js +126 -0
- package/dist/host/tools.d.ts +28 -0
- package/dist/host/tools.js +802 -0
- package/dist/index.d.ts +7 -34
- package/dist/index.js +5 -543
- package/dist/runtime/config.d.ts +36 -0
- package/dist/runtime/config.js +98 -0
- package/dist/runtime/freshness.d.ts +43 -0
- package/dist/runtime/freshness.js +446 -0
- package/dist/runtime/index-health.d.ts +16 -0
- package/dist/runtime/index-health.js +111 -0
- package/dist/runtime/runtime.d.ts +48 -0
- package/dist/runtime/runtime.js +265 -0
- package/dist/runtime/sqlite.d.ts +15 -0
- package/dist/runtime/sqlite.js +63 -0
- package/dist/runtime/types.d.ts +55 -0
- package/dist/runtime/types.js +25 -0
- package/dist/ui/card.d.ts +66 -0
- package/dist/ui/card.js +375 -0
- package/dist/ui/present.d.ts +89 -0
- package/dist/ui/present.js +391 -0
- package/package.json +8 -7
- package/dist/codemode/sandbox-worker.d.ts +0 -1
- package/dist/codemode/sandbox-worker.js +0 -204
- package/dist/present.d.ts +0 -70
- package/dist/present.js +0 -260
- package/dist/runtime.d.ts +0 -137
- package/dist/runtime.js +0 -799
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/** Neat asgrep tool chrome for the Pi TUI and the model-visible content. */
|
|
2
|
+
export const ASGREP_PROMPT_SNIPPET = "Code search by intent, symbol, defs, callers, pattern (asgrep; use without being asked)";
|
|
3
|
+
export const ASGREP_PROMPT_GUIDELINES = [
|
|
4
|
+
"Any code lookup (function, def, caller, intent, pattern): call asgrep first.",
|
|
5
|
+
"Compose in Code Mode (search/defs/read with Promise.all, return a small shaped value); grep only for exact strings or filenames. Bound with in: \"path\"; on 0 hits use suggested_next.",
|
|
6
|
+
];
|
|
7
|
+
export function paint(theme, role, text, bold = false) {
|
|
8
|
+
const body = bold && theme ? theme.bold(text) : text;
|
|
9
|
+
return theme ? theme.fg(role, body) : body;
|
|
10
|
+
}
|
|
11
|
+
export function hitLocation(hit) {
|
|
12
|
+
const file = sanitizeContent(String(hit.file ?? hit.path ?? ""));
|
|
13
|
+
const line = hit.start_line ?? hit.line ?? hit.lines;
|
|
14
|
+
if (typeof line === "number")
|
|
15
|
+
return `${file}:${line}`;
|
|
16
|
+
if (typeof line === "string" && line.length > 0)
|
|
17
|
+
return `${file}:${sanitizeContent(line)}`;
|
|
18
|
+
if (typeof hit.ref === "string" && hit.ref.length > 0)
|
|
19
|
+
return sanitizeContent(hit.ref);
|
|
20
|
+
return file || "?";
|
|
21
|
+
}
|
|
22
|
+
/** Hard caps for one result: rows shown, excerpt lines per hit, longest preview. */
|
|
23
|
+
const MAX_HIT_ROWS = 24;
|
|
24
|
+
const MAX_EXCERPT_LINES_OUT = 12;
|
|
25
|
+
const MAX_PREVIEW_CHARS = 96;
|
|
26
|
+
export function hitLabel(hit) {
|
|
27
|
+
const symbol = typeof hit.symbol === "string" ? sanitizeContent(hit.symbol) : "";
|
|
28
|
+
const kind = typeof hit.kind === "string" ? sanitizeContent(hit.kind) : "";
|
|
29
|
+
const raw = typeof hit.preview === "string" ? sanitizeContent(hit.preview).replace(/\s+/g, " ").trim() : "";
|
|
30
|
+
// Truncate instead of dropping: a long preview used to vanish entirely, so a
|
|
31
|
+
// hit could carry no hint at all about what it contained.
|
|
32
|
+
const preview = raw.length > MAX_PREVIEW_CHARS ? raw.slice(0, MAX_PREVIEW_CHARS - 1) + "\u2026" : raw;
|
|
33
|
+
return [symbol, kind, preview].filter(Boolean).join(" ");
|
|
34
|
+
}
|
|
35
|
+
export function formatEditResult(response, theme) {
|
|
36
|
+
const edits = Array.isArray(response.edits) ? response.edits : [];
|
|
37
|
+
const changed = edits.filter((e) => e.changed === true).length;
|
|
38
|
+
const rows = ["edit: " + changed + "/" + edits.length + " changed"];
|
|
39
|
+
for (const entry of edits.slice(0, 12)) {
|
|
40
|
+
const path = sanitizeContent(typeof entry.path === "string" ? entry.path : "?");
|
|
41
|
+
const line = typeof entry.line === "number" ? ":" + entry.line : "";
|
|
42
|
+
rows.push(" " + path + line);
|
|
43
|
+
}
|
|
44
|
+
return rows.join("\n");
|
|
45
|
+
}
|
|
46
|
+
/** Model-visible text for a read envelope: the window contents themselves. */
|
|
47
|
+
export function formatReadResult(response, theme) {
|
|
48
|
+
const windows = Array.isArray(response.windows) ? response.windows : [];
|
|
49
|
+
if (windows.length === 0)
|
|
50
|
+
return "read: 0 windows";
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const w of windows.slice(0, 8)) {
|
|
53
|
+
const path = sanitizeContent(typeof w.path === "string" ? w.path : "?");
|
|
54
|
+
// The window's own path+range is the line the model needs to cite back.
|
|
55
|
+
out.push(path + "#L" + (w.start ?? 1) + "-L" + (w.end ?? ""));
|
|
56
|
+
const text = sanitizeContent(typeof w.text === "string" ? w.text : "");
|
|
57
|
+
for (const line of text.split("\n").slice(0, 80))
|
|
58
|
+
out.push(line);
|
|
59
|
+
}
|
|
60
|
+
if (windows.length > 8)
|
|
61
|
+
out.push("… " + (windows.length - 8) + " more windows");
|
|
62
|
+
return out.join("\n");
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Model-facing result text is deliberately lean: the tool call already carries
|
|
66
|
+
* the query/mode, the TUI card renders timing and backend for the human, and
|
|
67
|
+
* every token here is re-sent with the whole transcript. The first line is the
|
|
68
|
+
* only chrome: "<command>: <payload summary>".
|
|
69
|
+
*/
|
|
70
|
+
export function formatSearchResult(response, meta, theme) {
|
|
71
|
+
const hits = Array.isArray(response.hits) ? response.hits : [];
|
|
72
|
+
// Capsules carry an excerpt whether or not the caller asked; render it only
|
|
73
|
+
// when they did, or every defs/imports answer pays for body text nobody
|
|
74
|
+
// requested (measured: 2.5k tokens of unasked-for excerpts before the guard).
|
|
75
|
+
const excerptBudget = Math.max(0, Math.min(meta.excerptLines ?? 0, MAX_EXCERPT_LINES_OUT));
|
|
76
|
+
let excerptLinesLeft = excerptBudget * Math.min(hits.length, MAX_HIT_ROWS);
|
|
77
|
+
const rows = [`${meta.command}: ${hits.length} hit${hits.length === 1 ? "" : "s"}`];
|
|
78
|
+
for (const hit of hits.slice(0, MAX_HIT_ROWS)) {
|
|
79
|
+
const loc = hitLocation(hit);
|
|
80
|
+
const label = hitLabel(hit);
|
|
81
|
+
// Single-space fields: same payload, fewer tokens per row, and every row
|
|
82
|
+
// is re-sent with the transcript on each turn.
|
|
83
|
+
rows.push(label ? ` ${loc} ${label}` : ` ${loc}`);
|
|
84
|
+
// Body excerpts only exist when the caller asked for them (excerptLines);
|
|
85
|
+
// they used to be dropped here, so asking cost nothing and returned less.
|
|
86
|
+
const excerpt = hit.excerpt;
|
|
87
|
+
if (excerptLinesLeft > 0 && typeof excerpt === "string" && excerpt.trim() !== "") {
|
|
88
|
+
for (const line of sanitizeContent(excerpt).split("\n")) {
|
|
89
|
+
if (excerptLinesLeft === 0)
|
|
90
|
+
break;
|
|
91
|
+
if (line.trim() === "")
|
|
92
|
+
continue;
|
|
93
|
+
rows.push(" " + line.replace(/\s+$/u, ""));
|
|
94
|
+
excerptLinesLeft -= 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (hits.length === 0) {
|
|
99
|
+
// A chain answer is nodes+edges, not hits: depth, site, symbol per row.
|
|
100
|
+
const nodes = Array.isArray(response.nodes)
|
|
101
|
+
? (response.nodes)
|
|
102
|
+
: [];
|
|
103
|
+
if (nodes.length > 0) {
|
|
104
|
+
rows[0] = `${meta.command}: ${nodes.length} nodes`;
|
|
105
|
+
for (const node of nodes.slice(0, 24)) {
|
|
106
|
+
const file = sanitizeContent(typeof node.file === "string" ? node.file : "?");
|
|
107
|
+
const line = typeof node.line_start === "number" ? ":" + node.line_start : "";
|
|
108
|
+
const depth = typeof node.depth === "number" ? "d" + node.depth + " " : "";
|
|
109
|
+
const symbol = typeof node.symbol === "string" ? " " + sanitizeContent(node.symbol) : "";
|
|
110
|
+
rows.push(` ${depth}${file}${line}${symbol}`);
|
|
111
|
+
}
|
|
112
|
+
return rows.join("\n");
|
|
113
|
+
}
|
|
114
|
+
const next = Array.isArray(response.suggested_next)
|
|
115
|
+
? response.suggested_next.filter((item) => typeof item === "string")
|
|
116
|
+
: [];
|
|
117
|
+
for (const query of next.slice(0, 3))
|
|
118
|
+
rows.push(` try: ${sanitizeContent(query)}`);
|
|
119
|
+
}
|
|
120
|
+
if (hits.length > 24)
|
|
121
|
+
rows.push(` … ${hits.length - 24} more`);
|
|
122
|
+
return rows.join("\n");
|
|
123
|
+
}
|
|
124
|
+
export function formatStatusResult(response, theme) {
|
|
125
|
+
const state = typeof response.status === "string" ? response.status
|
|
126
|
+
: typeof response.index_status === "string" ? response.index_status
|
|
127
|
+
: response.ok ? "ok" : "failed";
|
|
128
|
+
const counts = response.counts && typeof response.counts === "object"
|
|
129
|
+
? Object.entries(response.counts).map(([key, value]) => `${key}=${String(value)}`).join(" ")
|
|
130
|
+
: "";
|
|
131
|
+
const backend = typeof response.backend === "string" ? response.backend : "";
|
|
132
|
+
return ["status: " + state, counts, backend].filter(Boolean).join(" ");
|
|
133
|
+
}
|
|
134
|
+
export function formatIndexResult(command, response, theme) {
|
|
135
|
+
const count = typeof response.count === "number" ? response.count
|
|
136
|
+
: typeof response.total === "number" ? response.total
|
|
137
|
+
: typeof response.files_indexed === "number" ? response.files_indexed
|
|
138
|
+
: undefined;
|
|
139
|
+
return count === undefined ? `${command}: done` : `${command}: ${count} file${count === 1 ? "" : "s"}`;
|
|
140
|
+
}
|
|
141
|
+
/** Longest escape sequence we will skip as a unit; longer runs are treated as
|
|
142
|
+
* text so an unterminated sequence cannot swallow the rest of a line. */
|
|
143
|
+
const MAX_ESCAPE_LENGTH = 512;
|
|
144
|
+
/**
|
|
145
|
+
* Length of the terminal escape starting at `index`, or 0 when there is none.
|
|
146
|
+
*
|
|
147
|
+
* A lone or unterminated ESC measures zero and everything after it is text,
|
|
148
|
+
* which is what pi does (measured: "\u001b[31" is three cells, ESC included as
|
|
149
|
+
* zero). Skipping unterminated sequences whole would under-count, and pi kills
|
|
150
|
+
* the process for any rendered line wider than the terminal.
|
|
151
|
+
*/
|
|
152
|
+
function ansiLengthAt(text, index) {
|
|
153
|
+
if (text.charCodeAt(index) !== 0x1b)
|
|
154
|
+
return 0;
|
|
155
|
+
const limit = Math.min(text.length, index + MAX_ESCAPE_LENGTH);
|
|
156
|
+
const next = text[index + 1];
|
|
157
|
+
if (next === "[") {
|
|
158
|
+
for (let cursor = index + 2; cursor < limit; cursor += 1) {
|
|
159
|
+
const code = text.charCodeAt(cursor);
|
|
160
|
+
if (code >= 0x40 && code <= 0x7e)
|
|
161
|
+
return cursor - index + 1;
|
|
162
|
+
}
|
|
163
|
+
return 1;
|
|
164
|
+
}
|
|
165
|
+
if (next === "]") {
|
|
166
|
+
for (let cursor = index + 2; cursor < limit; cursor += 1) {
|
|
167
|
+
if (text.charCodeAt(cursor) === 0x07)
|
|
168
|
+
return cursor - index + 1;
|
|
169
|
+
if (text.charCodeAt(cursor) === 0x1b && text[cursor + 1] === "\\")
|
|
170
|
+
return cursor - index + 2;
|
|
171
|
+
}
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
if (next === "P" || next === "X" || next === "^" || next === "_") {
|
|
175
|
+
for (let cursor = index + 2; cursor < limit; cursor += 1) {
|
|
176
|
+
if (text.charCodeAt(cursor) === 0x1b && text[cursor + 1] === "\\")
|
|
177
|
+
return cursor - index + 2;
|
|
178
|
+
}
|
|
179
|
+
return 1;
|
|
180
|
+
}
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Strip terminal control sequences and C0/C1 controls from untrusted content
|
|
185
|
+
* (tool output, code windows, paths) before this extension paints it. Without
|
|
186
|
+
* this a stray ESC in a file would sit inside our own SGR span, leaving an
|
|
187
|
+
* unterminated color and mis-measuring the row's width.
|
|
188
|
+
*/
|
|
189
|
+
export function sanitizeContent(text) {
|
|
190
|
+
let out = "";
|
|
191
|
+
for (let index = 0; index < text.length;) {
|
|
192
|
+
const ansi = ansiLengthAt(text, index);
|
|
193
|
+
if (ansi > 0) {
|
|
194
|
+
index += ansi;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const code = text.charCodeAt(index);
|
|
198
|
+
if (code === 0x09 || code === 0x0a || (code >= 0x20 && code !== 0x7f && !(code >= 0x80 && code <= 0x9f))) {
|
|
199
|
+
out += text[index];
|
|
200
|
+
}
|
|
201
|
+
index += 1;
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Chrome glyphs this extension draws itself. Pi measures every one of them as a
|
|
207
|
+
* single cell (verified against `visibleWidth` from @earendil-works/pi-tui), so
|
|
208
|
+
* frame geometry can stay exact while everything else rounds UP.
|
|
209
|
+
*/
|
|
210
|
+
const ONE_CELL_CHROME = /^[\u00b7\u00d7\u2022\u2026\u2192\u23f5\u23f8\u2500-\u257f\u25a0-\u25cf\u2591-\u2593\u26d3\u2713\u2714\u2717\u276f\u2588]$/u;
|
|
211
|
+
/**
|
|
212
|
+
* Conservative display width on pi's scale: never under-counts what pi measures.
|
|
213
|
+
*
|
|
214
|
+
* Pi expands tabs to three spaces and measures grapheme clusters with East Asian
|
|
215
|
+
* Width (CJK/fullwidth/emoji = 2); lone combining marks, variation selectors and
|
|
216
|
+
* joiners are zero there. Re-deriving that table here would be a second source of
|
|
217
|
+
* truth that can drift, so this counts only what the card itself draws exactly
|
|
218
|
+
* (one cell) and rounds every other non-ASCII code point UP to two. A line that
|
|
219
|
+
* measures `width` here is therefore at most `width` in the terminal: over-counting
|
|
220
|
+
* can only leave slack before the right border, while under-counting is what pi
|
|
221
|
+
* kills the process for ("Rendered line N exceeds terminal width").
|
|
222
|
+
*/
|
|
223
|
+
export function displayWidth(text) {
|
|
224
|
+
let width = 0;
|
|
225
|
+
for (let index = 0; index < text.length;) {
|
|
226
|
+
const ansi = ansiLengthAt(text, index);
|
|
227
|
+
if (ansi > 0) {
|
|
228
|
+
index += ansi;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const cell = cellWidthAt(text, index);
|
|
232
|
+
width += cell.width;
|
|
233
|
+
index += cell.length;
|
|
234
|
+
}
|
|
235
|
+
return width;
|
|
236
|
+
}
|
|
237
|
+
function cellWidthAt(text, index) {
|
|
238
|
+
const code = text.charCodeAt(index);
|
|
239
|
+
if (code === 0x09)
|
|
240
|
+
return { width: 3, length: 1 };
|
|
241
|
+
if (code <= 0x1f || (code >= 0x7f && code <= 0x9f))
|
|
242
|
+
return { width: 0, length: 1 };
|
|
243
|
+
if (code <= 0x7e)
|
|
244
|
+
return { width: 1, length: 1 };
|
|
245
|
+
if (code >= 0xd800 && code <= 0xdbff)
|
|
246
|
+
return { width: 2, length: 2 };
|
|
247
|
+
if (ONE_CELL_CHROME.test(text[index] ?? ""))
|
|
248
|
+
return { width: 1, length: 1 };
|
|
249
|
+
return { width: 2, length: 1 };
|
|
250
|
+
}
|
|
251
|
+
/** Local stand-in so we do not take a pi-tui dependency. Over-counts wide glyphs rather than under-count. */
|
|
252
|
+
export function visibleWidth(text) {
|
|
253
|
+
return displayWidth(text);
|
|
254
|
+
}
|
|
255
|
+
export function truncateToWidth(text, maxWidth, ellipsis = "...") {
|
|
256
|
+
const limit = Math.max(0, maxWidth);
|
|
257
|
+
if (limit <= 0)
|
|
258
|
+
return "";
|
|
259
|
+
if (visibleWidth(text) <= limit)
|
|
260
|
+
return text;
|
|
261
|
+
const ellipsisWidth = visibleWidth(ellipsis);
|
|
262
|
+
if (ellipsisWidth >= limit)
|
|
263
|
+
return ellipsis.slice(0, limit);
|
|
264
|
+
const budget = limit - ellipsisWidth;
|
|
265
|
+
let kept = "";
|
|
266
|
+
let width = 0;
|
|
267
|
+
for (let index = 0; index < text.length;) {
|
|
268
|
+
const ansi = ansiLengthAt(text, index);
|
|
269
|
+
if (ansi > 0) {
|
|
270
|
+
kept += text.slice(index, index + ansi);
|
|
271
|
+
index += ansi;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const cell = cellWidthAt(text, index);
|
|
275
|
+
if (width + cell.width > budget)
|
|
276
|
+
break;
|
|
277
|
+
kept += text.slice(index, index + cell.length);
|
|
278
|
+
width += cell.width;
|
|
279
|
+
index += cell.length;
|
|
280
|
+
}
|
|
281
|
+
// The cut can land inside a painted span — its closing SGR is past the
|
|
282
|
+
// budget, so the ellipsis and everything after would inherit the open color.
|
|
283
|
+
// Close it explicitly; harmless when the kept spans were already balanced.
|
|
284
|
+
return kept + (kept.includes("\u001b[") ? "\u001b[0m" : "") + ellipsis;
|
|
285
|
+
}
|
|
286
|
+
/** Wire-envelope fields that describe the transport, not the answer. Showing
|
|
287
|
+
* them turns a one-line answer into a JSON dump of our own protocol. */
|
|
288
|
+
const TRANSPORT_KEYS = new Set([
|
|
289
|
+
"tool",
|
|
290
|
+
"command",
|
|
291
|
+
"schema_version",
|
|
292
|
+
"ok",
|
|
293
|
+
"ref",
|
|
294
|
+
"refs",
|
|
295
|
+
"index_path",
|
|
296
|
+
"expand_hint",
|
|
297
|
+
"snapshot",
|
|
298
|
+
"backend",
|
|
299
|
+
"wall_ms",
|
|
300
|
+
"exit_code",
|
|
301
|
+
"prevented_read_bytes",
|
|
302
|
+
"read_bytes_estimate",
|
|
303
|
+
"returned_excerpt_bytes",
|
|
304
|
+
]);
|
|
305
|
+
/**
|
|
306
|
+
* One summary line per interesting entry of a shaped value: known shapes get a
|
|
307
|
+
* sentence ("3 windows · path:1-340"), everything else `key: value` with the
|
|
308
|
+
* value compacted. Transport fields are dropped rather than rendered.
|
|
309
|
+
*/
|
|
310
|
+
export function summarizeValue(value, limit = 4) {
|
|
311
|
+
if (value === null || value === undefined || typeof value !== "object" || Array.isArray(value)) {
|
|
312
|
+
return [compactValue(value)];
|
|
313
|
+
}
|
|
314
|
+
const recordValue = value;
|
|
315
|
+
const rows = [];
|
|
316
|
+
const hits = Array.isArray(recordValue.hits) ? recordValue.hits : undefined;
|
|
317
|
+
const windows = Array.isArray(recordValue.windows)
|
|
318
|
+
? recordValue.windows
|
|
319
|
+
: undefined;
|
|
320
|
+
if (hits)
|
|
321
|
+
rows.push(hits.length + " hit" + (hits.length === 1 ? "" : "s"));
|
|
322
|
+
if (windows && windows.length > 0) {
|
|
323
|
+
const first = windows[0] ?? {};
|
|
324
|
+
const where = typeof first.path === "string" ? sanitizeContent(first.path) : "";
|
|
325
|
+
const range = typeof first.start === "number"
|
|
326
|
+
? ":" + first.start + (typeof first.end === "number" ? "-" + first.end : "")
|
|
327
|
+
: "";
|
|
328
|
+
rows.push(windows.length + " window" + (windows.length === 1 ? "" : "s") + (where ? " · " + where + range : ""));
|
|
329
|
+
}
|
|
330
|
+
for (const [key, entry] of Object.entries(recordValue)) {
|
|
331
|
+
if (rows.length >= limit)
|
|
332
|
+
break;
|
|
333
|
+
if (TRANSPORT_KEYS.has(key))
|
|
334
|
+
continue;
|
|
335
|
+
if (hits && key === "hits")
|
|
336
|
+
continue;
|
|
337
|
+
if (windows && (key === "windows" || key === "count"))
|
|
338
|
+
continue;
|
|
339
|
+
rows.push(key + ": " + compactValue(entry));
|
|
340
|
+
}
|
|
341
|
+
return rows;
|
|
342
|
+
}
|
|
343
|
+
function compactValue(value) {
|
|
344
|
+
if (value === null || value === undefined)
|
|
345
|
+
return String(value);
|
|
346
|
+
if (typeof value !== "object")
|
|
347
|
+
return sanitizeContent(String(value));
|
|
348
|
+
if (Array.isArray(value))
|
|
349
|
+
return `${value.length} item${value.length === 1 ? "" : "s"}`;
|
|
350
|
+
const json = JSON.stringify(value);
|
|
351
|
+
return json.length <= 80 ? json : `${json.slice(0, 79)}…`;
|
|
352
|
+
}
|
|
353
|
+
export function formatCodemodeResult(value, meta = {}, theme) {
|
|
354
|
+
if (value && typeof value === "object" && Array.isArray(value.hits)) {
|
|
355
|
+
const searchMeta = { command: "codemode" };
|
|
356
|
+
if (meta.wallMs !== undefined)
|
|
357
|
+
searchMeta.activationMs = meta.wallMs;
|
|
358
|
+
if (meta.backend !== undefined)
|
|
359
|
+
searchMeta.backend = meta.backend;
|
|
360
|
+
return formatSearchResult(value, searchMeta, theme);
|
|
361
|
+
}
|
|
362
|
+
const bits = [];
|
|
363
|
+
if (value && typeof value === "object") {
|
|
364
|
+
const record = value;
|
|
365
|
+
if (typeof record.hit_count === "number")
|
|
366
|
+
bits.push(`${record.hit_count} hit${record.hit_count === 1 ? "" : "s"}`);
|
|
367
|
+
else if (typeof record.node_count === "number")
|
|
368
|
+
bits.push(`${record.node_count} node${record.node_count === 1 ? "" : "s"}`);
|
|
369
|
+
}
|
|
370
|
+
if (meta.stats && meta.stats.calls > 0) {
|
|
371
|
+
bits.push(`${meta.stats.calls} call${meta.stats.calls === 1 ? "" : "s"}`);
|
|
372
|
+
if (meta.stats.waves > 1)
|
|
373
|
+
bits.push(`${meta.stats.waves} waves`);
|
|
374
|
+
}
|
|
375
|
+
// Backend and wall time are display concerns: the card renders them, the
|
|
376
|
+
// transcript does not need them repeated on every call.
|
|
377
|
+
const title = "codemode" + (bits.length > 0 ? ": " + bits.join(" ") : "");
|
|
378
|
+
if (value === undefined) {
|
|
379
|
+
return `${title}\n${paint(theme, "muted", " (no return statement; add `return` to send a value to the model)")}`;
|
|
380
|
+
}
|
|
381
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
382
|
+
// The program's own return shape is a deliberate choice: summarize it
|
|
383
|
+
// wider than a hit preview, or the model has to re-run to see its value.
|
|
384
|
+
const rows = summarizeValue(value, 12).map((row) => paint(theme, "toolOutput", ` ${row}`));
|
|
385
|
+
return [title, ...rows].join("\n");
|
|
386
|
+
}
|
|
387
|
+
if (Array.isArray(value)) {
|
|
388
|
+
return [title, paint(theme, "toolOutput", ` ${value.length} value${value.length === 1 ? "" : "s"}`)].join("\n");
|
|
389
|
+
}
|
|
390
|
+
return `${title}\n${paint(theme, "toolOutput", ` ${compactValue(value)}`)}`;
|
|
391
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-ast-sgrep",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Native Code Mode, structural, graph, and semantic code search for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"import": "./dist/index.js"
|
|
37
37
|
},
|
|
38
38
|
"./runtime": {
|
|
39
|
-
"types": "./dist/runtime.d.ts",
|
|
40
|
-
"import": "./dist/runtime.js"
|
|
39
|
+
"types": "./dist/runtime/runtime.d.ts",
|
|
40
|
+
"import": "./dist/runtime/runtime.js"
|
|
41
41
|
},
|
|
42
42
|
"./code-mode": {
|
|
43
43
|
"types": "./dist/code-mode.d.ts",
|
|
@@ -51,18 +51,19 @@
|
|
|
51
51
|
"image": "./assets/preview.png"
|
|
52
52
|
},
|
|
53
53
|
"scripts": {
|
|
54
|
-
"build": "tsc -p tsconfig.json",
|
|
54
|
+
"build": "tsc -p tsconfig.json && node scripts/copy-guest-worker.mjs",
|
|
55
55
|
"build:native": "cargo build -p ast-sgrep-codemode-napi --release && node ./scripts/copy-native.mjs",
|
|
56
|
-
"test": "ASGREP_CODEMODE_BACKEND=cli node --import tsx --test ../../../tests/pi/extension/code-mode.test.ts ../../../tests/pi/extension/codemode.test.ts ../../../tests/pi/extension/commands.test.ts ../../../tests/pi/extension/present.test.ts ../../../tests/pi/extension/runtime.test.ts ../../../tests/pi/extension/security.test.ts ../../../tests/pi/extension/session-pool.test.ts ../../../tests/pi/extension/skill-workflow.test.ts ../../../tests/pi/extension/tools.test.ts",
|
|
56
|
+
"test": "ASGREP_CODEMODE_BACKEND=cli node --import tsx --test ../../../tests/pi/extension/code-mode.test.ts ../../../tests/pi/extension/codemode.test.ts ../../../tests/pi/extension/commands.test.ts ../../../tests/pi/extension/present.test.ts ../../../tests/pi/extension/runtime.test.ts ../../../tests/pi/extension/security.test.ts ../../../tests/pi/extension/session-pool.test.ts ../../../tests/pi/extension/skill-workflow.test.ts ../../../tests/pi/extension/sqlite.test.ts ../../../tests/pi/extension/../../../tests/pi/extension/token-budget.test.ts ../../../tests/pi/extension/tools.test.ts",
|
|
57
57
|
"test:native": "node --import tsx --test ../../../tests/pi/extension/native-inprocess.test.ts",
|
|
58
58
|
"test:all": "npm test && npm run test:native",
|
|
59
|
-
"prepack": "npm run build"
|
|
59
|
+
"prepack": "npm run build",
|
|
60
|
+
"bench:p100": "node scripts/p100-bench.mjs"
|
|
60
61
|
},
|
|
61
62
|
"engines": {
|
|
62
63
|
"node": ">=22.19.0"
|
|
63
64
|
},
|
|
64
65
|
"dependencies": {
|
|
65
|
-
"ast-sgrep": "2.
|
|
66
|
+
"ast-sgrep": ">=2.1.0 <3",
|
|
66
67
|
"typebox": "^1.0.0"
|
|
67
68
|
},
|
|
68
69
|
"peerDependencies": {
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
import vm from "node:vm";
|
|
2
|
-
import { parentPort, workerData } from "node:worker_threads";
|
|
3
|
-
const port = (() => {
|
|
4
|
-
if (!parentPort)
|
|
5
|
-
throw new Error("codemode sandbox requires a parent port");
|
|
6
|
-
return parentPort;
|
|
7
|
-
})();
|
|
8
|
-
const data = workerData;
|
|
9
|
-
const pending = new Map();
|
|
10
|
-
const outgoing = [];
|
|
11
|
-
let nextCallId = 0;
|
|
12
|
-
let flushScheduled = false;
|
|
13
|
-
port.on("message", (message) => {
|
|
14
|
-
if (message.type !== "callResult")
|
|
15
|
-
return;
|
|
16
|
-
const resolve = pending.get(message.id);
|
|
17
|
-
if (!resolve)
|
|
18
|
-
return;
|
|
19
|
-
pending.delete(message.id);
|
|
20
|
-
resolve(message.payload);
|
|
21
|
-
});
|
|
22
|
-
const bridge = (method, payload) => new Promise((resolve) => {
|
|
23
|
-
if (nextCallId >= data.limits.bridgeCalls) {
|
|
24
|
-
resolve(JSON.stringify({
|
|
25
|
-
ok: false,
|
|
26
|
-
error: `codemode exceeds ${data.limits.bridgeCalls} host calls`,
|
|
27
|
-
}));
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
const id = nextCallId++;
|
|
31
|
-
pending.set(id, resolve);
|
|
32
|
-
outgoing.push({ id, method, payload });
|
|
33
|
-
if (!flushScheduled) {
|
|
34
|
-
flushScheduled = true;
|
|
35
|
-
queueMicrotask(() => {
|
|
36
|
-
flushScheduled = false;
|
|
37
|
-
const calls = outgoing.splice(0);
|
|
38
|
-
if (calls.length > 0)
|
|
39
|
-
port.postMessage({ type: "calls", calls });
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
});
|
|
43
|
-
void run();
|
|
44
|
-
async function run() {
|
|
45
|
-
const logs = [];
|
|
46
|
-
let logChars = 0;
|
|
47
|
-
const logBridge = (line) => {
|
|
48
|
-
if (logs.length >= data.limits.logLines || logChars >= data.limits.logChars)
|
|
49
|
-
return;
|
|
50
|
-
const remaining = data.limits.logChars - logChars;
|
|
51
|
-
const bounded = line.length <= remaining
|
|
52
|
-
? line
|
|
53
|
-
: `${line.slice(0, Math.max(0, remaining - 1))}…`;
|
|
54
|
-
logs.push(bounded);
|
|
55
|
-
logChars += bounded.length;
|
|
56
|
-
};
|
|
57
|
-
Object.setPrototypeOf(bridge, null);
|
|
58
|
-
Object.setPrototypeOf(logBridge, null);
|
|
59
|
-
Object.freeze(bridge);
|
|
60
|
-
Object.freeze(logBridge);
|
|
61
|
-
try {
|
|
62
|
-
const globals = Object.create(null);
|
|
63
|
-
globals.__asgrepBridge = bridge;
|
|
64
|
-
globals.__asgrepLog = logBridge;
|
|
65
|
-
const context = vm.createContext(globals, {
|
|
66
|
-
codeGeneration: { strings: false, wasm: false },
|
|
67
|
-
});
|
|
68
|
-
new vm.Script(bootstrap(data.limits), {
|
|
69
|
-
filename: "asgrep-codemode-bootstrap.js",
|
|
70
|
-
}).runInContext(context, { timeout: Math.min(data.timeoutMs, 1_000) });
|
|
71
|
-
const script = new vm.Script(data.code, { filename: "asgrep-codemode.js" });
|
|
72
|
-
const value = await Promise.resolve(script.runInContext(context, {
|
|
73
|
-
displayErrors: true,
|
|
74
|
-
timeout: data.timeoutMs,
|
|
75
|
-
}));
|
|
76
|
-
const setResult = context.__asgrepSetResult;
|
|
77
|
-
if (typeof setResult !== "function") {
|
|
78
|
-
throw new Error("codemode result bridge is unavailable");
|
|
79
|
-
}
|
|
80
|
-
setResult(value);
|
|
81
|
-
const serialized = new vm.Script("globalThis.__asgrepSerializeResult()", {
|
|
82
|
-
filename: "asgrep-codemode-result.js",
|
|
83
|
-
}).runInContext(context, {
|
|
84
|
-
displayErrors: true,
|
|
85
|
-
timeout: Math.min(data.timeoutMs, data.limits.serializeTimeoutMs),
|
|
86
|
-
});
|
|
87
|
-
const result = serialized === undefined ? undefined : JSON.parse(serialized);
|
|
88
|
-
finish({ type: "done", ok: true, result, logs });
|
|
89
|
-
}
|
|
90
|
-
catch (cause) {
|
|
91
|
-
finish({
|
|
92
|
-
type: "done",
|
|
93
|
-
ok: false,
|
|
94
|
-
error: safeErrorMessage(cause).slice(0, data.limits.errorChars),
|
|
95
|
-
logs,
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
function safeErrorMessage(cause) {
|
|
100
|
-
try {
|
|
101
|
-
return String(cause instanceof Error ? cause.message : cause);
|
|
102
|
-
}
|
|
103
|
-
catch {
|
|
104
|
-
return "codemode worker failed";
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
function finish(message) {
|
|
108
|
-
port.postMessage(message);
|
|
109
|
-
port.close();
|
|
110
|
-
}
|
|
111
|
-
function bootstrap(limits) {
|
|
112
|
-
return `
|
|
113
|
-
{
|
|
114
|
-
const hostCall = globalThis.__asgrepBridge;
|
|
115
|
-
const hostLog = globalThis.__asgrepLog;
|
|
116
|
-
delete globalThis.__asgrepBridge;
|
|
117
|
-
delete globalThis.__asgrepLog;
|
|
118
|
-
|
|
119
|
-
let resultValue;
|
|
120
|
-
const setResult = (value) => { resultValue = value; };
|
|
121
|
-
const stringify = JSON.stringify;
|
|
122
|
-
const stringifyBounded = (value, maxChars, label) => {
|
|
123
|
-
let remaining = maxChars;
|
|
124
|
-
const serialized = stringify(value, (key, item) => {
|
|
125
|
-
remaining -= key.length + 8;
|
|
126
|
-
if (typeof item === "string") remaining -= item.length;
|
|
127
|
-
if (remaining < 0) throw new Error(\`codemode \${label} exceeds \${maxChars} characters\`);
|
|
128
|
-
return item;
|
|
129
|
-
});
|
|
130
|
-
if (serialized !== undefined && serialized.length > maxChars) {
|
|
131
|
-
throw new Error(\`codemode \${label} exceeds \${maxChars} characters\`);
|
|
132
|
-
}
|
|
133
|
-
return serialized;
|
|
134
|
-
};
|
|
135
|
-
const serializeResult = () => stringifyBounded(resultValue, ${limits.resultJsonChars}, "result");
|
|
136
|
-
Object.freeze(setResult);
|
|
137
|
-
Object.freeze(serializeResult);
|
|
138
|
-
Object.defineProperty(globalThis, "__asgrepSetResult", {
|
|
139
|
-
value: setResult, configurable: false, writable: false,
|
|
140
|
-
});
|
|
141
|
-
Object.defineProperty(globalThis, "__asgrepSerializeResult", {
|
|
142
|
-
value: serializeResult, configurable: false, writable: false,
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
// Worker heap limits do not reliably account for backing stores. Code Mode
|
|
146
|
-
// exchanges JSON, so raw-memory and WebAssembly APIs add risk without utility.
|
|
147
|
-
for (const name of [
|
|
148
|
-
"ArrayBuffer", "SharedArrayBuffer", "DataView", "Atomics", "WebAssembly",
|
|
149
|
-
"Int8Array", "Uint8Array", "Uint8ClampedArray", "Int16Array", "Uint16Array",
|
|
150
|
-
"Int32Array", "Uint32Array", "Float32Array", "Float64Array",
|
|
151
|
-
"BigInt64Array", "BigUint64Array",
|
|
152
|
-
]) {
|
|
153
|
-
Object.defineProperty(globalThis, name, {
|
|
154
|
-
value: undefined, configurable: false, writable: false,
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const invoke = async (method, args = {}) => {
|
|
159
|
-
const payload = stringifyBounded(args, ${limits.bridgeRequestChars}, "call arguments");
|
|
160
|
-
const response = JSON.parse(await hostCall(method, payload));
|
|
161
|
-
if (!response.ok) throw new Error(response.error || \`asgrep.\${method} failed\`);
|
|
162
|
-
return response.value;
|
|
163
|
-
};
|
|
164
|
-
const api = Object.create(null);
|
|
165
|
-
for (const method of [
|
|
166
|
-
"search", "semantic", "chain", "defs", "callers", "imports",
|
|
167
|
-
"indexStatus", "indexRepo", "catalogSearch", "catalogDescribe",
|
|
168
|
-
]) {
|
|
169
|
-
Object.defineProperty(api, method, {
|
|
170
|
-
enumerable: true,
|
|
171
|
-
value: (args = {}) => invoke(method, args),
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
Object.freeze(api);
|
|
175
|
-
|
|
176
|
-
const formatLog = (value) => {
|
|
177
|
-
if (typeof value === "string") return value.slice(0, ${limits.logLineChars});
|
|
178
|
-
try { return stringifyBounded(value, ${limits.logLineChars}, "log line"); }
|
|
179
|
-
catch { return "[unserializable or oversized log value]"; }
|
|
180
|
-
};
|
|
181
|
-
const consoleApi = Object.create(null);
|
|
182
|
-
for (const level of ["log", "info", "warn", "error", "debug"]) {
|
|
183
|
-
Object.defineProperty(consoleApi, level, {
|
|
184
|
-
enumerable: true,
|
|
185
|
-
value: (...args) => {
|
|
186
|
-
let line = "";
|
|
187
|
-
for (const arg of args) {
|
|
188
|
-
const part = formatLog(arg);
|
|
189
|
-
const prefix = line.length === 0 ? "" : " ";
|
|
190
|
-
const remaining = ${limits.logLineChars} - line.length;
|
|
191
|
-
if (remaining <= 0) break;
|
|
192
|
-
line += (prefix + part).slice(0, remaining);
|
|
193
|
-
}
|
|
194
|
-
hostLog(line);
|
|
195
|
-
},
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
Object.freeze(consoleApi);
|
|
199
|
-
|
|
200
|
-
Object.defineProperty(globalThis, "asgrep", { value: api, configurable: false, writable: false });
|
|
201
|
-
Object.defineProperty(globalThis, "console", { value: consoleApi, configurable: false, writable: false });
|
|
202
|
-
}
|
|
203
|
-
`;
|
|
204
|
-
}
|