lynkr 9.7.2 → 9.9.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 (80) hide show
  1. package/README.md +29 -19
  2. package/bin/cli.js +11 -0
  3. package/bin/lynkr-init.js +14 -1
  4. package/bin/lynkr-usage.js +78 -0
  5. package/bin/wrap.js +60 -35
  6. package/config/difficulty-anchors.json +22 -0
  7. package/package.json +24 -3
  8. package/scripts/audit-log-reader.js +399 -0
  9. package/scripts/calibrate-thresholds.js +38 -157
  10. package/scripts/compact-dictionary.js +204 -0
  11. package/scripts/test-deduplication.js +448 -0
  12. package/scripts/ws7-anchor-replay.js +108 -0
  13. package/skills/lynkr/SKILL.md +195 -0
  14. package/src/agents/context-manager.js +18 -2
  15. package/src/agents/definitions/loader.js +90 -0
  16. package/src/agents/executor.js +24 -2
  17. package/src/agents/index.js +9 -1
  18. package/src/agents/parallel-coordinator.js +2 -2
  19. package/src/agents/reflector.js +11 -1
  20. package/src/api/middleware/loop-guard.js +87 -0
  21. package/src/api/middleware/request-logging.js +5 -64
  22. package/src/api/middleware/session.js +0 -0
  23. package/src/api/openai-router.js +120 -101
  24. package/src/api/providers-handler.js +27 -2
  25. package/src/api/router.js +450 -125
  26. package/src/budget/index.js +2 -19
  27. package/src/cache/semantic.js +9 -0
  28. package/src/clients/databricks.js +459 -146
  29. package/src/clients/gpt-utils.js +11 -105
  30. package/src/clients/openai-format.js +10 -3
  31. package/src/clients/openrouter-utils.js +49 -24
  32. package/src/clients/prompt-cache-injection.js +1 -0
  33. package/src/clients/provider-capabilities.js +1 -1
  34. package/src/clients/responses-format.js +34 -3
  35. package/src/clients/routing.js +15 -0
  36. package/src/config/index.js +36 -2
  37. package/src/context/gcf.js +275 -0
  38. package/src/context/tool-result-compressor.js +51 -9
  39. package/src/dashboard/api.js +1 -0
  40. package/src/logger/index.js +14 -1
  41. package/src/memory/search.js +12 -40
  42. package/src/memory/tools.js +3 -24
  43. package/src/orchestrator/bypass.js +4 -2
  44. package/src/orchestrator/index.js +144 -88
  45. package/src/routing/affinity-store.js +194 -0
  46. package/src/routing/agentic-detector.js +36 -6
  47. package/src/routing/bandit.js +25 -6
  48. package/src/routing/calibration.js +212 -0
  49. package/src/routing/client-profiles.js +292 -0
  50. package/src/routing/complexity-analyzer.js +48 -11
  51. package/src/routing/deescalator.js +148 -0
  52. package/src/routing/degradation.js +109 -0
  53. package/src/routing/feedback.js +157 -0
  54. package/src/routing/index.js +897 -87
  55. package/src/routing/intent-score.js +339 -0
  56. package/src/routing/interaction.js +3 -0
  57. package/src/routing/knn-router.js +70 -21
  58. package/src/routing/model-registry.js +28 -7
  59. package/src/routing/model-tiers.js +25 -2
  60. package/src/routing/reward-pipeline.js +68 -2
  61. package/src/routing/risk-analyzer.js +30 -1
  62. package/src/routing/risk-classifier.js +6 -2
  63. package/src/routing/session-affinity.js +162 -34
  64. package/src/routing/telemetry.js +298 -10
  65. package/src/routing/verifier.js +267 -0
  66. package/src/server.js +66 -21
  67. package/src/sessions/cleanup.js +17 -0
  68. package/src/tools/index.js +1 -15
  69. package/src/tools/smart-selection.js +10 -0
  70. package/src/tools/web-client.js +3 -3
  71. package/.eslintrc.cjs +0 -12
  72. package/benchmark-configs/litellm_config.yaml +0 -86
  73. package/benchmark-configs/lynkr.env +0 -48
  74. package/benchmark-configs/portkey-config.json +0 -60
  75. package/benchmark-configs/portkey-docker.sh +0 -23
  76. package/benchmark-tier-routing.js +0 -449
  77. package/funding.json +0 -110
  78. package/src/api/middleware/validation.js +0 -261
  79. package/src/routing/drift-monitor.js +0 -113
  80. package/src/workers/helpers.js +0 -185
