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.
- package/README.md +70 -21
- package/bin/cli.js +34 -4
- package/bin/lynkr-trajectory.js +136 -0
- package/bin/lynkr-usage.js +219 -0
- package/funding.json +110 -0
- package/index.js +7 -3
- package/install.sh +3 -3
- package/lynkr-skill.tar.gz +0 -0
- package/native/Cargo.toml +26 -0
- package/native/index.js +29 -0
- package/native/lynkr-native.node +0 -0
- package/native/src/lib.rs +321 -0
- package/package.json +6 -5
- package/public/dashboard.html +665 -0
- package/src/api/files-multipart.js +30 -0
- package/src/api/files-router.js +81 -0
- package/src/api/middleware/budget.js +19 -1
- package/src/api/middleware/load-shedding.js +17 -0
- package/src/api/openai-router.js +353 -301
- package/src/api/router.js +275 -40
- package/src/cache/prompt.js +13 -0
- package/src/clients/databricks.js +42 -18
- package/src/clients/ollama-utils.js +21 -17
- package/src/clients/openai-format.js +50 -10
- package/src/clients/openrouter-utils.js +42 -37
- package/src/clients/prompt-cache-injection.js +140 -0
- package/src/clients/provider-capabilities.js +41 -0
- package/src/clients/responses-format.js +8 -7
- package/src/clients/standard-tools.js +1 -1
- package/src/clients/xml-tool-extractor.js +307 -0
- package/src/cluster.js +82 -0
- package/src/config/index.js +16 -0
- package/src/context/distill.js +15 -0
- package/src/context/tool-result-compressor.js +563 -0
- package/src/dashboard/api.js +170 -0
- package/src/dashboard/router.js +13 -0
- package/src/headroom/client.js +3 -109
- package/src/headroom/index.js +0 -14
- package/src/memory/extractor.js +22 -0
- package/src/memory/search.js +0 -50
- package/src/orchestrator/index.js +163 -204
- package/src/orchestrator/preflight.js +188 -0
- package/src/routing/index.js +64 -32
- package/src/routing/interaction.js +183 -0
- package/src/routing/risk-analyzer.js +194 -0
- package/src/routing/telemetry.js +47 -2
- package/src/server.js +15 -0
- package/src/stores/file-store.js +104 -0
- package/src/stores/response-store.js +25 -0
- package/src/tools/index.js +1 -1
- package/src/tools/smart-selection.js +11 -2
- package/src/tools/web.js +1 -1
- package/src/training/trajectory-compressor.js +266 -0
- package/src/usage/aggregator.js +206 -0
- package/src/utils/markdown-ansi.js +146 -0
- package/.lynkr/telemetry.db +0 -0
- package/.lynkr/telemetry.db-shm +0 -0
- 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
|
+
};
|