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.
- package/README.md +238 -315
- package/bin/cli.js +16 -3
- 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 +8 -6
- package/src/api/files-multipart.js +30 -0
- package/src/api/files-router.js +81 -0
- package/src/api/openai-router.js +379 -308
- package/src/api/providers-handler.js +171 -3
- package/src/api/router.js +109 -5
- package/src/cache/prompt.js +13 -0
- package/src/clients/circuit-breaker.js +10 -247
- package/src/clients/codex-process.js +342 -0
- package/src/clients/codex-utils.js +143 -0
- package/src/clients/databricks.js +243 -76
- package/src/clients/ollama-utils.js +21 -17
- package/src/clients/openai-format.js +20 -6
- 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/resilience.js +540 -0
- package/src/clients/responses-format.js +8 -7
- package/src/clients/retry.js +22 -167
- 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 +66 -0
- package/src/context/compression.js +42 -9
- package/src/context/distill.js +507 -0
- package/src/context/tool-result-compressor.js +563 -0
- package/src/memory/extractor.js +22 -0
- package/src/orchestrator/index.js +147 -205
- package/src/routing/complexity-analyzer.js +258 -5
- package/src/routing/index.js +15 -34
- package/src/routing/latency-tracker.js +148 -0
- package/src/routing/model-tiers.js +2 -0
- package/src/routing/quality-scorer.js +113 -0
- package/src/routing/telemetry.js +502 -0
- package/src/server.js +23 -0
- package/src/stores/file-store.js +69 -0
- package/src/stores/response-store.js +25 -0
- package/src/tools/code-graph.js +538 -0
- package/src/tools/code-mode.js +304 -0
- package/src/tools/index.js +1 -1
- package/src/tools/lazy-loader.js +11 -0
- package/src/tools/mcp-remote.js +7 -0
- package/src/tools/smart-selection.js +11 -0
- package/src/tools/web.js +1 -1
- package/src/utils/payload.js +206 -0
- package/src/utils/perf-timer.js +80 -0
package/src/config/index.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
+
};
|