jev-lens 0.5.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/dist/views.js ADDED
@@ -0,0 +1,687 @@
1
+ /**
2
+ * Candidate views of a tool result. Every view is a deterministic subset of the original
3
+ * text (never generated), with line numbers so the agent can ask for exact ranges later.
4
+ */
5
+ const CODE_EXT = /\.(js|mjs|cjs|ts|tsx|jsx|py|go|rs|java|kt|kts|rb|php|c|h|cc|cpp|hpp|cs|swift|scala|sh|bash|zsh|lua|sql)$/i;
6
+ const DATA_EXT = /\.(csv|tsv|jsonl|ndjson|log|json|xml|yaml|yml|toml)$/i;
7
+ const PROSE_EXT = /\.(md|txt|rst|adoc)$/i;
8
+ import { displayedFiles } from "./shell-display.js";
9
+ export { displayedFiles } from "./shell-display.js";
10
+ function countLines(text, re, max = 400) {
11
+ let n = 0;
12
+ for (const l of text.split("\n", max))
13
+ if (re.test(l))
14
+ n++;
15
+ return n;
16
+ }
17
+ /** Mixed or unknown file types retain the ordinary command policy. */
18
+ export function kindOfFiles(files) {
19
+ if (files.length === 0)
20
+ return undefined;
21
+ if (files.every((f) => CODE_EXT.test(f)))
22
+ return "code";
23
+ if (files.every((f) => PROSE_EXT.test(f)))
24
+ return "prose";
25
+ return undefined;
26
+ }
27
+ /** Guess what kind of content this is from the tool, its arguments and the text itself. */
28
+ export function detectKind(toolName, args, text) {
29
+ const a = (args ?? {});
30
+ const path = typeof a.path === "string" ? a.path : "";
31
+ if (/^Here's the files and directories up to \d+ levels deep/.test(text) || looksLikePathList(text))
32
+ return "listing";
33
+ if (toolName === "bash" || toolName === "powershell") {
34
+ // Agents that read files with cat/sed/head get the code and prose views, not the command ones.
35
+ const shown = toolName === "bash" && typeof a.command === "string" ? displayedFiles(a.command) : undefined;
36
+ const shownKind = shown ? kindOfFiles(shown) : undefined;
37
+ if (shownKind === "code" && countLines(text, SIG_RE) >= 3)
38
+ return "code";
39
+ if (shownKind === "prose" && countLines(text, HEADING_RE) >= 2)
40
+ return "prose";
41
+ return "command";
42
+ }
43
+ if (toolName === "ls" || toolName === "find" || toolName === "grep")
44
+ return "listing";
45
+ if (CODE_EXT.test(path))
46
+ return "code";
47
+ if (DATA_EXT.test(path))
48
+ return "data";
49
+ if (PROSE_EXT.test(path))
50
+ return "prose";
51
+ if (looksRepetitive(text))
52
+ return "data";
53
+ if (SIG_RE.test(text))
54
+ return "code";
55
+ return "prose";
56
+ }
57
+ /** Mostly lines that are file paths → a directory listing or find output. */
58
+ export function looksLikePathList(text) {
59
+ const lines = text.split("\n").filter((l) => l.trim()).slice(0, 300);
60
+ if (lines.length < 15)
61
+ return false;
62
+ const pathy = lines.filter((l) => /^\s*[\w./-]+\/[\w./-]*$/.test(l.trim()) || /^\s*\S+\.(py|js|ts|md|json|txt|yml|yaml|toml|cfg|ini|rs|go|java|c|h)$/.test(l.trim())).length;
63
+ return pathy / lines.length > 0.7;
64
+ }
65
+ /** Many lines with the same delimiter count → tabular or log-like data. */
66
+ export function looksRepetitive(text) {
67
+ const lines = text.split("\n").filter((l) => l.trim()).slice(0, 200);
68
+ if (lines.length < 20)
69
+ return false;
70
+ const counts = new Map();
71
+ for (const l of lines) {
72
+ const sig = `${(l.match(/,/g) || []).length}|${(l.match(/\t/g) || []).length}|${(l.match(/\|/g) || []).length}`;
73
+ counts.set(sig, (counts.get(sig) || 0) + 1);
74
+ }
75
+ const top = Math.max(...counts.values());
76
+ return top / lines.length > 0.7 && !/^[\s]*[,|\t]*$/.test(lines[0]) && lines[0].length > 0 && (lines[0].includes(",") || lines[0].includes("\t") || lines[0].includes("|"));
77
+ }
78
+ const SIG_RE = /^\s*(export\s+|pub(\([^)]*\))?\s+|public\s+|private\s+|protected\s+|internal\s+|static\s+|final\s+|abstract\s+|async\s+|default\s+|override\s+|open\s+|suspend\s+|inline\s+|data\s+|sealed\s+|enum\s+|annotation\s+|companion\s+|unsafe\s+|const\s+|declare\s+|@\w+(\([^)]*\))?\s+)*(function|class|interface|type|enum|struct|impl|trait|fn|fun|def|object|val|var|let|const|module|namespace|import|from|require|package|use|record|typealias|macro_rules!|mod|static|extern)\b|^\s*(export\s+)?(async\s+)?[A-Za-z_$][\w$]*\s*\([^)]*\)\s*\{?\s*$|^\s*(get|set)\s+\w+\s*\(|^\s*(\w+)\s*[:=]\s*(async\s*)?(\([^)]*\)|\w+)\s*=>|^\s*@\w+|^\s*#\[|^\s*#\s|^\s*\/\*\*|^\s*\*\/|^[A-Za-z_][\w]*\s*=\s*(function|class|\(|require)|^\s*(public|private|protected|static|final|abstract|synchronized|native)\s+[\w<>\[\], ?]+\s+\w+\s*\(/;
79
+ const HEADING_RE = /^\s*(#{1,6}\s|=+\s*$|-+\s*$|\d+\.\s+[A-Z])/;
80
+ const SIGNAL_RE = /\b(error|fail(ed|ing|ure)?|exception|traceback|panic|fatal|warn(ing)?|not ok|✖|✗|denied|refused|missing|cannot|undefined is not|is not defined|no such file|ENOENT|EACCES|timeout|timed out|assert|expected|actual)\b|^\s*at\s+\S+\s+\(|^ℹ\s|^#\s(pass|fail|tests)|failing|Tests:|Suites:|\d+\s+(passed|failed)/i;
81
+ /** Collapse decorative runs (=====, -----, ......) and very long lines so views stay small. */
82
+ export function tidyLine(l) {
83
+ let out = l.replace(/([=\-_.*#~])\1{24,}/g, (m) => m.slice(0, 24) + "…").replace(/((?:[=\-_.*#~] ){12,})/g, (m) => m.slice(0, 24) + "…").replace(/(\S)[ \t]{8,}(?=\S)/g, "$1 ").trimEnd();
84
+ if (out.length > 400)
85
+ out = out.slice(0, 400) + " …";
86
+ return out;
87
+ }
88
+ function numbered(lines, idx, tidy = false) {
89
+ const width = String(lines.length).length;
90
+ const out = [];
91
+ let prev = -1;
92
+ for (const i of idx) {
93
+ if (prev >= 0 && i > prev + 1)
94
+ out.push(`${" ".repeat(width)} ⋯ ${i - prev - 1} lines omitted`);
95
+ out.push(`${String(i + 1).padStart(width)}│ ${tidy ? tidyLine(lines[i]) : lines[i]}`);
96
+ prev = i;
97
+ }
98
+ if (prev >= 0 && prev < lines.length - 1)
99
+ out.push(`${" ".repeat(width)} ⋯ ${lines.length - 1 - prev} lines omitted`);
100
+ return out.join("\n");
101
+ }
102
+ function withContext(lines, hits, ctx) {
103
+ const keep = new Set();
104
+ for (const h of hits)
105
+ for (let i = Math.max(0, h - ctx); i <= Math.min(lines.length - 1, h + ctx); i++)
106
+ keep.add(i);
107
+ return [...keep].sort((a, b) => a - b);
108
+ }
109
+ function make(kind, lines, idx, tidy = false) {
110
+ const text = numbered(lines, idx, tidy);
111
+ return { kind, text, lines: idx.length, chars: text.length, included: idx.map((i) => i + 1) };
112
+ }
113
+ export function fullView(text) {
114
+ const lines = text.split("\n");
115
+ return { kind: "full", text, lines: lines.length, chars: text.length, included: lines.map((_, i) => i + 1) };
116
+ }
117
+ export function headTailView(text, head = 40, tail = 20, tidy = false) {
118
+ const lines = text.split("\n");
119
+ if (lines.length <= head + tail + 5)
120
+ return fullView(text);
121
+ const idx = [...Array.from({ length: head }, (_, i) => i), ...Array.from({ length: tail }, (_, i) => lines.length - tail + i)];
122
+ return make("head_tail", lines, idx, tidy);
123
+ }
124
+ /** Code and prose structure: signatures, exports, imports, doc comments, headings. */
125
+ export function outlineView(text, kind) {
126
+ const lines = text.split("\n");
127
+ const hits = new Set();
128
+ const re = kind === "prose" ? HEADING_RE : SIG_RE;
129
+ for (let i = 0; i < lines.length; i++)
130
+ if (re.test(lines[i]))
131
+ hits.add(i);
132
+ if (kind === "prose")
133
+ for (let i = 0; i < lines.length; i++)
134
+ if (hits.has(i) && i + 1 < lines.length && lines[i + 1].trim())
135
+ hits.add(i + 1);
136
+ if (hits.size < 3)
137
+ return headTailView(text);
138
+ return make("outline", lines, withContext(lines, hits, 0));
139
+ }
140
+ /** Lines mentioning any of the given terms, with context. */
141
+ export function focusView(text, terms, ctx = 3, tidy = false) {
142
+ const t = terms.map((x) => x.trim()).filter((x) => x.length >= 3);
143
+ if (t.length === 0)
144
+ return undefined;
145
+ const lines = text.split("\n");
146
+ const hits = new Set();
147
+ const lowered = t.map((x) => x.toLowerCase());
148
+ for (let i = 0; i < lines.length; i++) {
149
+ const l = lines[i].toLowerCase();
150
+ if (lowered.some((x) => l.includes(x)))
151
+ hits.add(i);
152
+ }
153
+ if (hits.size === 0)
154
+ return undefined;
155
+ const idx = withContext(lines, hits, ctx);
156
+ if (idx.length >= lines.length * 0.8)
157
+ return undefined;
158
+ return make("focus", lines, idx, tidy);
159
+ }
160
+ /** Command output: error/warning/summary lines with context, plus the tail. */
161
+ export function signalsView(text, ctx = 2, tail = 8, tidy = true) {
162
+ const lines = text.split("\n");
163
+ const hits = new Set();
164
+ for (let i = 0; i < lines.length; i++)
165
+ if (SIGNAL_RE.test(lines[i]))
166
+ hits.add(i);
167
+ for (let i = Math.max(0, lines.length - tail); i < lines.length; i++)
168
+ hits.add(i);
169
+ const idx = withContext(lines, hits, ctx);
170
+ if (idx.length >= lines.length * 0.8)
171
+ return fullView(text);
172
+ return make("signals", lines, idx, tidy);
173
+ }
174
+ const TEST_MARKERS = /test session starts|passed|failed|FAILED|ERROR|✖|✔|not ok|^ok \d|Tests:|Test Suites:|# (pass|fail|tests)|PASS |FAIL |AssertionError|assert /m;
175
+ const TEST_FAIL_LINE = /^(FAILED|ERROR) |^E\s{2,}|AssertionError|^\s*assert |✖|not ok|^\s+at .*\(|Error:|Exception|Traceback|^\s*File ".*", line \d+/;
176
+ const TEST_SECTION = /^=+ (FAILURES|ERRORS|short test summary info|warnings summary) =+|^_{3,} .* _{3,}$|^(_ ){5,}_?\s*$|^\[\.\.\. Observation truncated|^(ℹ|✖) |^# (Subtest|Failure)/;
177
+ const TEST_SUMMARY = /^=+ .*(passed|failed|error|skipped|deselected|xfailed|no tests ran).* =+$|^(Tests:|Test Suites:|Time:|Ran \d+ tests|OK|FAILED \(|ℹ (pass|fail|tests|duration)|# (pass|fail|tests))/;
178
+ /** Is this command output a test run? */
179
+ export function looksLikeTestLog(text) {
180
+ const m = text.match(TEST_MARKERS);
181
+ return !!m && (text.match(/\b(passed|failed|✔|✖|not ok|ok \d)\b/gi) ?? []).length >= 2;
182
+ }
183
+ /**
184
+ * Test run output reduced to what the agent acts on: the session header, every failing test's
185
+ * section (test id, assertion, the last frames of its traceback), the short summary, and the
186
+ * final counts. Passing tests, dots and decorative bars are dropped.
187
+ */
188
+ export function testlogView(text, ctx = 2, maxFailLines = 60, maxIds = 25) {
189
+ if (!looksLikeTestLog(text))
190
+ return undefined;
191
+ const lines = text.split("\n");
192
+ const keep = new Set();
193
+ let inFailures = false;
194
+ let failLines = 0;
195
+ for (let i = 0; i < lines.length; i++) {
196
+ const l = lines[i];
197
+ if (i < 3)
198
+ keep.add(i);
199
+ if (TEST_SECTION.test(l)) {
200
+ keep.add(i);
201
+ inFailures = /FAILURES|ERRORS|_{3,}/.test(l) ? true : false;
202
+ failLines = 0;
203
+ continue;
204
+ }
205
+ if (TEST_SUMMARY.test(l)) {
206
+ keep.add(i);
207
+ inFailures = false;
208
+ continue;
209
+ }
210
+ if (TEST_FAIL_LINE.test(l)) {
211
+ for (let j = Math.max(0, i - ctx); j <= Math.min(lines.length - 1, i + ctx); j++)
212
+ keep.add(j);
213
+ continue;
214
+ }
215
+ if (inFailures && failLines < maxFailLines && l.trim()) {
216
+ keep.add(i);
217
+ failLines++;
218
+ }
219
+ }
220
+ // inside failure sections, drop traceback paragraphs that end in a library frame (site-packages, /opt/conda, /usr/lib):
221
+ // the agent cannot edit those, and the repo frame plus the E-lines carry the information
222
+ const LIB = /(site-packages|dist-packages|\/opt\/conda|\/usr\/lib|\/usr\/local\/lib|\.pyenv|\/node_modules\/)/;
223
+ // A frame runs from the previous boundary (section marker, chain separator or previous footer) to its
224
+ // footer line "path:line: Error". Library frames lose their source lines; their E-lines (the message) stay.
225
+ const FOOTER = /^\S+\.(py|js|ts|rb|go|rs|java):\d+: ?\w*$|^\s*File "[^"]+", line \d+/;
226
+ let boundary = -1;
227
+ for (let i = 0; i < lines.length; i++) {
228
+ if (TEST_SECTION.test(lines[i]) || TEST_SUMMARY.test(lines[i])) {
229
+ boundary = i;
230
+ continue;
231
+ }
232
+ if (!FOOTER.test(lines[i]))
233
+ continue;
234
+ if (LIB.test(lines[i])) {
235
+ for (let j = boundary + 1; j <= i; j++)
236
+ if (keep.has(j) && !/^E\s{2,}/.test(lines[j]))
237
+ keep.delete(j);
238
+ }
239
+ boundary = i;
240
+ }
241
+ // keep a compact index of test ids (agents pick one to re-run), capped so verbose runs stay small
242
+ let ids = 0;
243
+ for (let i = 0; i < lines.length && ids < maxIds; i++) {
244
+ if (keep.has(i))
245
+ continue;
246
+ if (/^\S+\.(py|js|ts|rb|go|rs)::\S+ (PASSED|FAILED|ERROR|SKIPPED|XFAIL)/.test(lines[i]) || /^(✔|✓|ok \d+ -) /.test(lines[i])) {
247
+ keep.add(i);
248
+ ids++;
249
+ }
250
+ }
251
+ // always keep the last 5 lines (final summary)
252
+ for (let i = Math.max(0, lines.length - 5); i < lines.length; i++)
253
+ keep.add(i);
254
+ const idx = [...keep].sort((a, b) => a - b);
255
+ if (idx.length >= lines.length * 0.8)
256
+ return undefined;
257
+ return make("testlog", lines, idx, true);
258
+ }
259
+ /**
260
+ * Directory listings and find output: group paths by directory, keep the first entries of each
261
+ * directory and say how many more there are. Directories with many files (tests, fixtures) collapse.
262
+ */
263
+ export function treeView(text, perDir = 8, terms = [], tidy = true) {
264
+ const lines = text.split("\n");
265
+ const lowered = terms.map((t) => t.toLowerCase()).filter((t) => t.length >= 4);
266
+ const byDir = new Map();
267
+ for (let i = 0; i < lines.length; i++) {
268
+ const t = lines[i].trim();
269
+ if (!t)
270
+ continue;
271
+ const path = t.replace(/\/$/, "");
272
+ const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
273
+ if (!byDir.has(dir))
274
+ byDir.set(dir, []);
275
+ byDir.get(dir).push(i);
276
+ }
277
+ if (byDir.size < 2)
278
+ return undefined;
279
+ const keep = new Set();
280
+ for (let i = 0; i < Math.min(2, lines.length); i++)
281
+ if (!/^\s*[\w./-]+\/?$/.test(lines[i].trim()))
282
+ keep.add(i);
283
+ for (const idx of byDir.values())
284
+ for (const i of idx.slice(0, perDir))
285
+ keep.add(i);
286
+ if (lowered.length) {
287
+ // match terms against the basename only, and ignore terms that match a large share of entries (repo names, common dirs)
288
+ const bases = lines.map((l) => l.trim().replace(/\/$/, "").split("/").pop()?.toLowerCase() ?? "");
289
+ for (const t of lowered) {
290
+ const hits = bases.map((b, i) => (b.includes(t) ? i : -1)).filter((i) => i >= 0);
291
+ if (hits.length === 0 || hits.length > Math.max(5, lines.length * 0.2))
292
+ continue;
293
+ for (const i of hits)
294
+ keep.add(i);
295
+ }
296
+ }
297
+ const idx = [...keep].sort((a, b) => a - b);
298
+ if (idx.length >= lines.length * 0.8)
299
+ return undefined;
300
+ return make("tree", lines, idx, tidy);
301
+ }
302
+ const GREP_LINE = /^([^:\s][^:]*?):(\d+)[:-]/;
303
+ /**
304
+ * grep / rg / git grep output (path:line:content): keep the first matches of every file and say how
305
+ * many more each file has. Files are what the agent navigates by; the tail of a long match list rarely matters.
306
+ */
307
+ export function matchesView(text, perFile = 6, tidy = true) {
308
+ const lines = text.split("\n");
309
+ let grepLines = 0;
310
+ const byFile = new Map();
311
+ for (let i = 0; i < lines.length; i++) {
312
+ const m = lines[i].match(GREP_LINE);
313
+ if (!m)
314
+ continue;
315
+ grepLines++;
316
+ if (!byFile.has(m[1]))
317
+ byFile.set(m[1], []);
318
+ byFile.get(m[1]).push(i);
319
+ }
320
+ const nonEmpty = lines.filter((l) => l.trim()).length;
321
+ if (grepLines < 10 || grepLines < nonEmpty * 0.6 || byFile.size < 1)
322
+ return undefined;
323
+ const keep = new Set();
324
+ for (let i = 0; i < lines.length; i++)
325
+ if (!GREP_LINE.test(lines[i]) && lines[i].trim() && !/^--$/.test(lines[i]))
326
+ keep.add(i); // non-match lines (headers, errors)
327
+ for (const idx of byFile.values())
328
+ for (const i of idx.slice(0, perFile))
329
+ keep.add(i);
330
+ const idx = [...keep].sort((a, b) => a - b);
331
+ if (idx.length >= lines.length * 0.8)
332
+ return undefined;
333
+ return make("matches", lines, idx, tidy);
334
+ }
335
+ /** Normalise a log line to its template: numbers, hex, timestamps and quoted strings removed. */
336
+ function lineTemplate(l) {
337
+ return l.replace(/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}[.,]?\d*/g, "<ts>").replace(/0x[0-9a-f]+/gi, "<hex>").replace(/\b\d+(\.\d+)?/g, "<n>").replace(/(["']).*?\1/g, "<str>").trim();
338
+ }
339
+ /**
340
+ * Log-like output (scripts, servers, repeated progress lines): keep the first two and the last
341
+ * occurrence of every line template, so repeated lines collapse while the story stays readable.
342
+ */
343
+ export function logView(text, tidy = true) {
344
+ const lines = text.split("\n");
345
+ if (lines.length < 40)
346
+ return undefined;
347
+ const seen = new Map();
348
+ for (let i = 0; i < lines.length; i++) {
349
+ if (!lines[i].trim())
350
+ continue;
351
+ const t = lineTemplate(lines[i]);
352
+ if (!seen.has(t))
353
+ seen.set(t, []);
354
+ seen.get(t).push(i);
355
+ }
356
+ // only worth it when templates repeat a lot
357
+ const repeated = [...seen.values()].filter((v) => v.length >= 3).reduce((a, v) => a + v.length, 0);
358
+ if (repeated < lines.length * 0.3)
359
+ return undefined;
360
+ const keep = new Set();
361
+ for (const idx of seen.values()) {
362
+ keep.add(idx[0]);
363
+ if (idx.length > 1)
364
+ keep.add(idx[1]);
365
+ keep.add(idx[idx.length - 1]);
366
+ }
367
+ for (let i = 0; i < lines.length; i++)
368
+ if (SIGNAL_RE.test(lines[i]))
369
+ keep.add(i);
370
+ for (let i = Math.max(0, lines.length - 5); i < lines.length; i++)
371
+ keep.add(i);
372
+ const idx = [...keep].sort((a, b) => a - b);
373
+ if (idx.length >= lines.length * 0.8)
374
+ return undefined;
375
+ return make("log", lines, idx, tidy);
376
+ }
377
+ /** Data files: header plus a sample of rows and the count. */
378
+ export function sampleView(text, rows = 12, tidy = true) {
379
+ const lines = text.split("\n");
380
+ if (lines.length <= rows + 6)
381
+ return fullView(text);
382
+ const idx = [...Array.from({ length: rows }, (_, i) => i), lines.length - 2, lines.length - 1].filter((i, k, arr) => i >= 0 && arr.indexOf(i) === k);
383
+ return make("sample", lines, idx, tidy);
384
+ }
385
+ /** Pull identifier-like terms out of task text and tool arguments to drive the focus view. */
386
+ export function extractTerms(...texts) {
387
+ const found = new Map();
388
+ for (const t of texts) {
389
+ for (const m of t.matchAll(/[A-Za-z_$][A-Za-z0-9_$]{3,}/g)) {
390
+ const w = m[0];
391
+ if (STOP.has(w.toLowerCase()))
392
+ continue;
393
+ if (/^[A-Z][a-z]+$/.test(w) && w.length < 8)
394
+ continue; // capitalised common words
395
+ found.set(w, (found.get(w) || 0) + 1);
396
+ }
397
+ for (const m of t.matchAll(/`([^`\n]{3,60})`/g))
398
+ found.set(m[1], (found.get(m[1]) || 0) + 3);
399
+ for (const m of t.matchAll(/["']([^"'\n]{4,60})["']/g))
400
+ found.set(m[1], (found.get(m[1]) || 0) + 2);
401
+ }
402
+ return [...found.entries()]
403
+ .filter(([w]) => /[a-z]/.test(w) && (/[A-Z_.$]/.test(w.slice(1)) || w.length >= 8))
404
+ .sort((a, b) => b[1] - a[1])
405
+ .slice(0, 25)
406
+ .map(([w]) => w);
407
+ }
408
+ const STOP = new Set("this that with from have will would should could there their about which where when what make sure does into then than only also just some more most such each other after before because while please tests test file files function functions module modules code change changes changed running return returns using used uses need needs needed does must keep keeps always never every existing behaviour behavior following current update updated added adding delete remove rename renamed read write import export const class type string number object array value values default defaults lines line".split(/\s+/));
409
+ export const DEFAULT_VIEW_PARAMS = { headLines: 40, tailLines: 20, focusCtx: 3, sampleRows: 12, signalsCtx: 1, signalsTail: 8, testIds: 25, testFailLines: 60, matchesPerFile: 6, logView: true, sectionsView: true, sectionMinLines: 3, sectionMaxBlocks: 24, sectionChunkLines: 50, minShrink: 0.6 };
410
+ /** Build the candidate views for a tool result. Full is always first. Views that do not shrink the text enough are dropped. */
411
+ export function buildCandidates(toolName, args, text, terms, params = {}) {
412
+ const P = { ...DEFAULT_VIEW_PARAMS, ...params };
413
+ const kind = detectKind(toolName, args, text);
414
+ const full = fullView(text);
415
+ const cands = [full];
416
+ // The agent's own edit/write results echo the file it is working on and will edit again: never reduce them.
417
+ if (toolName === "edit" || toolName === "write" || toolName === "str_replace_editor")
418
+ return { kind, views: cands };
419
+ const add = (v) => {
420
+ if (!v || v.kind === "full")
421
+ return;
422
+ if (v.chars > full.chars * P.minShrink)
423
+ return;
424
+ if (cands.some((c) => c.kind === v.kind))
425
+ return;
426
+ cands.push(v);
427
+ };
428
+ // Code and prose views keep every retained line exactly, so an edit whose oldText was copied from the view
429
+ // still matches the file. Command, listing and data output is never edited, so decorative bars, long runs
430
+ // of spaces and very long lines are shortened there (tidyLine); that is what keeps signals/log views small.
431
+ const tidy = kind === "command" || kind === "listing" || kind === "data";
432
+ if (kind === "code" || kind === "prose")
433
+ add(outlineView(text, kind));
434
+ if (kind === "command")
435
+ add(testlogView(text, P.signalsCtx, P.testFailLines, P.testIds));
436
+ if (kind === "command" || kind === "listing")
437
+ add(matchesView(text, P.matchesPerFile, tidy));
438
+ if (kind === "command" && P.logView)
439
+ add(logView(text, tidy));
440
+ // A plain file display that stayed "command" (mixed file types) may still be edited from: no section headers for it.
441
+ const isFileDisplay = kind === "command" && typeof args?.command === "string" && displayedFiles(args.command) !== undefined;
442
+ if (kind === "command" && P.sectionsView && !isFileDisplay)
443
+ add(sectionsView(text, splitSections(text, P.sectionMinLines, P.sectionMaxBlocks, P.sectionChunkLines), tidy));
444
+ if (kind === "listing")
445
+ add(treeView(text, 8, terms, tidy));
446
+ if (kind === "command" || kind === "listing")
447
+ add(signalsView(text, P.signalsCtx, P.signalsTail, tidy));
448
+ if (kind === "data")
449
+ add(sampleView(text, P.sampleRows, tidy));
450
+ add(focusView(text, terms, P.focusCtx, tidy));
451
+ add(headTailView(text, P.headLines, P.tailLines, tidy));
452
+ return { kind, views: cands };
453
+ }
454
+ export function footer(view, toolCallId, total, opts = {}) {
455
+ if (view.kind === "full")
456
+ return "";
457
+ const recall = opts.recall ? opts.recall(toolCallId) : `Call recall(id: "${toolCallId}") for the full output, or recall(id, lines: "a-b") / recall(id, pattern: "...") for a slice.`;
458
+ return `\n\n[jev-lens: showing the "${view.kind}" view, ${view.lines} of ${total} lines. Omitted lines are marked ⋯. ${recall}${opts.note ? ` ${opts.note}` : ""}]`;
459
+ }
460
+ /**
461
+ * Split code into top-level blocks: each block starts at a signature line with no indentation
462
+ * (or the least indentation seen) and runs until the next one. Leading imports form one block.
463
+ */
464
+ export function splitBlocks(text, maxBlocks = 32) {
465
+ const lines = text.split("\n");
466
+ const starts = [];
467
+ for (let i = 0; i < lines.length; i++) {
468
+ const l = lines[i];
469
+ if (!l.trim())
470
+ continue;
471
+ if (/^\S/.test(l) && SIG_RE.test(l) && !/^\s*(import|from|require|use|package)\b/.test(l) && !/^\s*(\/\*\*|\*\/|\*|#|\/\/)/.test(l))
472
+ starts.push(i);
473
+ }
474
+ if (starts.length < 2)
475
+ return [];
476
+ const blocks = [];
477
+ if (starts[0] > 0)
478
+ blocks.push({ name: "(header: imports, constants)", from: 1, to: starts[0] });
479
+ for (let k = 0; k < starts.length; k++) {
480
+ const from = starts[k];
481
+ let to = k + 1 < starts.length ? starts[k + 1] - 1 : lines.length - 1;
482
+ // give a preceding doc comment to the block it documents
483
+ blocks.push({ name: lines[from].trim().slice(0, 120), from: from + 1, to: to + 1 });
484
+ }
485
+ for (let k = 1; k < blocks.length; k++) {
486
+ const prevEnd = blocks[k - 1].to;
487
+ let j = prevEnd - 1;
488
+ while (j >= blocks[k - 1].from && /^\s*(\/\*\*|\*|\/\/|#)/.test(lines[j]))
489
+ j--;
490
+ if (j < prevEnd - 1 && j + 1 > blocks[k - 1].from - 1) {
491
+ blocks[k].from = j + 2;
492
+ blocks[k - 1].to = j + 1;
493
+ }
494
+ }
495
+ return blocks.slice(0, maxBlocks);
496
+ }
497
+ const SECTION_MARK_RE = /^(?:#{1,6}\s|\*{3,}\s*$|={3,}|-{3,}\s*$|_{3,}\s*$|(?:COMMAND|URL|EXIT|FILE|STEP|TEST|RUN|Traceback \(most recent call last\)|Error|ERROR|WARNING|Warning)\b|[A-Z][A-Z0-9 _-]{2,40}:\s*\S|\[[^\]]{1,60}\]\s*$|Running |Collecting |Installing |Processing )/;
498
+ const GREP_PREFIX_RE = /^([^\s:]+?)[-:](\d+)[-:]/;
499
+ const TRIVIAL_LINE_RE = /^\s*(?:[{}\[\]],?|---|```\w*.*|--|\*{3,}|={3,}|-{3,})\s*$/;
500
+ /**
501
+ * Split command output into sections: grep context groups (separated by `--` or a change of file), the
502
+ * top-level keys or items of a JSON document, markdown headings, marker lines (COMMAND:, URL:, ALL-CAPS labels,
503
+ * ===== bars, tracebacks) and paragraphs separated by blank lines. Small sections are merged into their
504
+ * predecessor; at most maxBlocks sections are kept. Output with no structure at all falls back to fixed chunks.
505
+ */
506
+ export function splitSections(text, minLines = 3, maxBlocks = 24, chunkLines = 50) {
507
+ const lines = text.split("\n");
508
+ if (lines.length < 6)
509
+ return [];
510
+ const starts = [];
511
+ let prevBlank = true, prevFile = "", afterSep = false;
512
+ // JSON: inside a top-level { or [ at column 0, every line at the first indentation level starts a section
513
+ let jsonIndent = -1, inJson = false;
514
+ for (let i = 0; i < lines.length; i++) {
515
+ const l = lines[i];
516
+ const blank = !l.trim();
517
+ let start = false;
518
+ if (!blank) {
519
+ if (/^[\[{]\s*$/.test(l)) {
520
+ start = true;
521
+ inJson = true;
522
+ jsonIndent = -1;
523
+ }
524
+ else if (inJson) {
525
+ if (/^[\]}],?\s*$/.test(l)) {
526
+ inJson = false;
527
+ }
528
+ else {
529
+ const indent = l.length - l.trimStart().length;
530
+ if (jsonIndent < 0 && indent > 0)
531
+ jsonIndent = indent;
532
+ if (indent === jsonIndent && /^\s*(?:"|\{|\[|[\w-]+:)/.test(l))
533
+ start = true;
534
+ }
535
+ }
536
+ if (!inJson) {
537
+ if (prevBlank || afterSep)
538
+ start = true;
539
+ if (l === "--") {
540
+ start = false;
541
+ afterSep = true;
542
+ } // grep separator: ends the group before it
543
+ else
544
+ afterSep = false;
545
+ const g = GREP_PREFIX_RE.exec(l);
546
+ if (g) {
547
+ if (g[1] !== prevFile)
548
+ start = true;
549
+ prevFile = g[1];
550
+ }
551
+ else if (!/^\s/.test(l) && SECTION_MARK_RE.test(l))
552
+ start = true;
553
+ }
554
+ }
555
+ if (start && starts[starts.length - 1] !== i)
556
+ starts.push(i);
557
+ prevBlank = blank;
558
+ }
559
+ if (starts.length < 2 && chunkLines > 0 && lines.length >= chunkLines * 2) {
560
+ starts.length = 0;
561
+ for (let i = 0; i < lines.length; i += chunkLines)
562
+ starts.push(i);
563
+ }
564
+ if (starts.length === 0 || starts[0] !== 0)
565
+ starts.unshift(0);
566
+ let blocks = starts.map((from, k) => ({ name: "", from: from + 1, to: (k + 1 < starts.length ? starts[k + 1] : lines.length) }));
567
+ // a section made only of braces, fences or bars belongs to the section after it
568
+ for (let k = 0; k + 1 < blocks.length; k++) {
569
+ if (lines.slice(blocks[k].from - 1, blocks[k].to).every((l) => !l.trim() || TRIVIAL_LINE_RE.test(l))) {
570
+ blocks[k + 1].from = blocks[k].from;
571
+ blocks[k].to = 0;
572
+ }
573
+ }
574
+ blocks = blocks.filter((b) => b.to > 0);
575
+ // merge sections that are too small into their predecessor
576
+ const merged = [];
577
+ for (const b of blocks) {
578
+ const last = merged[merged.length - 1];
579
+ if (last && b.to - b.from + 1 < minLines)
580
+ last.to = b.to;
581
+ else
582
+ merged.push({ ...b });
583
+ }
584
+ blocks = merged;
585
+ // cap the count by merging adjacent sections evenly
586
+ while (blocks.length > maxBlocks) {
587
+ const next = [];
588
+ for (let k = 0; k < blocks.length; k += 2)
589
+ next.push(k + 1 < blocks.length ? { name: "", from: blocks[k].from, to: blocks[k + 1].to } : blocks[k]);
590
+ blocks = next;
591
+ }
592
+ if (blocks.length < 2)
593
+ return [];
594
+ for (const b of blocks)
595
+ b.name = sectionName(lines, b);
596
+ return blocks;
597
+ }
598
+ /** First meaningful line of a section; a bare brace, fence or bar is joined with the line after it. */
599
+ function sectionName(lines, b) {
600
+ const body = lines.slice(b.from - 1, b.to).filter((l) => l.trim());
601
+ const first = body[0] ?? "";
602
+ if (TRIVIAL_LINE_RE.test(first) && body[1])
603
+ return tidyLine(`${first.trim()} ${body[1].trim()}`).slice(0, 120);
604
+ return tidyLine(first).trim().slice(0, 120);
605
+ }
606
+ /** The first non-empty line of every section, line-numbered, with omission markers between. */
607
+ export function sectionsView(text, blocks, tidy = true) {
608
+ if (blocks.length < 2)
609
+ return undefined;
610
+ const lines = text.split("\n");
611
+ const idx = [];
612
+ for (const b of blocks) {
613
+ let i = b.from - 1;
614
+ while (i < b.to - 1 && (!lines[i].trim() || lines[i] === "--"))
615
+ i++;
616
+ idx.push(i);
617
+ }
618
+ return make("sections", lines, idx, tidy);
619
+ }
620
+ /** Outline plus the full bodies of the chosen blocks. */
621
+ export function relevantView(text, kind, blocks, expand, outlineIncluded) {
622
+ const lines = text.split("\n");
623
+ const outline = outlineIncluded ? { included: outlineIncluded } : outlineView(text, kind);
624
+ const idx = new Set(outline.included.map((n) => n - 1));
625
+ for (const b of expand) {
626
+ const blk = blocks[b];
627
+ if (!blk)
628
+ continue;
629
+ for (let i = blk.from - 1; i <= blk.to - 1; i++)
630
+ idx.add(i);
631
+ }
632
+ return make("relevant", lines, [...idx].sort((a, b) => a - b));
633
+ }
634
+ /**
635
+ * Async variant of buildCandidates that uses tree-sitter for the outline of code files when the
636
+ * grammar is available, and returns the blocks so the second step can reuse them.
637
+ */
638
+ export async function buildCandidatesAsync(toolName, args, text, terms, params = {}) {
639
+ const minShrink = params.minShrink ?? DEFAULT_VIEW_PARAMS.minShrink;
640
+ const base = buildCandidates(toolName, args, text, terms, params);
641
+ if (base.views.length < 2)
642
+ return base; // nothing to choose from (e.g. the agent's own edit/write results)
643
+ if (base.kind === "command") {
644
+ const P = { ...DEFAULT_VIEW_PARAMS, ...params };
645
+ const blocks = base.views.some((v) => v.kind === "sections") ? splitSections(text, P.sectionMinLines, P.sectionMaxBlocks, P.sectionChunkLines) : [];
646
+ return { ...base, blocks: blocks.length >= 2 ? blocks : undefined };
647
+ }
648
+ if (base.kind !== "code")
649
+ return base;
650
+ try {
651
+ const { languageForPath, treeSitterBlocks, treeSitterOutline } = await import("./treesitter.js");
652
+ const path = grammarPath(args, languageForPath);
653
+ if (!path)
654
+ return base;
655
+ const [blocks, outlineIdx] = await Promise.all([treeSitterBlocks(path, text), treeSitterOutline(path, text)]);
656
+ if (!outlineIdx || outlineIdx.length < 2)
657
+ return { ...base, blocks: blocks ?? undefined };
658
+ const lines = text.split("\n");
659
+ const full = base.views[0];
660
+ const outline = make("outline", lines, outlineIdx);
661
+ const views = base.views.filter((v) => v.kind !== "outline");
662
+ if (outline.chars <= full.chars * minShrink)
663
+ views.splice(1, 0, outline);
664
+ return { kind: base.kind, views, blocks: blocks ?? undefined };
665
+ }
666
+ catch {
667
+ return base;
668
+ }
669
+ }
670
+ /**
671
+ * The path whose grammar should parse this result: the read path, or, for a shell display command,
672
+ * the first shown file when every shown file is in the same language (concatenated same-language files parse fine).
673
+ */
674
+ function grammarPath(args, languageForPath) {
675
+ const a = (args ?? {});
676
+ if (typeof a.path === "string")
677
+ return a.path;
678
+ if (typeof a.command !== "string")
679
+ return undefined;
680
+ const files = displayedFiles(a.command);
681
+ if (!files)
682
+ return undefined;
683
+ const lang = languageForPath(files[0]);
684
+ if (!lang || files.some((f) => languageForPath(f) !== lang))
685
+ return undefined;
686
+ return files[0];
687
+ }