lynkr 9.0.1 → 9.1.2

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 (58) hide show
  1. package/README.md +70 -21
  2. package/bin/cli.js +34 -4
  3. package/bin/lynkr-trajectory.js +136 -0
  4. package/bin/lynkr-usage.js +219 -0
  5. package/funding.json +110 -0
  6. package/index.js +7 -3
  7. package/install.sh +3 -3
  8. package/lynkr-skill.tar.gz +0 -0
  9. package/native/Cargo.toml +26 -0
  10. package/native/index.js +29 -0
  11. package/native/lynkr-native.node +0 -0
  12. package/native/src/lib.rs +321 -0
  13. package/package.json +6 -5
  14. package/public/dashboard.html +665 -0
  15. package/src/api/files-multipart.js +30 -0
  16. package/src/api/files-router.js +81 -0
  17. package/src/api/middleware/budget.js +19 -1
  18. package/src/api/middleware/load-shedding.js +17 -0
  19. package/src/api/openai-router.js +353 -301
  20. package/src/api/router.js +275 -40
  21. package/src/cache/prompt.js +13 -0
  22. package/src/clients/databricks.js +42 -18
  23. package/src/clients/ollama-utils.js +21 -17
  24. package/src/clients/openai-format.js +50 -10
  25. package/src/clients/openrouter-utils.js +42 -37
  26. package/src/clients/prompt-cache-injection.js +140 -0
  27. package/src/clients/provider-capabilities.js +41 -0
  28. package/src/clients/responses-format.js +8 -7
  29. package/src/clients/standard-tools.js +1 -1
  30. package/src/clients/xml-tool-extractor.js +307 -0
  31. package/src/cluster.js +82 -0
  32. package/src/config/index.js +16 -0
  33. package/src/context/distill.js +15 -0
  34. package/src/context/tool-result-compressor.js +563 -0
  35. package/src/dashboard/api.js +170 -0
  36. package/src/dashboard/router.js +13 -0
  37. package/src/headroom/client.js +3 -109
  38. package/src/headroom/index.js +0 -14
  39. package/src/memory/extractor.js +22 -0
  40. package/src/memory/search.js +0 -50
  41. package/src/orchestrator/index.js +163 -204
  42. package/src/orchestrator/preflight.js +188 -0
  43. package/src/routing/index.js +64 -32
  44. package/src/routing/interaction.js +183 -0
  45. package/src/routing/risk-analyzer.js +194 -0
  46. package/src/routing/telemetry.js +47 -2
  47. package/src/server.js +15 -0
  48. package/src/stores/file-store.js +104 -0
  49. package/src/stores/response-store.js +25 -0
  50. package/src/tools/index.js +1 -1
  51. package/src/tools/smart-selection.js +11 -2
  52. package/src/tools/web.js +1 -1
  53. package/src/training/trajectory-compressor.js +266 -0
  54. package/src/usage/aggregator.js +206 -0
  55. package/src/utils/markdown-ansi.js +146 -0
  56. package/.lynkr/telemetry.db +0 -0
  57. package/.lynkr/telemetry.db-shm +0 -0
  58. package/.lynkr/telemetry.db-wal +0 -0
