lynkr 9.9.0 → 9.10.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 +92 -23
- package/bin/cli.js +13 -7
- package/bin/lynkr-init.js +34 -12
- package/bin/lynkr-reset.js +71 -0
- package/config/model-tiers.json +199 -52
- package/package.json +3 -3
- package/scripts/build-eval-set.js +256 -0
- package/scripts/mine-difficulty-anchors.js +288 -0
- package/scripts/validate-difficulty-classifier.js +144 -0
- package/scripts/validate-intent-anchors.js +186 -0
- package/src/api/providers-handler.js +0 -1
- package/src/api/router.js +292 -160
- package/src/clients/databricks.js +95 -6
- package/src/config/index.js +3 -43
- package/src/context/tool-result-compressor.js +883 -40
- package/src/orchestrator/index.js +117 -1235
- package/src/orchestrator/passthrough-stream.js +382 -0
- package/src/orchestrator/sse-transformer.js +408 -0
- package/src/routing/affinity-store.js +17 -3
- package/src/routing/classifier-setup.js +207 -0
- package/src/routing/complexity-analyzer.js +40 -4
- package/src/routing/difficulty-classifier.js +261 -0
- package/src/routing/index.js +70 -7
- package/src/routing/intent-score.js +132 -12
- package/src/routing/session-affinity.js +8 -1
- package/src/routing/side-channel-detector.js +103 -0
- package/src/server.js +20 -46
- package/src/agents/context-manager.js +0 -236
- package/src/agents/decomposition/dispatcher.js +0 -185
- package/src/agents/decomposition/gate.js +0 -136
- package/src/agents/decomposition/index.js +0 -183
- package/src/agents/decomposition/model-call.js +0 -75
- package/src/agents/decomposition/planner.js +0 -223
- package/src/agents/decomposition/synthesizer.js +0 -89
- package/src/agents/decomposition/telemetry.js +0 -55
- package/src/agents/definitions/loader.js +0 -653
- package/src/agents/executor.js +0 -457
- package/src/agents/index.js +0 -165
- package/src/agents/parallel-coordinator.js +0 -68
- package/src/agents/reflector.js +0 -331
- package/src/agents/skillbook.js +0 -331
- package/src/agents/store.js +0 -259
- package/src/edits/index.js +0 -171
- package/src/indexer/babel-parser.js +0 -213
- package/src/indexer/index.js +0 -1629
- package/src/indexer/navigation/index.js +0 -32
- package/src/indexer/navigation/providers/treeSitter.js +0 -36
- package/src/indexer/parser.js +0 -443
- package/src/tasks/store.js +0 -349
- package/src/tests/coverage.js +0 -173
- package/src/tests/index.js +0 -171
- package/src/tests/store.js +0 -213
- package/src/tools/agent-task.js +0 -145
- package/src/tools/code-mode.js +0 -304
- package/src/tools/decompose.js +0 -91
- package/src/tools/edits.js +0 -94
- package/src/tools/execution.js +0 -171
- package/src/tools/git.js +0 -1346
- package/src/tools/index.js +0 -306
- package/src/tools/indexer.js +0 -360
- package/src/tools/lazy-loader.js +0 -366
- package/src/tools/mcp-remote.js +0 -88
- package/src/tools/mcp.js +0 -116
- package/src/tools/process.js +0 -167
- package/src/tools/smart-selection.js +0 -180
- package/src/tools/stubs.js +0 -55
- package/src/tools/tasks.js +0 -260
- package/src/tools/tests.js +0 -132
- package/src/tools/tinyfish.js +0 -358
- package/src/tools/truncate.js +0 -106
- package/src/tools/web-client.js +0 -71
- package/src/tools/web.js +0 -415
- package/src/tools/workspace.js +0 -204
|
@@ -76,9 +76,19 @@ function getMetrics() {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
// ── Pattern Detectors & Compressors ──────────────────────────────────
|
|
79
|
+
//
|
|
80
|
+
// Compressors take (text, opts). opts.trusted means the dispatcher KNOWS
|
|
81
|
+
// which command produced this output (see command-aware dispatch below),
|
|
82
|
+
// so a compressor may relax its shape-detection guards — those guards
|
|
83
|
+
// exist only to avoid misfiring on unknown text.
|
|
84
|
+
|
|
85
|
+
function stripAnsi(text) {
|
|
86
|
+
// eslint-disable-next-line no-control-regex
|
|
87
|
+
return text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
88
|
+
}
|
|
79
89
|
|
|
80
90
|
// 1. Test output (jest, vitest, pytest, cargo test, go test, rspec)
|
|
81
|
-
function compressTestOutput(text) {
|
|
91
|
+
function compressTestOutput(text, opts = {}) {
|
|
82
92
|
const isTest = /(?:Tests?:?\s+\d+\s+(?:passed|failed)|PASSED|FAILED|test result:|✓|✗|✘|PASS |FAIL |\d+ passing|\d+ failing|test session starts|=+ short test summary|tests? (?:passed|failed)|ok \d+|not ok \d+)/i.test(text);
|
|
83
93
|
if (!isTest) return null;
|
|
84
94
|
|
|
@@ -128,7 +138,8 @@ function compressTestOutput(text) {
|
|
|
128
138
|
// README quoting "1041 passing" would otherwise compress the whole doc
|
|
129
139
|
// down to that quote. Require failures, a second summary line, or at
|
|
130
140
|
// least three per-test result markers before treating it as a test run.
|
|
131
|
-
|
|
141
|
+
// Skipped when the dispatcher knows the command was a test runner.
|
|
142
|
+
if (!opts.trusted && failures.length === 0 && summary.length < 2) {
|
|
132
143
|
const perTestMarkers = lines.filter(l =>
|
|
133
144
|
/^\s*(?:✓|✔|✗|✘|ok \d+|not ok \d+|PASS\b|FAIL\b)/.test(l.trim())
|
|
134
145
|
).length;
|
|
@@ -143,6 +154,443 @@ function compressTestOutput(text) {
|
|
|
143
154
|
return parts.join("\n\n") || null;
|
|
144
155
|
}
|
|
145
156
|
|
|
157
|
+
// 1b. Jest/Vitest structured output. Command-dispatch only (never runs on
|
|
158
|
+
// shape detection). Ported from 9router RTK's vitest parser
|
|
159
|
+
// (rtk/src/cmds/js/vitest_cmd.rs): Tier 1 parses the shared jest
|
|
160
|
+
// --json / vitest --reporter=json schema (with RTK's extract_json_object
|
|
161
|
+
// fallback for pnpm/dotenv-prefixed output), Tier 2 regex-extracts the
|
|
162
|
+
// default text reporters, Tier 3 falls back to the generic test
|
|
163
|
+
// compressor. RTK forces --json when it runs the command; Lynkr only
|
|
164
|
+
// sees the output, so Tier 2 is our common case.
|
|
165
|
+
|
|
166
|
+
const JS_TEST_MAX_FAILURES = 20;
|
|
167
|
+
const JS_TEST_MAX_SUITES = 40;
|
|
168
|
+
const JS_TEST_FAILURE_LINES = 8;
|
|
169
|
+
|
|
170
|
+
function extractJsonObject(text) {
|
|
171
|
+
const start = text.indexOf("{");
|
|
172
|
+
const end = text.lastIndexOf("}");
|
|
173
|
+
if (start === -1 || end <= start) return null;
|
|
174
|
+
try {
|
|
175
|
+
return JSON.parse(text.slice(start, end + 1));
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function compressJsTestOutput(text, opts = {}) {
|
|
182
|
+
const clean = stripAnsi(text);
|
|
183
|
+
|
|
184
|
+
// Tier 1: jest --json / vitest --reporter=json (shared schema)
|
|
185
|
+
let json = null;
|
|
186
|
+
try { json = JSON.parse(clean.trim()); } catch { json = extractJsonObject(clean); }
|
|
187
|
+
if (json && typeof json.numTotalTests === "number" && Array.isArray(json.testResults)) {
|
|
188
|
+
const parts = [];
|
|
189
|
+
let summary = `Tests: ${json.numPassedTests} passed, ${json.numFailedTests} failed`;
|
|
190
|
+
if (json.numPendingTests) summary += `, ${json.numPendingTests} skipped`;
|
|
191
|
+
summary += ` (${json.numTotalTests} total)`;
|
|
192
|
+
parts.push(summary);
|
|
193
|
+
|
|
194
|
+
const suites = [];
|
|
195
|
+
const failures = [];
|
|
196
|
+
for (const file of json.testResults) {
|
|
197
|
+
const asserts = Array.isArray(file.assertionResults) ? file.assertionResults : [];
|
|
198
|
+
const failed = asserts.filter(t => t && t.status === "failed");
|
|
199
|
+
suites.push(` ${failed.length ? "FAIL" : "PASS"} ${file.name} (${asserts.length - failed.length}/${asserts.length})`);
|
|
200
|
+
for (const t of failed) {
|
|
201
|
+
const msg = (t.failureMessages || []).join("\n")
|
|
202
|
+
.split("\n").slice(0, JS_TEST_FAILURE_LINES).join("\n");
|
|
203
|
+
failures.push(`✗ ${t.fullName}\n${msg}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (suites.length > 0) {
|
|
207
|
+
const shown = suites.slice(0, JS_TEST_MAX_SUITES);
|
|
208
|
+
if (suites.length > shown.length) shown.push(` ... +${suites.length - shown.length} more suites`);
|
|
209
|
+
parts.push("Suites:\n" + shown.join("\n"));
|
|
210
|
+
}
|
|
211
|
+
if (failures.length > 0) {
|
|
212
|
+
const shown = failures.slice(0, JS_TEST_MAX_FAILURES);
|
|
213
|
+
let block = "Failures:\n" + shown.join("\n---\n");
|
|
214
|
+
if (failures.length > shown.length) block += `\n... +${failures.length - shown.length} more failures`;
|
|
215
|
+
parts.push(block);
|
|
216
|
+
}
|
|
217
|
+
return parts.join("\n\n");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Tier 2: default text reporters. Anchors: vitest's `Tests N passed`
|
|
221
|
+
// block or jest's `Tests: N passed, N total` line.
|
|
222
|
+
const vitestSummary = /Tests\s+(?:\d+\s+failed\s+\|\s+)?\d+\s+passed/.test(clean);
|
|
223
|
+
const jestSummary = /Tests:\s+(?:\d+\s+failed,\s+)?\d+\s+passed,\s+\d+\s+total/.test(clean);
|
|
224
|
+
if (vitestSummary || jestSummary) {
|
|
225
|
+
const lines = clean.split("\n");
|
|
226
|
+
const summary = [];
|
|
227
|
+
const suites = [];
|
|
228
|
+
const failures = [];
|
|
229
|
+
let i = 0;
|
|
230
|
+
while (i < lines.length) {
|
|
231
|
+
const line = lines[i];
|
|
232
|
+
const trimmed = line.trim();
|
|
233
|
+
// Summary block lines (both reporters)
|
|
234
|
+
if (/^(?:Test Files|Tests:?|Test Suites:|Snapshots:|Duration|Time:|Start at)\s/.test(trimmed)) {
|
|
235
|
+
summary.push(trimmed);
|
|
236
|
+
i++;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
// Per-suite lines: jest `PASS/FAIL path`, vitest `✓/❯/✗ path (N tests...)`
|
|
240
|
+
if (/^(?:PASS|FAIL)\s+\S/.test(trimmed) || /^[✓✗×❯]\s+\S+\s+\(\d+\s+tests?/.test(trimmed)) {
|
|
241
|
+
suites.push(trimmed);
|
|
242
|
+
i++;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
// Failure blocks: marker line + indented continuation (RTK's
|
|
246
|
+
// extract_failures_regex)
|
|
247
|
+
if (/(?:\[x\]|✗|×|●|FAIL)/.test(line)) {
|
|
248
|
+
const block = [trimmed];
|
|
249
|
+
i++;
|
|
250
|
+
while (i < lines.length && /^\s{2,}/.test(lines[i]) && lines[i].trim()) {
|
|
251
|
+
block.push(lines[i].trim());
|
|
252
|
+
i++;
|
|
253
|
+
}
|
|
254
|
+
failures.push(block.slice(0, JS_TEST_FAILURE_LINES).join("\n "));
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
i++;
|
|
258
|
+
}
|
|
259
|
+
if (summary.length === 0) return compressTestOutput(text, opts);
|
|
260
|
+
const parts = [summary.join("\n")];
|
|
261
|
+
if (suites.length > 0) {
|
|
262
|
+
const shown = suites.slice(0, JS_TEST_MAX_SUITES);
|
|
263
|
+
if (suites.length > shown.length) shown.push(`... +${suites.length - shown.length} more suites`);
|
|
264
|
+
parts.push("Suites:\n " + shown.join("\n "));
|
|
265
|
+
}
|
|
266
|
+
if (failures.length > 0) {
|
|
267
|
+
const shown = failures.slice(0, JS_TEST_MAX_FAILURES);
|
|
268
|
+
let block = "Failures:\n" + shown.join("\n---\n");
|
|
269
|
+
if (failures.length > shown.length) block += `\n... +${failures.length - shown.length} more failures`;
|
|
270
|
+
parts.push(block);
|
|
271
|
+
}
|
|
272
|
+
return parts.join("\n\n");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Tier 3: whatever the generic test compressor can make of it.
|
|
276
|
+
return compressTestOutput(text, opts);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 1c. Pytest output. Command-dispatch only. Ported from 9router RTK's
|
|
280
|
+
// pytest state machine (rtk/src/cmds/python/pytest_cmd.rs:
|
|
281
|
+
// filter_pytest_output + parse_summary_line).
|
|
282
|
+
|
|
283
|
+
const PYTEST_MAX_FAILURES = 10;
|
|
284
|
+
const PYTEST_MAX_XFAIL = 10;
|
|
285
|
+
|
|
286
|
+
function parsePytestSummaryLine(summary) {
|
|
287
|
+
const counts = { passed: 0, failed: 0, skipped: 0, xfailed: 0, xpassed: 0 };
|
|
288
|
+
for (const part of summary.split(",")) {
|
|
289
|
+
const words = part.trim().split(/\s+/);
|
|
290
|
+
for (let i = 1; i < words.length; i++) {
|
|
291
|
+
const n = parseInt(words[i - 1], 10);
|
|
292
|
+
if (Number.isNaN(n)) continue;
|
|
293
|
+
const word = words[i];
|
|
294
|
+
// Order matters: "xpassed"/"xfailed" contain "passed"/"failed".
|
|
295
|
+
if (word.includes("xpassed")) counts.xpassed = n;
|
|
296
|
+
else if (word.includes("xfailed")) counts.xfailed = n;
|
|
297
|
+
else if (word.includes("passed")) counts.passed = n;
|
|
298
|
+
else if (word.includes("failed")) counts.failed = n;
|
|
299
|
+
else if (word.includes("skipped")) counts.skipped = n;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return counts;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function compressPytestOutput(text, opts = {}) {
|
|
306
|
+
const clean = stripAnsi(text);
|
|
307
|
+
let state = "header";
|
|
308
|
+
const failures = [];
|
|
309
|
+
let currentFailure = [];
|
|
310
|
+
const xfailLines = [];
|
|
311
|
+
let summaryLine = "";
|
|
312
|
+
|
|
313
|
+
for (const line of clean.split("\n")) {
|
|
314
|
+
const trimmed = line.trim();
|
|
315
|
+
|
|
316
|
+
if (trimmed.startsWith("===") && trimmed.includes("test session starts")) {
|
|
317
|
+
state = "header";
|
|
318
|
+
continue;
|
|
319
|
+
} else if (trimmed.startsWith("===") && trimmed.includes("FAILURES")) {
|
|
320
|
+
state = "failures";
|
|
321
|
+
continue;
|
|
322
|
+
} else if (trimmed.startsWith("===") && trimmed.includes("short test summary")) {
|
|
323
|
+
state = "summary";
|
|
324
|
+
if (currentFailure.length > 0) { failures.push(currentFailure.join("\n")); currentFailure = []; }
|
|
325
|
+
continue;
|
|
326
|
+
} else if (trimmed.startsWith("===") &&
|
|
327
|
+
(trimmed.includes("passed") || trimmed.includes("failed") || trimmed.includes("skipped"))) {
|
|
328
|
+
summaryLine = trimmed;
|
|
329
|
+
continue;
|
|
330
|
+
} else if (summaryLine === "" && !trimmed.startsWith("===") &&
|
|
331
|
+
!trimmed.startsWith("FAILED") && !trimmed.startsWith("ERROR") &&
|
|
332
|
+
(trimmed.includes(" passed") || trimmed.includes(" failed") || trimmed.includes(" skipped")) &&
|
|
333
|
+
trimmed.includes(" in ")) {
|
|
334
|
+
// quiet mode (-q): bare summary without === wrapper
|
|
335
|
+
summaryLine = trimmed;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (state === "failures") {
|
|
340
|
+
if (trimmed.startsWith("___")) {
|
|
341
|
+
if (currentFailure.length > 0) { failures.push(currentFailure.join("\n")); currentFailure = []; }
|
|
342
|
+
currentFailure.push(trimmed);
|
|
343
|
+
} else if (trimmed && !trimmed.startsWith("===")) {
|
|
344
|
+
currentFailure.push(trimmed);
|
|
345
|
+
}
|
|
346
|
+
} else if (state === "summary") {
|
|
347
|
+
if (trimmed.startsWith("FAILED") || trimmed.startsWith("ERROR")) failures.push(trimmed);
|
|
348
|
+
else if (trimmed.startsWith("XFAIL") || trimmed.startsWith("XPASS")) xfailLines.push(trimmed);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (currentFailure.length > 0) failures.push(currentFailure.join("\n"));
|
|
352
|
+
|
|
353
|
+
const c = parsePytestSummaryLine(summaryLine);
|
|
354
|
+
if (c.passed === 0 && c.failed === 0 && c.skipped === 0 && c.xfailed === 0 && c.xpassed === 0) {
|
|
355
|
+
return compressTestOutput(text, opts);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const extras = c.skipped > 0 || c.xfailed > 0 || c.xpassed > 0 || xfailLines.length > 0;
|
|
359
|
+
if (c.failed === 0 && c.passed > 0 && !extras && failures.length === 0) {
|
|
360
|
+
return `Pytest: ${c.passed} passed`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
let out = `Pytest: ${c.passed} passed, ${c.failed} failed`;
|
|
364
|
+
if (c.skipped > 0) out += `, ${c.skipped} skipped`;
|
|
365
|
+
if (c.xfailed > 0) out += `, ${c.xfailed} xfailed`;
|
|
366
|
+
if (c.xpassed > 0) out += `, ${c.xpassed} xpassed`;
|
|
367
|
+
|
|
368
|
+
// XPASS in particular signals that something expected-to-fail now passes.
|
|
369
|
+
if (xfailLines.length > 0) {
|
|
370
|
+
out += "\n\nExpected-failure outcomes:\n" +
|
|
371
|
+
xfailLines.slice(0, PYTEST_MAX_XFAIL).map(l => ` ${l.slice(0, 120)}`).join("\n");
|
|
372
|
+
if (xfailLines.length > PYTEST_MAX_XFAIL) out += `\n ... +${xfailLines.length - PYTEST_MAX_XFAIL} more`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (failures.length > 0) {
|
|
376
|
+
out += "\n\nFailures:\n";
|
|
377
|
+
const blocks = [];
|
|
378
|
+
for (const [i, failure] of failures.slice(0, PYTEST_MAX_FAILURES).entries()) {
|
|
379
|
+
const lines = failure.split("\n");
|
|
380
|
+
const first = lines[0] || "";
|
|
381
|
+
let block;
|
|
382
|
+
if (first.startsWith("___")) {
|
|
383
|
+
block = `${i + 1}. [FAIL] ${first.replace(/^_+|_+$/g, "").trim()}`;
|
|
384
|
+
} else if (first.startsWith("FAILED") || first.startsWith("ERROR")) {
|
|
385
|
+
// "FAILED tests/test_foo.py::test_bar - AssertionError: ..."
|
|
386
|
+
const [testPath, ...reason] = first.split(" - ");
|
|
387
|
+
block = `${i + 1}. [FAIL] ${testPath.replace(/^(?:FAILED|ERROR)\s+/, "")}`;
|
|
388
|
+
if (reason.length > 0) block += `\n ${reason.join(" - ").slice(0, 100)}`;
|
|
389
|
+
blocks.push(block);
|
|
390
|
+
continue;
|
|
391
|
+
} else {
|
|
392
|
+
block = `${i + 1}. [FAIL] ${first.slice(0, 100)}`;
|
|
393
|
+
}
|
|
394
|
+
// Keep the assertion/error/location lines only
|
|
395
|
+
let kept = 0;
|
|
396
|
+
for (const l of lines.slice(1)) {
|
|
397
|
+
const lower = l.toLowerCase();
|
|
398
|
+
const relevant = l.trim().startsWith(">") || l.trim().startsWith("E") ||
|
|
399
|
+
lower.includes("assert") || lower.includes("error") || l.includes(".py:");
|
|
400
|
+
if (relevant && kept < 3) {
|
|
401
|
+
block += `\n ${l.slice(0, 100)}`;
|
|
402
|
+
kept++;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
blocks.push(block);
|
|
406
|
+
}
|
|
407
|
+
out += blocks.join("\n");
|
|
408
|
+
if (failures.length > PYTEST_MAX_FAILURES) out += `\n... +${failures.length - PYTEST_MAX_FAILURES} more failures`;
|
|
409
|
+
}
|
|
410
|
+
return out;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// 1d. Cargo test output. Command-dispatch only. Ported from 9router RTK
|
|
414
|
+
// (rtk/src/cmds/rust/cargo_cmd.rs: filter_cargo_test +
|
|
415
|
+
// AggregatedTestResult).
|
|
416
|
+
|
|
417
|
+
const CARGO_MAX_FAILURES = 10;
|
|
418
|
+
const CARGO_TEST_RESULT_RE = /test result: (\w+)\.\s+(\d+) passed;\s+(\d+) failed;\s+(\d+) ignored;\s+(\d+) measured;\s+(\d+) filtered out(?:;\s+finished in ([\d.]+)s)?/;
|
|
419
|
+
|
|
420
|
+
function compressCargoTestOutput(text, opts = {}) {
|
|
421
|
+
const clean = stripAnsi(text);
|
|
422
|
+
const failures = [];
|
|
423
|
+
const summaryLines = [];
|
|
424
|
+
let inFailureSection = false;
|
|
425
|
+
let currentFailure = [];
|
|
426
|
+
|
|
427
|
+
for (const line of clean.split("\n")) {
|
|
428
|
+
const lead = line.trimStart();
|
|
429
|
+
if (lead.startsWith("Compiling") || lead.startsWith("Downloading") ||
|
|
430
|
+
lead.startsWith("Downloaded") || lead.startsWith("Finished")) continue;
|
|
431
|
+
if (line.startsWith("running ") || (line.startsWith("test ") && line.endsWith("... ok"))) continue;
|
|
432
|
+
|
|
433
|
+
if (line === "failures:") { inFailureSection = true; continue; }
|
|
434
|
+
|
|
435
|
+
if (inFailureSection) {
|
|
436
|
+
if (line.startsWith("test result:")) {
|
|
437
|
+
inFailureSection = false;
|
|
438
|
+
summaryLines.push(line);
|
|
439
|
+
} else if (line.startsWith(" ") || line.startsWith("---- ")) {
|
|
440
|
+
currentFailure.push(line);
|
|
441
|
+
} else if (!line.trim() && currentFailure.length > 0) {
|
|
442
|
+
failures.push(currentFailure.join("\n"));
|
|
443
|
+
currentFailure = [];
|
|
444
|
+
} else if (line.trim()) {
|
|
445
|
+
currentFailure.push(line);
|
|
446
|
+
}
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if (line.startsWith("test result:")) summaryLines.push(line);
|
|
450
|
+
}
|
|
451
|
+
if (currentFailure.length > 0) failures.push(currentFailure.join("\n"));
|
|
452
|
+
|
|
453
|
+
if (summaryLines.length === 0) return compressTestOutput(text, opts);
|
|
454
|
+
|
|
455
|
+
if (failures.length === 0) {
|
|
456
|
+
// All passed — aggregate the per-suite summary lines into one.
|
|
457
|
+
const agg = { passed: 0, ignored: 0, filteredOut: 0, suites: 0, duration: 0, hasDuration: true };
|
|
458
|
+
let allParsed = true;
|
|
459
|
+
for (const line of summaryLines) {
|
|
460
|
+
const m = line.match(CARGO_TEST_RESULT_RE);
|
|
461
|
+
if (!m || m[1] !== "ok") { allParsed = false; break; }
|
|
462
|
+
agg.passed += parseInt(m[2], 10);
|
|
463
|
+
agg.ignored += parseInt(m[4], 10);
|
|
464
|
+
agg.filteredOut += parseInt(m[6], 10);
|
|
465
|
+
agg.suites++;
|
|
466
|
+
if (m[7]) agg.duration += parseFloat(m[7]);
|
|
467
|
+
else agg.hasDuration = false;
|
|
468
|
+
}
|
|
469
|
+
if (allParsed && agg.suites > 0) {
|
|
470
|
+
const parts = [`${agg.passed} passed`];
|
|
471
|
+
if (agg.ignored > 0) parts.push(`${agg.ignored} ignored`);
|
|
472
|
+
if (agg.filteredOut > 0) parts.push(`${agg.filteredOut} filtered out`);
|
|
473
|
+
const suiteText = agg.suites === 1 ? "1 suite" : `${agg.suites} suites`;
|
|
474
|
+
return agg.hasDuration
|
|
475
|
+
? `cargo test: ${parts.join(", ")} (${suiteText}, ${agg.duration.toFixed(2)}s)`
|
|
476
|
+
: `cargo test: ${parts.join(", ")} (${suiteText})`;
|
|
477
|
+
}
|
|
478
|
+
return summaryLines.join("\n");
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
let out = `FAILURES (${failures.length}):\n`;
|
|
482
|
+
out += failures.slice(0, CARGO_MAX_FAILURES)
|
|
483
|
+
.map((f, i) => `${i + 1}. ${f.slice(0, 240)}`).join("\n");
|
|
484
|
+
if (failures.length > CARGO_MAX_FAILURES) out += `\n... +${failures.length - CARGO_MAX_FAILURES} more failures`;
|
|
485
|
+
out += "\n\n" + summaryLines.join("\n");
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// 1e. Go test output. Command-dispatch only. Tier 1 parses the `go test
|
|
490
|
+
// -json` event stream (ported from 9router RTK's
|
|
491
|
+
// rtk/src/cmds/go/go_cmd.rs: filter_go_test_json — RTK forces -json;
|
|
492
|
+
// Lynkr also handles the default text mode as Tier 2).
|
|
493
|
+
|
|
494
|
+
const GO_MAX_FAILURES = 10;
|
|
495
|
+
|
|
496
|
+
function compressGoTestOutput(text, opts = {}) {
|
|
497
|
+
const clean = stripAnsi(text);
|
|
498
|
+
const lines = clean.split("\n");
|
|
499
|
+
|
|
500
|
+
// Tier 1: -json event stream (one JSON event per line)
|
|
501
|
+
if (/^\s*\{"/.test(clean.trimStart())) {
|
|
502
|
+
const packages = new Map(); // pkg -> {pass, fail, skip, failedTests: [{test, output[]}]}
|
|
503
|
+
const testOutput = new Map(); // "pkg::test" -> output lines
|
|
504
|
+
let events = 0;
|
|
505
|
+
for (const line of lines) {
|
|
506
|
+
const trimmed = line.trim();
|
|
507
|
+
if (!trimmed) continue;
|
|
508
|
+
let ev;
|
|
509
|
+
try { ev = JSON.parse(trimmed); } catch { continue; }
|
|
510
|
+
if (!ev || typeof ev.Action !== "string") continue;
|
|
511
|
+
events++;
|
|
512
|
+
const pkg = ev.Package || "unknown";
|
|
513
|
+
if (!packages.has(pkg)) packages.set(pkg, { pass: 0, fail: 0, skip: 0, failedTests: [] });
|
|
514
|
+
const p = packages.get(pkg);
|
|
515
|
+
if (ev.Action === "pass" && ev.Test) p.pass++;
|
|
516
|
+
else if (ev.Action === "skip" && ev.Test) p.skip++;
|
|
517
|
+
else if (ev.Action === "fail" && ev.Test) {
|
|
518
|
+
p.fail++;
|
|
519
|
+
const key = `${pkg}::${ev.Test}`;
|
|
520
|
+
p.failedTests.push({ test: ev.Test, output: testOutput.get(key) || [] });
|
|
521
|
+
testOutput.delete(key);
|
|
522
|
+
} else if (ev.Action === "output" && ev.Test && ev.Output) {
|
|
523
|
+
const key = `${pkg}::${ev.Test}`;
|
|
524
|
+
if (!testOutput.has(key)) testOutput.set(key, []);
|
|
525
|
+
testOutput.get(key).push(ev.Output.trimEnd());
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (events > 0 && packages.size > 0) {
|
|
529
|
+
const totalPass = [...packages.values()].reduce((n, p) => n + p.pass, 0);
|
|
530
|
+
const totalFail = [...packages.values()].reduce((n, p) => n + p.fail, 0);
|
|
531
|
+
const totalSkip = [...packages.values()].reduce((n, p) => n + p.skip, 0);
|
|
532
|
+
if (totalFail === 0 && totalPass === 0) return compressTestOutput(text, opts);
|
|
533
|
+
if (totalFail === 0) return `Go test: ${totalPass} passed in ${packages.size} packages`;
|
|
534
|
+
let out = `Go test: ${totalPass} passed, ${totalFail} failed`;
|
|
535
|
+
if (totalSkip > 0) out += `, ${totalSkip} skipped`;
|
|
536
|
+
const blocks = [];
|
|
537
|
+
for (const [pkg, p] of packages) {
|
|
538
|
+
for (const ft of p.failedTests) {
|
|
539
|
+
if (blocks.length >= GO_MAX_FAILURES) break;
|
|
540
|
+
// Drop the === RUN / --- FAIL framing, keep the t.Errorf output
|
|
541
|
+
const detail = ft.output
|
|
542
|
+
.filter(l => l.trim() && !/^(?:=== RUN|--- FAIL|=== (?:PAUSE|CONT))/.test(l.trim()))
|
|
543
|
+
.slice(0, 5).map(l => ` ${l.trim()}`).join("\n");
|
|
544
|
+
blocks.push(`FAIL ${pkg} > ${ft.test}` + (detail ? `\n${detail}` : ""));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (blocks.length > 0) out += "\n\nFailures:\n" + blocks.join("\n---\n");
|
|
548
|
+
if (totalFail > blocks.length) out += `\n... +${totalFail - blocks.length} more failures`;
|
|
549
|
+
return out;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Tier 2: default text mode — keep per-package ok/FAIL lines and
|
|
554
|
+
// `--- FAIL:` blocks with their indented output; drop === RUN / --- PASS.
|
|
555
|
+
const pkgLines = [];
|
|
556
|
+
const failBlocks = [];
|
|
557
|
+
let i = 0;
|
|
558
|
+
let sawGoShape = false;
|
|
559
|
+
while (i < lines.length) {
|
|
560
|
+
const line = lines[i];
|
|
561
|
+
const trimmed = line.trim();
|
|
562
|
+
if (/^(?:ok|FAIL|---\s|===\s|\?)\s/.test(trimmed) || trimmed === "PASS" || trimmed === "FAIL") sawGoShape = true;
|
|
563
|
+
if (/^(?:ok\s+\S+|FAIL\s+\S+|\?\s+\S+\s+\[no test files\])/.test(trimmed)) {
|
|
564
|
+
pkgLines.push(trimmed);
|
|
565
|
+
i++;
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if (/^--- FAIL:/.test(trimmed)) {
|
|
569
|
+
const block = [trimmed];
|
|
570
|
+
i++;
|
|
571
|
+
while (i < lines.length && /^\s{4,}/.test(lines[i]) && lines[i].trim()) {
|
|
572
|
+
block.push(lines[i].trim());
|
|
573
|
+
i++;
|
|
574
|
+
}
|
|
575
|
+
failBlocks.push(block.slice(0, 6).join("\n "));
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
i++;
|
|
579
|
+
}
|
|
580
|
+
if (!sawGoShape || pkgLines.length === 0) return compressTestOutput(text, opts);
|
|
581
|
+
|
|
582
|
+
const failedPkgs = pkgLines.filter(l => l.startsWith("FAIL")).length;
|
|
583
|
+
const okPkgs = pkgLines.length - failedPkgs;
|
|
584
|
+
let out = `Go test: ${okPkgs} package${okPkgs === 1 ? "" : "s"} ok, ${failedPkgs} failed`;
|
|
585
|
+
if (failBlocks.length > 0) {
|
|
586
|
+
out += "\n\nFailures:\n" + failBlocks.slice(0, GO_MAX_FAILURES).join("\n---\n");
|
|
587
|
+
if (failBlocks.length > GO_MAX_FAILURES) out += `\n... +${failBlocks.length - GO_MAX_FAILURES} more failures`;
|
|
588
|
+
}
|
|
589
|
+
out += "\n\nPackages:\n " + pkgLines.slice(0, 30).join("\n ");
|
|
590
|
+
if (pkgLines.length > 30) out += `\n ... +${pkgLines.length - 30} more (${pkgLines.length} total)`;
|
|
591
|
+
return out;
|
|
592
|
+
}
|
|
593
|
+
|
|
146
594
|
// 2. Git diff
|
|
147
595
|
function compressGitDiff(text) {
|
|
148
596
|
if (!text.startsWith("diff --git") && !text.includes("\ndiff --git")) return null;
|
|
@@ -237,13 +685,17 @@ function compressGitLog(text) {
|
|
|
237
685
|
}
|
|
238
686
|
|
|
239
687
|
// 5. Directory listings (ls, find, tree)
|
|
240
|
-
function compressDirectoryListing(text) {
|
|
688
|
+
function compressDirectoryListing(text, opts = {}) {
|
|
241
689
|
const lines = text.split("\n").filter(l => l.trim());
|
|
242
690
|
if (lines.length < 10) return null;
|
|
243
691
|
|
|
244
|
-
// Detect: mostly file paths (one per line)
|
|
245
|
-
|
|
246
|
-
|
|
692
|
+
// Detect: mostly file paths (one per line). A known ls/find/tree command
|
|
693
|
+
// skips this check — real listings can contain names (spaces, unicode)
|
|
694
|
+
// the shape regex would reject.
|
|
695
|
+
if (!opts.trusted) {
|
|
696
|
+
const pathLines = lines.filter(l => /^[.\w\/-]+\.\w+$/.test(l.trim()) || /^[.\w\/-]+\/$/.test(l.trim()) || /^[-drwx]{10}/.test(l.trim()));
|
|
697
|
+
if (pathLines.length < lines.length * 0.6) return null;
|
|
698
|
+
}
|
|
247
699
|
|
|
248
700
|
// Group by directory
|
|
249
701
|
const dirs = {};
|
|
@@ -270,36 +722,131 @@ function compressDirectoryListing(text) {
|
|
|
270
722
|
return result.length > 0 ? result.join("\n") : null;
|
|
271
723
|
}
|
|
272
724
|
|
|
273
|
-
// 6. Lint output (eslint, tsc, ruff, clippy, biome)
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
725
|
+
// 6. Lint output (eslint, tsc, ruff, clippy, biome). Grouping by rule and
|
|
726
|
+
// by file ported from 9router RTK's lint filter
|
|
727
|
+
// (rtk/src/cmds/js/lint_cmd.rs: filter_eslint_json + compact_path).
|
|
728
|
+
|
|
729
|
+
const LINT_MAX_RULES = 15;
|
|
730
|
+
const LINT_MAX_FILES = 8;
|
|
731
|
+
|
|
732
|
+
// Compact file path (remove common prefixes) — RTK's compact_path.
|
|
733
|
+
function compactPath(path) {
|
|
734
|
+
const p = path.replace(/\\/g, "/");
|
|
735
|
+
const srcPos = p.lastIndexOf("/src/");
|
|
736
|
+
if (srcPos !== -1) return "src/" + p.slice(srcPos + 5);
|
|
737
|
+
const libPos = p.lastIndexOf("/lib/");
|
|
738
|
+
if (libPos !== -1) return "lib/" + p.slice(libPos + 5);
|
|
739
|
+
return p;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// ESLint `-f json`: array of {filePath, messages[], errorCount, warningCount}
|
|
743
|
+
function formatEslintJson(results) {
|
|
744
|
+
const totalErrors = results.reduce((n, r) => n + (r.errorCount || 0), 0);
|
|
745
|
+
const totalWarnings = results.reduce((n, r) => n + (r.warningCount || 0), 0);
|
|
746
|
+
const withIssues = results.filter(r => r.messages.length > 0);
|
|
747
|
+
if (totalErrors === 0 && totalWarnings === 0) return "ESLint: No issues found";
|
|
748
|
+
|
|
749
|
+
const byRule = new Map();
|
|
750
|
+
for (const r of withIssues) {
|
|
751
|
+
for (const m of r.messages) {
|
|
752
|
+
if (m && m.ruleId) byRule.set(m.ruleId, (byRule.get(m.ruleId) || 0) + 1);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const out = [`ESLint: ${totalErrors} errors, ${totalWarnings} warnings in ${withIssues.length} files`];
|
|
757
|
+
const rules = [...byRule.entries()].sort((a, b) => b[1] - a[1]);
|
|
758
|
+
if (rules.length > 0) {
|
|
759
|
+
out.push("Top rules:");
|
|
760
|
+
for (const [rule, count] of rules.slice(0, 10)) out.push(` ${rule} (${count}x)`);
|
|
761
|
+
}
|
|
762
|
+
const byFile = withIssues
|
|
763
|
+
.map(r => ({ r, count: r.messages.length }))
|
|
764
|
+
.sort((a, b) => b.count - a.count);
|
|
765
|
+
out.push("Top files:");
|
|
766
|
+
for (const { r, count } of byFile.slice(0, LINT_MAX_FILES)) {
|
|
767
|
+
out.push(` ${compactPath(r.filePath)} (${count} issues)`);
|
|
768
|
+
const fileRules = new Map();
|
|
769
|
+
for (const m of r.messages) {
|
|
770
|
+
if (m && m.ruleId) fileRules.set(m.ruleId, (fileRules.get(m.ruleId) || 0) + 1);
|
|
771
|
+
}
|
|
772
|
+
for (const [rule, n] of [...fileRules.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3)) {
|
|
773
|
+
out.push(` ${rule} (${n})`);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
if (byFile.length > LINT_MAX_FILES) out.push(` ... +${byFile.length - LINT_MAX_FILES} more files`);
|
|
777
|
+
return out.join("\n");
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function compressLintOutput(text, opts = {}) {
|
|
781
|
+
const clean = stripAnsi(text);
|
|
782
|
+
|
|
783
|
+
// ESLint JSON reporter output — precise schema check so shape mode
|
|
784
|
+
// can't misread arbitrary JSON arrays.
|
|
785
|
+
const trimmedText = clean.trim();
|
|
786
|
+
if (trimmedText.startsWith("[")) {
|
|
787
|
+
let parsed = null;
|
|
788
|
+
try { parsed = JSON.parse(trimmedText); } catch { /* not JSON */ }
|
|
789
|
+
if (Array.isArray(parsed) && parsed.length > 0 &&
|
|
790
|
+
parsed.every(r => r && typeof r.filePath === "string" && Array.isArray(r.messages) &&
|
|
791
|
+
typeof r.errorCount === "number")) {
|
|
792
|
+
return formatEslintJson(parsed);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Detect lint patterns: file:line:col or rule IDs. A known lint/tsc
|
|
797
|
+
// command skips this gate (it misses tsc's `(line,col): error TSxxxx`
|
|
798
|
+
// form; the per-line parsers below still decide what counts).
|
|
799
|
+
const hasLintPattern = /(?:\d+:\d+\s+(?:error|warning)|error\[E\d+\]|:\d+:\d+:?\s+\w+\/[\w-]+|✖|⚠)/i.test(clean);
|
|
800
|
+
if (!hasLintPattern && !opts.trusted) return null;
|
|
278
801
|
|
|
279
802
|
const ruleGroups = {};
|
|
280
803
|
const fileGroups = {};
|
|
281
804
|
let errorCount = 0;
|
|
282
805
|
let warningCount = 0;
|
|
806
|
+
let currentFile = null;
|
|
283
807
|
|
|
284
|
-
|
|
285
|
-
|
|
808
|
+
const record = (rule, severity, file) => {
|
|
809
|
+
if (!ruleGroups[rule]) ruleGroups[rule] = { count: 0, severity };
|
|
810
|
+
ruleGroups[rule].count++;
|
|
811
|
+
if (severity === "error") errorCount++;
|
|
812
|
+
else warningCount++;
|
|
813
|
+
if (file) {
|
|
814
|
+
if (!fileGroups[file]) fileGroups[file] = { count: 0, rules: {} };
|
|
815
|
+
fileGroups[file].count++;
|
|
816
|
+
fileGroups[file].rules[rule] = (fileGroups[file].rules[rule] || 0) + 1;
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
for (const line of clean.split("\n")) {
|
|
821
|
+
// ESLint stylish prints each file as a standalone path line above its
|
|
822
|
+
// issue block — track it so issues group per file.
|
|
823
|
+
if (/^\S+[\/\\]\S+\.\w+$/.test(line.trim()) && !line.includes(":")) {
|
|
824
|
+
currentFile = line.trim();
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// ESLint stylish issue: line:col error/warning message rule-name
|
|
286
829
|
const eslintMatch = line.match(/(\d+:\d+)\s+(error|warning)\s+(.+?)\s+([\w\-/@]+)\s*$/i);
|
|
287
830
|
if (eslintMatch) {
|
|
288
831
|
const [, , severity, , rule] = eslintMatch;
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
832
|
+
record(rule, severity.toLowerCase(), currentFile);
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Biome: file.ts:10:5 lint/group/ruleName ━━━ (also assist/, syntax/)
|
|
837
|
+
const biomeMatch = line.match(/^(\S+?):(\d+):(\d+)\s+((?:lint|assist|syntax)\/[\w/.-]+)/);
|
|
838
|
+
if (biomeMatch) {
|
|
839
|
+
const [, file, , , rule] = biomeMatch;
|
|
840
|
+
record(rule, "error", file);
|
|
293
841
|
continue;
|
|
294
842
|
}
|
|
295
843
|
|
|
296
844
|
// TypeScript style: file(line,col): error TSxxxx: message
|
|
297
|
-
|
|
845
|
+
// (regex per 9router RTK's tsc parser, src/cmds/js/tsc_cmd.rs)
|
|
846
|
+
const tsMatch = line.match(/^(.+?)\((\d+),(\d+)\):\s+(error|warning)\s+(TS\d+):\s+(.+)$/);
|
|
298
847
|
if (tsMatch) {
|
|
299
|
-
const [, , , code] = tsMatch;
|
|
300
|
-
|
|
301
|
-
ruleGroups[code].count++;
|
|
302
|
-
errorCount++;
|
|
848
|
+
const [, file, , , severity, code] = tsMatch;
|
|
849
|
+
record(code, severity, file.trim());
|
|
303
850
|
continue;
|
|
304
851
|
}
|
|
305
852
|
|
|
@@ -307,10 +854,7 @@ function compressLintOutput(text) {
|
|
|
307
854
|
const rustMatch = line.match(/^(error|warning)\[(\w+)\]:\s*(.+)/);
|
|
308
855
|
if (rustMatch) {
|
|
309
856
|
const [, severity, code] = rustMatch;
|
|
310
|
-
|
|
311
|
-
ruleGroups[code].count++;
|
|
312
|
-
if (severity === "error") errorCount++;
|
|
313
|
-
else warningCount++;
|
|
857
|
+
record(code, severity, null);
|
|
314
858
|
}
|
|
315
859
|
}
|
|
316
860
|
|
|
@@ -320,9 +864,21 @@ function compressLintOutput(text) {
|
|
|
320
864
|
.sort((a, b) => b[1].count - a[1].count);
|
|
321
865
|
|
|
322
866
|
const summary = [`${errorCount} errors, ${warningCount} warnings`];
|
|
323
|
-
for (const [rule, data] of sorted) {
|
|
867
|
+
for (const [rule, data] of sorted.slice(0, LINT_MAX_RULES)) {
|
|
324
868
|
summary.push(` ${rule}: ${data.count}x (${data.severity})`);
|
|
325
869
|
}
|
|
870
|
+
if (sorted.length > LINT_MAX_RULES) summary.push(` ... +${sorted.length - LINT_MAX_RULES} more rules`);
|
|
871
|
+
|
|
872
|
+
const files = Object.entries(fileGroups).sort((a, b) => b[1].count - a[1].count);
|
|
873
|
+
if (files.length > 1) {
|
|
874
|
+
summary.push("Top files:");
|
|
875
|
+
for (const [file, data] of files.slice(0, LINT_MAX_FILES)) {
|
|
876
|
+
summary.push(` ${compactPath(file)} (${data.count} issues)`);
|
|
877
|
+
const topRules = Object.entries(data.rules).sort((a, b) => b[1] - a[1]).slice(0, 3);
|
|
878
|
+
for (const [rule, n] of topRules) summary.push(` ${rule} (${n})`);
|
|
879
|
+
}
|
|
880
|
+
if (files.length > LINT_MAX_FILES) summary.push(` ... +${files.length - LINT_MAX_FILES} more files`);
|
|
881
|
+
}
|
|
326
882
|
|
|
327
883
|
return summary.join("\n");
|
|
328
884
|
}
|
|
@@ -467,7 +1023,7 @@ const CONTAINER_HEADER_COLUMNS = [
|
|
|
467
1023
|
"NAMESPACE", "NAME", "READY", "RESTARTS", "AGE", "CLUSTER-IP",
|
|
468
1024
|
"EXTERNAL-IP", "TYPE", "DESIRED", "CURRENT", "AVAILABLE", "UP-TO-DATE",
|
|
469
1025
|
];
|
|
470
|
-
function compressContainerOutput(text) {
|
|
1026
|
+
function compressContainerOutput(text, opts = {}) {
|
|
471
1027
|
const lines = text.split("\n").filter(l => l.trim());
|
|
472
1028
|
if (lines.length < 3) return null;
|
|
473
1029
|
|
|
@@ -482,7 +1038,13 @@ function compressContainerOutput(text) {
|
|
|
482
1038
|
rest = rest.split(col).join(" ");
|
|
483
1039
|
}
|
|
484
1040
|
}
|
|
485
|
-
if (columns < 3 || rest.trim() !== "")
|
|
1041
|
+
if (columns < 3 || rest.trim() !== "") {
|
|
1042
|
+
// Known docker/kubectl command: custom columns (--format, -o custom)
|
|
1043
|
+
// won't be in the token list, so accept any header that is visibly a
|
|
1044
|
+
// column row — at least two runs of 2+ spaces separating columns.
|
|
1045
|
+
const columnGaps = (header.match(/\S {2,}(?=\S)/g) || []).length;
|
|
1046
|
+
if (!opts.trusted || columnGaps < 2) return null;
|
|
1047
|
+
}
|
|
486
1048
|
|
|
487
1049
|
const dataLines = lines.slice(1);
|
|
488
1050
|
if (dataLines.length <= 10) return null; // Not enough to compress
|
|
@@ -490,6 +1052,71 @@ function compressContainerOutput(text) {
|
|
|
490
1052
|
return `${header}\n${dataLines.slice(0, 10).join("\n")}\n... +${dataLines.length - 10} more (${dataLines.length} total)`;
|
|
491
1053
|
}
|
|
492
1054
|
|
|
1055
|
+
// 10b. `gh pr list` / `gh issue list` output. Command-dispatch only.
|
|
1056
|
+
// Line format ported from 9router RTK (rtk/src/cmds/git/gh_cmd.rs:
|
|
1057
|
+
// format_pr_list / format_issue_list). RTK re-runs gh with --json and
|
|
1058
|
+
// formats that; Lynkr sees whatever the agent got — non-tty gh emits
|
|
1059
|
+
// header-less tab-separated rows, tty-mode emits space-aligned columns,
|
|
1060
|
+
// and both are handled here. Commands with an explicit --json flag are
|
|
1061
|
+
// never dispatched (the caller asked for those exact fields).
|
|
1062
|
+
|
|
1063
|
+
const GH_LIST_MAX = 30;
|
|
1064
|
+
const GH_STATE_TOKENS = new Set(["OPEN", "CLOSED", "MERGED", "DRAFT"]);
|
|
1065
|
+
|
|
1066
|
+
function formatGhRow(fields) {
|
|
1067
|
+
const number = fields[0].replace(/^#/, "");
|
|
1068
|
+
const title = (fields[1] || "").slice(0, 60);
|
|
1069
|
+
const rest = fields.slice(2)
|
|
1070
|
+
.filter(f => f && !/^\d{4}-\d{2}-\d{2}T/.test(f) && !/^about /.test(f));
|
|
1071
|
+
const state = rest.find(f => GH_STATE_TOKENS.has(f.toUpperCase()));
|
|
1072
|
+
const extra = rest.filter(f => f !== state).slice(0, 2);
|
|
1073
|
+
let line = ` ${state ? `[${state.toLowerCase()}] ` : ""}#${number} ${title}`;
|
|
1074
|
+
if (extra.length > 0) line += ` (${extra.join(", ")})`;
|
|
1075
|
+
return line;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function compressGhList(text) {
|
|
1079
|
+
const clean = stripAnsi(text);
|
|
1080
|
+
const trimmedText = clean.trim();
|
|
1081
|
+
|
|
1082
|
+
// gh --json shape (guarded in dispatch, but an alias may still yield it)
|
|
1083
|
+
if (trimmedText.startsWith("[")) {
|
|
1084
|
+
let parsed = null;
|
|
1085
|
+
try { parsed = JSON.parse(trimmedText); } catch { /* not JSON */ }
|
|
1086
|
+
if (Array.isArray(parsed) && parsed.length > 0 &&
|
|
1087
|
+
parsed.every(r => r && typeof r.number === "number" && typeof r.title === "string")) {
|
|
1088
|
+
const rows = parsed.map(r => {
|
|
1089
|
+
const author = r.author && r.author.login ? ` (${r.author.login})` : "";
|
|
1090
|
+
const state = r.state ? `[${String(r.state).toLowerCase()}] ` : "";
|
|
1091
|
+
return ` ${state}#${r.number} ${r.title.slice(0, 60)}${author}`;
|
|
1092
|
+
});
|
|
1093
|
+
let out = `${parsed.length} items:\n` + rows.slice(0, GH_LIST_MAX).join("\n");
|
|
1094
|
+
if (rows.length > GH_LIST_MAX) out += `\n ... +${rows.length - GH_LIST_MAX} more (${rows.length} total)`;
|
|
1095
|
+
return out;
|
|
1096
|
+
}
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// Table rows: tab-separated (non-tty) or 2+-space aligned (tty), first
|
|
1101
|
+
// field the PR/issue number.
|
|
1102
|
+
const rows = [];
|
|
1103
|
+
let total = 0;
|
|
1104
|
+
for (const line of clean.split("\n")) {
|
|
1105
|
+
if (!line.trim()) continue;
|
|
1106
|
+
if (/^Showing \d+ of \d+/i.test(line.trim())) continue; // tty header
|
|
1107
|
+
const fields = (line.includes("\t") ? line.split("\t") : line.trim().split(/ {2,}/))
|
|
1108
|
+
.map(f => f.trim()).filter(Boolean);
|
|
1109
|
+
if (fields.length < 2 || !/^#?\d+$/.test(fields[0])) return null; // not a gh list table
|
|
1110
|
+
total++;
|
|
1111
|
+
if (rows.length < GH_LIST_MAX) rows.push(formatGhRow(fields));
|
|
1112
|
+
}
|
|
1113
|
+
if (total === 0) return null;
|
|
1114
|
+
|
|
1115
|
+
let out = `${total} items:\n` + rows.join("\n");
|
|
1116
|
+
if (total > rows.length) out += `\n ... +${total - rows.length} more (${total} total)`;
|
|
1117
|
+
return out;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
493
1120
|
// 11. Grep / ripgrep output ("file:lineno:content"), per-file match cap.
|
|
494
1121
|
// Ported from 9router RTK grep filter (rtk/src/cmds/system/pipe_cmd.rs).
|
|
495
1122
|
const GREP_PER_FILE_MAX = 10;
|
|
@@ -600,6 +1227,14 @@ function compressSmartTruncate(text) {
|
|
|
600
1227
|
// back (live incident: /docker/i matched "Dockerfile" in an ls listing,
|
|
601
1228
|
// truncated it to 10 lines, and the model invented the rest).
|
|
602
1229
|
|
|
1230
|
+
// Generic fallbacks last: dedup exact-duplicate spam, then hard head/tail
|
|
1231
|
+
// truncation only if nothing more specific applied. Also the only layer
|
|
1232
|
+
// that runs when a command-dispatched compressor declines (see below).
|
|
1233
|
+
const GENERIC_FALLBACKS = [
|
|
1234
|
+
{ name: "dedup_log", fn: compressDedupLog },
|
|
1235
|
+
{ name: "smart_truncate", fn: compressSmartTruncate },
|
|
1236
|
+
];
|
|
1237
|
+
|
|
603
1238
|
const COMPRESSORS = [
|
|
604
1239
|
{ name: "test_output", fn: compressTestOutput },
|
|
605
1240
|
{ name: "git_diff", fn: compressGitDiff },
|
|
@@ -612,12 +1247,203 @@ const COMPRESSORS = [
|
|
|
612
1247
|
{ name: "grep_output", fn: compressGrep },
|
|
613
1248
|
{ name: "directory_listing", fn: compressDirectoryListing },
|
|
614
1249
|
{ name: "large_file", fn: compressLargeFile },
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
1250
|
+
...GENERIC_FALLBACKS,
|
|
1251
|
+
];
|
|
1252
|
+
|
|
1253
|
+
// ── Command-Aware Dispatch ───────────────────────────────────────────
|
|
1254
|
+
//
|
|
1255
|
+
// RTK never misfires format detection because it knows the command it
|
|
1256
|
+
// wraps. Lynkr has the command too: every tool_result's tool_use block
|
|
1257
|
+
// carries the literal shell command in its input. When the command is
|
|
1258
|
+
// known, dispatch straight to the matching compressor and skip shape
|
|
1259
|
+
// detection entirely; shape anchoring (DETECTION RULE above) remains the
|
|
1260
|
+
// fallback for unknown commands and non-shell tools. Dispatch tables
|
|
1261
|
+
// modeled on 9router RTK's command routing (rtk/src/cmds/mod.rs).
|
|
1262
|
+
|
|
1263
|
+
// Shell-executing tool names across supported harnesses — see
|
|
1264
|
+
// client-profiles.js baselines (claude-code: Bash; goose/codex: shell;
|
|
1265
|
+
// cursor: run_terminal_command; codex v0.142+: exec_command).
|
|
1266
|
+
const SHELL_TOOL_NAMES = new Set([
|
|
1267
|
+
"Bash", "bash", "shell", "run_terminal_command", "run_shell_command",
|
|
1268
|
+
"exec_command", "execute_command",
|
|
1269
|
+
]);
|
|
1270
|
+
|
|
1271
|
+
// Ordered dispatch table: `match` tokens are checked against the
|
|
1272
|
+
// command's non-flag tokens (first token exact after basename/runner
|
|
1273
|
+
// stripping, rest as an in-order subsequence, so `git -C x status`
|
|
1274
|
+
// matches). A token also matches its npm-script variants (`test` ⇢
|
|
1275
|
+
// `test:unit`). A wrong dispatch is safe: every compressor still
|
|
1276
|
+
// validates its input's structure and declines what it can't parse.
|
|
1277
|
+
const COMMAND_DISPATCH = [
|
|
1278
|
+
{ match: ["git", "status"], name: "git_status" },
|
|
1279
|
+
{ match: ["git", "diff"], name: "git_diff" },
|
|
1280
|
+
{ match: ["git", "log"], name: "git_log" },
|
|
1281
|
+
{ match: ["docker", "ps"], name: "container_output" },
|
|
1282
|
+
{ match: ["docker", "images"], name: "container_output" },
|
|
1283
|
+
{ match: ["docker", "container"], name: "container_output" },
|
|
1284
|
+
{ match: ["podman", "ps"], name: "container_output" },
|
|
1285
|
+
{ match: ["kubectl", "get"], name: "container_output" },
|
|
1286
|
+
{ match: ["ls"], name: "directory_listing" },
|
|
1287
|
+
{ match: ["find"], name: "directory_listing" },
|
|
1288
|
+
{ match: ["tree"], name: "directory_listing" },
|
|
1289
|
+
{ match: ["fd"], name: "directory_listing" },
|
|
1290
|
+
{ match: ["grep"], name: "grep_output" },
|
|
1291
|
+
{ match: ["egrep"], name: "grep_output" },
|
|
1292
|
+
{ match: ["rg"], name: "grep_output" },
|
|
1293
|
+
{ match: ["tsc"], name: "lint_output" },
|
|
1294
|
+
{ match: ["eslint"], name: "lint_output" },
|
|
1295
|
+
{ match: ["biome"], name: "lint_output" },
|
|
1296
|
+
{ match: ["ruff"], name: "lint_output" },
|
|
1297
|
+
{ match: ["jest"], name: "js_test_output" },
|
|
1298
|
+
{ match: ["vitest"], name: "js_test_output" },
|
|
1299
|
+
{ match: ["pytest"], name: "pytest_output" },
|
|
1300
|
+
// `-m` is a flag, so it's already stripped before matching:
|
|
1301
|
+
// `python -m pytest` arrives here as [python, pytest].
|
|
1302
|
+
{ match: ["python", "pytest"], name: "pytest_output" },
|
|
1303
|
+
{ match: ["python3", "pytest"], name: "pytest_output" },
|
|
1304
|
+
{ match: ["cargo", "test"], name: "cargo_test_output" },
|
|
1305
|
+
{ match: ["cargo", "nextest"], name: "cargo_test_output" },
|
|
1306
|
+
{ match: ["go", "test"], name: "go_test_output" },
|
|
1307
|
+
{ match: ["gh", "pr", "list"], name: "gh_list_output" },
|
|
1308
|
+
{ match: ["gh", "issue", "list"], name: "gh_list_output" },
|
|
1309
|
+
{ match: ["npm", "test"], name: "test_output" },
|
|
1310
|
+
{ match: ["yarn", "test"], name: "test_output" },
|
|
1311
|
+
{ match: ["pnpm", "test"], name: "test_output" },
|
|
1312
|
+
{ match: ["npm", "run", "test"], name: "test_output" },
|
|
1313
|
+
{ match: ["yarn", "run", "test"], name: "test_output" },
|
|
1314
|
+
{ match: ["pnpm", "run", "test"], name: "test_output" },
|
|
1315
|
+
{ match: ["cargo", "build"], name: "build_output" },
|
|
1316
|
+
{ match: ["npm", "run", "build"], name: "build_output" },
|
|
1317
|
+
{ match: ["make"], name: "build_output" },
|
|
619
1318
|
];
|
|
620
1319
|
|
|
1320
|
+
const COMPRESSOR_BY_NAME = new Map([
|
|
1321
|
+
...COMPRESSORS.map(c => [c.name, c.fn]),
|
|
1322
|
+
// Dispatch-only parsers: reachable via a known command, never via shape
|
|
1323
|
+
// detection (their format gates are too loose to run on unknown text).
|
|
1324
|
+
["js_test_output", compressJsTestOutput],
|
|
1325
|
+
["pytest_output", compressPytestOutput],
|
|
1326
|
+
["cargo_test_output", compressCargoTestOutput],
|
|
1327
|
+
["go_test_output", compressGoTestOutput],
|
|
1328
|
+
["gh_list_output", compressGhList],
|
|
1329
|
+
]);
|
|
1330
|
+
|
|
1331
|
+
function extractCommandString(input) {
|
|
1332
|
+
if (input == null) return null;
|
|
1333
|
+
if (typeof input === "string") {
|
|
1334
|
+
// OpenAI-shaped arguments arrive as a JSON string if this ever runs
|
|
1335
|
+
// pre-conversion (today it runs post-conversion, Anthropic shape).
|
|
1336
|
+
try { input = JSON.parse(input); } catch { return null; }
|
|
1337
|
+
}
|
|
1338
|
+
const cmd = input.command ?? input.cmd ?? input.script;
|
|
1339
|
+
return typeof cmd === "string" && cmd.trim() ? cmd : null;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// Reduce a raw shell command to the tokens that identify what ran, or
|
|
1343
|
+
// null when the output can't be attributed to a single command.
|
|
1344
|
+
function commandTokens(raw) {
|
|
1345
|
+
let s = raw.trim();
|
|
1346
|
+
|
|
1347
|
+
// Strip leading env assignments, wrappers, and `cd x &&` chains.
|
|
1348
|
+
for (;;) {
|
|
1349
|
+
let m;
|
|
1350
|
+
if ((m = s.match(/^[A-Za-z_][A-Za-z0-9_]*=(?:"(?:\\.|[^"])*"|'[^']*'|\S*)\s+/))) { s = s.slice(m[0].length); continue; }
|
|
1351
|
+
if ((m = s.match(/^(?:sudo|command|time|env)\s+/))) { s = s.slice(m[0].length); continue; }
|
|
1352
|
+
if ((m = s.match(/^cd\s+(?:"(?:\\.|[^"])*"|'[^']*'|\S+)\s*(?:&&|;)\s*/))) { s = s.slice(m[0].length); continue; }
|
|
1353
|
+
break;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// Separator analysis on a copy with quoted strings and redirections
|
|
1357
|
+
// blanked out (a `|` inside a grep pattern is not a pipe; `2>&1` is not
|
|
1358
|
+
// a chain).
|
|
1359
|
+
const bare = s
|
|
1360
|
+
.replace(/"(?:\\.|[^"])*"|'[^']*'/g, '""')
|
|
1361
|
+
.replace(/\d*>{1,2}\s*&\d+/g, " ")
|
|
1362
|
+
.replace(/\d*>{1,2}\s*\S+/g, " ");
|
|
1363
|
+
|
|
1364
|
+
// Chained commands produce mixed output — dispatching on the first
|
|
1365
|
+
// command would compress the rest as the wrong format. Bail to shape
|
|
1366
|
+
// detection. Pipes are allowed only into display-only filters.
|
|
1367
|
+
if (/&&|\|\||;|&\s*$/.test(bare)) return null;
|
|
1368
|
+
const segments = bare.split("|");
|
|
1369
|
+
if (segments.slice(1).some(seg => !/^\s*(?:head|tail|cat)\b/.test(seg))) return null;
|
|
1370
|
+
|
|
1371
|
+
const tokens = segments[0].trim().split(/\s+/).filter(Boolean);
|
|
1372
|
+
if (tokens.length === 0) return null;
|
|
1373
|
+
tokens[0] = tokens[0].split("/").pop(); // /usr/bin/git → git
|
|
1374
|
+
|
|
1375
|
+
// Skip package-runner prefixes: npx tsc, pnpm exec eslint, bunx vitest.
|
|
1376
|
+
if (tokens[0] === "npx" || tokens[0] === "bunx") tokens.shift();
|
|
1377
|
+
else if (/^(?:npm|pnpm|yarn|bun)$/.test(tokens[0]) && /^(?:exec|dlx|x)$/.test(tokens[1] || "")) tokens.splice(0, 2);
|
|
1378
|
+
|
|
1379
|
+
return tokens.length > 0 ? tokens : null;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function tokenMatches(pattern, token) {
|
|
1383
|
+
return token === pattern || token.startsWith(pattern + ":");
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function matchDispatch(nonFlag) {
|
|
1387
|
+
for (const entry of COMMAND_DISPATCH) {
|
|
1388
|
+
if (!tokenMatches(entry.match[0], nonFlag[0])) continue;
|
|
1389
|
+
// Remaining pattern tokens must appear in order among the next few
|
|
1390
|
+
// non-flag tokens (allows `git -C x status`, `kubectl get pods -A`).
|
|
1391
|
+
let pos = 1;
|
|
1392
|
+
let ok = true;
|
|
1393
|
+
for (const pat of entry.match.slice(1)) {
|
|
1394
|
+
let found = false;
|
|
1395
|
+
while (pos < Math.min(nonFlag.length, 5)) {
|
|
1396
|
+
if (tokenMatches(pat, nonFlag[pos++])) { found = true; break; }
|
|
1397
|
+
}
|
|
1398
|
+
if (!found) { ok = false; break; }
|
|
1399
|
+
}
|
|
1400
|
+
if (ok) return { name: entry.name, fn: COMPRESSOR_BY_NAME.get(entry.name) };
|
|
1401
|
+
}
|
|
1402
|
+
return null;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
function resolveCommandCompressor(command) {
|
|
1406
|
+
const tokens = commandTokens(command);
|
|
1407
|
+
if (!tokens) return null;
|
|
1408
|
+
const nonFlag = tokens.filter(t => !t.startsWith("-"));
|
|
1409
|
+
if (nonFlag.length === 0) return null;
|
|
1410
|
+
|
|
1411
|
+
const hit = matchDispatch(nonFlag);
|
|
1412
|
+
if (hit) {
|
|
1413
|
+
// RTK rule: an explicit --json means the caller asked for those exact
|
|
1414
|
+
// fields — pass gh output through rather than reformatting it.
|
|
1415
|
+
if (hit.name === "gh_list_output" && tokens.some(t => t === "--json" || t.startsWith("--json="))) {
|
|
1416
|
+
return null;
|
|
1417
|
+
}
|
|
1418
|
+
return hit;
|
|
1419
|
+
}
|
|
1420
|
+
// Bare package-manager bin invocation (`yarn jest`, `pnpm vitest`):
|
|
1421
|
+
// no entry matched with the PM in front, retry without it — but only
|
|
1422
|
+
// for known JS bins. `pnpm ls` must NOT become an `ls` dispatch (its
|
|
1423
|
+
// output is a dependency tree, not a directory listing).
|
|
1424
|
+
if (nonFlag.length > 1 && /^(?:npm|pnpm|yarn|bun)$/.test(nonFlag[0]) &&
|
|
1425
|
+
/^(?:jest|vitest|tsc|eslint|biome)$/.test(nonFlag[1])) {
|
|
1426
|
+
return matchDispatch(nonFlag.slice(1));
|
|
1427
|
+
}
|
|
1428
|
+
return null;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// tool_use_id → command string, from the tool_use blocks that precede
|
|
1432
|
+
// every tool_result in the message stream.
|
|
1433
|
+
function buildCommandMap(messages) {
|
|
1434
|
+
const map = new Map();
|
|
1435
|
+
for (const msg of messages) {
|
|
1436
|
+
if (!Array.isArray(msg.content)) continue;
|
|
1437
|
+
for (const block of msg.content) {
|
|
1438
|
+
if (block.type !== "tool_use" || !block.id) continue;
|
|
1439
|
+
if (!SHELL_TOOL_NAMES.has(block.name)) continue;
|
|
1440
|
+
const cmd = extractCommandString(block.input);
|
|
1441
|
+
if (cmd) map.set(block.id, cmd);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
return map;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
621
1447
|
// Compression levels tied to routing tiers
|
|
622
1448
|
const TIER_THRESHOLDS = {
|
|
623
1449
|
SIMPLE: 300, // Compress if > 300 chars
|
|
@@ -626,13 +1452,10 @@ const TIER_THRESHOLDS = {
|
|
|
626
1452
|
REASONING: Infinity, // Never compress
|
|
627
1453
|
};
|
|
628
1454
|
|
|
629
|
-
function
|
|
630
|
-
const
|
|
631
|
-
if (text.length < threshold) return null;
|
|
632
|
-
|
|
633
|
-
for (const { name, fn } of COMPRESSORS) {
|
|
1455
|
+
function runCompressors(list, text, opts) {
|
|
1456
|
+
for (const { name, fn } of list) {
|
|
634
1457
|
try {
|
|
635
|
-
const result = fn(text);
|
|
1458
|
+
const result = fn(text, opts);
|
|
636
1459
|
if (result && result.length < text.length * 0.7) {
|
|
637
1460
|
return { compressed: result, pattern: name };
|
|
638
1461
|
}
|
|
@@ -643,6 +1466,24 @@ function tryCompress(text, tier) {
|
|
|
643
1466
|
return null;
|
|
644
1467
|
}
|
|
645
1468
|
|
|
1469
|
+
function tryCompress(text, tier, command) {
|
|
1470
|
+
const threshold = TIER_THRESHOLDS[tier] || TIER_THRESHOLDS.MEDIUM;
|
|
1471
|
+
if (text.length < threshold) return null;
|
|
1472
|
+
|
|
1473
|
+
// Command known → dispatch directly, no shape guessing. If the mapped
|
|
1474
|
+
// compressor declines (e.g. `docker ps` with 3 rows), only the generic
|
|
1475
|
+
// fallbacks may run: we KNOW the format, so no other structure detector
|
|
1476
|
+
// gets to second-guess it.
|
|
1477
|
+
const dispatch = command ? resolveCommandCompressor(command) : null;
|
|
1478
|
+
if (dispatch) {
|
|
1479
|
+
const hit = runCompressors([dispatch], text, { trusted: true });
|
|
1480
|
+
if (hit) return { compressed: hit.compressed, pattern: `cmd:${dispatch.name}` };
|
|
1481
|
+
return runCompressors(GENERIC_FALLBACKS, text, {});
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
return runCompressors(COMPRESSORS, text, {});
|
|
1485
|
+
}
|
|
1486
|
+
|
|
646
1487
|
// ── Main Entry Point ─────────────────────────────────────────────────
|
|
647
1488
|
|
|
648
1489
|
/**
|
|
@@ -663,6 +1504,8 @@ function compressToolResults(messages, options = {}) {
|
|
|
663
1504
|
let compressedCount = 0;
|
|
664
1505
|
let tokensSaved = 0;
|
|
665
1506
|
|
|
1507
|
+
const commandByToolUse = buildCommandMap(messages);
|
|
1508
|
+
|
|
666
1509
|
for (const msg of messages) {
|
|
667
1510
|
if (!Array.isArray(msg.content)) continue;
|
|
668
1511
|
|
|
@@ -673,7 +1516,7 @@ function compressToolResults(messages, options = {}) {
|
|
|
673
1516
|
metrics.totalToolResults++;
|
|
674
1517
|
const original = block.content;
|
|
675
1518
|
|
|
676
|
-
const result = tryCompress(original, tier);
|
|
1519
|
+
const result = tryCompress(original, tier, commandByToolUse.get(block.tool_use_id));
|
|
677
1520
|
if (result) {
|
|
678
1521
|
const teeId = teeStore(original);
|
|
679
1522
|
block.content = result.compressed + `\n[full: ${teeId}]`;
|