@@ -0,0 +1,339 @@
1
+ /**
2
+ * WS7 — payload-invariant intent scoring via embedding anchors.
3
+ *
4
+ * The lexical scorer's noise exceeds its band width (same semantic ask
5
+ * measured 31 offline vs 56 live once the payload envelope — tool schemas,
6
+ * history, injected reminders — is attached; paraphrase-only noise is still
7
+ * ±10-12 on a 24-point band). This module replaces the ex-ante difficulty
8
+ * signal with something the evidence supports:
9
+ *
10
+ * - Score CLEANED USER TEXT ONLY. Tool schemas, conversation history,
11
+ * <system-reminder> blocks and tool_result payloads never touch the
12
+ * score. Envelope concerns (agentic detection, context guard, client
13
+ * profiles) keep their own triggers — they escalate tiers, they don't
14
+ * inflate this score.
15
+ * - Classify against embedding centroids built from ~10 REAL session
16
+ * texts (data/difficulty-anchors.json): {trivial, substantive,
17
+ * heavyweight}. The query embedding is already computed per request
18
+ * (WS5.5, nomic-embed-text via local Ollama, cache-backed), so the
19
+ * three cosine sims cost microseconds and no new I/O.
20
+ * - Blend sims → a continuous 0-100 score (softmax over class values)
21
+ * so the existing band mapping, calibration, pins and drift margin all
22
+ * keep working unchanged.
23
+ *
24
+ * Contracts (tested in test/intent-score.test.js):
25
+ * - Envelope invariance: score(text) === score(text + schemas + reminders
26
+ * + history).
27
+ * - REASONING (76+) is unreachable from text alone — trigger-only
28
+ * (risk / force phrase / agentic / kNN / tier-fallback). CLASS_VALUES
29
+ * top out at heavyweight=68 and the lexical fallback clamps at 75.
30
+ * - embed() failure never throws — falls back to a lexical score of the
31
+ * SAME cleaned text (still envelope-invariant, just noisier).
32
+ *
33
+ * Escape hatch: LYNKR_INTENT_SCORE_MODE=legacy restores the pre-WS7
34
+ * full-payload lexical score.
35
+ */
36
+
37
+ const fs = require('fs');
38
+ const path = require('path');
39
+ const crypto = require('crypto');
40
+ const logger = require('../logger');
41
+
42
+ // data/ (user-editable, gitignored) wins; config/ is the bundled default —
43
+ // data/ is excluded from the npm tarball, so installs load from config/.
44
+ const ANCHORS_PATHS = [
45
+ path.join(__dirname, '../../data/difficulty-anchors.json'),
46
+ path.join(__dirname, '../../config/difficulty-anchors.json'),
47
+ ];
48
+ const VECTORS_CACHE_PATH = path.join(__dirname, '../../data/difficulty-anchors.vectors.json');
49
+
50
+ // Class → representative score. Chosen so each class lands inside an
51
+ // existing default band (SIMPLE 0-25, MEDIUM 26-50, COMPLEX 51-75) and the
52
+ // blend can NEVER reach REASONING (76+). Calibration may later collapse
53
+ // bands into degenerate ranges without touching these.
54
+ const CLASS_VALUES = {
55
+ trivial: 10,
56
+ substantive: 45,
57
+ heavyweight: 68,
58
+ };
59
+
60
+ // Softmax temperature over cosine sims. Real inter-class sim gaps on
61
+ // nomic-embed-text run ~0.05-0.15, so 0.05 is sharp enough to commit to a
62
+ // class when the winner is clear, soft enough to interpolate borderline
63
+ // asks instead of cliffing between bands.
64
+ const BLEND_TEMPERATURE = 0.05;
65
+
66
+ // The CLASS is the decision; the blend only positions within the class's
67
+ // band — without the clamp, a close runner-up sim leaks trivial asks
68
+ // across the band edge.
69
+ //
70
+ // NOTE: the trivial band [0,25] deliberately overlaps the MEDIUM tier
71
+ // (which starts at 20 — see model-tiers.js). Trivial-classified asks whose
72
+ // blend lands in the top of the band (20-25) route MEDIUM: RouterArena
73
+ // optimality data (2026-07-16) showed cheap-model failures cluster exactly
74
+ // there (miss median 23 vs hit median 14). Clamping trivial to [0,19]
75
+ // would erase that signal — do not "fix" the mismatch.
76
+ const CLASS_BANDS = {
77
+ trivial: [0, 25],
78
+ substantive: [26, 50],
79
+ heavyweight: [51, 75],
80
+ };
81
+
82
+ function intentScoreMode() {
83
+ const m = (process.env.LYNKR_INTENT_SCORE_MODE || 'anchor').toLowerCase();
84
+ return m === 'legacy' ? 'legacy' : 'anchor';
85
+ }
86
+
87
+ /**
88
+ * Extract the text the USER actually authored this turn: the latest user
89
+ * message that has real text after stripping harness-injected content.
90
+ * Returns null when there is nothing to score (e.g. tool-result-only turn).
91
+ */
92
+ function extractCleanUserText(payload) {
93
+ const msgs = payload?.messages;
94
+ if (!Array.isArray(msgs)) return null;
95
+ for (let i = msgs.length - 1; i >= 0; i--) {
96
+ const msg = msgs[i];
97
+ if (msg?.role !== 'user') continue;
98
+ let text = null;
99
+ if (typeof msg.content === 'string') {
100
+ text = msg.content;
101
+ } else if (Array.isArray(msg.content)) {
102
+ text = msg.content
103
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
104
+ .map((b) => b.text)
105
+ .join(' ');
106
+ }
107
+ if (text == null) continue;
108
+ text = text
109
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
110
+ // Harness task/background-agent notifications — not user-authored.
111
+ .replace(/<task-notification>[\s\S]*?<\/task-notification>/g, '')
112
+ // Codex harness blocks (merged into the typed text upstream):
113
+ // sandbox/permission profile and AGENTS.md contents.
114
+ .replace(/<environment_context>[\s\S]*?<\/environment_context>/g, '')
115
+ .replace(/<user_instructions>[\s\S]*?<\/user_instructions>/g, '')
116
+ // Goose harness block wrapping every typed message (time, cwd, todo
117
+ // notes) — its "tasks"/"update"/"requirements" vocabulary scored a
118
+ // bare "Hi" as substantive/MEDIUM.
119
+ .replace(/<turn-context>[\s\S]*?<\/turn-context>/g, '')
120
+ // Lynkr's own injected notices (quota banners, badges) start with the
121
+ // [Lynkr] marker — the user didn't type those.
122
+ .replace(/^\s*\[Lynkr\][^\n]*$/gm, '')
123
+ .trim();
124
+ // Whole-message harness content: compaction/continuation summaries and
125
+ // system notifications arrive as user-role messages the user never
126
+ // typed. Treat as empty and keep walking back.
127
+ if (/^\s*(\[SYSTEM NOTIFICATION|<conversation[\s>]|<session[\s>]|\[Request interrupted|This session is being continued from a previous conversation)/i.test(text)) {
128
+ // Continuation summaries PARAPHRASE the prior session, so they carry
129
+ // force-phrase vocabulary the user typed hours ago.
130
+ text = '';
131
+ }
132
+ if (text) return text;
133
+ // A user message that was ALL injected content (or all tool_results)
134
+ // doesn't end the search — keep walking back for the real user turn.
135
+ }
136
+ return null;
137
+ }
138
+
139
+ function cosine(a, b) {
140
+ let dot = 0;
141
+ let na = 0;
142
+ let nb = 0;
143
+ for (let i = 0; i < a.length; i++) {
144
+ dot += a[i] * b[i];
145
+ na += a[i] * a[i];
146
+ nb += b[i] * b[i];
147
+ }
148
+ const denom = Math.sqrt(na) * Math.sqrt(nb);
149
+ return denom > 0 ? dot / denom : 0;
150
+ }
151
+
152
+ /**
153
+ * Embed every anchor text and mean them per class.
154
+ * @param {Object<string,string[]>} anchorsByClass
155
+ * @param {(text:string)=>Promise<number[]|null>} embedFn
156
+ * @returns {Promise<Object<string,number[]>|null>} null if any class has no vectors
157
+ */
158
+ async function buildCentroids(anchorsByClass, embedFn) {
159
+ const centroids = {};
160
+ for (const [cls, texts] of Object.entries(anchorsByClass)) {
161
+ if (cls.startsWith('_') || !Array.isArray(texts)) continue;
162
+ const vectors = [];
163
+ for (const t of texts) {
164
+ try {
165
+ const v = await embedFn(t);
166
+ if (Array.isArray(v) && v.length > 0) vectors.push(v);
167
+ } catch { /* embed never throws by contract, belt-and-braces */ }
168
+ }
169
+ if (vectors.length === 0) return null; // a class with no anchors is unusable
170
+ const dim = vectors[0].length;
171
+ const mean = new Array(dim).fill(0);
172
+ for (const v of vectors) for (let i = 0; i < dim; i++) mean[i] += v[i] / vectors.length;
173
+ centroids[cls] = mean;
174
+ }
175
+ return Object.keys(centroids).length === Object.keys(CLASS_VALUES).length ? centroids : null;
176
+ }
177
+
178
+ /**
179
+ * Cosine sims against each class centroid.
180
+ * @returns {{cls:string, sims:Object<string,number>}}
181
+ */
182
+ function classify(embedding, centroids) {
183
+ const sims = {};
184
+ let best = null;
185
+ for (const cls of Object.keys(CLASS_VALUES)) {
186
+ const c = centroids[cls];
187
+ sims[cls] = Array.isArray(c) ? cosine(embedding, c) : -1;
188
+ if (best === null || sims[cls] > sims[best]) best = cls;
189
+ }
190
+ return { cls: best, sims };
191
+ }
192
+
193
+ /**
194
+ * Softmax-blend class sims into a continuous score. Bounded by construction
195
+ * to [min(CLASS_VALUES), max(CLASS_VALUES)] — REASONING stays unreachable.
196
+ */
197
+ function blendScore(sims) {
198
+ const classes = Object.keys(CLASS_VALUES);
199
+ const max = Math.max(...classes.map((c) => sims[c] ?? -1));
200
+ let totalW = 0;
201
+ let total = 0;
202
+ for (const cls of classes) {
203
+ const w = Math.exp(((sims[cls] ?? -1) - max) / BLEND_TEMPERATURE);
204
+ totalW += w;
205
+ total += w * CLASS_VALUES[cls];
206
+ }
207
+ const score = totalW > 0 ? total / totalW : CLASS_VALUES.substantive;
208
+ return Math.round(Math.max(0, Math.min(75, score)));
209
+ }
210
+
211
+ // --- default centroids (lazy singleton, disk-cached) ------------------------
212
+
213
+ let _centroidsPromise = null;
214
+
215
+ function _anchorsHash(anchors, model) {
216
+ return crypto.createHash('sha256')
217
+ .update(JSON.stringify(anchors) + '|' + model)
218
+ .digest('hex');
219
+ }
220
+
221
+ async function _loadDefaultCentroids() {
222
+ let anchors = null;
223
+ for (const p of ANCHORS_PATHS) {
224
+ try {
225
+ anchors = JSON.parse(fs.readFileSync(p, 'utf8'));
226
+ break;
227
+ } catch { /* try next path */ }
228
+ }
229
+ if (!anchors) {
230
+ logger.warn('[IntentScore] No difficulty-anchors.json — anchor mode unavailable');
231
+ return null;
232
+ }
233
+ const config = require('../config');
234
+ const model = config.ollama?.embeddingsModel || 'unknown';
235
+ const hash = _anchorsHash(anchors, model);
236
+
237
+ // Disk cache: survives Ollama being down at boot.
238
+ try {
239
+ const cached = JSON.parse(fs.readFileSync(VECTORS_CACHE_PATH, 'utf8'));
240
+ if (cached?.hash === hash && cached.centroids) {
241
+ logger.debug('[IntentScore] Anchor centroids loaded from disk cache');
242
+ return cached.centroids;
243
+ }
244
+ } catch { /* no cache yet */ }
245
+
246
+ const { getKnnRouter } = require('./knn-router');
247
+ const router = getKnnRouter();
248
+ const centroids = await buildCentroids(anchors, (t) => router.embed(t));
249
+ if (!centroids) {
250
+ logger.warn('[IntentScore] Anchor embedding failed (Ollama down?) — lexical fallback until next attempt');
251
+ return null;
252
+ }
253
+ try {
254
+ fs.writeFileSync(VECTORS_CACHE_PATH, JSON.stringify({ hash, model, centroids }));
255
+ } catch (err) {
256
+ logger.debug({ err: err.message }, '[IntentScore] Could not persist centroid cache');
257
+ }
258
+ logger.info({ classes: Object.keys(centroids) }, '[IntentScore] Anchor centroids built');
259
+ return centroids;
260
+ }
261
+
262
+ function getDefaultCentroids() {
263
+ if (!_centroidsPromise) {
264
+ _centroidsPromise = _loadDefaultCentroids().catch((err) => {
265
+ logger.warn({ err: err.message }, '[IntentScore] Centroid load failed');
266
+ return null;
267
+ });
268
+ // A failed load should retry on the next request, not stick forever.
269
+ _centroidsPromise.then((c) => { if (!c) _centroidsPromise = null; });
270
+ }
271
+ return _centroidsPromise;
272
+ }
273
+
274
+ /**
275
+ * Lexical fallback: the OLD scorer's content dimensions, but fed ONLY the
276
+ * cleaned user text (fresh single-message payload — no tools, no history),
277
+ * so it stays envelope-invariant. Clamped below the REASONING band.
278
+ */
279
+ function _lexicalCleanScore(text) {
280
+ const { calculateWeightedScore } = require('./complexity-analyzer');
281
+ const minimal = { messages: [{ role: 'user', content: text }] };
282
+ const { score } = calculateWeightedScore(minimal, text);
283
+ return Math.max(0, Math.min(75, Math.round(score)));
284
+ }
285
+
286
+ /**
287
+ * Score the user's intent for this turn.
288
+ *
289
+ * @param {object} payload — full request payload (only cleaned user text is used)
290
+ * @param {object} [opts]
291
+ * @param {(text:string)=>Promise<number[]|null>} [opts.embedFn] — injected for tests
292
+ * @param {Object<string,number[]>} [opts.centroids] — injected for tests
293
+ * @param {string} [opts.mode] — 'anchor' | 'legacy' (default: env LYNKR_INTENT_SCORE_MODE)
294
+ * @returns {Promise<{score:number, mode:'anchor'|'lexical', class?:string, sims?:object, text:string}|null>}
295
+ * null → caller keeps its legacy score (legacy mode, or nothing to score)
296
+ */
297
+ async function scoreIntent(payload, opts = {}) {
298
+ const mode = opts.mode ?? intentScoreMode();
299
+ if (mode === 'legacy') return null;
300
+
301
+ const text = extractCleanUserText(payload);
302
+ if (!text) return null;
303
+
304
+ try {
305
+ const centroids = opts.centroids !== undefined ? opts.centroids : await getDefaultCentroids();
306
+ if (centroids) {
307
+ let embedFn = opts.embedFn;
308
+ if (!embedFn) {
309
+ const { getKnnRouter } = require('./knn-router');
310
+ const router = getKnnRouter();
311
+ embedFn = (t) => router.embed(t);
312
+ }
313
+ const embedding = await embedFn(text);
314
+ if (Array.isArray(embedding) && embedding.length > 0) {
315
+ const { cls, sims } = classify(embedding, centroids);
316
+ const [lo, hi] = CLASS_BANDS[cls];
317
+ const score = Math.max(lo, Math.min(hi, blendScore(sims)));
318
+ return { score, mode: 'anchor', class: cls, sims, text };
319
+ }
320
+ }
321
+ } catch (err) {
322
+ logger.debug({ err: err.message }, '[IntentScore] anchor scoring failed — lexical fallback');
323
+ }
324
+
325
+ return { score: _lexicalCleanScore(text), mode: 'lexical', text };
326
+ }
327
+
328
+ module.exports = {
329
+ CLASS_VALUES,
330
+ intentScoreMode,
331
+ extractCleanUserText,
332
+ cosine,
333
+ buildCentroids,
334
+ classify,
335
+ blendScore,
336
+ scoreIntent,
337
+ // exposed for the replay script
338
+ getDefaultCentroids,
339
+ };
@@ -137,6 +137,9 @@ function buildInteractionBlock(decision) {
137
137
  ...(decision.risk?.pathHits || []),
138
138
  ])),
