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.
Files changed (73) hide show
  1. package/README.md +92 -23
  2. package/bin/cli.js +13 -7
  3. package/bin/lynkr-init.js +34 -12
  4. package/bin/lynkr-reset.js +71 -0
  5. package/config/model-tiers.json +199 -52
  6. package/package.json +3 -3
  7. package/scripts/build-eval-set.js +256 -0
  8. package/scripts/mine-difficulty-anchors.js +288 -0
  9. package/scripts/validate-difficulty-classifier.js +144 -0
  10. package/scripts/validate-intent-anchors.js +186 -0
  11. package/src/api/providers-handler.js +0 -1
  12. package/src/api/router.js +292 -160
  13. package/src/clients/databricks.js +95 -6
  14. package/src/config/index.js +3 -43
  15. package/src/context/tool-result-compressor.js +883 -40
  16. package/src/orchestrator/index.js +117 -1235
  17. package/src/orchestrator/passthrough-stream.js +382 -0
  18. package/src/orchestrator/sse-transformer.js +408 -0
  19. package/src/routing/affinity-store.js +17 -3
  20. package/src/routing/classifier-setup.js +207 -0
  21. package/src/routing/complexity-analyzer.js +40 -4
  22. package/src/routing/difficulty-classifier.js +261 -0
  23. package/src/routing/index.js +70 -7
  24. package/src/routing/intent-score.js +132 -12
  25. package/src/routing/session-affinity.js +8 -1
  26. package/src/routing/side-channel-detector.js +103 -0
  27. package/src/server.js +20 -46
  28. package/src/agents/context-manager.js +0 -236
  29. package/src/agents/decomposition/dispatcher.js +0 -185
  30. package/src/agents/decomposition/gate.js +0 -136
  31. package/src/agents/decomposition/index.js +0 -183
  32. package/src/agents/decomposition/model-call.js +0 -75
  33. package/src/agents/decomposition/planner.js +0 -223
  34. package/src/agents/decomposition/synthesizer.js +0 -89
  35. package/src/agents/decomposition/telemetry.js +0 -55
  36. package/src/agents/definitions/loader.js +0 -653
  37. package/src/agents/executor.js +0 -457
  38. package/src/agents/index.js +0 -165
  39. package/src/agents/parallel-coordinator.js +0 -68
  40. package/src/agents/reflector.js +0 -331
  41. package/src/agents/skillbook.js +0 -331
  42. package/src/agents/store.js +0 -259
  43. package/src/edits/index.js +0 -171
  44. package/src/indexer/babel-parser.js +0 -213
  45. package/src/indexer/index.js +0 -1629
  46. package/src/indexer/navigation/index.js +0 -32
  47. package/src/indexer/navigation/providers/treeSitter.js +0 -36
  48. package/src/indexer/parser.js +0 -443
  49. package/src/tasks/store.js +0 -349
  50. package/src/tests/coverage.js +0 -173
  51. package/src/tests/index.js +0 -171
  52. package/src/tests/store.js +0 -213
  53. package/src/tools/agent-task.js +0 -145
  54. package/src/tools/code-mode.js +0 -304
  55. package/src/tools/decompose.js +0 -91
  56. package/src/tools/edits.js +0 -94
  57. package/src/tools/execution.js +0 -171
  58. package/src/tools/git.js +0 -1346
  59. package/src/tools/index.js +0 -306
  60. package/src/tools/indexer.js +0 -360
  61. package/src/tools/lazy-loader.js +0 -366
  62. package/src/tools/mcp-remote.js +0 -88
  63. package/src/tools/mcp.js +0 -116
  64. package/src/tools/process.js +0 -167
  65. package/src/tools/smart-selection.js +0 -180
  66. package/src/tools/stubs.js +0 -55
  67. package/src/tools/tasks.js +0 -260
  68. package/src/tools/tests.js +0 -132
  69. package/src/tools/tinyfish.js +0 -358
  70. package/src/tools/truncate.js +0 -106
  71. package/src/tools/web-client.js +0 -71
  72. package/src/tools/web.js +0 -415
  73. 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
- if (failures.length === 0 && summary.length < 2) {
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
- const pathLines = lines.filter(l => /^[.\w\/-]+\.\w+$/.test(l.trim()) || /^[.\w\/-]+\/$/.test(l.trim()) || /^[-drwx]{10}/.test(l.trim()));
246
- if (pathLines.length < lines.length * 0.6) return null;
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
- function compressLintOutput(text) {
275
- // Detect lint patterns: file:line:col or rule IDs
276
- const hasLintPattern = /(?:\d+:\d+\s+(?:error|warning)|error\[E\d+\]|:\d+:\d+:?\s+\w+\/[\w-]+|✖|⚠)/i.test(text);
277
- if (!hasLintPattern) return null;
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
- for (const line of text.split("\n")) {
285
- // ESLint/Biome style: file:line:col error/warning message rule-name
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
- if (!ruleGroups[rule]) ruleGroups[rule] = { count: 0, severity };
290
- ruleGroups[rule].count++;
291
- if (severity === "error") errorCount++;
292
- else warningCount++;
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
- const tsMatch = line.match(/\((\d+,\d+)\):\s*(error)\s+(TS\d+):\s*(.+)/);
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
- if (!ruleGroups[code]) ruleGroups[code] = { count: 0, severity: "error" };
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
- if (!ruleGroups[code]) ruleGroups[code] = { count: 0, severity };
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() !== "") return null;
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
- // Generic fallbacks last: dedup exact-duplicate spam, then hard head/tail
616
- // truncation only if nothing more specific applied.
617
- { name: "dedup_log", fn: compressDedupLog },
618
- { name: "smart_truncate", fn: compressSmartTruncate },
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 tryCompress(text, tier) {
630
- const threshold = TIER_THRESHOLDS[tier] || TIER_THRESHOLDS.MEDIUM;
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}]`;