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.
- package/README.md +29 -19
- package/bin/cli.js +11 -0
- package/bin/lynkr-init.js +14 -1
- package/bin/lynkr-usage.js +78 -0
- package/bin/wrap.js +60 -35
- package/config/difficulty-anchors.json +22 -0
- package/package.json +24 -3
- package/scripts/audit-log-reader.js +399 -0
- package/scripts/calibrate-thresholds.js +38 -157
- package/scripts/compact-dictionary.js +204 -0
- package/scripts/test-deduplication.js +448 -0
- package/scripts/ws7-anchor-replay.js +108 -0
- package/skills/lynkr/SKILL.md +195 -0
- package/src/agents/context-manager.js +18 -2
- package/src/agents/definitions/loader.js +90 -0
- package/src/agents/executor.js +24 -2
- package/src/agents/index.js +9 -1
- package/src/agents/parallel-coordinator.js +2 -2
- package/src/agents/reflector.js +11 -1
- package/src/api/middleware/loop-guard.js +87 -0
- package/src/api/middleware/request-logging.js +5 -64
- package/src/api/middleware/session.js +0 -0
- package/src/api/openai-router.js +120 -101
- package/src/api/providers-handler.js +27 -2
- package/src/api/router.js +450 -125
- package/src/budget/index.js +2 -19
- package/src/cache/semantic.js +9 -0
- package/src/clients/databricks.js +459 -146
- package/src/clients/gpt-utils.js +11 -105
- package/src/clients/openai-format.js +10 -3
- package/src/clients/openrouter-utils.js +49 -24
- package/src/clients/prompt-cache-injection.js +1 -0
- package/src/clients/provider-capabilities.js +1 -1
- package/src/clients/responses-format.js +34 -3
- package/src/clients/routing.js +15 -0
- package/src/config/index.js +36 -2
- package/src/context/gcf.js +275 -0
- package/src/context/tool-result-compressor.js +51 -9
- package/src/dashboard/api.js +1 -0
- package/src/logger/index.js +14 -1
- package/src/memory/search.js +12 -40
- package/src/memory/tools.js +3 -24
- package/src/orchestrator/bypass.js +4 -2
- package/src/orchestrator/index.js +144 -88
- package/src/routing/affinity-store.js +194 -0
- package/src/routing/agentic-detector.js +36 -6
- package/src/routing/bandit.js +25 -6
- package/src/routing/calibration.js +212 -0
- package/src/routing/client-profiles.js +292 -0
- package/src/routing/complexity-analyzer.js +48 -11
- package/src/routing/deescalator.js +148 -0
- package/src/routing/degradation.js +109 -0
- package/src/routing/feedback.js +157 -0
- package/src/routing/index.js +897 -87
- package/src/routing/intent-score.js +339 -0
- package/src/routing/interaction.js +3 -0
- package/src/routing/knn-router.js +70 -21
- package/src/routing/model-registry.js +28 -7
- package/src/routing/model-tiers.js +25 -2
- package/src/routing/reward-pipeline.js +68 -2
- package/src/routing/risk-analyzer.js +30 -1
- package/src/routing/risk-classifier.js +6 -2
- package/src/routing/session-affinity.js +162 -34
- package/src/routing/telemetry.js +298 -10
- package/src/routing/verifier.js +267 -0
- package/src/server.js +66 -21
- package/src/sessions/cleanup.js +17 -0
- package/src/tools/index.js +1 -15
- package/src/tools/smart-selection.js +10 -0
- package/src/tools/web-client.js +3 -3
- package/.eslintrc.cjs +0 -12
- package/benchmark-configs/litellm_config.yaml +0 -86
- package/benchmark-configs/lynkr.env +0 -48
- package/benchmark-configs/portkey-config.json +0 -60
- package/benchmark-configs/portkey-docker.sh +0 -23
- package/benchmark-tier-routing.js +0 -449
- package/funding.json +0 -110
- package/src/api/middleware/validation.js +0 -261
- package/src/routing/drift-monitor.js +0 -113
- 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
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
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
|
|
121
|
-
|
|
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 = {
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
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
|
};
|