lynkr 8.0.1 → 9.0.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 (55) hide show
  1. package/README.md +238 -315
  2. package/bin/cli.js +16 -3
  3. package/index.js +7 -3
  4. package/install.sh +3 -3
  5. package/lynkr-skill.tar.gz +0 -0
  6. package/native/Cargo.toml +26 -0
  7. package/native/index.js +29 -0
  8. package/native/lynkr-native.node +0 -0
  9. package/native/src/lib.rs +321 -0
  10. package/package.json +8 -6
  11. package/src/api/files-multipart.js +30 -0
  12. package/src/api/files-router.js +81 -0
  13. package/src/api/openai-router.js +379 -308
  14. package/src/api/providers-handler.js +171 -3
  15. package/src/api/router.js +109 -5
  16. package/src/cache/prompt.js +13 -0
  17. package/src/clients/circuit-breaker.js +10 -247
  18. package/src/clients/codex-process.js +342 -0
  19. package/src/clients/codex-utils.js +143 -0
  20. package/src/clients/databricks.js +243 -76
  21. package/src/clients/ollama-utils.js +21 -17
  22. package/src/clients/openai-format.js +20 -6
  23. package/src/clients/openrouter-utils.js +42 -37
  24. package/src/clients/prompt-cache-injection.js +140 -0
  25. package/src/clients/provider-capabilities.js +41 -0
  26. package/src/clients/resilience.js +540 -0
  27. package/src/clients/responses-format.js +8 -7
  28. package/src/clients/retry.js +22 -167
  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 +66 -0
  33. package/src/context/compression.js +42 -9
  34. package/src/context/distill.js +507 -0
  35. package/src/context/tool-result-compressor.js +563 -0
  36. package/src/memory/extractor.js +22 -0
  37. package/src/orchestrator/index.js +147 -205
  38. package/src/routing/complexity-analyzer.js +258 -5
  39. package/src/routing/index.js +15 -34
  40. package/src/routing/latency-tracker.js +148 -0
  41. package/src/routing/model-tiers.js +2 -0
  42. package/src/routing/quality-scorer.js +113 -0
  43. package/src/routing/telemetry.js +502 -0
  44. package/src/server.js +23 -0
  45. package/src/stores/file-store.js +69 -0
  46. package/src/stores/response-store.js +25 -0
  47. package/src/tools/code-graph.js +538 -0
  48. package/src/tools/code-mode.js +304 -0
  49. package/src/tools/index.js +1 -1
  50. package/src/tools/lazy-loader.js +11 -0
  51. package/src/tools/mcp-remote.js +7 -0
  52. package/src/tools/smart-selection.js +11 -0
  53. package/src/tools/web.js +1 -1
  54. package/src/utils/payload.js +206 -0
  55. package/src/utils/perf-timer.js +80 -0
@@ -618,6 +618,12 @@ var config = {
618
618
  endpoint: moonshotEndpoint,
619
619
  model: moonshotModel,
620
620
  },
621
+ codex: {
622
+ enabled: process.env.CODEX_ENABLED !== "false",
623
+ binaryPath: process.env.CODEX_BINARY_PATH?.trim() || null,
624
+ model: process.env.CODEX_MODEL?.trim() || "gpt-5.3-codex",
625
+ timeout: Number.parseInt(process.env.CODEX_TIMEOUT || "120000", 10) || 120000,
626
+ },
621
627
  hotReload: {
622
628
  enabled: hotReloadEnabled,
623
629
  debounceMs: Number.isNaN(hotReloadDebounceMs) ? 1000 : hotReloadDebounceMs,
@@ -632,6 +638,9 @@ var config = {
632
638
  fallbackProvider,
633
639
  },
634
640
  toolExecutionMode,
641
+ toolResultCompression: {
642
+ enabled: true,
643
+ },
635
644
  server: {
636
645
  jsonLimit: process.env.REQUEST_JSON_LIMIT ?? "1gb",
637
646
  },
@@ -733,6 +742,10 @@ var config = {
733
742
  manifestPath: sandboxManifestPath,
734
743
  manifestDirs: sandboxManifestDirs,
735
744
  },
745
+ codeMode: {
746
+ enabled: process.env.CODE_MODE_ENABLED === 'true',
747
+ toolListCacheTtl: parseInt(process.env.CODE_MODE_CACHE_TTL, 10) || 60_000,
748
+ },
736
749
  },
