lynkr 9.10.0 → 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 (38) hide show
  1. package/bin/lynkr-init.js +7 -0
  2. package/config/model-tiers.json +5 -5
  3. package/package.json +3 -3
  4. package/public/dashboard.html +29 -0
  5. package/src/api/router.js +34 -0
  6. package/src/clients/databricks.js +120 -65
  7. package/src/clients/gpt-utils.js +8 -1
  8. package/src/clients/ollama-utils.js +19 -10
  9. package/src/clients/prompt-cache-injection.js +93 -30
  10. package/src/config/index.js +79 -0
  11. package/src/context/compression.js +27 -8
  12. package/src/context/distill.js +2 -1
  13. package/src/dashboard/api.js +5 -1
  14. package/src/memory/distiller.js +350 -0
  15. package/src/memory/extractor.js +0 -8
  16. package/src/memory/format.js +17 -2
  17. package/src/memory/index.js +10 -0
  18. package/src/memory/retriever.js +7 -2
  19. package/src/memory/skills-cache.js +180 -0
  20. package/src/memory/tencentdb-launcher.js +459 -0
  21. package/src/memory/wiki.js +171 -0
  22. package/src/orchestrator/index.js +85 -2
  23. package/src/prompts/system.js +20 -6
  24. package/src/routing/affinity-store.js +68 -1
  25. package/src/routing/agentic-detector.js +9 -1
  26. package/src/routing/cache-economics.js +123 -0
  27. package/src/routing/cache-switch-cost.js +194 -0
  28. package/src/routing/calibration.js +8 -1
  29. package/src/routing/complexity-analyzer.js +7 -1
  30. package/src/routing/context-compressor.js +170 -0
  31. package/src/routing/difficulty-classifier.js +115 -13
  32. package/src/routing/index.js +161 -53
  33. package/src/routing/model-registry.js +35 -10
  34. package/src/routing/session-affinity.js +60 -0
  35. package/src/routing/telemetry.js +139 -2
  36. package/src/routing/verifier.js +41 -2
  37. package/src/server.js +36 -3
  38. package/src/tools/code-graph.js +6 -4
@@ -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
+ };
@@ -7,14 +7,16 @@
7
7
  * hard problems. An LLM reading the actual sentence knows better.
8
8
  *
9
9
  * DESIGN:
10
- * - Uses whatever model is configured for the SIMPLE tier (fetched at call
11
- * time via getModelTierSelector). When the user later swaps in a
12
- * fine-tuned classifier as SIMPLE, this picks it up automatically.
10
+ * - Dedicated classifier model (CLASSIFIER_MODEL below), deliberately
11
+ * decoupled from tier serving — swapping TIER_* env vars does not change
12
+ * what classifies prompts. The tier DEPLOYMENT (which models the tiers
13
+ * route to) is surfaced to the classifier as tie-breaker context; see
14
+ * _buildTierContext.
13
15
  * - Structured JSON output; parse failure → null → caller falls back.
14
- * - Hard 2500ms timeout; on timeout → null → caller falls back.
16
+ * - Hard timeout (TIMEOUT_MS); on timeout → null → caller falls back.
15
17
  * - LRU cache keyed by sha256(text.trim().toLowerCase()); capacity 500.
16
18
  * - Skip conditions surface via classifyDifficulty returning null with
17
- * reason=skipped: text.length<15, force-pattern matched, risk=high,
19
+ * reason=skipped: empty text, force-pattern matched, risk=high,
18
20
  * cache hit is transparent (returns cached).
19
21
  * - Hardcoded kill-switch CLASSIFIER_ENABLED — no env var per user policy.
20
22
  *
@@ -31,7 +33,6 @@ const CLASSIFIER_ENABLED = true;
31
33
  // LRU cache to keep amortized latency low.
32
34
  const TIMEOUT_MS = 10000;
33
35
  const CACHE_CAPACITY = 500;
34
- const MIN_TEXT_LENGTH = 15;
35
36
 
36
37
  // Classifier model — decoupled from tier serving so SIMPLE tier can run a
37
38
  // more capable model for real traffic while the classifier stays fast and
@@ -44,7 +45,7 @@ const CLASSIFIER_MODEL = 'qwen2.5:3b';
44
45
 
45
46
  const VALID_TIERS = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
46
47
 
47
- // One-shot classification prompt (v2). Kept in a const so drift is diffable.
48
+ // One-shot classification prompt (v3). Kept in a const so drift is diffable.
48
49
  // Difficulty framing (not intent) — matches config B routing goals.
49
50
  //
50
51
  // v2 (2026-07-21): added the follow-up rule, negative examples under
@@ -54,11 +55,16 @@ const VALID_TIERS = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
54
55
  // flavored and nothing said surface vocabulary isn't the signal. Baseline