139
139
  complexity_score: typeof decision.score === 'number' ? decision.score : null,
140
+ // Pin-serve turns: the score that originally created the session pin.
141
+ // Lets the badge show both this turn's fresh score and the pin's.
142
+ pin_score: typeof decision._pinScore === 'number' ? decision._pinScore : null,
140
143
  estimated_savings_percent: estimateSavingsPercent(decision.tier, decision.provider),
141
144
  next_step,
142
145
  };
@@ -29,10 +29,16 @@ const META_FILE = path.join(INDEX_DIR, 'meta.json');
29
29
  const MAX_ELEMENTS = 50000;
30
30
  const DIM = 768; // nomic-embed-text default
31
31
  const K = 10;
32
- // Default 1000 is a safety floor for quality; override via env when you
33
- // want to activate kNN with less data (e.g. bootstrapping from your own
34
- // telemetry before reaching 1k entries).
35
- const MIN_INDEX_SIZE = Number.parseInt(process.env.LYNKR_KNN_MIN_INDEX_SIZE, 10) || 1000;
32
+ // WS5.2 lowered from 1000 100 so kNN advises earlier. The `query()`
33
+ // path dampens confidence by `min(1, size/1000)` when the index is small,
34
+ // so the HIGH/LOW thresholds in `src/routing/index.js` still gate strong
35
+ // advice properly. Override with LYNKR_KNN_MIN_INDEX_SIZE.
36
+ const MIN_INDEX_SIZE = Number.parseInt(process.env.LYNKR_KNN_MIN_INDEX_SIZE, 10) || 100;
37
+ // Cold-start confidence damping — under this many entries, confidence is
38
+ // linearly damped; at DAMP_FULL_SIZE and above, damping is a no-op.
39
+ const DAMP_FULL_SIZE = 1000;
40
+ // Persist the index every N `add()` calls so online growth survives crashes.
41
+ const SAVE_EVERY_N_ADDS = 50;
36
42
 
