pi-ast-sgrep 2.1.1 → 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 +1 -0
- package/dist/codemode/connector.d.ts +1 -0
- package/dist/codemode/connector.js +32 -6
- package/dist/codemode/dispatch.js +4 -1
- package/dist/codemode/native.d.ts +1 -1
- package/dist/codemode/native.js +1 -1
- package/dist/codemode/types.d.ts +9 -0
- package/dist/codemode/types.js +12 -13
- package/dist/host/results.d.ts +8 -0
- package/dist/host/results.js +47 -1
- package/dist/host/tools.d.ts +19 -0
- package/dist/host/tools.js +319 -127
- package/dist/runtime/freshness.js +40 -7
- package/dist/runtime/index-health.d.ts +1 -1
- package/dist/runtime/index-health.js +4 -2
- package/dist/runtime/types.d.ts +1 -1
- package/dist/runtime/types.js +1 -1
- package/dist/ui/card.d.ts +4 -1
- package/dist/ui/card.js +125 -66
- package/dist/ui/present.d.ts +37 -36
- package/dist/ui/present.js +226 -154
- package/package.json +5 -4
package/dist/ui/present.js
CHANGED
|
@@ -1,80 +1,59 @@
|
|
|
1
1
|
/** Neat asgrep tool chrome for the Pi TUI and the model-visible content. */
|
|
2
|
-
export const ASGREP_PROMPT_SNIPPET = "
|
|
2
|
+
export const ASGREP_PROMPT_SNIPPET = "Code search by intent, symbol, defs, callers, pattern (asgrep; use without being asked)";
|
|
3
3
|
export const ASGREP_PROMPT_GUIDELINES = [
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"Use grep only for exact log strings, filenames, or config keys. asgrep.edit does unique string replace plus targeted reindex; oldText must match exactly once.",
|
|
7
|
-
"If a search returns 0 hits, use suggested_next or retry with asgrep.find, asgrep.defs, or asgrep.search(query, { in: \"src\" }).",
|
|
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.",
|
|
8
6
|
];
|
|
9
7
|
export function paint(theme, role, text, bold = false) {
|
|
10
8
|
const body = bold && theme ? theme.bold(text) : text;
|
|
11
9
|
return theme ? theme.fg(role, body) : body;
|
|
12
10
|
}
|
|
13
11
|
export function hitLocation(hit) {
|
|
14
|
-
const file = String(hit.file ?? hit.path ?? "");
|
|
12
|
+
const file = sanitizeContent(String(hit.file ?? hit.path ?? ""));
|
|
15
13
|
const line = hit.start_line ?? hit.line ?? hit.lines;
|
|
16
14
|
if (typeof line === "number")
|
|
17
15
|
return `${file}:${line}`;
|
|
18
16
|
if (typeof line === "string" && line.length > 0)
|
|
19
|
-
return `${file}:${line}`;
|
|
17
|
+
return `${file}:${sanitizeContent(line)}`;
|
|
20
18
|
if (typeof hit.ref === "string" && hit.ref.length > 0)
|
|
21
|
-
return hit.ref;
|
|
19
|
+
return sanitizeContent(hit.ref);
|
|
22
20
|
return file || "?";
|
|
23
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;
|
|
24
26
|
export function hitLabel(hit) {
|
|
25
|
-
const symbol = typeof hit.symbol === "string" ? hit.symbol : "";
|
|
26
|
-
const kind = typeof hit.kind === "string" ? hit.kind : "";
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return [
|
|
32
|
-
}
|
|
33
|
-
export function formatSearchCall(params, theme) {
|
|
34
|
-
return header(theme, "search", [
|
|
35
|
-
params.query ? JSON.stringify(params.query) : undefined,
|
|
36
|
-
params.mode ?? "natural",
|
|
37
|
-
params.limit !== undefined ? `limit ${params.limit}` : undefined,
|
|
38
|
-
params.excerptLines ? `excerpt ${params.excerptLines}` : undefined,
|
|
39
|
-
]);
|
|
40
|
-
}
|
|
41
|
-
export function formatIndexCall(force, theme) {
|
|
42
|
-
return header(theme, force ? "reindex" : "index", []);
|
|
43
|
-
}
|
|
44
|
-
export function formatStatusCall(theme) {
|
|
45
|
-
return header(theme, "status", []);
|
|
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(" ");
|
|
46
34
|
}
|
|
47
|
-
export function formatEditCall(params, theme) {
|
|
48
|
-
const n = Array.isArray(params.edits) ? params.edits.length : 0;
|
|
49
|
-
return header(theme, "edit", [params.path, n > 1 ? n + " edits" : undefined]);
|
|
50
|
-
}
|
|
51
|
-
export function formatReadCall(params, theme) {
|
|
52
|
-
const target = params.path ?? params.ref;
|
|
53
|
-
const range = params.start !== undefined ? "L" + params.start + "-L" + (params.end ?? "") : undefined;
|
|
54
|
-
return header(theme, "read", [target, range]);
|
|
55
|
-
}
|
|
56
|
-
/** Model-visible text for an edit envelope: what changed, per file. */
|
|
57
35
|
export function formatEditResult(response, theme) {
|
|
58
36
|
const edits = Array.isArray(response.edits) ? response.edits : [];
|
|
59
37
|
const changed = edits.filter((e) => e.changed === true).length;
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
const path = typeof
|
|
63
|
-
const line = typeof
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
return
|
|
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");
|
|
67
45
|
}
|
|
68
46
|
/** Model-visible text for a read envelope: the window contents themselves. */
|
|
69
47
|
export function formatReadResult(response, theme) {
|
|
70
48
|
const windows = Array.isArray(response.windows) ? response.windows : [];
|
|
71
49
|
if (windows.length === 0)
|
|
72
|
-
return
|
|
50
|
+
return "read: 0 windows";
|
|
73
51
|
const out = [];
|
|
74
52
|
for (const w of windows.slice(0, 8)) {
|
|
75
|
-
const path = typeof w.path === "string" ? w.path : "?";
|
|
76
|
-
|
|
77
|
-
|
|
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 : "");
|
|
78
57
|
for (const line of text.split("\n").slice(0, 80))
|
|
79
58
|
out.push(line);
|
|
80
59
|
}
|
|
@@ -82,111 +61,166 @@ export function formatReadResult(response, theme) {
|
|
|
82
61
|
out.push("… " + (windows.length - 8) + " more windows");
|
|
83
62
|
return out.join("\n");
|
|
84
63
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
+
*/
|
|
89
70
|
export function formatSearchResult(response, meta, theme) {
|
|
90
71
|
const hits = Array.isArray(response.hits) ? response.hits : [];
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const rows = hits.slice(0, 24).map((hit) => {
|
|
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)) {
|
|
99
79
|
const loc = hitLocation(hit);
|
|
100
80
|
const label = hitLabel(hit);
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
}
|
|
103
98
|
if (hits.length === 0) {
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
? response.
|
|
99
|
+
// A chain answer is nodes+edges, not hits: depth, site, symbol per row.
|
|
100
|
+
const nodes = Array.isArray(response.nodes)
|
|
101
|
+
? (response.nodes)
|
|
107
102
|
: [];
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
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
111
|
}
|
|
112
|
+
return rows.join("\n");
|
|
112
113
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
rows.push(paint(theme, "muted", ` … ${hits.length - 24} more`));
|
|
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
119
|
}
|
|
120
|
-
|
|
120
|
+
if (hits.length > 24)
|
|
121
|
+
rows.push(` … ${hits.length - 24} more`);
|
|
122
|
+
return rows.join("\n");
|
|
121
123
|
}
|
|
122
124
|
export function formatStatusResult(response, theme) {
|
|
123
125
|
const state = typeof response.status === "string" ? response.status
|
|
124
126
|
: typeof response.index_status === "string" ? response.index_status
|
|
125
127
|
: response.ok ? "ok" : "failed";
|
|
126
128
|
const counts = response.counts && typeof response.counts === "object"
|
|
127
|
-
? Object.entries(response.counts).map(([key, value]) => `${key}=${String(value)}`).join("
|
|
129
|
+
? Object.entries(response.counts).map(([key, value]) => `${key}=${String(value)}`).join(" ")
|
|
128
130
|
: "";
|
|
129
131
|
const backend = typeof response.backend === "string" ? response.backend : "";
|
|
130
|
-
|
|
131
|
-
return title;
|
|
132
|
+
return ["status: " + state, counts, backend].filter(Boolean).join(" ");
|
|
132
133
|
}
|
|
133
134
|
export function formatIndexResult(command, response, theme) {
|
|
134
135
|
const count = typeof response.count === "number" ? response.count
|
|
135
136
|
: typeof response.total === "number" ? response.total
|
|
136
|
-
:
|
|
137
|
-
|
|
138
|
-
return
|
|
137
|
+
: typeof response.files_indexed === "number" ? response.files_indexed
|
|
138
|
+
: undefined;
|
|
139
|
+
return count === undefined ? `${command}: done` : `${command}: ${count} file${count === 1 ? "" : "s"}`;
|
|
139
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
|
+
*/
|
|
140
152
|
function ansiLengthAt(text, index) {
|
|
141
153
|
if (text.charCodeAt(index) !== 0x1b)
|
|
142
154
|
return 0;
|
|
155
|
+
const limit = Math.min(text.length, index + MAX_ESCAPE_LENGTH);
|
|
143
156
|
const next = text[index + 1];
|
|
144
157
|
if (next === "[") {
|
|
145
|
-
let cursor = index + 2;
|
|
146
|
-
while (cursor < text.length) {
|
|
158
|
+
for (let cursor = index + 2; cursor < limit; cursor += 1) {
|
|
147
159
|
const code = text.charCodeAt(cursor);
|
|
148
160
|
if (code >= 0x40 && code <= 0x7e)
|
|
149
161
|
return cursor - index + 1;
|
|
150
|
-
cursor += 1;
|
|
151
162
|
}
|
|
152
|
-
return
|
|
163
|
+
return 1;
|
|
153
164
|
}
|
|
154
165
|
if (next === "]") {
|
|
155
|
-
let cursor = index + 2;
|
|
156
|
-
while (cursor < text.length) {
|
|
166
|
+
for (let cursor = index + 2; cursor < limit; cursor += 1) {
|
|
157
167
|
if (text.charCodeAt(cursor) === 0x07)
|
|
158
168
|
return cursor - index + 1;
|
|
159
169
|
if (text.charCodeAt(cursor) === 0x1b && text[cursor + 1] === "\\")
|
|
160
170
|
return cursor - index + 2;
|
|
161
|
-
cursor += 1;
|
|
162
171
|
}
|
|
163
|
-
return
|
|
172
|
+
return 1;
|
|
164
173
|
}
|
|
165
174
|
if (next === "P" || next === "X" || next === "^" || next === "_") {
|
|
166
|
-
let cursor = index + 2;
|
|
167
|
-
while (cursor < text.length) {
|
|
175
|
+
for (let cursor = index + 2; cursor < limit; cursor += 1) {
|
|
168
176
|
if (text.charCodeAt(cursor) === 0x1b && text[cursor + 1] === "\\")
|
|
169
177
|
return cursor - index + 2;
|
|
170
|
-
cursor += 1;
|
|
171
178
|
}
|
|
172
|
-
return
|
|
179
|
+
return 1;
|
|
173
180
|
}
|
|
174
|
-
return
|
|
181
|
+
return 1;
|
|
175
182
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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;
|
|
187
204
|
}
|
|
188
|
-
/**
|
|
189
|
-
|
|
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) {
|
|
190
224
|
let width = 0;
|
|
191
225
|
for (let index = 0; index < text.length;) {
|
|
192
226
|
const ansi = ansiLengthAt(text, index);
|
|
@@ -200,6 +234,24 @@ export function visibleWidth(text) {
|
|
|
200
234
|
}
|
|
201
235
|
return width;
|
|
202
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
|
+
}
|
|
203
255
|
export function truncateToWidth(text, maxWidth, ellipsis = "...") {
|
|
204
256
|
const limit = Math.max(0, maxWidth);
|
|
205
257
|
if (limit <= 0)
|
|
@@ -231,11 +283,68 @@ export function truncateToWidth(text, maxWidth, ellipsis = "...") {
|
|
|
231
283
|
// Close it explicitly; harmless when the kept spans were already balanced.
|
|
232
284
|
return kept + (kept.includes("\u001b[") ? "\u001b[0m" : "") + ellipsis;
|
|
233
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
|
+
}
|
|
234
343
|
function compactValue(value) {
|
|
235
344
|
if (value === null || value === undefined)
|
|
236
345
|
return String(value);
|
|
237
346
|
if (typeof value !== "object")
|
|
238
|
-
return String(value);
|
|
347
|
+
return sanitizeContent(String(value));
|
|
239
348
|
if (Array.isArray(value))
|
|
240
349
|
return `${value.length} item${value.length === 1 ? "" : "s"}`;
|
|
241
350
|
const json = JSON.stringify(value);
|
|
@@ -258,30 +367,21 @@ export function formatCodemodeResult(value, meta = {}, theme) {
|
|
|
258
367
|
else if (typeof record.node_count === "number")
|
|
259
368
|
bits.push(`${record.node_count} node${record.node_count === 1 ? "" : "s"}`);
|
|
260
369
|
}
|
|
261
|
-
if (meta.backend === "napi")
|
|
262
|
-
bits.push("in-process");
|
|
263
|
-
else if (meta.backend === "cli")
|
|
264
|
-
bits.push("cli-sticky");
|
|
265
370
|
if (meta.stats && meta.stats.calls > 0) {
|
|
266
|
-
|
|
267
|
-
? `native ${meta.stats.stickyCalls}`
|
|
268
|
-
: meta.stats.batchedCalls > 0
|
|
269
|
-
? `batched ${meta.stats.batchedCalls}`
|
|
270
|
-
: meta.stats.parallelSpawnCalls > 0
|
|
271
|
-
? `parallel-spawn ${meta.stats.parallelSpawnCalls}`
|
|
272
|
-
: `${meta.stats.calls} call${meta.stats.calls === 1 ? "" : "s"}`;
|
|
273
|
-
bits.push(via);
|
|
371
|
+
bits.push(`${meta.stats.calls} call${meta.stats.calls === 1 ? "" : "s"}`);
|
|
274
372
|
if (meta.stats.waves > 1)
|
|
275
373
|
bits.push(`${meta.stats.waves} waves`);
|
|
276
374
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const title =
|
|
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(" ") : "");
|
|
280
378
|
if (value === undefined) {
|
|
281
379
|
return `${title}\n${paint(theme, "muted", " (no return statement; add `return` to send a value to the model)")}`;
|
|
282
380
|
}
|
|
283
381
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
284
|
-
|
|
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}`));
|
|
285
385
|
return [title, ...rows].join("\n");
|
|
286
386
|
}
|
|
287
387
|
if (Array.isArray(value)) {
|
|
@@ -289,31 +389,3 @@ export function formatCodemodeResult(value, meta = {}, theme) {
|
|
|
289
389
|
}
|
|
290
390
|
return `${title}\n${paint(theme, "toolOutput", ` ${compactValue(value)}`)}`;
|
|
291
391
|
}
|
|
292
|
-
/** Minimal pi-tui Text stand-in so we do not take a TUI package dependency. */
|
|
293
|
-
export class AsgrepText {
|
|
294
|
-
#text;
|
|
295
|
-
constructor(text = "") {
|
|
296
|
-
this.#text = text;
|
|
297
|
-
}
|
|
298
|
-
setText(text) {
|
|
299
|
-
this.#text = text;
|
|
300
|
-
}
|
|
301
|
-
invalidate() { }
|
|
302
|
-
render(width) {
|
|
303
|
-
const maxWidth = Math.max(1, width);
|
|
304
|
-
if (this.#text.length === 0)
|
|
305
|
-
return [""];
|
|
306
|
-
return this.#text.split("\n").map((line) => truncateToWidth(line, maxWidth));
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
export function presentText(formatted, last) {
|
|
310
|
-
if (last instanceof AsgrepText) {
|
|
311
|
-
last.setText(formatted);
|
|
312
|
-
return last;
|
|
313
|
-
}
|
|
314
|
-
if (last && typeof last === "object" && last !== null && "setText" in last && typeof last.setText === "function") {
|
|
315
|
-
last.setText(formatted);
|
|
316
|
-
return last;
|
|
317
|
-
}
|
|
318
|
-
return new AsgrepText(formatted);
|
|
319
|
-
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-ast-sgrep",
|
|
3
|
-
"version": "2.
|
|
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",
|
|
@@ -53,16 +53,17 @@
|
|
|
53
53
|
"scripts": {
|
|
54
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/sqlite.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": {
|