55
56
  // on data/difficulty-eval-followups.jsonl: 60% overall, 33% on SIMPLE,
56
57
  // 3 SIMPLE→REASONING criticals.
58
+ //
59
+ // v3 (2026-08-09): added trivial-arithmetic SIMPLE examples after a live
60
+ // over-route: "12+21" scored anchor 25 (MEDIUM band) and qwen ALSO said
61
+ // MEDIUM conf 1.0 — bare arithmetic read as "a specific mechanical task".
62
+ // Any tiny model adds two numbers; it belongs in SIMPLE.
57
63
  const CLASSIFY_PROMPT = `You are a classifier for an LLM routing proxy. Classify the difficulty of the CURRENT user prompt into exactly one of four tiers. Reply with ONLY valid JSON on a single line, no other text.
58
64
 
59
65
  Tiers:
60
66
  - SIMPLE: casual acknowledgments, greetings, one-word answers, trivial factual lookups, and short conversational follow-up questions about people, stories, events, or everyday facts. Any tiny model handles.
61
- examples: "hi", "ok thanks", "yes continue", "what time is it", "who is doctor doom?", "who kills him?", "why did he do that?", "and then what happened?", "does bleach kill mold?"
67
+ examples: "hi", "ok thanks", "yes continue", "what time is it", "who is doctor doom?", "who kills him?", "why did he do that?", "and then what happened?", "does bleach kill mold?", "12+21", "what is 15% of 80?", "convert 3km to miles"
62
68
  - MEDIUM: one specific mechanical task or a focused explanation. Mid-size local model suffices.
63
69
  examples: "list the exports from this file", "run the unit tests", "fix the linter warnings", "explain this regex", "add error handling to this block", "verify the file exists before reading it"
64
70
  - COMPLEX: multi-file design, systemic refactor, architecture review, debugging that requires broad code understanding. Needs a strong general model.
@@ -81,15 +87,101 @@ Reply format (strict): {"tier":"SIMPLE|MEDIUM|COMPLEX|REASONING","confidence":0.
81
87
  const CONTEXT_MAX_TEXT_LENGTH = 40;
82
88
  const CONTEXT_MAX_CHARS = 300;
83
89
 
90
+ // --- Tier deployment context -------------------------------------------------
91
+ //
92
+ // Tells the classifier WHAT each tier routes to (model + coarse traits from
93
+ // the registry), so borderline verdicts can weigh the actual fleet — e.g.
94
+ // prefer MEDIUM when its configured model is a large cloud model, or lean
95
+ // away from a tier whose model lacks the needed capability.
96
+ //
97
+ // Constraints that shape the design:
98
+ // - TIE-BREAKER ONLY. The difficulty rubric in CLASSIFY_PROMPT governs;
99
+ // the deployment block must not redefine what the tiers mean, or eval
100
+ // labels (data/difficulty-eval*.jsonl) stop being comparable.
101
+ // - BYTE-STABLE per process. Tier config is env-driven and fixed for the
102
+ // process lifetime, so the block is built once and memoized. It also
103
+ // participates in the LRU cache key (fingerprint) so a reconfigured
104
+ // deployment never serves verdicts cached under the old one.
105
+ // - FAIL-SAFE. Registry misses or config errors degrade to provider-type
106
+ // traits or to no block at all — classification proceeds regardless.
107
+ // MEASURED OFF (2026-08-08): A/B on data/difficulty-eval-followups.jsonl —
108
+ // baseline 84.0% overall / 0 SIMPLE→REASONING criticals; with the block
109
+ // 76.0% / 1 critical ('prove me wrong lol' → REASONING conf 1.0, the exact
110
+ // failure class prompt v2 was built to kill). qwen2.5:3b can't use fleet
111
+ // info — it dilutes the rubric. Machinery kept dark for a future stronger
112
+ // classifier: flip this flag, then re-run
113
+ // scripts/validate-difficulty-classifier.js on BOTH eval files and hold
114
+ // the ship bar (>=85% overall, zero MEDIUM→REASONING false positives).
115
+ const TIER_CONTEXT_ENABLED = false;
116
+ const LOCAL_PROVIDERS = new Set(['ollama', 'llamacpp', 'lmstudio']);
117
+
118
+ /** Pure builder — exported for tests. @param {Object} tiers {TIER: 'provider:model'} */
119
+ function _buildTierContext(tiers) {
120
+ const lines = [];
121
+ for (const tier of VALID_TIERS) {
122
+ const spec = tiers?.[tier];
123
+ if (typeof spec !== 'string' || !spec.includes(':')) continue;
124
+ const sep = spec.indexOf(':');
125
+ const provider = spec.slice(0, sep);
126
+ const model = spec.slice(sep + 1);
127
+ const traits = [LOCAL_PROVIDERS.has(provider) ? 'local' : 'cloud'];
128
+ try {
129
+ const { getModelRegistrySync } = require('./model-registry');
130
+ const cost = getModelRegistrySync().getCost(model);
131
+ if (cost && !cost.unknown) {
132
+ if (Number.isFinite(cost.context) && cost.context >= 1000) {
133
+ traits.push(`${Math.round(cost.context / 1000)}k ctx`);
134
+ }
135
+ if (cost.reasoning) traits.push('reasoning');
136
+ if (cost.vision) traits.push('vision');
137
+ }
138
+ } catch { /* registry unavailable — provider trait only */ }
139
+ lines.push(`- ${tier} routes to ${model} (${traits.join(', ')})`);
140
+ }
141
+ if (!lines.length) return null;
142
+ const block = `Deployment (what each tier currently routes to — use only as a tie-breaker between adjacent tiers; the tier definitions above still govern):
143
+ ${lines.join('\n')}`;
144
+ return {
145
+ block,
146
+ fingerprint: crypto.createHash('sha256').update(block).digest('hex').slice(0, 16),
147
+ };
148
+ }
149
+
150
+ /** @type {{block:string, fingerprint:string}|null|undefined} undefined = not built yet */
151
+ let _tierContext;
152
+
153
+ function _tierDeploymentContext() {
154
+ // Test override (set via _setTierContextForTests) wins over the flag so
155
+ // the machinery stays exercised while shipping dark.
156
+ if (_tierContext !== undefined) return _tierContext;
157
+ if (!TIER_CONTEXT_ENABLED) { _tierContext = null; return null; }
158
+ try {
159
+ const config = require('../config');
160
+ _tierContext = _buildTierContext(config.modelTiers);
161
+ } catch {
162
+ _tierContext = null;
163
+ }
164
+ return _tierContext;
165
+ }
166
+
167
+ /** Test helper — override or reset (pass undefined) the memoized block. */
168
+ function _setTierContextForTests(tiers) {
169
+ _tierContext = tiers === undefined ? undefined : _buildTierContext(tiers);
170
+ }
171
+
84
172
  function _buildPrompt(text, context) {
173
+ const tierCtx = _tierDeploymentContext();
174
+ const preamble = tierCtx ? `${CLASSIFY_PROMPT}
175
+ ${tierCtx.block}
176
+ ` : CLASSIFY_PROMPT;
85
177
  if (context) {
86
- return `${CLASSIFY_PROMPT}
178
+ return `${preamble}
87
179
  Conversation so far (context only — classify the CURRENT prompt, inheriting topic difficulty per the rules):
88
180
  ${context}
89
181
 
90
182
  CURRENT user prompt: """${text}"""`;