@@ -0,0 +1,563 @@
1
+ /**
2
+ * Tool Result Compressor
3
+ *
4
+ * RTK-inspired compression for tool_result blocks in client mode.
5
+ * Detects known output patterns (test runners, git, lint, builds, file reads)
6
+ * and compresses them before they reach the model.
7
+ *
8
+ * @module context/tool-result-compressor
9
+ */
10
+
11
+ const logger = require("../logger");
12
+
13
+ // ── Tee Recovery Cache ───────────────────────────────────────────────
14
+
15
+ const teeCache = new Map();
16
+ const TEE_MAX_SIZE = 200;
17
+ const TEE_TTL_MS = 5 * 60 * 1000; // 5 minutes
18
+ let teeCounter = 0;
19
+
20
+ function teeStore(original) {
21
+ if (teeCache.size >= TEE_MAX_SIZE) {
22
+ const oldest = teeCache.keys().next().value;
23
+ teeCache.delete(oldest);
24
+ }
25
+ const id = `tee_${Date.now()}_${teeCounter++}`;
26
+ teeCache.set(id, { content: original, createdAt: Date.now() });
27
+ return id;
28
+ }
29
+
30
+ function teeGet(id) {
31
+ const entry = teeCache.get(id);
32
+ if (!entry) return null;
33
+ if (Date.now() - entry.createdAt > TEE_TTL_MS) {
34
+ teeCache.delete(id);
35
+ return null;
36
+ }
37
+ return entry.content;
38
+ }
39
+
40
+ // ── Metrics ──────────────────────────────────────────────────────────
41
+
42
+ const metrics = {
43
+ totalToolResults: 0,
44
+ compressed: 0,
45
+ tokensOriginal: 0,
46
+ tokensAfter: 0,
47
+ patterns: {},
48
+ };
49
+
50
+ function recordMetric(pattern, originalLen, compressedLen) {
51
+ metrics.totalToolResults++;
52
+ metrics.compressed++;
53
+ metrics.tokensOriginal += Math.ceil(originalLen / 4);
54
+ metrics.tokensAfter += Math.ceil(compressedLen / 4);
55
+ if (!metrics.patterns[pattern]) {
56
+ metrics.patterns[pattern] = { count: 0, tokensSaved: 0 };
57
+ }
58
+ metrics.patterns[pattern].count++;
59
+ metrics.patterns[pattern].tokensSaved += Math.ceil((originalLen - compressedLen) / 4);
60
+ }
61
+
62
+ function getMetrics() {
63
+ return {
64
+ ...metrics,
65
+ savingsPercent: metrics.tokensOriginal > 0
66
+ ? Math.round((1 - metrics.tokensAfter / metrics.tokensOriginal) * 100)
67
+ : 0,
68
+ topSavings: Object.entries(metrics.patterns)
69
+ .map(([pattern, data]) => ({ pattern, ...data }))
70
+ .sort((a, b) => b.tokensSaved - a.tokensSaved),
71
+ };
72
+ }
73
+
74
+ // ── Pattern Detectors & Compressors ──────────────────────────────────
75
+
76
+ // 1. Test output (jest, vitest, pytest, cargo test, go test, rspec)
77
+ function compressTestOutput(text) {
78
+ 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
+ if (!isTest) return null;
80
+
81
+ const lines = text.split("\n");
82
+ const failures = [];
83
+ const summary = [];
84
+ let inFailure = false;
85
+ let failureBuffer = [];
86
+
87
+ for (const line of lines) {
88
+ const trimmed = line.trim();
89
+
90
+ // Capture summary lines
91
+ if (/(?:Tests?:?\s+\d|test result:|tests? passed|tests? failed|\d+ passing|\d+ failing|Test Suites?:|Ran \d+ test)/i.test(trimmed)) {
92
+ summary.push(trimmed);
93
+ continue;
94
+ }
95
+
96
+ // Detect failure start
97
+ if (/(?:FAIL|FAILED|✗|✘|not ok|ERRORS?|AssertionError|assert|panicked|Error:|×)/i.test(trimmed) && !inFailure) {
98
+ inFailure = true;
99
+ failureBuffer = [line];
100
+ continue;
101
+ }
102
+
103
+ // Accumulate failure details (indented or stack trace)
104
+ if (inFailure) {
105
+ if (trimmed === "" || (/^(?:✓|✗|PASS|FAIL|ok \d|not ok|test |Tests:)/i.test(trimmed) && !trimmed.startsWith(" "))) {
106
+ failures.push(failureBuffer.join("\n"));
107
+ failureBuffer = [];
108
+ inFailure = false;
109
+ // Check if this line starts a new failure
110
+ if (/(?:FAIL|FAILED|✗|✘|not ok)/i.test(trimmed)) {
111
+ inFailure = true;
112
+ failureBuffer = [line];
113
+ }
114
+ } else {
115
+ failureBuffer.push(line);
116
+ }
117
+ }
118
+ }
119
+ if (failureBuffer.length > 0) failures.push(failureBuffer.join("\n"));
120
+
121
+ if (summary.length === 0 && failures.length === 0) return null;
122
+
123
+ const parts = [];
124
+ if (summary.length > 0) parts.push(summary.join("\n"));
125
+ if (failures.length > 0) {
126
+ parts.push("Failures:\n" + failures.join("\n---\n"));
127
+ }
128
+ return parts.join("\n\n") || null;
129
+ }
130
+
131
+ // 2. Git diff
132
+ function compressGitDiff(text) {
133
+ if (!text.startsWith("diff --git") && !text.includes("\ndiff --git")) return null;
134
+
135
+ const files = [];
136
+ let currentFile = null;
137
+ let additions = 0;
138
+ let deletions = 0;
139
+ let changedLines = [];
140
+
141
+ for (const line of text.split("\n")) {
142
+ if (line.startsWith("diff --git")) {
143
+ if (currentFile) {
144
+ files.push({ file: currentFile, additions, deletions, changes: changedLines.slice(0, 20) });
145
+ }
146
+ const match = line.match(/diff --git a\/(.+?) b\//);
147
+ currentFile = match ? match[1] : "unknown";
148
+ additions = 0;
149
+ deletions = 0;
150
+ changedLines = [];
151
+ } else if (line.startsWith("+") && !line.startsWith("+++")) {
152
+ additions++;
153
+ changedLines.push(line);
154
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
155
+ deletions++;
156
+ changedLines.push(line);
157
+ }
158
+ }
159
+ if (currentFile) {
160
+ files.push({ file: currentFile, additions, deletions, changes: changedLines.slice(0, 20) });
161
+ }
162
+
163
+ if (files.length === 0) return null;
164
+
165
+ return files.map(f => {
166
+ const header = `${f.file} (+${f.additions}/-${f.deletions})`;
167
+ const changes = f.changes.length > 0 ? "\n" + f.changes.join("\n") : "";
168
+ const truncated = f.additions + f.deletions > 20 ? `\n... ${f.additions + f.deletions - 20} more lines` : "";
169
+ return header + changes + truncated;
170
+ }).join("\n\n");
171
+ }
172
+
173
+ // 3. Git status
174
+ function compressGitStatus(text) {
175
+ if (!text.includes("Changes not staged") && !text.includes("Changes to be committed") &&
176
+ !text.includes("Untracked files") && !text.includes("On branch") &&
177
+ !text.includes("modified:") && !text.includes("new file:")) return null;
178
+
179
+ const staged = [];
180
+ const modified = [];
181
+ const untracked = [];
182
+ let section = null;
183
+
184
+ for (const line of text.split("\n")) {
185
+ const trimmed = line.trim();
186
+ if (trimmed.includes("Changes to be committed")) section = "staged";
187
+ else if (trimmed.includes("Changes not staged")) section = "modified";
188
+ else if (trimmed.includes("Untracked files")) section = "untracked";
189
+ else if (trimmed.startsWith("modified:")) (section === "staged" ? staged : modified).push("M " + trimmed.replace("modified:", "").trim());
190
+ else if (trimmed.startsWith("new file:")) staged.push("A " + trimmed.replace("new file:", "").trim());
191
+ else if (trimmed.startsWith("deleted:")) (section === "staged" ? staged : modified).push("D " + trimmed.replace("deleted:", "").trim());
192
+ else if (section === "untracked" && trimmed && !trimmed.startsWith("(") && !trimmed.startsWith("no changes")) {
193
+ untracked.push("? " + trimmed);
194
+ }
195
+ }
196
+
197
+ const branchMatch = text.match(/On branch (\S+)/);
198
+ const parts = [];
199
+ if (branchMatch) parts.push(`branch: ${branchMatch[1]}`);
200
+ if (staged.length > 0) parts.push(`staged: ${staged.join(", ")}`);
201
+ if (modified.length > 0) parts.push(`modified: ${modified.join(", ")}`);
202
+ if (untracked.length > 0) parts.push(`untracked: ${untracked.join(", ")}`);
203
+
204
+ return parts.length > 0 ? parts.join("\n") : null;
205
+ }
206
+
207
+ // 4. Git log
208
+ function compressGitLog(text) {
209
+ if (!/^commit [a-f0-9]{40}/m.test(text)) return null;
210
+
211
+ const commits = [];
212
+ const re = /commit ([a-f0-9]{40})\n(?:Merge: .+\n)?Author:\s*(.+?)\nDate:\s*(.+?)\n\n\s*(.+)/g;
213
+ let m;
214
+ while ((m = re.exec(text)) !== null) {
215
+ commits.push(`${m[1].substring(0, 7)} ${m[4].trim()} (${m[2].trim().split(" <")[0]}, ${m[3].trim()})`);
216
+ }
217
+
218
+ return commits.length > 0 ? commits.join("\n") : null;
219
+ }
220
+
221
+ // 5. Directory listings (ls, find, tree)
222
+ function compressDirectoryListing(text) {
223
+ const lines = text.split("\n").filter(l => l.trim());
224
+ if (lines.length < 10) return null;
225
+
226
+ // Detect: mostly file paths (one per line)
227
+ const pathLines = lines.filter(l => /^[.\w\/-]+\.\w+$/.test(l.trim()) || /^[.\w\/-]+\/$/.test(l.trim()) || /^[-drwx]{10}/.test(l.trim()));
228
+ if (pathLines.length < lines.length * 0.6) return null;
229
+
230
+ // Group by directory
231
+ const dirs = {};
232
+ for (const line of lines) {
233
+ const trimmed = line.trim().replace(/^[-drwxlrwst@.+\s\d]+\s+\w+\s+\w+\s+[\d,]+\s+\w+\s+\d+\s+[\d:]+\s+/, ""); // strip ls -la prefix
234
+ const parts = trimmed.split("/");
235
+ if (parts.length > 1) {
236
+ const dir = parts.slice(0, -1).join("/");
237
+ if (!dirs[dir]) dirs[dir] = [];
238
+ dirs[dir].push(parts[parts.length - 1]);
239
+ } else {
240
+ if (!dirs["./"]) dirs["./"] = [];
241
+ dirs["./"].push(trimmed);
242
+ }
243
+ }
244
+
245
+ const result = Object.entries(dirs)
246
+ .sort((a, b) => b[1].length - a[1].length)
247
+ .map(([dir, files]) => {
248
+ if (files.length <= 5) return `${dir}: ${files.join(", ")}`;
249
+ return `${dir}: ${files.slice(0, 3).join(", ")} ... +${files.length - 3} more (${files.length} total)`;
250
+ });
251
+
252
+ return result.length > 0 ? result.join("\n") : null;
253
+ }
254
+
255
+ // 6. Lint output (eslint, tsc, ruff, clippy, biome)
256
+ function compressLintOutput(text) {
257
+ // Detect lint patterns: file:line:col or rule IDs
258
+ const hasLintPattern = /(?:\d+:\d+\s+(?:error|warning)|error\[E\d+\]|:\d+:\d+:?\s+\w+\/[\w-]+|✖|⚠)/i.test(text);
259
+ if (!hasLintPattern) return null;
260
+
261
+ const ruleGroups = {};
262
+ const fileGroups = {};
263
+ let errorCount = 0;
264
+ let warningCount = 0;
265
+
266
+ for (const line of text.split("\n")) {
267
+ // ESLint/Biome style: file:line:col error/warning message rule-name
268
+ const eslintMatch = line.match(/(\d+:\d+)\s+(error|warning)\s+(.+?)\s+([\w\-/@]+)\s*$/i);
269
+ if (eslintMatch) {
270
+ const [, , severity, , rule] = eslintMatch;
271
+ if (!ruleGroups[rule]) ruleGroups[rule] = { count: 0, severity };
272
+ ruleGroups[rule].count++;
273
+ if (severity === "error") errorCount++;
274
+ else warningCount++;
275
+ continue;
276
+ }
277
+
278
+ // TypeScript style: file(line,col): error TSxxxx: message
279
+ const tsMatch = line.match(/\((\d+,\d+)\):\s*(error)\s+(TS\d+):\s*(.+)/);
280
+ if (tsMatch) {
281
+ const [, , , code] = tsMatch;
282
+ if (!ruleGroups[code]) ruleGroups[code] = { count: 0, severity: "error" };
283
+ ruleGroups[code].count++;
284
+ errorCount++;
285
+ continue;
286
+ }
287
+
288
+ // Rust clippy: error[Exxxx]: message
289
+ const rustMatch = line.match(/^(error|warning)\[(\w+)\]:\s*(.+)/);
290
+ if (rustMatch) {
291
+ const [, severity, code] = rustMatch;
292
+ if (!ruleGroups[code]) ruleGroups[code] = { count: 0, severity };
293
+ ruleGroups[code].count++;
294
+ if (severity === "error") errorCount++;
295
+ else warningCount++;
296
+ }
297
+ }
298
+
299
+ if (Object.keys(ruleGroups).length === 0) return null;
300
+
301
+ const sorted = Object.entries(ruleGroups)
302
+ .sort((a, b) => b[1].count - a[1].count);
303
+
304
+ const summary = [`${errorCount} errors, ${warningCount} warnings`];
305
+ for (const [rule, data] of sorted) {
306
+ summary.push(` ${rule}: ${data.count}x (${data.severity})`);
307
+ }
308
+
309
+ return summary.join("\n");
310
+ }
311
+
312
+ // 7. Build output (npm, cargo, webpack)
313
+ function compressBuildOutput(text) {
314
+ const isBuild = /(?:Compiling|Building|Bundling|compiled|webpack|Successfully|ERROR in|Build error|npm warn|npm error)/i.test(text);
315
+ if (!isBuild) return null;
316
+
317
+ const lines = text.split("\n");
318
+ const errors = [];
319
+ const warnings = [];
320
+ let successLine = null;
321
+
322
+ for (const line of lines) {
323
+ const trimmed = line.trim();
324
+ if (!trimmed) continue;
325
+ if (/(?:error|ERROR|failed|FAILED)/i.test(trimmed) && !/warning/i.test(trimmed)) {
326
+ errors.push(trimmed);
327
+ } else if (/(?:warning|WARN)/i.test(trimmed)) {
328
+ if (warnings.length < 5) warnings.push(trimmed); // Cap warnings
329
+ } else if (/(?:compiled|Successfully|Build complete|Finished)/i.test(trimmed)) {
330
+ successLine = trimmed;
331
+ }
332
+ }
333
+
334
+ if (errors.length === 0 && !successLine) return null;
335
+
336
+ const parts = [];
337
+ if (successLine) parts.push(successLine);
338
+ if (errors.length > 0) parts.push("Errors:\n" + errors.join("\n"));
339
+ if (warnings.length > 0) {
340
+ const totalWarnings = (text.match(/warning/gi) || []).length;
341
+ parts.push(`Warnings (${totalWarnings} total, showing ${warnings.length}):\n` + warnings.join("\n"));
342
+ }
343
+
344
+ return parts.join("\n\n");
345
+ }
346
+
347
+ // 8. Large file / code skeleton
348
+ function compressLargeFile(text) {
349
+ const lines = text.split("\n");
350
+ if (lines.length < 80) return null;
351
+
352
+ // Detect code-like content
353
+ const codeIndicators = lines.filter(l =>
354
+ /^(?:import |from |require\(|export |function |class |def |fn |pub |const |let |var |type |interface |struct |enum |module |package |#include|using |namespace )/.test(l.trim())
355
+ ).length;
356
+
357
+ if (codeIndicators < 3) return null; // Not code
358
+
359
+ // Extract structural skeleton
360
+ const skeleton = [];
361
+ let inBlock = false;
362
+ let braceDepth = 0;
363
+
364
+ for (let i = 0; i < lines.length; i++) {
365
+ const line = lines[i];
366
+ const trimmed = line.trim();
367
+
368
+ // Always keep: imports, exports, function/class/type signatures
369
+ if (/^(?:import |from |require\(|export |#include|using |package )/.test(trimmed)) {
370
+ skeleton.push(line);
371
+ continue;
372
+ }
373
+
374
+ if (/^(?:(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum|struct|trait|impl|def|fn|pub\s+fn|pub\s+struct|pub\s+enum|const|let|var)\s)/.test(trimmed)) {
375
+ skeleton.push(line);
376
+ // If it's a one-liner, keep it
377
+ if (trimmed.endsWith(";") || trimmed.endsWith(",")) continue;
378
+ // Otherwise mark that we're entering a block
379
+ if (trimmed.includes("{") || trimmed.endsWith(":")) {
380
+ skeleton.push(" // ... implementation");
381
+ }
382
+ continue;
383
+ }
384
+
385
+ // Keep decorators/attributes
386
+ if (/^[@#\[]/.test(trimmed)) {
387
+ skeleton.push(line);
388
+ continue;
389
+ }
390
+ }
391
+
392
+ if (skeleton.length < 5) return null;
393
+
394
+ return `[${lines.length} lines, showing skeleton]\n` + skeleton.join("\n");
395
+ }
396
+
397
+ // 9. JSON response compression
398
+ function compressJSON(text) {
399
+ const trimmed = text.trim();
400
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null;
401
+ if (trimmed.length < 500) return null;
402
+
403
+ try {
404
+ const parsed = JSON.parse(trimmed);
405
+ // Don't compress search/fetch results — they ARE the content the model needs
406
+ if (parsed && typeof parsed === "object") {
407
+ if (Array.isArray(parsed.results) && parsed.results.some(r => r?.url || r?.snippet || r?.content || r?.title)) {
408
+ return null; // Looks like search results — preserve
409
+ }
410
+ if (parsed.url && (parsed.body || parsed.content || parsed.text || parsed.html)) {
411
+ return null; // Looks like a fetched page — preserve
412
+ }
413
+ }
414
+ const structure = extractJSONStructure(parsed, 0, 3);
415
+ return `[JSON structure, ${trimmed.length} chars original]\n` + JSON.stringify(structure, null, 2);
416
+ } catch {
417
+ return null;
418
+ }
419
+ }
420
+
421
+ function extractJSONStructure(obj, depth, maxDepth) {
422
+ if (depth >= maxDepth) return typeof obj === "object" ? (Array.isArray(obj) ? `[Array:${obj.length}]` : "{...}") : typeof obj;
423
+ if (Array.isArray(obj)) {
424
+ if (obj.length === 0) return [];
425
+ return [`${typeof obj[0] === "object" ? extractJSONStructure(obj[0], depth + 1, maxDepth) : typeof obj[0]} (×${obj.length})`];
426
+ }
427
+ if (typeof obj === "object" && obj !== null) {
428
+ const result = {};
429
+ for (const [key, value] of Object.entries(obj)) {
430
+ if (typeof value === "object" && value !== null) {
431
+ result[key] = extractJSONStructure(value, depth + 1, maxDepth);
432
+ } else {
433
+ result[key] = typeof value;
434
+ }
435
+ }
436
+ return result;
437
+ }
438
+ return typeof obj;
439
+ }
440
+
441
+ // 10. Docker/kubectl output
442
+ function compressContainerOutput(text) {
443
+ const isDocker = /(?:CONTAINER ID|IMAGE|PORTS|STATUS|docker|NAMESPACE|READY|RESTARTS|AGE|kubectl|pod\/)/i.test(text);
444
+ if (!isDocker) return null;
445
+
446
+ const lines = text.split("\n").filter(l => l.trim());
447
+ if (lines.length < 3) return null;
448
+
449
+ // Keep header + data rows, strip verbose columns
450
+ const header = lines[0];
451
+ const dataLines = lines.slice(1).filter(l => l.trim());
452
+
453
+ if (dataLines.length <= 10) return null; // Not enough to compress
454
+
455
+ return `${header}\n${dataLines.slice(0, 10).join("\n")}\n... +${dataLines.length - 10} more (${dataLines.length} total)`;
456
+ }
457
+
458
+ // ── Compression Pipeline ─────────────────────────────────────────────
459
+
460
+ const COMPRESSORS = [
461
+ { name: "test_output", fn: compressTestOutput },
462
+ { name: "git_diff", fn: compressGitDiff },
463
+ { name: "git_status", fn: compressGitStatus },
464
+ { name: "git_log", fn: compressGitLog },
465
+ { name: "lint_output", fn: compressLintOutput },
466
+ { name: "build_output", fn: compressBuildOutput },
467
+ { name: "container_output", fn: compressContainerOutput },
468
+ { name: "json_response", fn: compressJSON },
469
+ { name: "directory_listing", fn: compressDirectoryListing },
470
+ { name: "large_file", fn: compressLargeFile },
471
+ ];
472
+
473
+ // Compression levels tied to routing tiers
474
+ const TIER_THRESHOLDS = {
475
+ SIMPLE: 300, // Compress if > 300 chars
476
+ MEDIUM: 800, // Compress if > 800 chars
477
+ COMPLEX: 2000, // Compress if > 2000 chars
478
+ REASONING: Infinity, // Never compress
479
+ };
480
+
481
+ function tryCompress(text, tier) {
482
+ const threshold = TIER_THRESHOLDS[tier] || TIER_THRESHOLDS.MEDIUM;
483
+ if (text.length < threshold) return null;
484
+
485
+ for (const { name, fn } of COMPRESSORS) {
486
+ try {
487
+ const result = fn(text);
488
+ if (result && result.length < text.length * 0.7) {
489
+ return { compressed: result, pattern: name };
490
+ }
491
+ } catch (err) {
492
+ logger.debug({ compressor: name, error: err.message }, "Compressor failed, trying next");
493
+ }
494
+ }
495
+ return null;
496
+ }
497
+
498
+ // ── Main Entry Point ─────────────────────────────────────────────────
499
+
500
+ /**
501
+ * Compress tool_result blocks in conversation messages.
502
+ * Scans for known output patterns and replaces with compressed versions.
503
+ *
504
+ * @param {Array} messages - Conversation messages (mutated in place)
505
+ * @param {Object} options
506
+ * @param {string} options.tier - Routing tier (SIMPLE/MEDIUM/COMPLEX/REASONING)
507
+ * @param {boolean} options.enabled - Whether compression is enabled (default: true)
508
+ * @returns {Object} - { compressed: number, saved: number }
509
+ */
510
+ function compressToolResults(messages, options = {}) {
511
+ if (options.enabled === false) return { compressed: 0, saved: 0 };
512
+ if (!Array.isArray(messages)) return { compressed: 0, saved: 0 };
513
+
514
+ const tier = options.tier || "MEDIUM";
515
+ let compressedCount = 0;
516
+ let tokensSaved = 0;
517
+
518
+ for (const msg of messages) {
519
+ if (!Array.isArray(msg.content)) continue;
520
+
521
+ for (const block of msg.content) {
522
+ if (block.type !== "tool_result") continue;
523
+ if (typeof block.content !== "string") continue;
524
+
525
+ metrics.totalToolResults++;
526
+ const original = block.content;
527
+
528
+ const result = tryCompress(original, tier);
529
+ if (result) {
530
+ const teeId = teeStore(original);
531
+ block.content = result.compressed + `\n[full: ${teeId}]`;
532
+
533
+ recordMetric(result.pattern, original.length, block.content.length);
534
+ compressedCount++;
535
+ tokensSaved += Math.ceil((original.length - block.content.length) / 4);
536
+
537
+ logger.debug({
538
+ pattern: result.pattern,
539
+ originalChars: original.length,
540
+ compressedChars: block.content.length,
541
+ savings: Math.round((1 - block.content.length / original.length) * 100) + "%",
542
+ teeId,
543
+ }, "Compressed tool_result");
544
+ }
545
+ }
546
+ }
547
+
548
+ if (compressedCount > 0) {
549
+ logger.info({
550
+ compressed: compressedCount,
551
+ tokensSaved,
552
+ tier,
553
+ }, "Tool result compression applied");
554
+ }
555
+
556
+ return { compressed: compressedCount, saved: tokensSaved };
557
+ }
558
+
559
+ module.exports = {
560
+ compressToolResults,
561
+ teeGet,
562
+ getMetrics,
563
+ };