lynkr 9.9.1 → 9.11.0

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 (93) hide show
  1. package/README.md +50 -9
  2. package/bin/cli.js +2 -0
  3. package/bin/lynkr-init.js +11 -12
  4. package/bin/lynkr-reset.js +71 -0
  5. package/config/model-tiers.json +199 -52
  6. package/package.json +3 -3
  7. package/public/dashboard.html +29 -0
  8. package/scripts/validate-difficulty-classifier.js +27 -6
  9. package/src/api/providers-handler.js +0 -1
  10. package/src/api/router.js +309 -160
  11. package/src/clients/databricks.js +215 -71
  12. package/src/clients/gpt-utils.js +8 -1
  13. package/src/clients/ollama-utils.js +19 -10
  14. package/src/clients/prompt-cache-injection.js +93 -30
  15. package/src/config/index.js +82 -43
  16. package/src/context/compression.js +27 -8
  17. package/src/context/distill.js +2 -1
  18. package/src/dashboard/api.js +5 -1
  19. package/src/memory/distiller.js +350 -0
  20. package/src/memory/extractor.js +0 -8
  21. package/src/memory/format.js +17 -2
  22. package/src/memory/index.js +10 -0
  23. package/src/memory/retriever.js +7 -2
  24. package/src/memory/skills-cache.js +180 -0
  25. package/src/memory/tencentdb-launcher.js +459 -0
  26. package/src/memory/wiki.js +171 -0
  27. package/src/orchestrator/index.js +202 -1237
  28. package/src/orchestrator/passthrough-stream.js +382 -0
  29. package/src/orchestrator/sse-transformer.js +408 -0
  30. package/src/prompts/system.js +20 -6
  31. package/src/routing/affinity-store.js +85 -4
  32. package/src/routing/agentic-detector.js +9 -1
  33. package/src/routing/cache-economics.js +123 -0
  34. package/src/routing/cache-switch-cost.js +194 -0
  35. package/src/routing/calibration.js +8 -1
  36. package/src/routing/complexity-analyzer.js +7 -1
  37. package/src/routing/context-compressor.js +170 -0
  38. package/src/routing/difficulty-classifier.js +161 -17
  39. package/src/routing/index.js +196 -56
  40. package/src/routing/intent-score.js +20 -2
  41. package/src/routing/model-registry.js +35 -10
  42. package/src/routing/session-affinity.js +68 -1
  43. package/src/routing/side-channel-detector.js +103 -0
  44. package/src/routing/telemetry.js +139 -2
  45. package/src/routing/verifier.js +41 -2
  46. package/src/server.js +36 -49
  47. package/src/tools/code-graph.js +6 -4
  48. package/src/agents/context-manager.js +0 -236
  49. package/src/agents/decomposition/dispatcher.js +0 -185
  50. package/src/agents/decomposition/gate.js +0 -136
  51. package/src/agents/decomposition/index.js +0 -183
  52. package/src/agents/decomposition/model-call.js +0 -75
  53. package/src/agents/decomposition/planner.js +0 -223
  54. package/src/agents/decomposition/synthesizer.js +0 -89
  55. package/src/agents/decomposition/telemetry.js +0 -55
  56. package/src/agents/definitions/loader.js +0 -653
  57. package/src/agents/executor.js +0 -457
  58. package/src/agents/index.js +0 -165
  59. package/src/agents/parallel-coordinator.js +0 -68
  60. package/src/agents/reflector.js +0 -331
  61. package/src/agents/skillbook.js +0 -331
  62. package/src/agents/store.js +0 -259
  63. package/src/edits/index.js +0 -171
  64. package/src/indexer/babel-parser.js +0 -213
  65. package/src/indexer/index.js +0 -1629
  66. package/src/indexer/navigation/index.js +0 -32
  67. package/src/indexer/navigation/providers/treeSitter.js +0 -36
  68. package/src/indexer/parser.js +0 -443
  69. package/src/tasks/store.js +0 -349
  70. package/src/tests/coverage.js +0 -173
  71. package/src/tests/index.js +0 -171
  72. package/src/tests/store.js +0 -213
  73. package/src/tools/agent-task.js +0 -145
  74. package/src/tools/code-mode.js +0 -304
  75. package/src/tools/decompose.js +0 -91
  76. package/src/tools/edits.js +0 -94
  77. package/src/tools/execution.js +0 -171
  78. package/src/tools/git.js +0 -1346
  79. package/src/tools/index.js +0 -306
  80. package/src/tools/indexer.js +0 -360
  81. package/src/tools/lazy-loader.js +0 -366
  82. package/src/tools/mcp-remote.js +0 -88
  83. package/src/tools/mcp.js +0 -116
  84. package/src/tools/process.js +0 -167
  85. package/src/tools/smart-selection.js +0 -180
  86. package/src/tools/stubs.js +0 -55
  87. package/src/tools/tasks.js +0 -260
  88. package/src/tools/tests.js +0 -132
  89. package/src/tools/tinyfish.js +0 -358
  90. package/src/tools/truncate.js +0 -106
  91. package/src/tools/web-client.js +0 -71
  92. package/src/tools/web.js +0 -415
  93. package/src/tools/workspace.js +0 -204
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Per-provider prompt-cache economics.
3
+ *
4
+ * Single resolution point for "what does the prompt cache cost on this
5
+ * provider/model": read price, write price, TTL, and mechanism. The
6
+ * switch-cost math in `cache-switch-cost.js` and the cache-state tracker in
7
+ * `session-affinity.js` read from here — decision code never hardcodes
8
+ * per-model numbers.
9
+ *
10
+ * Resolution order per field:
11
+ * 1. model-registry entry (models.dev carries absolute cacheRead/cacheWrite
12
+ * $/1M for many models)
13
+ * 2. provider-type fallback table below (multipliers of the model's input
14
+ * price), researched Aug 2026.
15
+ *
16
+ * Mechanisms:
17
+ * - 'explicit' — cache_control breakpoints, paid writes (Anthropic-style).
18
+ * TTL refreshes on every read, so lastRequestAt + ttlMs is a live clock.
19
+ * - 'automatic' — provider caches transparently, writes are free
20
+ * (OpenAI/DeepSeek/Gemini-style). TTL is best-effort.
21
+ * - 'local' — no dollar cost at all; the "cost" of a cold prefix is
22
+ * prefill latency, handled separately in cache-switch-cost.js.
23
+ *
24
+ * @module routing/cache-economics
25
+ */
26
+
27
+ const logger = require('../logger');
28
+
29
+ // Fallback economics keyed by Lynkr provider type. `readMult`/`writeMult`
30
+ // are multipliers of the model's per-1M input price.
31
+ const PROVIDER_CACHE_DEFAULTS = {
32
+ // Anthropic-hosted (explicit cache_control, 1.25x write for the 5-min TTL,
33
+ // TTL refreshed on every read).
34
+ 'azure-anthropic': { readMult: 0.1, writeMult: 1.25, ttlMs: 5 * 60 * 1000, mechanism: 'explicit' },
35
+ bedrock: { readMult: 0.1, writeMult: 1.25, ttlMs: 5 * 60 * 1000, mechanism: 'explicit' },
36
+ databricks: { readMult: 0.1, writeMult: 1.25, ttlMs: 5 * 60 * 1000, mechanism: 'explicit' },
37
+
38
+ // OpenAI automatic prefix caching: writes free, ~5-10 min idle eviction.
39
+ openai: { readMult: 0.1, writeMult: 0, ttlMs: 10 * 60 * 1000, mechanism: 'automatic' },
40
+ 'azure-openai': { readMult: 0.1, writeMult: 0, ttlMs: 10 * 60 * 1000, mechanism: 'automatic' },
41
+
42
+ // Gemini implicit caching / GLM / Kimi / aggregators: free automatic
43
+ // caching, best-effort TTL. Aggregators (openrouter/edenai) depend on the
44
+ // underlying model; models.dev per-model data wins when present.
45
+ vertex: { readMult: 0.1, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
46
+ zai: { readMult: 0.1, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
47
+ moonshot: { readMult: 0.1, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
48
+ openrouter: { readMult: 0.1, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
49
+ edenai: { readMult: 0.1, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
50
+ // DeepSeek direct (via aggregators today, kept for model-level matches):
51
+ deepseek: { readMult: 0.02, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
52
+
53
+ // Local runtimes: no dollars; KV cache lives as long as the model stays
54
+ // loaded (ollama keep_alive default ~5 min).
55
+ ollama: { readMult: 0, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'local' },
56
+ llamacpp: { readMult: 0, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'local' },
57
+ lmstudio: { readMult: 0, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'local' },
58
+ };
59
+
60
+ const GENERIC_DEFAULT = { readMult: 0.1, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' };
61
+
62
+ /**
63
+ * Fallback cache economics for a provider type (no model lookup).
64
+ * @param {string|null} providerType
65
+ * @returns {{readMult:number, writeMult:number, ttlMs:number, mechanism:string}}
66
+ */
67
+ function getProviderCacheDefaults(providerType) {
68
+ return PROVIDER_CACHE_DEFAULTS[providerType] || GENERIC_DEFAULT;
69
+ }
70
+
71
+ /**
72
+ * Resolve full cache economics for a provider/model pair.
73
+ *
74
+ * @param {string|null} providerType - Lynkr provider type (e.g. 'databricks')
75
+ * @param {string|null} model - model name for registry lookup
76
+ * @returns {{
77
+ * inputPerM:number, outputPerM:number,
78
+ * cacheReadPerM:number, cacheWritePerM:number,
79
+ * ttlMs:number, mechanism:string, unknownPricing:boolean
80
+ * }} All prices are USD per 1M tokens.
81
+ */
82
+ function resolveCacheEconomics(providerType, model) {
83
+ const defaults = getProviderCacheDefaults(providerType);
84
+
85
+ let cost = null;
86
+ try {
87
+ const { getModelRegistrySync } = require('./model-registry');
88
+ cost = model ? getModelRegistrySync().getCost(model) : null;
89
+ } catch (err) {
90
+ logger.debug({ err: err.message }, '[CacheEconomics] registry lookup failed');
91
+ }
92
+
93
+ const inputPerM = typeof cost?.input === 'number' ? cost.input : 0;
94
+ const outputPerM = typeof cost?.output === 'number' ? cost.output : 0;
95
+
96
+ // Registry cacheTtlMs/cacheMechanism (Phase 4 entries) win over the
97
+ // provider fallback; models.dev absolute cache prices win over multipliers.
98
+ const mechanism = cost?.cacheMechanism || defaults.mechanism;
99
+ const ttlMs = typeof cost?.cacheTtlMs === 'number' ? cost.cacheTtlMs : defaults.ttlMs;
100
+
101
+ const cacheReadPerM = mechanism === 'local'
102
+ ? 0
103
+ : (typeof cost?.cacheRead === 'number' ? cost.cacheRead : inputPerM * defaults.readMult);
104
+ const cacheWritePerM = mechanism === 'local'
105
+ ? 0
106
+ : (typeof cost?.cacheWrite === 'number' ? cost.cacheWrite : inputPerM * defaults.writeMult);
107
+
108
+ return {
109
+ inputPerM,
110
+ outputPerM,
111
+ cacheReadPerM,
112
+ cacheWritePerM,
113
+ ttlMs,
114
+ mechanism,
115
+ unknownPricing: !!cost?.unknown,
116
+ };
117
+ }
118
+
119
+ module.exports = {
120
+ PROVIDER_CACHE_DEFAULTS,
121
+ getProviderCacheDefaults,
122
+ resolveCacheEconomics,
123
+ };
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Switch-cost break-even model (Phase 3, cache-aware routing).
3
+ *
4
+ * Answers one question for a session that holds a warm prompt-cache prefix
5
+ * on its pinned model: does switching to a cheaper target model pay for the
6
+ * cache it breaks, within the turns this session is expected to have left?
7
+ *
8
+ * stayPerTurn = warm*cacheRead(cur) + new*input(cur) + out*output(cur)
9
+ * switchOnce = (warm + new) * cacheWrite(target) // one-time
10
+ * switchPerTurn = warm*cacheRead(tgt) + new*input(tgt) + out*output(tgt)
11
+ * breakEvenTurns = switchOnce / (stayPerTurn - switchPerTurn)
12
+ *
13
+ * Switch iff breakEvenTurns <= expectedRemainingTurns.
14
+ *
15
+ * The output-token term is deliberately included: for de-escalation the
16
+ * dominant per-turn saving is often the output-price spread (e.g. Opus $25/M
17
+ * vs Haiku $5/M out), and omitting it makes the router hold expensive pins
18
+ * long past the point the economics justify.
19
+ *
20
+ * Scope: this module prices COST-MOTIVATED switches (de-escalator, bandit
21
+ * exploration, economic downgrades). Hard triggers — risk keywords, force
22
+ * phrases, context overflow, vision, upward drift — are never gated here;
23
+ * correctness beats cost and the math can never favor a pricier model anyway
24
+ * (savings would be negative).
25
+ *
26
+ * Local models have no dollar cost; the price of a cold prefix is prefill
27
+ * latency, so a warm local prefix above a size threshold holds the pin.
28
+ *
29
+ * All pricing comes from cache-economics.js (registry-first, provider
30
+ * fallback) — no per-model numbers live in this file.
31
+ *
32
+ * @module routing/cache-switch-cost
33
+ */
34
+
35
+ const config = require('../config');
36
+ const logger = require('../logger');
37
+
38
+ const PER_TOKEN = 1 / 1_000_000; // registry prices are USD per 1M tokens
39
+
40
+ function _cfg() {
41
+ const c = config.routing?.cacheAware || {};
42
+ return {
43
+ enabled: c.enabled !== false,
44
+ defaultRemainingTurns: c.defaultRemainingTurns ?? 10,
45
+ newTokensPerTurn: c.newTokensPerTurn ?? 2000,
46
+ outputTokensPerTurn: c.outputTokensPerTurn ?? 800,
47
+ localMaxSwitchPrefixTokens: c.localMaxSwitchPrefixTokens ?? 16000,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Evaluate whether a cost-motivated switch away from the session's warm
53
+ * prefix should be allowed.
54
+ *
55
+ * @param {Object} args
56
+ * @param {Object|null} args.cacheState - from sessionAffinity.getCacheState:
57
+ * {warmPrefixTokens, provider, model, lastRequestAt, ttlMs, cold} or null.
58
+ * @param {{provider:string, model:string|null}} args.current - pinned target.
59
+ * @param {{provider:string, model:string|null}} args.target - proposed target.
60
+ * @param {number} [args.newTokensPerTurn] - est. fresh input tokens per turn.
61
+ * @param {number} [args.outputTokensPerTurn] - est. output tokens per turn.
62
+ * @param {number|null} [args.expectedRemainingTurns] - median remaining
63
+ * turns; falls back to the conservative config default when null.
64
+ * @param {Object} [args.deps] - test injection: {resolveCacheEconomics}.
65
+ * @returns {{
66
+ * switchAllowed: boolean,
67
+ * reason: string,
68
+ * breakEvenTurns: number|null,
69
+ * expectedRemainingTurns: number,
70
+ * warmPrefixTokens: number,
71
+ * switchOnceUsd: number|null,
72
+ * stayPerTurnUsd: number|null,
73
+ * switchPerTurnUsd: number|null,
74
+ * projectedStaySavingsUsd: number|null,
75
+ * }}
76
+ */
77
+ function evaluateSwitch({
78
+ cacheState,
79
+ current,
80
+ target,
81
+ newTokensPerTurn,
82
+ outputTokensPerTurn,
83
+ expectedRemainingTurns,
84
+ deps = {},
85
+ } = {}) {
86
+ const cfg = _cfg();
87
+ const remaining = Number.isFinite(expectedRemainingTurns) && expectedRemainingTurns > 0
88
+ ? expectedRemainingTurns
89
+ : cfg.defaultRemainingTurns;
90
+
91
+ const base = {
92
+ switchAllowed: true,
93
+ reason: 'no_cache_state',
94
+ breakEvenTurns: null,
95
+ expectedRemainingTurns: remaining,
96
+ warmPrefixTokens: cacheState?.warmPrefixTokens ?? 0,
97
+ switchOnceUsd: null,
98
+ stayPerTurnUsd: null,
99
+ switchPerTurnUsd: null,
100
+ projectedStaySavingsUsd: null,
101
+ };
102
+
103
+ if (!cfg.enabled) return { ...base, reason: 'feature_disabled' };
104
+ if (!current?.provider || !target?.provider) return base;
105
+ if (current.provider === target.provider && current.model === target.model) {
106
+ return { ...base, reason: 'same_model' };
107
+ }
108
+
109
+ // No tracked state → nothing warm to protect (provider reports no cache
110
+ // usage, or no response seen yet). Switching is cache-free.
111
+ if (!cacheState || !(cacheState.warmPrefixTokens > 0)) return base;
112
+
113
+ // State recorded for a different model than the pin we're defending —
114
+ // stale after a prior switch; treat as no protection.
115
+ if (cacheState.model && current.model && cacheState.model !== current.model) {
116
+ return { ...base, reason: 'stale_cache_state' };
117
+ }
118
+
119
+ // Phase 2 — TTL clock. Anthropic-style caches refresh on every read, so
120
+ // lastRequestAt + ttlMs going stale means the prefix is already cold and
121
+ // the switch costs nothing extra.
122
+ if (cacheState.cold) return { ...base, reason: 'cache_cold' };
123
+
124
+ let resolve = deps.resolveCacheEconomics;
125
+ if (typeof resolve !== 'function') {
126
+ ({ resolveCacheEconomics: resolve } = require('./cache-economics'));
127
+ }
128
+
129
+ const econCur = resolve(current.provider, current.model);
130
+ const econTgt = resolve(target.provider, target.model);
131
+
132
+ const W = cacheState.warmPrefixTokens;
133
+ const N = Number.isFinite(newTokensPerTurn) && newTokensPerTurn >= 0
134
+ ? newTokensPerTurn : cfg.newTokensPerTurn;
135
+ const O = Number.isFinite(outputTokensPerTurn) && outputTokensPerTurn >= 0
136
+ ? outputTokensPerTurn : cfg.outputTokensPerTurn;
137
+
138
+ // Local pin: no dollars in either column — the cost of abandoning the
139
+ // warm prefix is prefill latency on whatever serves the next turn.
140
+ // Hold while the prefix is large; small prefixes re-fill fast enough.
141
+ if (econCur.mechanism === 'local' && econTgt.mechanism === 'local') {
142
+ const allowed = W <= cfg.localMaxSwitchPrefixTokens;
143
+ return {
144
+ ...base,
145
+ switchAllowed: allowed,
146
+ reason: allowed ? 'local_prefix_small' : 'local_prefill_hold',
147
+ };
148
+ }
149
+
150
+ const stayPerTurn = (W * econCur.cacheReadPerM + N * econCur.inputPerM + O * econCur.outputPerM) * PER_TOKEN;
151
+ const switchOnce = (W + N) * econTgt.cacheWritePerM * PER_TOKEN;
152
+ const switchPerTurn = (W * econTgt.cacheReadPerM + N * econTgt.inputPerM + O * econTgt.outputPerM) * PER_TOKEN;
153
+ const savingsPerTurn = stayPerTurn - switchPerTurn;
154
+
155
+ const priced = {
156
+ ...base,
157
+ switchOnceUsd: switchOnce,
158
+ stayPerTurnUsd: stayPerTurn,
159
+ switchPerTurnUsd: switchPerTurn,
160
+ projectedStaySavingsUsd: savingsPerTurn,
161
+ };
162
+
163
+ // Switching to a same-or-more-expensive target never pays on economics.
164
+ // (Escalations don't route through this gate, so blocking is safe.)
165
+ if (savingsPerTurn <= 0) {
166
+ return {
167
+ ...priced,
168
+ switchAllowed: false,
169
+ reason: 'never_profitable',
170
+ breakEvenTurns: Infinity,
171
+ };
172
+ }
173
+
174
+ const breakEvenTurns = switchOnce / savingsPerTurn;
175
+ const allowed = breakEvenTurns <= remaining;
176
+
177
+ logger.debug({
178
+ current: `${current.provider}:${current.model}`,
179
+ target: `${target.provider}:${target.model}`,
180
+ warmPrefixTokens: W,
181
+ breakEvenTurns: Number(breakEvenTurns.toFixed(2)),
182
+ expectedRemainingTurns: remaining,
183
+ allowed,
184
+ }, '[CacheSwitchCost] break-even evaluated');
185
+
186
+ return {
187
+ ...priced,
188
+ switchAllowed: allowed,
189
+ reason: allowed ? 'break_even_cleared' : 'break_even_blocked',
190
+ breakEvenTurns,
191
+ };
192
+ }
193
+
194
+ module.exports = { evaluateSwitch };
@@ -146,7 +146,8 @@ function runCalibration({ days = DEFAULT_DAYS, dryRun = false, dbPath, outputPat
146
146
  continue;
147
147
  }
148
148
  const ordered = Array.from(tierBuckets.entries()).sort((a, b) => a[0] - b[0]);
149
- let suggestedUpper = DEFAULT_RANGES[tier][1];
149
+ const [defaultLower, defaultUpper] = DEFAULT_RANGES[tier];
150
+ let suggestedUpper = defaultUpper;
150
151
  const bucketsSummary = [];
151
152
  for (const [lo, vals] of ordered) {
152
153
  if (vals.length < 5) {
@@ -155,6 +156,12 @@ function runCalibration({ days = DEFAULT_DAYS, dryRun = false, dbPath, outputPat
155
156
  }
156
157
  const med = _median(vals);
157
158
  bucketsSummary.push({ bucket: lo, samples: vals.length, median: med });
159
+ // Buckets outside the tier's own default range come from fallback-served
160
+ // requests (another tier's scores recorded under the serving tier). They
161
+ // say nothing about this tier's real range, and letting them shrink the
162
+ // upper bound below the lower bound collapses the tier to a single point
163
+ // in re-stitching (observed 2026-07-26: MEDIUM → [20,20]).
164
+ if (lo + 4 < defaultLower || lo > defaultUpper) continue;
158
165
  if (med < floor && lo + 4 < suggestedUpper) {
159
166
  // shrink tier upper bound just below the failing bucket
160
167
  suggestedUpper = lo + 4;
@@ -795,7 +795,13 @@ async function analyzeComplexity(payload, options = {}) {
795
795
  threshold,
796
796
  mode: 'weighted',
797
797
  recommendation,
798
- breakdown: weighted.dimensions,
798
+ // taskType rides alongside the numeric dimensions. The legacy path
799
+ // has always carried breakdown.taskType, and both the deescalator
800
+ // (tier + request_type evidence key) and the telemetry request_type
801
+ // column derive from it — without it here, weighted-mode requests
802
+ // (the default) recorded request_type NULL and could never
803
+ // accumulate demotion evidence.
804
+ breakdown: { ...weighted.dimensions, taskType: taskTypeResult },
799
805
  weights: weighted.weights,
800
806
  meta: weighted.meta,
801
807
  forceReason: taskTypeResult.reason?.startsWith('force_') ? taskTypeResult.reason : null,
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Context Compression Module
3
+ *
4
+ * Inspired by TencentDB Agent Memory's short-term memory compression.
5
+ * Reduces conversation context size before complexity scoring to enable
6
+ * more accurate tier routing.
7
+ *
8
+ * Key insight: Long conversations bloat context → force expensive routing
9
+ * even for simple follow-ups. By compressing tool outputs and previous
10
+ * turns, we keep more requests in SIMPLE/MEDIUM tiers.
11
+ *
12
+ * @module routing/context-compressor
13
+ */
14
+
15
+ const logger = require('../logger');
16
+
17
+ /**
18
+ * Compress conversation messages by:
19
+ * 1. Offloading verbose tool_result content
20
+ * 2. Summarizing repetitive patterns
21
+ * 3. Keeping only essential context for scoring
22
+ *
23
+ * Does NOT modify the actual request sent to LLM — only used for complexity scoring.
24
+ *
25
+ * @param {Array} messages - Conversation messages
26
+ * @returns {{ compressed: Array, stats: Object }} - Compressed messages + stats
27
+ */
28
+ function compressMessages(messages) {
29
+ if (!Array.isArray(messages) || messages.length === 0) {
30
+ return { compressed: messages, stats: { original: 0, compressed: 0, ratio: 1.0 } };
31
+ }
32
+
33
+ const compressed = [];
34
+ let originalSize = 0;
35
+ let compressedSize = 0;
36
+ let toolResultsOffloaded = 0;
37
+ let messagesKept = 0;
38
+
39
+ // Keep system message always
40
+ const systemMsg = messages.find(m => m.role === 'system');
41
+ if (systemMsg) {
42
+ compressed.push(systemMsg);
43
+ const size = estimateMessageSize(systemMsg);
44
+ originalSize += size;
45
+ compressedSize += size;
46
+ }
47
+
48
+ // Keep last N user/assistant turns (sliding window)
49
+ const WINDOW_SIZE = 5;
50
+ const recentTurns = messages
51
+ .filter(m => m.role === 'user' || m.role === 'assistant')
52
+ .slice(-WINDOW_SIZE);
53
+
54
+ for (const msg of recentTurns) {
55
+ const original = estimateMessageSize(msg);
56
+ originalSize += original;
57
+
58
+ // Compress tool_result blocks (biggest token consumers)
59
+ if (Array.isArray(msg.content)) {
60
+ const compressedContent = msg.content.map(block => {
61
+ if (block?.type === 'tool_result') {
62
+ toolResultsOffloaded++;
63
+ // Keep only metadata, offload actual content
64
+ return {
65
+ type: 'tool_result',
66
+ tool_use_id: block.tool_use_id,
67
+ content: '[offloaded]', // Replaced with placeholder
68
+ _compressed: true,
69
+ };
70
+ }
71
+ return block;
72
+ });
73
+
74
+ const compressedMsg = { ...msg, content: compressedContent };
75
+ compressed.push(compressedMsg);
76
+ compressedSize += estimateMessageSize(compressedMsg);
77
+ messagesKept++;
78
+ } else {
79
+ // No compression needed for string content
80
+ compressed.push(msg);
81
+ compressedSize += original;
82
+ messagesKept++;
83
+ }
84
+ }
85
+
86
+ const ratio = originalSize > 0 ? compressedSize / originalSize : 1.0;
87
+
88
+ const stats = {
89
+ original: originalSize,
90
+ compressed: compressedSize,
91
+ ratio,
92
+ reduction: Math.round((1 - ratio) * 100),
93
+ toolResultsOffloaded,
94
+ messagesKept,
95
+ messagesDropped: messages.length - messagesKept - (systemMsg ? 1 : 0),
96
+ };
97
+
98
+ logger.debug({
99
+ ...stats,
100
+ originalMsgs: messages.length,
101
+ compressedMsgs: compressed.length,
102
+ }, '[context-compressor] Compression complete');
103
+
104
+ return { compressed, stats };
105
+ }
106
+
107
+ /**
108
+ * Estimate message size in tokens (rough heuristic: chars / 4)
109
+ */
110
+ function estimateMessageSize(msg) {
111
+ if (!msg) return 0;
112
+
113
+ let size = 0;
114
+
115
+ // Role + metadata overhead
116
+ size += 10;
117
+
118
+ // Content
119
+ if (typeof msg.content === 'string') {
120
+ size += Math.ceil(msg.content.length / 4);
121
+ } else if (Array.isArray(msg.content)) {
122
+ for (const block of msg.content) {
123
+ if (block?.type === 'text' && block.text) {
124
+ size += Math.ceil(block.text.length / 4);
125
+ } else if (block?.type === 'tool_result') {
126
+ // Tool results are verbose (code, logs, errors)
127
+ const content = Array.isArray(block.content)
128
+ ? block.content.map(c => c?.text || '').join('')
129
+ : (block.content || '');
130
+ size += Math.ceil(content.length / 4);
131
+ } else if (block?.type === 'tool_use') {
132
+ // Tool use is compact (function name + args)
133
+ size += 50;
134
+ }
135
+ }
136
+ }
137
+
138
+ return size;
139
+ }
140
+
141
+ /**
142
+ * Check if compression is beneficial for this payload.
143
+ * Only compress if conversation is long enough to matter.
144
+ */
145
+ function shouldCompress(payload) {
146
+ if (!payload?.messages || !Array.isArray(payload.messages)) {
147
+ return false;
148
+ }
149
+
150
+ const msgCount = payload.messages.length;
151
+
152
+ // Count total tool_result blocks (not just messages with tool results)
153
+ let toolResultCount = 0;
154
+ for (const msg of payload.messages) {
155
+ if (Array.isArray(msg.content)) {
156
+ toolResultCount += msg.content.filter(c => c?.type === 'tool_result').length;
157
+ }
158
+ }
159
+
160
+ // Compress if:
161
+ // 1. More than 10 messages (long conversation), OR
162
+ // 2. More than 3 tool_result blocks (verbose outputs)
163
+ return msgCount > 10 || toolResultCount > 3;
164
+ }
165
+
166
+ module.exports = {
167
+ compressMessages,
168
+ shouldCompress,
169
+ estimateMessageSize,
170
+ };