737
750
  promptCache: {
738
751
  enabled: promptCacheEnabled,
@@ -918,6 +931,31 @@ var config = {
918
931
  COMPLEX: process.env.TIER_COMPLEX?.trim() || null,
919
932
  REASONING: process.env.TIER_REASONING?.trim() || null,
920
933
  },
934
+
935
+ // Cluster mode (multi-core scaling for 50+ concurrent users)
936
+ cluster: {
937
+ enabled: process.env.CLUSTER_ENABLED === 'true',
938
+ workers: process.env.CLUSTER_WORKERS || 'auto',
939
+ },
940
+
941
+ // Graphify knowledge graph integration (structural analysis)
942
+ codeGraph: {
943
+ enabled: process.env.CODE_GRAPH_ENABLED === 'true',
944
+ command: process.env.CODE_GRAPH_COMMAND || 'graphify',
945
+ workspace: process.env.CODE_GRAPH_WORKSPACE || process.cwd(),
946
+ timeout: parseInt(process.env.CODE_GRAPH_TIMEOUT, 10) || 10000,
947
+ },
948
+
949
+ // Large payload optimization (skip cloning media blocks that get discarded)
950
+ largePayload: {
951
+ enabled: process.env.LARGE_PAYLOAD_OPTIMIZATION !== 'false',
952
+ threshold: parseInt(process.env.LARGE_PAYLOAD_THRESHOLD, 10) || 1_048_576,
953
+ },
954
+
955
+ // OpenClaw integration
956
+ openclaw: {
957
+ enabled: process.env.OPENCLAW_MODE === "true",
958
+ },
921
959
  };
922
960
 