37
43
  let _hnsw = null;
38
44
  let _hnswLoaded = false;
@@ -110,6 +116,35 @@ class KnnRouter {
110
116
  this.index.addPoint(embedding, this.size);
111
117
  this.meta.push(outcome);
112
118
  this.size++;
119
+ // WS5.2 — persist online growth incrementally so a crash doesn't lose
120
+ // the learning done since the last full-index rebuild.
121
+ if (this.size % SAVE_EVERY_N_ADDS === 0) this.save();
122
+ }
123
+
124
+ /**
125
+ * WS5.2 — expose the embedder to callers so the routing decision can
126
+ * capture the query's embedding at decision time and attach it to the
127
+ * decision object for the feedback path to consume without paying for a
128
+ * second embedding call. Returns null when the embedder is unavailable
129
+ * or the returned vector doesn't match the index dimension.
130
+ *
131
+ * @param {string} text
132
+ * @returns {Promise<number[]|null>}
133
+ */
134
+ async embed(text) {
135
+ if (!text || typeof text !== 'string') return null;
136
+ const cache = getEmbeddingCache();
137
+ const cached = cache.get(text);
138
+ if (cached) return cached;
139
+ try {
140
+ const embedding = await generateEmbedding(text);
141
+ if (!embedding || embedding.length !== this.dim) return null;
142
+ cache.set(text, embedding);
143
+ return embedding;
144
+ } catch (err) {
145
+ logger.debug({ err: err.message }, '[KnnRouter] embed() failed');
146
+ return null;
147
+ }
113
148
  }
