lynkr 9.7.3 → 9.9.1
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 +63 -25
- package/bin/cli.js +16 -1
- package/bin/lynkr-init.js +44 -1
- package/bin/lynkr-usage.js +78 -0
- package/bin/wrap.js +60 -35
- package/config/difficulty-anchors.json +22 -0
- package/package.json +23 -2
- package/scripts/audit-log-reader.js +399 -0
- package/scripts/build-eval-set.js +256 -0
- package/scripts/calibrate-thresholds.js +38 -157
- package/scripts/compact-dictionary.js +204 -0
- package/scripts/mine-difficulty-anchors.js +288 -0
- package/scripts/test-deduplication.js +448 -0
- package/scripts/validate-difficulty-classifier.js +123 -0
- package/scripts/validate-intent-anchors.js +186 -0
- package/scripts/ws7-anchor-replay.js +108 -0
- package/skills/lynkr/SKILL.md +195 -0
- package/src/api/middleware/loop-guard.js +87 -0
- package/src/api/middleware/request-logging.js +5 -64
- package/src/api/middleware/session.js +0 -0
- package/src/api/openai-router.js +120 -101
- package/src/api/providers-handler.js +27 -2
- package/src/api/router.js +467 -125
- package/src/budget/index.js +2 -19
- package/src/cache/semantic.js +9 -0
- package/src/clients/databricks.js +455 -142
- package/src/clients/gpt-utils.js +11 -105
- package/src/clients/openai-format.js +10 -3
- package/src/clients/openrouter-utils.js +49 -24
- package/src/clients/prompt-cache-injection.js +1 -0
- package/src/clients/provider-capabilities.js +1 -1
- package/src/clients/responses-format.js +34 -3
- package/src/clients/routing.js +15 -0
- package/src/config/index.js +36 -2
- package/src/context/gcf.js +275 -0
- package/src/context/tool-result-compressor.js +932 -47
- package/src/dashboard/api.js +1 -0
- package/src/logger/index.js +14 -1
- package/src/memory/search.js +12 -40
- package/src/memory/tools.js +3 -24
- package/src/orchestrator/bypass.js +4 -2
- package/src/orchestrator/index.js +120 -85
- package/src/routing/affinity-store.js +194 -0
- package/src/routing/agentic-detector.js +36 -6
- package/src/routing/bandit.js +25 -6
- package/src/routing/calibration.js +212 -0
- package/src/routing/classifier-setup.js +207 -0
- package/src/routing/client-profiles.js +292 -0
- package/src/routing/complexity-analyzer.js +88 -15
- package/src/routing/deescalator.js +148 -0
- package/src/routing/degradation.js +109 -0
- package/src/routing/difficulty-classifier.js +219 -0
- package/src/routing/feedback.js +157 -0
- package/src/routing/index.js +931 -90
- package/src/routing/intent-score.js +441 -0
- package/src/routing/interaction.js +3 -0
- package/src/routing/knn-router.js +70 -21
- package/src/routing/model-registry.js +28 -7
- package/src/routing/model-tiers.js +25 -2
- package/src/routing/reward-pipeline.js +68 -2
- package/src/routing/risk-analyzer.js +30 -1
- package/src/routing/risk-classifier.js +6 -2
- package/src/routing/session-affinity.js +162 -34
- package/src/routing/telemetry.js +286 -13
- package/src/routing/verifier.js +267 -0
- package/src/server.js +86 -21
- package/src/sessions/cleanup.js +17 -0
- package/src/tools/index.js +1 -15
- package/src/tools/smart-selection.js +10 -0
- package/src/tools/web-client.js +3 -3
- package/.eslintrc.cjs +0 -12
- package/benchmark-configs/litellm_config.yaml +0 -86
- package/benchmark-configs/lynkr.env +0 -48
- package/benchmark-configs/portkey-config.json +0 -60
- package/benchmark-configs/portkey-docker.sh +0 -23
- package/benchmark-tier-routing.js +0 -449
- package/funding.json +0 -110
- package/src/api/middleware/validation.js +0 -261
- package/src/routing/drift-monitor.js +0 -113
- package/src/workers/helpers.js +0 -185
|
@@ -56,7 +56,11 @@ function recordMetric(pattern, originalLen, compressedLen) {
|
|
|
56
56
|
metrics.patterns[pattern] = { count: 0, tokensSaved: 0 };
|
|
57
57
|
}
|
|
58
58
|
metrics.patterns[pattern].count++;
|
|
59
|
-
|
|
59
|
+
const saved = Math.ceil((originalLen - compressedLen) / 4);
|
|
60
|
+
metrics.patterns[pattern].tokensSaved += saved;
|
|
61
|
+
try {
|
|
62
|
+
require('../routing/telemetry').recordSavings('compression', saved);
|
|
63
|
+
} catch { /* telemetry is best-effort */ }
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
function getMetrics() {
|
|
@@ -72,9 +76,19 @@ function getMetrics() {
|
|
|
72
76
|
}
|
|
73
77
|
|
|
74
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
|
+
}
|
|
75
89
|
|
|
76
90
|
// 1. Test output (jest, vitest, pytest, cargo test, go test, rspec)
|
|
77
|
-
function compressTestOutput(text) {
|
|
91
|
+
function compressTestOutput(text, opts = {}) {
|
|
78
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);
|
|
79
93
|
if (!isTest) return null;
|
|
80
94
|
|
|
@@ -120,6 +134,18 @@ function compressTestOutput(text) {
|
|
|
120
134
|
|
|
121
135
|
if (summary.length === 0 && failures.length === 0) return null;
|
|
122
136
|
|
|
137
|
+
// Corroboration: one summary-looking line alone is not a test run — a
|
|
138
|
+
// README quoting "1041 passing" would otherwise compress the whole doc
|
|
139
|
+
// down to that quote. Require failures, a second summary line, or at
|
|
140
|
+
// least three per-test result markers before treating it as a test run.
|
|
141
|
+
// Skipped when the dispatcher knows the command was a test runner.
|
|
142
|
+
if (!opts.trusted && failures.length === 0 && summary.length < 2) {
|
|
143
|
+
const perTestMarkers = lines.filter(l =>
|
|
144
|
+
/^\s*(?:✓|✔|✗|✘|ok \d+|not ok \d+|PASS\b|FAIL\b)/.test(l.trim())
|
|
145
|
+
).length;
|
|
146
|
+
if (perTestMarkers < 3) return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
123
149
|
const parts = [];
|
|
124
150
|
if (summary.length > 0) parts.push(summary.join("\n"));
|
|
125
151
|
if (failures.length > 0) {
|
|
@@ -128,6 +154,443 @@ function compressTestOutput(text) {
|
|
|
128
154
|
return parts.join("\n\n") || null;
|
|
129
155
|
}
|
|
130
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
|
+
|
|
131
594
|
// 2. Git diff
|
|
132
595
|
function compressGitDiff(text) {
|
|
133
596
|
if (!text.startsWith("diff --git") && !text.includes("\ndiff --git")) return null;
|
|
@@ -172,9 +635,12 @@ function compressGitDiff(text) {
|
|
|
172
635
|
|
|
173
636
|
// 3. Git status
|
|
174
637
|
function compressGitStatus(text) {
|
|
638
|
+
// Anchor on git-status STRUCTURE (branch line or section headers), not on
|
|
639
|
+
// bare "modified:" / "new file:" substrings — source code or prose that
|
|
640
|
+
// merely contains those strings must not be compressed into a fake status.
|
|
175
641
|
if (!text.includes("Changes not staged") && !text.includes("Changes to be committed") &&
|
|
176
|
-
!text.includes("Untracked files") &&
|
|
177
|
-
|
|
642
|
+
!text.includes("Untracked files") && !/^On branch \S+/m.test(text) &&
|
|
643
|
+
!/^HEAD detached/m.test(text)) return null;
|
|
178
644
|
|
|
179
645
|
const staged = [];
|
|
180
646
|
const modified = [];
|
|
@@ -219,13 +685,17 @@ function compressGitLog(text) {
|
|
|
219
685
|
}
|
|
220
686
|
|
|
221
687
|
// 5. Directory listings (ls, find, tree)
|
|
222
|
-
function compressDirectoryListing(text) {
|
|
688
|
+
function compressDirectoryListing(text, opts = {}) {
|
|
223
689
|
const lines = text.split("\n").filter(l => l.trim());
|
|
224
690
|
if (lines.length < 10) return null;
|
|
225
691
|
|
|
226
|
-
// Detect: mostly file paths (one per line)
|
|
227
|
-
|
|
228
|
-
|
|
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
|
+
}
|
|
229
699
|
|
|
230
700
|
// Group by directory
|
|
231
701
|
const dirs = {};
|
|
@@ -252,36 +722,131 @@ function compressDirectoryListing(text) {
|
|
|
252
722
|
return result.length > 0 ? result.join("\n") : null;
|
|
253
723
|
}
|
|
254
724
|
|
|
255
|
-
// 6. Lint output (eslint, tsc, ruff, clippy, biome)
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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;
|
|
260
801
|
|
|
261
802
|
const ruleGroups = {};
|
|
262
803
|
const fileGroups = {};
|
|
263
804
|
let errorCount = 0;
|
|
264
805
|
let warningCount = 0;
|
|
806
|
+
let currentFile = null;
|
|
265
807
|
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
268
829
|
const eslintMatch = line.match(/(\d+:\d+)\s+(error|warning)\s+(.+?)\s+([\w\-/@]+)\s*$/i);
|
|
269
830
|
if (eslintMatch) {
|
|
270
831
|
const [, , severity, , rule] = eslintMatch;
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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);
|
|
275
841
|
continue;
|
|
276
842
|
}
|
|
277
843
|
|
|
278
844
|
// TypeScript style: file(line,col): error TSxxxx: message
|
|
279
|
-
|
|
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+(.+)$/);
|
|
280
847
|
if (tsMatch) {
|
|
281
|
-
const [, , , code] = tsMatch;
|
|
282
|
-
|
|
283
|
-
ruleGroups[code].count++;
|
|
284
|
-
errorCount++;
|
|
848
|
+
const [, file, , , severity, code] = tsMatch;
|
|
849
|
+
record(code, severity, file.trim());
|
|
285
850
|
continue;
|
|
286
851
|
}
|
|
287
852
|
|
|
@@ -289,10 +854,7 @@ function compressLintOutput(text) {
|
|
|
289
854
|
const rustMatch = line.match(/^(error|warning)\[(\w+)\]:\s*(.+)/);
|
|
290
855
|
if (rustMatch) {
|
|
291
856
|
const [, severity, code] = rustMatch;
|
|
292
|
-
|
|
293
|
-
ruleGroups[code].count++;
|
|
294
|
-
if (severity === "error") errorCount++;
|
|
295
|
-
else warningCount++;
|
|
857
|
+
record(code, severity, null);
|
|
296
858
|
}
|
|
297
859
|
}
|
|
298
860
|
|
|
@@ -302,9 +864,21 @@ function compressLintOutput(text) {
|
|
|
302
864
|
.sort((a, b) => b[1].count - a[1].count);
|
|
303
865
|
|
|
304
866
|
const summary = [`${errorCount} errors, ${warningCount} warnings`];
|
|
305
|
-
for (const [rule, data] of sorted) {
|
|
867
|
+
for (const [rule, data] of sorted.slice(0, LINT_MAX_RULES)) {
|
|
306
868
|
summary.push(` ${rule}: ${data.count}x (${data.severity})`);
|
|
307
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
|
+
}
|
|
308
882
|
|
|
309
883
|
return summary.join("\n");
|
|
310
884
|
}
|
|
@@ -438,23 +1012,111 @@ function extractJSONStructure(obj, depth, maxDepth) {
|
|
|
438
1012
|
return typeof obj;
|
|
439
1013
|
}
|
|
440
1014
|
|
|
441
|
-
// 10. Docker/kubectl output
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
1015
|
+
// 10. Docker/kubectl table output (docker ps, docker images, kubectl get).
|
|
1016
|
+
// Detection anchors on the TABLE HEADER, never on a keyword appearing
|
|
1017
|
+
// anywhere in the text: the old /docker/i containment trigger fired on any
|
|
1018
|
+
// `ls` output that contained "Dockerfile", truncated the listing to 10
|
|
1019
|
+
// lines, and the model hallucinated the dropped file names.
|
|
1020
|
+
const CONTAINER_HEADER_COLUMNS = [
|
|
1021
|
+
"CONTAINER ID", "IMAGE ID", "IMAGE", "COMMAND", "CREATED", "STATUS",
|
|
1022
|
+
"PORTS", "NAMES", "REPOSITORY", "TAG", "SIZE",
|
|
1023
|
+
"NAMESPACE", "NAME", "READY", "RESTARTS", "AGE", "CLUSTER-IP",
|
|
1024
|
+
"EXTERNAL-IP", "TYPE", "DESIRED", "CURRENT", "AVAILABLE", "UP-TO-DATE",
|
|
1025
|
+
];
|
|
1026
|
+
function compressContainerOutput(text, opts = {}) {
|
|
446
1027
|
const lines = text.split("\n").filter(l => l.trim());
|
|
447
1028
|
if (lines.length < 3) return null;
|
|
448
1029
|
|
|
449
|
-
//
|
|
1030
|
+
// The first line must be a real docker/kubectl column header: at least
|
|
1031
|
+
// three known column tokens and nothing but known tokens and whitespace.
|
|
450
1032
|
const header = lines[0];
|
|
451
|
-
|
|
1033
|
+
let rest = header;
|
|
1034
|
+
let columns = 0;
|
|
1035
|
+
for (const col of CONTAINER_HEADER_COLUMNS) {
|
|
1036
|
+
if (rest.includes(col)) {
|
|
1037
|
+
columns++;
|
|
1038
|
+
rest = rest.split(col).join(" ");
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
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
|
+
}
|
|
452
1048
|
|
|
1049
|
+
const dataLines = lines.slice(1);
|
|
453
1050
|
if (dataLines.length <= 10) return null; // Not enough to compress
|
|
454
1051
|
|
|
455
1052
|
return `${header}\n${dataLines.slice(0, 10).join("\n")}\n... +${dataLines.length - 10} more (${dataLines.length} total)`;
|
|
456
1053
|
}
|
|
457
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
|
+
|
|
458
1120
|
// 11. Grep / ripgrep output ("file:lineno:content"), per-file match cap.
|
|
459
1121
|
// Ported from 9router RTK grep filter (rtk/src/cmds/system/pipe_cmd.rs).
|
|
460
1122
|
const GREP_PER_FILE_MAX = 10;
|
|
@@ -557,6 +1219,21 @@ function compressSmartTruncate(text) {
|
|
|
557
1219
|
}
|
|
558
1220
|
|
|
559
1221
|
// ── Compression Pipeline ─────────────────────────────────────────────
|
|
1222
|
+
//
|
|
1223
|
+
// DETECTION RULE: a compressor's trigger must anchor on the STRUCTURE of
|
|
1224
|
+
// its target format (header line anatomy, per-line shape, section markers)
|
|
1225
|
+
// — never on a keyword appearing anywhere in the text. A misfire doesn't
|
|
1226
|
+
// just waste tokens: it silently drops content the model then hallucinates
|
|
1227
|
+
// back (live incident: /docker/i matched "Dockerfile" in an ls listing,
|
|
1228
|
+
// truncated it to 10 lines, and the model invented the rest).
|
|
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
|
+
];
|
|
560
1237
|
|
|
561
1238
|
const COMPRESSORS = [
|
|
562
1239
|
{ name: "test_output", fn: compressTestOutput },
|
|
@@ -570,12 +1247,203 @@ const COMPRESSORS = [
|
|
|
570
1247
|
{ name: "grep_output", fn: compressGrep },
|
|
571
1248
|
{ name: "directory_listing", fn: compressDirectoryListing },
|
|
572
1249
|
{ name: "large_file", fn: compressLargeFile },
|
|
573
|
-
|
|
574
|
-
// truncation only if nothing more specific applied.
|
|
575
|
-
{ name: "dedup_log", fn: compressDedupLog },
|
|
576
|
-
{ name: "smart_truncate", fn: compressSmartTruncate },
|
|
1250
|
+
...GENERIC_FALLBACKS,
|
|
577
1251
|
];
|
|
578
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" },
|
|
1318
|
+
];
|
|
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
|
+
|
|
579
1447
|
// Compression levels tied to routing tiers
|
|
580
1448
|
const TIER_THRESHOLDS = {
|
|
581
1449
|
SIMPLE: 300, // Compress if > 300 chars
|
|
@@ -584,13 +1452,10 @@ const TIER_THRESHOLDS = {
|
|
|
584
1452
|
REASONING: Infinity, // Never compress
|
|
585
1453
|
};
|
|
586
1454
|
|
|
587
|
-
function
|
|
588
|
-
const
|
|
589
|
-
if (text.length < threshold) return null;
|
|
590
|
-
|
|
591
|
-
for (const { name, fn } of COMPRESSORS) {
|
|
1455
|
+
function runCompressors(list, text, opts) {
|
|
1456
|
+
for (const { name, fn } of list) {
|
|
592
1457
|
try {
|
|
593
|
-
const result = fn(text);
|
|
1458
|
+
const result = fn(text, opts);
|
|
594
1459
|
if (result && result.length < text.length * 0.7) {
|
|
595
1460
|
return { compressed: result, pattern: name };
|
|
596
1461
|
}
|
|
@@ -601,6 +1466,24 @@ function tryCompress(text, tier) {
|
|
|
601
1466
|
return null;
|
|
602
1467
|
}
|
|
603
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
|
+
|
|
604
1487
|
// ── Main Entry Point ─────────────────────────────────────────────────
|
|
605
1488
|
|
|
606
1489
|
/**
|
|
@@ -621,6 +1504,8 @@ function compressToolResults(messages, options = {}) {
|
|
|
621
1504
|
let compressedCount = 0;
|
|
622
1505
|
let tokensSaved = 0;
|
|
623
1506
|
|
|
1507
|
+
const commandByToolUse = buildCommandMap(messages);
|
|
1508
|
+
|
|
624
1509
|
for (const msg of messages) {
|
|
625
1510
|
if (!Array.isArray(msg.content)) continue;
|
|
626
1511
|
|
|
@@ -631,7 +1516,7 @@ function compressToolResults(messages, options = {}) {
|
|
|
631
1516
|
metrics.totalToolResults++;
|
|
632
1517
|
const original = block.content;
|
|
633
1518
|
|
|
634
|
-
const result = tryCompress(original, tier);
|
|
1519
|
+
const result = tryCompress(original, tier, commandByToolUse.get(block.tool_use_id));
|
|
635
1520
|
if (result) {
|
|
636
1521
|
const teeId = teeStore(original);
|
|
637
1522
|
block.content = result.compressed + `\n[full: ${teeId}]`;
|