923
961
  /**
@@ -964,9 +1002,37 @@ function reloadConfig() {
964
1002
  config.toon.failOpen = process.env.TOON_FAIL_OPEN !== "false";
965
1003
  config.toon.logStats = process.env.TOON_LOG_STATS !== "false";
966
1004
 
1005
+ // Tier routing (critical for fixing model name issues without restart)
1006
+ config.modelTiers.SIMPLE = process.env.TIER_SIMPLE?.trim() || null;
1007
+ config.modelTiers.MEDIUM = process.env.TIER_MEDIUM?.trim() || null;
1008
+ config.modelTiers.COMPLEX = process.env.TIER_COMPLEX?.trim() || null;
1009
+ config.modelTiers.REASONING = process.env.TIER_REASONING?.trim() || null;
1010
+ config.modelTiers.enabled = !!(config.modelTiers.SIMPLE && config.modelTiers.MEDIUM && config.modelTiers.COMPLEX && config.modelTiers.REASONING);
1011
+
1012
+ // Ollama model
1013
+ config.ollama.endpoint = process.env.OLLAMA_ENDPOINT ?? config.ollama.endpoint;
1014
+
1015
+ // OpenClaw
1016
+ config.openclaw.enabled = process.env.OPENCLAW_MODE === "true";
1017
+
1018
+ // Graphify
1019
+ config.codeGraph.enabled = process.env.CODE_GRAPH_ENABLED === 'true';
1020
+
1021
+ // Code Mode
1022
+ config.mcp.codeMode.enabled = process.env.CODE_MODE_ENABLED === 'true';
1023
+
967
1024
  // Log level
968
1025
  config.logger.level = process.env.LOG_LEVEL ?? "info";
969
1026
 
1027
+ // Reset circuit breakers so stale OPEN states don't persist
1028
+ try {
1029
+ const { getCircuitBreakerRegistry } = require('../clients/circuit-breaker');
1030
+ getCircuitBreakerRegistry().resetAll();
1031
+ console.log("[CONFIG] Circuit breakers reset");
1032
+ } catch (e) {
1033
+ // Ignore if not yet initialized
1034
+ }
1035
+
970
1036
  console.log("[CONFIG] Configuration reloaded from environment");
971
1037
  return config;
972
1038
  }
@@ -11,6 +11,7 @@
11
11
 
12
12
  const logger = require('../logger');
13
13
  const config = require('../config');
14
+ const distill = require('./distill');
14
15
 
15
16
  /**
16
17
  * Compress conversation history to fit within token budget
@@ -62,6 +63,18 @@ function compressHistory(messages, options = {}) {
62
63
  compressed = oldMessages.map(msg => compressMessage(msg));
63
64
  }
64
65
 
66
+ // Apply Distill dedup across all old messages to collapse repetitive tool results
67
+ if (compressed.length > 0) {
68
+ const dedupResult = distill.deduplicateHistory(compressed);
69
+ if (dedupResult.stats.deduplicated > 0) {
70
+ compressed = dedupResult.messages;
71
+ logger.debug({
72
+ checked: dedupResult.stats.checked,
73
+ deduplicated: dedupResult.stats.deduplicated,
74
+ }, '[Distill] History dedup applied to old messages');
75
+ }
76
+ }
77
+
65
78
  // Add recent messages (may compress tool results but keep content)
66
79
  const recentCompressed = recentMessages.map(msg => compressToolResults(msg));
67
80
 
@@ -248,12 +261,15 @@ function compressContentBlock(block) {
248
261
  * Compress tool result block
249
262
  *
250
263
  * Tool results can be very large (file contents, bash output).
251
- * Compress while preserving essential information.
264
+ * Uses Distill algorithms (normalization, delta, dedup) before
265
+ * falling back to head/tail truncation.
252
266
  *
253
267
  * @param {Object} block - tool_result block
268
+ * @param {Object} options - Options for compression
269
+ * @param {string} options.previousResult - Previous tool result for delta rendering
254
270
  * @returns {Object} Compressed tool_result
255
271
  */