91
183
  }
92
- return `${CLASSIFY_PROMPT}
184
+ return `${preamble}
93
185
  User prompt: """${text}"""`;
94
186
  }
95
187
 
@@ -212,7 +304,12 @@ async function classifyDifficulty(text, opts = {}) {
212
304
  if (!CLASSIFIER_ENABLED) return null;
213
305
  if (typeof text !== 'string') return null;
214
306
  const trimmed = text.trim();
215
- if (trimmed.length < MIN_TEXT_LENGTH) return null;
307
+ // No minimum length (removed 2026-08-09): short prompts are exactly what
308
+ // the v2 context feature was built for, and the 15-char gate meant the
309
+ // classifier could never rescue anchor over-reads like '12+21' -> MEDIUM.
310
+ // Greetings still skip upstream via force patterns (opts.forceMatched),
311
+ // and the LRU absorbs repeats, so the added model calls are bounded.
312
+ if (!trimmed) return null;
216
313
  if (opts.forceMatched) return null;
217
314
  if (opts.riskLevel === 'high') return null;
218
315
 
@@ -222,8 +319,11 @@ async function classifyDifficulty(text, opts = {}) {
222
319
  : null;
223
320
 
224
321
  // Context participates in the cache key: the same follow-up text means
225
- // different things in different conversations.
226
- const key = _cacheKey(context ? `${trimmed}${context}` : trimmed);
322
+ // different things in different conversations. The tier-deployment
323
+ // fingerprint participates too (NUL separators are collision-proof), so
324
+ // verdicts cached under one deployment never serve a reconfigured one.
325
+ const tierFp = _tierDeploymentContext()?.fingerprint ?? "";
326
+ const key = _cacheKey(`${tierFp}${context ? `${trimmed}${context}` : trimmed}`);
227
327
  const cached = _cache.get(key);
228
328
  if (cached) return { ...cached, source: 'cache' };
229
329
 
@@ -256,6 +356,8 @@ module.exports = {
256
356
  _parseResult,
257
357
  _cacheKey,
258
358
  _buildPrompt,
359
+ _buildTierContext,
360
+ _setTierContextForTests,
259
361
  _clearCacheForTests,
260
362
  _getCacheStats,
261
363
  };