114
149
 
115
150
  async query(text) {
@@ -117,21 +152,8 @@ class KnnRouter {
117
152
  if (!this.ready || !this.index || this.size < MIN_INDEX_SIZE) return null;
118
153
  if (!text || typeof text !== 'string') return null;
119
154
 
120
- const cache = getEmbeddingCache();
121
- let embedding = cache.get(text);
122
- if (!embedding) {
123
- try {
124
- embedding = await generateEmbedding(text);
125
- if (!embedding || embedding.length !== this.dim) {
126
- // Skip if dim mismatch (embedder produced different dimensions)
127
- return null;
128
- }
129
- cache.set(text, embedding);
130
- } catch (err) {
131
- logger.debug({ err: err.message }, '[KnnRouter] Embedding failed, skipping');
132
- return null;
133
- }
134
- }
155
+ const embedding = await this.embed(text);
156
+ if (!embedding) return null;
135
157
 
136
158
  let result;
137
159
  try {
@@ -166,6 +188,13 @@ class KnnRouter {
166
188
  agg.count++;
167
189
  }
168
190
 
191
+ // WS5.2 — cold-start damping. Below DAMP_FULL_SIZE, multiply confidence
192
+ // by size/DAMP_FULL_SIZE so a small index advises weakly. The upstream
193
+ // HIGH/LOW thresholds in index.js then naturally treat sparse advice as
194
+ // ambiguous rather than trusting it. At/above DAMP_FULL_SIZE the factor
195
+ // is 1 (no damping).
196
+ const dampFactor = Math.min(1, this.size / DAMP_FULL_SIZE);
197
+
169
198
  let best = null;
170
199
  let bestScore = -Infinity;
171
200
  for (const [model, agg] of byModel) {
@@ -182,7 +211,7 @@ class KnnRouter {
182
211
  expectedQuality: avgQ,
183
212
  expectedCost: avgC,
184
213
  expectedLatency: agg.latency / agg.weight,
185
- confidence: Math.min(1, agg.weight / K),
214
+ confidence: Math.min(1, agg.weight / K) * dampFactor,
186
215
  neighborCount: agg.count,
187
216
  };
188
217
  }
@@ -202,12 +231,32 @@ class KnnRouter {
202
231
  }
203
232
 
204
233
  let _instance = null;
234
+ let _beforeExitBound = false;
205
235
  function getKnnRouter() {
206
236
  if (!_instance) {
207
237
  _instance = new KnnRouter();
208
238
  _instance.load();
239
+ // WS5.2 — best-effort persistence on graceful exit so any online
240
+ // learning done since the last incremental save isn't lost. Bound once
241
+ // per process; the save() call itself no-ops when the index isn't
242
+ // ready, so this is safe even in test environments that never populate
243
+ // the router.
244
+ if (!_beforeExitBound) {
245
+ _beforeExitBound = true;
246
+ try {
247
+ process.on('beforeExit', () => {
248
+ try { _instance && _instance.save(); } catch (_) { /* best-effort */ }
249
+ });
250
+ } catch (_) { /* not a node process (e.g. worker) */ }
251
+ }
209
252
  }
210
253
  return _instance;
211
254
  }
212
255
 
213
- module.exports = { KnnRouter, getKnnRouter };
256
+ module.exports = {
257
+ KnnRouter,
258
+ getKnnRouter,
259
+ MIN_INDEX_SIZE,
260
+ DAMP_FULL_SIZE,
261
+ SAVE_EVERY_N_ADDS,
262
+ };
@@ -15,6 +15,7 @@ const MODELS_DEV_URL = 'https://models.dev/api.json';
15
15
  // Cache settings
16
16
  const CACHE_FILE = path.join(__dirname, '../../data/model-prices-cache.json');
17
17
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
18
+ const REFRESH_RETRY_MS = 5 * 60 * 1000; // backoff between failed refresh attempts
18
19
 
19
20
  // Databricks fallback pricing (based on Anthropic direct API prices)
20
21
  const DATABRICKS_FALLBACK = {
@@ -103,17 +104,17 @@ class ModelRegistry {
103
104
  * Initialize registry - load from cache or fetch fresh data
104
105
  */
105
106
  async initialize() {
106
- if (this.loaded) return;
107
+ if (this.loaded) {
108
+ // The sync accessor marks the instance loaded without a staleness
109
+ // check, so the refresh must not hide behind this return.
110
+ this._refreshIfStale();
111
+ return;
112
+ }
107
113
 
108
114
  // Try cache first
109
115
  if (this._loadFromCache()) {
110
116
  this.loaded = true;
111
- // Background refresh if stale
112
- if (Date.now() - this.lastFetch > CACHE_TTL_MS) {
113
- this._fetchAll().catch(err =>
114
- logger.warn({ err: err.message }, '[ModelRegistry] Background refresh failed')
115
- );
116
- }
117
+ this._refreshIfStale();
117
118
  return;
118
119
  }
119
120
 
@@ -122,6 +123,25 @@ class ModelRegistry {
122
123
  this.loaded = true;
123
124
  }
124
125
 
126
+ /**
127
+ * Fire-and-forget background refresh when the cache is past its TTL.
128
+ * Safe to call from sync paths; concurrent calls coalesce onto one fetch.
129
+ */
130
+ _refreshIfStale() {
131
+ if (Date.now() - this.lastFetch <= CACHE_TTL_MS) return;
132
+ if (this._refreshInFlight) return;
133
+ // Failed attempts don't advance lastFetch — back off so a dead network
134
+ // doesn't turn every routed request into a fetch.
135
+ if (this._lastRefreshAttempt && Date.now() - this._lastRefreshAttempt < REFRESH_RETRY_MS) return;
136
+ this._lastRefreshAttempt = Date.now();
137
+ this._refreshInFlight = true;
138
+ this._fetchAll()
139
+ .catch(err =>
140
+ logger.warn({ err: err.message }, '[ModelRegistry] Background refresh failed')
141
+ )
142
+ .finally(() => { this._refreshInFlight = false; });
143
+ }
144
+
125
145
  /**
126
146
  * Fetch from both sources
127
147
  */
@@ -488,6 +508,7 @@ function getModelRegistrySync() {
488
508
  instance._buildIndex();
489
509
  instance.loaded = true;
490
510
  }
511
+ instance._refreshIfStale();
491
512
  return instance;
492
513
  }
493
514
 
@@ -19,12 +19,19 @@ const CALIBRATED_PATH = path.join(__dirname, '../../data/calibrated-thresholds.j
19
19
  const TIER_DEFINITIONS = {
20
20
  SIMPLE: {
21
21
  description: 'Greetings, simple Q&A, confirmations',
22
- range: [0, 25],
22
+ // Boundary at 20, not 25: the top of the trivial intent band (20-25) is
23
+ // where cheap-model failures cluster. Diagnosed 2026-07-16 from
24
+ // RouterArena optimality data — SIMPLE-routed queries the cheap model
25
+ // got wrong (but a bigger pool model got right) re-scored at median 23,
26
+ // while correctly-served SIMPLE traffic scored median 14. At 20 the
27
+ // split recovers ~70% of those misses while escalating ~12% of
28
+ // correct-cheap traffic (usually to a tier that is also local/free).
29
+ range: [0, 19],
23
30
  priority: 1,
24
31
  },
25
32
  MEDIUM: {
26
33
  description: 'Code reading, simple edits, research',
27
- range: [26, 50],
34
+ range: [20, 50],
28
35
  priority: 2,
29
36
  },
30
37
  COMPLEX: {
@@ -330,6 +337,8 @@ class ModelTierSelector {
330
337
  return config.ollama?.model || null;
331
338
  case 'openrouter':
332
339
  return config.openrouter?.model || null;
340
+ case 'edenai':
341
+ return config.edenai?.model || null;
333
342
  case 'llamacpp':
334
343
  return config.llamacpp?.model || null;
335
344
  case 'lmstudio':
@@ -487,8 +496,22 @@ function getModelTierSelector() {
487
496
  return instance;
488
497
  }
489
498
 
499
+ /**
500
+ * WS5.6 — re-read `data/calibrated-thresholds.json` into the singleton
501
+ * selector so an in-process calibration run takes effect immediately
502
+ * (no server restart required). Idempotent: safe to call repeatedly.
503
+ * When the file is missing or malformed, ranges fall back to defaults —
504
+ * same behaviour as the initial construction path.
505
+ */
506
+ function reloadCalibratedThresholds() {
507
+ const selector = getModelTierSelector();
508
+ selector._loadCalibrated();
509
+ return selector.ranges;
510
+ }
511
+
490
512
  module.exports = {
491
513
  ModelTierSelector,
492
514
  getModelTierSelector,
515
+ reloadCalibratedThresholds,
493
516
  TIER_DEFINITIONS,
494
517
  };