256
- function compressToolResultBlock(block) {
272
+ function compressToolResultBlock(block, options = {}) {
257
273
  if (!block || block.type !== 'tool_result') return block;
258
274
 
259
275
  const compressed = {
@@ -261,18 +277,32 @@ function compressToolResultBlock(block) {
261
277
  tool_use_id: block.tool_use_id,
262
278
  };
263
279
 
264
- // Compress content
280
+ // Compress content using Distill when content is large enough to benefit
265
281
  if (typeof block.content === 'string') {
266
- compressed.content = compressText(block.content, 500);
282
+ if (block.content.length > 500) {
283
+ const result = distill.compressToolResult(block.content, {
284
+ previousResult: options.previousResult,
285
+ maxLength: 500,
286
+ });
287
+ compressed.content = result.text;
288
+ } else {
289
+ compressed.content = block.content;
290
+ }
267
291
  } else if (Array.isArray(block.content)) {
268
292
  compressed.content = block.content.map(item => {
269
293
  if (typeof item === 'string') {
270
- return compressText(item, 500);
294
+ if (item.length > 500) {
295
+ return distill.compressToolResult(item, { maxLength: 500 }).text;
296
+ }
297
+ return item;
271
298
  } else if (item.type === 'text') {
272
- return {
273
- type: 'text',
274
- text: compressText(item.text, 500)
275
- };
299
+ if (item.text && item.text.length > 500) {
300
+ return {
301
+ type: 'text',
302
+ text: distill.compressToolResult(item.text, { maxLength: 500 }).text,
303
+ };
304
+ }
305
+ return item;
276
306
  }
277
307
  return item;
278
308
  });
@@ -453,7 +483,10 @@ module.exports = {
453
483
  compressHistory,
454
484
  compressMessage,
455
485
  compressToolResults,
486
+ compressToolResultBlock,
456
487
  calculateCompressionStats,
457
488
  needsCompression,
458
489
  summarizeOldHistory,
490
+ // Distill re-exports for direct access
491
+ distill,
459
492
  };
@@ -0,0 +1,507 @@
1
+ /**
2
+ * Distill — Core Algorithms for Intelligent Compression
3
+ *
4
+ * Ported from samuelfaj/distill (TypeScript CLI tool).
5
+ * Provides structural similarity detection, delta rendering,
6
+ * burst detection, text normalization, and bad distillation detection
7
+ * for LLM-optimized context compression.
8
+ *
9
+ * @module context/distill
10
+ */
11
+
12
+ const logger = require('../logger');
13
+
14
+ // ── Text Normalization ──────────────────────────────────────────────
15
+
16
+ /**
17
+ * Strip ANSI escape codes from text
18
+ */
19
+ function stripAnsi(text) {
20
+ if (!text) return '';
21
+ // eslint-disable-next-line no-control-regex
22
+ return text.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '');
23
+ }
24
+
25
+ /**
26
+ * Normalize text for comparison:
27
+ * - Strip ANSI codes
28
+ * - Normalize line endings
29
+ * - Collapse whitespace runs
30
+ * - Trim
31
+ */
32
+ function normalizeText(text) {
33
+ if (!text) return '';
34
+ let result = stripAnsi(text);
35
+ result = result.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
36
+ result = result.replace(/[ \t]+/g, ' ');
37
+ result = result.replace(/\n{3,}/g, '\n\n');
38
+ return result.trim();
39
+ }
40
+
41
+ /**
42
+ * Extract a structural signature from text.
43
+ * Splits into lines, normalizes each, filters empties,
44
+ * returns a Set of unique line signatures for Jaccard comparison.
45
+ */
46
+ function extractSignature(text) {
47
+ const normalized = normalizeText(text);
48
+ const lines = normalized.split('\n').map(l => l.trim()).filter(Boolean);
49
+ return new Set(lines);
50
+ }
51
+
52
+ // ── Structural Similarity (Jaccard) ─────────────────────────────────
53
+
54
+ /**
55
+ * Compute Jaccard similarity between two Sets.
56
+ * Returns a value in [0, 1].
57
+ */
58
+ function jaccardSimilarity(setA, setB) {
59
+ if (setA.size === 0 && setB.size === 0) return 1;
60
+ if (setA.size === 0 || setB.size === 0) return 0;
61
+
62
+ let intersection = 0;
63
+ const smaller = setA.size <= setB.size ? setA : setB;
64
+ const larger = setA.size <= setB.size ? setB : setA;
65
+
66
+ for (const item of smaller) {
67
+ if (larger.has(item)) intersection++;
68
+ }
69
+
70
+ const union = setA.size + setB.size - intersection;
71
+ return union === 0 ? 0 : intersection / union;
72
+ }
73
+
74
+ // Try to load native Rust implementation (3.7x faster for 100+ line blocks)
75
+ let nativeSimilarity = null;
76
+ try {
77
+ const native = require('../../native');
78
+ if (native.available && native.structuralSimilarity) {
79
+ nativeSimilarity = native.structuralSimilarity;
80
+ }
81
+ } catch { /* native module not available — use JS */ }
82
+
83
+ /**
84
+ * Compute structural similarity between two text blocks.
85
+ * Uses normalized line signatures + Jaccard index.
86
+ * Delegates to Rust native when available (3.7x faster).
87
+ *
88
+ * @param {string} a - First text
89
+ * @param {string} b - Second text
90
+ * @returns {number} Similarity score in [0, 1]
91
+ */
92
+ function structuralSimilarity(a, b) {
93
+ if (!a && !b) return 1;
94
+ if (!a || !b) return 0;
95
+
96
+ // Use Rust for large inputs where the speedup offsets Napi boundary cost
97
+ if (nativeSimilarity && a.length + b.length > 500) {
98
+ return nativeSimilarity(a, b);
99
+ }
100
+
101
+ const sigA = extractSignature(a);
102
+ const sigB = extractSignature(b);
103
+
104
+ return jaccardSimilarity(sigA, sigB);
105
+ }
106
+
107
+ // ── Delta Rendering ─────────────────────────────────────────────────
108
+
109
+ /**
110
+ * Compute a delta between two text blocks.
111
+ * Returns only the lines that changed (added/removed).
112
+ * If similarity is above threshold, returns a compact diff summary.
113
+ *
114
+ * @param {string} previous - Previous text
115
+ * @param {string} current - Current text
116
+ * @param {Object} options
117
+ * @param {number} options.similarityThreshold - Min similarity to use delta (default 0.3)
118
+ * @returns {Object} { isDelta, similarity, result, addedCount, removedCount }
119
+ */
120
+ function deltaRender(previous, current, options = {}) {
121
+ const threshold = options.similarityThreshold ?? 0.3;
122
+
123
+ if (!previous) {
124
+ return { isDelta: false, similarity: 0, result: current, addedCount: 0, removedCount: 0 };
125
+ }
126
+
127
+ const similarity = structuralSimilarity(previous, current);
128
+
129
+ // If not similar enough, return full text (no point diffing unrelated content)
130
+ if (similarity < threshold) {
131
+ return { isDelta: false, similarity, result: current, addedCount: 0, removedCount: 0 };
132
+ }
133
+
134
+ const prevLines = normalizeText(previous).split('\n');
135
+ const currLines = normalizeText(current).split('\n');
136
+
137
+ const prevSet = new Set(prevLines);
138
+ const currSet = new Set(currLines);
139
+
140
+ const added = currLines.filter(l => !prevSet.has(l));
141
+ const removed = prevLines.filter(l => !currSet.has(l));
142
+
143
+ if (added.length === 0 && removed.length === 0) {
144
+ return {
145
+ isDelta: true,
146
+ similarity: 1,
147
+ result: '[No changes]',
148
+ addedCount: 0,
149
+ removedCount: 0,
150
+ };
151
+ }
152
+
153
+ const parts = [];
154
+ if (removed.length > 0) {
155
+ parts.push(`[Removed ${removed.length} lines]`);
156
+ }
157
+ if (added.length > 0) {
158
+ parts.push(`[Added ${added.length} lines]`);
159
+ parts.push(added.join('\n'));
160
+ }
161
+
162
+ return {
163
+ isDelta: true,
164
+ similarity,
165
+ result: parts.join('\n'),
166
+ addedCount: added.length,
167
+ removedCount: removed.length,
168
+ };
169
+ }
170
+
171
+ // ── Burst Detection ─────────────────────────────────────────────────
172
+
173
+ /**
174
+ * Detect output bursts — groups of data separated by idle periods.
175
+ * Used to determine if output is streaming (many small bursts)
176
+ * or batch (few large bursts).
177
+ *
178
+ * @param {Array<{timestamp: number, size: number}>} chunks - Output chunks with timing
179
+ * @param {number} idleThresholdMs - Idle time to split bursts (default 2000ms)
180
+ * @returns {Object} { burstCount, avgBurstSize, mode: 'streaming'|'batch' }
181
+ */
182
+ function detectBursts(chunks, idleThresholdMs = 2000) {
183
+ if (!chunks || chunks.length === 0) {
184
+ return { burstCount: 0, avgBurstSize: 0, mode: 'batch' };
185
+ }
186
+
187
+ if (chunks.length === 1) {
188
+ return { burstCount: 1, avgBurstSize: chunks[0].size, mode: 'batch' };
189
+ }
190
+
191
+ let burstCount = 1;
192
+ let currentBurstSize = chunks[0].size;
193
+ const burstSizes = [];
194
+
195
+ for (let i = 1; i < chunks.length; i++) {
196
+ const gap = chunks[i].timestamp - chunks[i - 1].timestamp;
197
+
198
+ if (gap > idleThresholdMs) {
199
+ burstSizes.push(currentBurstSize);
200
+ burstCount++;
201
+ currentBurstSize = chunks[i].size;
202
+ } else {
203
+ currentBurstSize += chunks[i].size;
204
+ }
205
+ }
206
+ burstSizes.push(currentBurstSize);
207
+
208
+ const avgBurstSize = burstSizes.reduce((a, b) => a + b, 0) / burstSizes.length;
209
+
210
+ return {
211
+ burstCount,
212
+ avgBurstSize: Math.round(avgBurstSize),
213
+ mode: burstCount > 5 ? 'streaming' : 'batch',
214
+ };
215
+ }
216
+
217
+ // ── Bad Distillation Detection ──────────────────────────────────────
218
+
219
+ /**
220
+ * Heuristics to detect when a compression/summary is worse than original.
221
+ * Checks for:
222
+ * - Summary is longer than original
223
+ * - Summary lost too much information (similarity too low)
224
+ * - Summary introduced hallucinated content (low overlap)
225
+ * - Summary is just a truncation
226
+ *
227
+ * @param {string} original - Original text
228
+ * @param {string} summary - Compressed/summarized text
229
+ * @param {Object} options
230
+ * @param {number} options.maxExpansionRatio - Max allowed summary/original ratio (default 1.1)
231
+ * @param {number} options.minRetention - Min similarity to consider useful (default 0.15)
232
+ * @returns {Object} { isBad, reasons: string[] }
233
+ */
234
+ function detectBadDistillation(original, summary, options = {}) {
235
+ const maxExpansionRatio = options.maxExpansionRatio ?? 1.1;
236
+ const minRetention = options.minRetention ?? 0.15;
237
+
238
+ const reasons = [];
239
+
240
+ if (!original || !summary) {
241
+ return { isBad: false, reasons };
242
+ }
243
+
244
+ const origLen = normalizeText(original).length;
245
+ const sumLen = normalizeText(summary).length;
246
+
247
+ // Check expansion
248
+ if (origLen > 0 && sumLen / origLen > maxExpansionRatio) {
249
+ reasons.push(`Summary is ${((sumLen / origLen) * 100).toFixed(0)}% of original (expanded)`);
250
+ }
251
+
252
+ // Check retention via similarity
253
+ const similarity = structuralSimilarity(original, summary);
254
+ if (similarity < minRetention && sumLen > 50) {
255
+ reasons.push(`Low similarity (${(similarity * 100).toFixed(0)}%) — summary may not represent original`);
256
+ }
257
+
258
+ // Check if summary is just a truncation of original
259
+ const origNorm = normalizeText(original);
260
+ const sumNorm = normalizeText(summary);
261
+ if (origNorm.startsWith(sumNorm) && sumLen < origLen * 0.9) {
262
+ reasons.push('Summary appears to be a simple truncation');
263
+ }
264
+
265
+ return {
266
+ isBad: reasons.length > 0,
267
+ reasons,
268
+ similarity,
269
+ expansionRatio: origLen > 0 ? sumLen / origLen : 0,
270
+ };
271
+ }
272
+
273
+ // ── Repetition Detection ────────────────────────────────────────────
274
+
275
+ /**
276
+ * Detect repetitive blocks in a sequence of text outputs.
277
+ * Groups consecutive similar blocks and replaces them with a count.
278
+ *
279
+ * @param {string[]} blocks - Array of text blocks (e.g., tool results)
280
+ * @param {Object} options
281
+ * @param {number} options.similarityThreshold - Threshold for "same" (default 0.8)
282
+ * @returns {Object} { compressed: string[], stats: { totalBlocks, uniqueBlocks, duplicatesRemoved } }
283
+ */
284
+ function deduplicateBlocks(blocks, options = {}) {
285
+ const threshold = options.similarityThreshold ?? 0.8;
286
+
287
+ if (!blocks || blocks.length <= 1) {
288
+ return {
289
+ compressed: blocks || [],
290
+ stats: { totalBlocks: blocks?.length || 0, uniqueBlocks: blocks?.length || 0, duplicatesRemoved: 0 },
291
+ };
292
+ }
293
+
294
+ const compressed = [];
295
+ let runStart = 0;
296
+ let runCount = 1;
297
+
298
+ for (let i = 1; i < blocks.length; i++) {
299
+ const sim = structuralSimilarity(blocks[runStart], blocks[i]);
300
+
301
+ if (sim >= threshold) {
302
+ runCount++;
303
+ } else {
304
+ // Flush the current run
305
+ compressed.push(blocks[runStart]);
306
+ if (runCount > 1) {
307
+ compressed.push(`[...repeated ${runCount - 1} more time${runCount - 1 > 1 ? 's' : ''} with minor variations]`);
308
+ }
309
+ runStart = i;
310
+ runCount = 1;
311
+ }
312
+ }
313
+
314
+ // Flush last run
315
+ compressed.push(blocks[runStart]);
316
+ if (runCount > 1) {
317
+ compressed.push(`[...repeated ${runCount - 1} more time${runCount - 1 > 1 ? 's' : ''} with minor variations]`);
318
+ }
319
+
320
+ const duplicatesRemoved = blocks.length - compressed.length;
321
+
322
+ return {
323
+ compressed,
324
+ stats: {
325
+ totalBlocks: blocks.length,
326
+ uniqueBlocks: compressed.filter(b => !b.startsWith('[...repeated')).length,
327
+ duplicatesRemoved: Math.max(0, duplicatesRemoved),
328
+ },
329
+ };
330
+ }
331
+
332
+ // ── Smart Tool Result Compression ───────────────────────────────────
333
+
334
+ /**
335
+ * Intelligently compress a tool result using Distill algorithms.
336
+ * Applies in order:
337
+ * 1. Text normalization (ANSI strip, whitespace cleanup)
338
+ * 2. Delta rendering against previous result (if available)
339
+ * 3. Structural dedup of repetitive sections within the result
340
+ *
341
+ * @param {string} text - Tool result text
342
+ * @param {Object} options
343
+ * @param {string} options.previousResult - Previous tool result for delta rendering
344
+ * @param {number} options.maxLength - Max output length (default 1000)
345
+ * @returns {Object} { text, method, stats }
346
+ */
347
+ function compressToolResult(text, options = {}) {
348
+ if (!text) return { text: '', method: 'empty', stats: {} };
349
+
350
+ const maxLength = options.maxLength ?? 1000;
351
+ const originalLength = text.length;
352
+
353
+ // Step 1: Normalize
354
+ let result = normalizeText(text);
355
+
356
+ // Step 2: Delta rendering against previous result
357
+ if (options.previousResult) {
358
+ const delta = deltaRender(options.previousResult, result);
359
+ if (delta.isDelta && delta.similarity > 0.5) {
360
+ result = delta.result;
361
+ logger.debug({
362
+ similarity: delta.similarity.toFixed(2),
363
+ addedLines: delta.addedCount,
364
+ removedLines: delta.removedCount,
365
+ }, '[Distill] Delta rendering applied');
366
+
367
+ if (result.length <= maxLength) {
368
+ return {
369
+ text: result,
370
+ method: 'delta',
371
+ stats: {
372
+ originalLength,
373
+ compressedLength: result.length,
374
+ similarity: delta.similarity,
375
+ savings: ((1 - result.length / originalLength) * 100).toFixed(1) + '%',
376
+ },
377
+ };
378
+ }
379
+ }
380
+ }
381
+
382
+ // Step 3: Internal dedup — split into logical sections and dedup
383
+ const sections = result.split(/\n{2,}/);
384
+ if (sections.length > 3) {
385
+ const { compressed, stats } = deduplicateBlocks(sections);
386
+ if (stats.duplicatesRemoved > 0) {
387
+ result = compressed.join('\n\n');
388
+ logger.debug({
389
+ sectionsOriginal: stats.totalBlocks,
390
+ duplicatesRemoved: stats.duplicatesRemoved,
391
+ }, '[Distill] Section dedup applied');
392
+ }
393
+ }
394
+
395
+ // Step 4: Truncate if still over limit
396
+ if (result.length > maxLength) {
397
+ const keepStart = Math.floor(maxLength * 0.4);
398
+ const keepEnd = Math.floor(maxLength * 0.4);
399
+ const start = result.substring(0, keepStart);
400
+ const end = result.substring(result.length - keepEnd);
401
+ result = `${start}\n...[${result.length - maxLength} chars compressed]...\n${end}`;
402
+ }
403
+
404
+ return {
405
+ text: result,
406
+ method: result.length < originalLength ? 'distill' : 'passthrough',
407
+ stats: {
408
+ originalLength,
409
+ compressedLength: result.length,
410
+ savings: ((1 - result.length / originalLength) * 100).toFixed(1) + '%',
411
+ },
412
+ };
413
+ }
414
+
415
+ // ── History Dedup ───────────────────────────────────────────────────
416
+
417
+ /**
418
+ * Deduplicate repetitive tool results across conversation history.
419
+ * Scans tool_result blocks, finds structurally similar ones,
420
+ * and replaces duplicates with references.
421
+ *
422
+ * @param {Array} messages - Conversation messages
423
+ * @param {Object} options
424
+ * @param {number} options.similarityThreshold - Threshold (default 0.8)
425
+ * @returns {Object} { messages, stats }
426
+ */
427
+ function deduplicateHistory(messages, options = {}) {
428
+ if (!messages || messages.length === 0) {
429
+ return { messages: messages || [], stats: { checked: 0, deduplicated: 0 } };
430
+ }
431
+
432
+ const threshold = options.similarityThreshold ?? 0.8;
433
+ const seenResults = []; // { text, signature, index }
434
+ let deduplicated = 0;
435
+ let checked = 0;
436
+
437
+ const processed = messages.map((msg, msgIdx) => {
438
+ if (!Array.isArray(msg.content)) return msg;
439
+
440
+ const newContent = msg.content.map(block => {
441
+ if (block.type !== 'tool_result') return block;
442
+
443
+ const text = typeof block.content === 'string'
444
+ ? block.content
445
+ : Array.isArray(block.content)
446
+ ? block.content.map(c => (typeof c === 'string' ? c : c.text || '')).join('\n')
447
+ : '';
448
+
449
+ if (!text || text.length < 100) return block; // Skip short results
450
+
451
+ checked++;
452
+ const signature = extractSignature(text);
453
+
454
+ // Check against seen results
455
+ for (const seen of seenResults) {
456
+ const sim = jaccardSimilarity(signature, seen.signature);
457
+ if (sim >= threshold) {
458
+ deduplicated++;
459
+ return {
460
+ ...block,
461
+ content: `[Similar to earlier tool result — ${(sim * 100).toFixed(0)}% match, ${text.length} chars compressed]`,
462
+ };
463
+ }
464
+ }
465
+
466
+ // Register this result
467
+ seenResults.push({ text, signature, index: msgIdx });
468
+ return block;
469
+ });
470
+
471
+ return { ...msg, content: newContent };
472
+ });
473
+
474
+ return {
475
+ messages: processed,
476
+ stats: { checked, deduplicated },
477
+ };
478
+ }
479
+
480
+ module.exports = {
481
+ // Text normalization
482
+ stripAnsi,
483
+ normalizeText,
484
+ extractSignature,
485
+
486
+ // Structural similarity
487
+ jaccardSimilarity,
488
+ structuralSimilarity,
489
+
490
+ // Delta rendering
491
+ deltaRender,
492
+
493
+ // Burst detection
494
+ detectBursts,
495
+
496
+ // Bad distillation detection
497
+ detectBadDistillation,
498
+
499
+ // Repetition detection
500
+ deduplicateBlocks,
501
+
502
+ // Smart compression
503
+ compressToolResult,
504
+
505
+ // History dedup
506
+ deduplicateHistory,
507
+ };