lynkr 9.14.1 → 9.14.3

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 (96) hide show
  1. package/README.md +5 -1
  2. package/bin/cli.js +14 -6
  3. package/bin/lynkr-desktop-token.js +108 -0
  4. package/bin/lynkr-init.js +13 -1
  5. package/bin/lynkr-restart.js +125 -0
  6. package/bin/opencode-setup.js +175 -0
  7. package/bin/run.js +92 -0
  8. package/config/model-capabilities.json +20 -0
  9. package/config/model-capability-seeds.json +75 -0
  10. package/config/model-tiers.json +13 -1
  11. package/package.json +16 -22
  12. package/scripts/claude-desktop.js +212 -0
  13. package/scripts/ope-report.js +78 -0
  14. package/scripts/seed-capabilities.js +230 -0
  15. package/src/api/claude-desktop-gateway.js +123 -0
  16. package/src/api/health.js +0 -1
  17. package/src/api/mcp-broker.js +149 -0
  18. package/src/api/middleware/budget-enforcer.js +77 -7
  19. package/src/api/middleware/budget.js +36 -2
  20. package/src/api/middleware/session.js +0 -0
  21. package/src/api/openai-router.js +210 -15
  22. package/src/api/providers-handler.js +16 -1
  23. package/src/api/router.js +73 -106
  24. package/src/auth-mode.js +40 -4
  25. package/src/budget/index.js +86 -0
  26. package/src/cache/embeddings.js +125 -35
  27. package/src/cache/onnx-embedder.js +92 -0
  28. package/src/cache/prompt.js +0 -7
  29. package/src/clients/bedrock-utils.js +2 -1
  30. package/src/clients/codex-process.js +1 -1
  31. package/src/clients/codex-utils.js +0 -2
  32. package/src/clients/databricks.js +337 -46
  33. package/src/clients/gpt-utils.js +84 -8
  34. package/src/clients/health-probe.js +157 -0
  35. package/src/clients/openai-format.js +73 -23
  36. package/src/clients/provider-capabilities.js +27 -2
  37. package/src/clients/resilience.js +0 -1
  38. package/src/clients/responses-format.js +0 -56
  39. package/src/clients/routing.js +1 -1
  40. package/src/config/index.js +13 -2
  41. package/src/context/budget.js +1 -1
  42. package/src/context/compression.js +127 -19
  43. package/src/context/tool-result-compressor.js +3 -5
  44. package/src/dashboard/api.js +1 -1
  45. package/src/logger/oversized-error-stream.js +1 -1
  46. package/src/memory/extractor.js +3 -3
  47. package/src/memory/search.js +2 -2
  48. package/src/memory/store.js +9 -24
  49. package/src/memory/tools.js +14 -0
  50. package/src/observability/metrics.js +52 -3
  51. package/src/observability/otel.js +180 -0
  52. package/src/orchestrator/azure-responses-sse.js +223 -0
  53. package/src/orchestrator/index.js +216 -212
  54. package/src/orchestrator/passthrough-stream.js +7 -1
  55. package/src/orchestrator/sse-transformer.js +130 -13
  56. package/src/policy/safe-commands.js +0 -1
  57. package/src/routing/bandit.js +39 -0
  58. package/src/routing/calibration.js +21 -1
  59. package/src/routing/capabilities.js +88 -0
  60. package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
  61. package/src/routing/capability-seeds/family-heuristics.js +77 -0
  62. package/src/routing/capability-seeds/family.js +105 -0
  63. package/src/routing/capability-seeds/models-dev.js +64 -0
  64. package/src/routing/capability-seeds/normalize.js +160 -0
  65. package/src/routing/capability-seeds/registry.js +125 -0
  66. package/src/routing/capability-seeds/sources.js +69 -0
  67. package/src/routing/capability-seeds/swebench.js +66 -0
  68. package/src/routing/client-profiles.js +26 -0
  69. package/src/routing/complexity-analyzer.js +5 -8
  70. package/src/routing/confidence-scorer.js +2 -2
  71. package/src/routing/cost-optimizer.js +1 -2
  72. package/src/routing/decide.js +149 -0
  73. package/src/routing/deescalator.js +38 -1
  74. package/src/routing/embedding-cache.js +0 -1
  75. package/src/routing/index.js +202 -86
  76. package/src/routing/intent-score.js +0 -1
  77. package/src/routing/knn-router.js +27 -5
  78. package/src/routing/latency-tracker.js +1 -1
  79. package/src/routing/model-registry.js +25 -0
  80. package/src/routing/model-slots.js +65 -0
  81. package/src/routing/model-tiers.js +2 -0
  82. package/src/routing/ope.js +255 -0
  83. package/src/routing/openai-model-slots.js +82 -0
  84. package/src/routing/regret-estimator.js +1 -1
  85. package/src/routing/shortfall.js +290 -0
  86. package/src/routing/stuck-detector.js +127 -0
  87. package/src/routing/telemetry.js +11 -2
  88. package/src/server.js +41 -3
  89. package/src/stores/file-store.js +5 -1
  90. package/src/tools/web-search-exec.js +182 -0
  91. package/src/training/trajectory-compressor.js +1 -1
  92. package/src/workers/pool.js +1 -1
  93. package/src/workers/worker.js +1 -1
  94. package/scripts/convert-routellm.py +0 -105
  95. package/src/diff/comments.js +0 -153
  96. package/src/policy/web-fallback.js +0 -33
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Shortfall matching (item 1: HyDRA port, phase 1).
3
+ *
4
+ * Selects the cheapest candidate whose capabilities cover the predicted
5
+ * requirement vector within tolerance τ:
6
+ *
7
+ * shortfall(m) = Σ w_k · max(0, req_k − cap_mk)
8
+ *
9
+ * All four heads share the [0,1] bandwidth, so v1 weights are uniform
10
+ * (band compensation is a no-op until heads diverge — noted for phase 2).
11
+ *
12
+ * Contracts:
13
+ * - Catalog-decoupled: candidates arrive as [{provider, model, tier}];
14
+ * capabilities resolve per MODEL FAMILY (provider never matters:
15
+ * zai/baidu/ollama servings of glm-5.2 share caps) via operator
16
+ * modelOverrides → seed snapshot/shipped → family heuristic → tier
17
+ * profile. Adding, removing, or repricing a model re-routes via config
18
+ * with zero retraining.
19
+ * - Self-contained config: enabled/tau/weights also live in
20
+ * config/model-capabilities.json — no env vars. The file is read once at
21
+ * boot (same lifecycle as config/model-tiers.json); restart to pick up
22
+ * edits.
23
+ * - Cost comes from the caller (model-registry) or per-1k blended estimate;
24
+ * unknown cost sorts last (conservative — never wins on cheapness alone).
25
+ * - No covering candidate (all shortfalls > τ) → minimal shortfall wins,
26
+ * tiebreak higher tier (correctness over cost, matches tier-fallback.js
27
+ * escalate-then-demote bias). Never returns null on valid input; returns
28
+ * null only on malformed input so callers fail open to legacy routing.
29
+ * - Pure except for config load (cached). Never throws.
30
+ */
31
+
32
+ const fs = require('fs');
33
+ const path = require('path');
34
+ const logger = require('../logger');
35
+ const { HEADS } = require('./capabilities');
36
+
37
+ const PROFILES_PATH = path.join(__dirname, '../../config/model-capabilities.json');
38
+
39
+ const DEFAULT_TAU = 0.24; // HyDRA iso-quality operating point
40
+ const TIER_PRIORITY = { SIMPLE: 1, MEDIUM: 2, COMPLEX: 3, REASONING: 4 };
41
+
42
+ let _profilesCache = null;
43
+
44
+ function _defaultWeights() {
45
+ const w = {};
46
+ for (const h of HEADS) w[h] = 1 / HEADS.length;
47
+ return w;
48
+ }
49
+
50
+ function _sanitizeCaps(raw) {
51
+ const caps = {};
52
+ for (const h of HEADS) {
53
+ const v = Number(raw?.[h]);
54
+ caps[h] = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0.5;
55
+ }
56
+ return caps;
57
+ }
58
+
59
+ function loadProfiles() {
60
+ if (_profilesCache) return _profilesCache;
61
+ try {
62
+ const raw = JSON.parse(fs.readFileSync(PROFILES_PATH, 'utf8'));
63
+ const tierProfiles = {};
64
+ const fileTiers = raw?.tierProfiles && typeof raw.tierProfiles === 'object'
65
+ ? raw.tierProfiles
66
+ : {};
67
+ for (const [tier, caps] of Object.entries(fileTiers)) {
68
+ if (caps && typeof caps === 'object') tierProfiles[tier] = _sanitizeCaps(caps);
69
+ }
70
+ const modelOverrides = {};
71
+ const fileOverrides = raw?.modelOverrides && typeof raw.modelOverrides === 'object'
72
+ ? raw.modelOverrides
73
+ : {};
74
+ for (const [key, caps] of Object.entries(fileOverrides)) {
75
+ if (caps && typeof caps === 'object') modelOverrides[String(key).toLowerCase()] = _sanitizeCaps(caps);
76
+ }
77
+ const tau = Number(raw?.tau);
78
+ const weights = _normalizeWeights(raw?.weights);
79
+ _profilesCache = {
80
+ tierProfiles,
81
+ modelOverrides,
82
+ enabled: raw?.enabled === true,
83
+ tau: Number.isFinite(tau) && tau >= 0 ? tau : DEFAULT_TAU,
84
+ weights,
85
+ };
86
+ } catch (err) {
87
+ logger.debug({ err: err.message }, '[Shortfall] profiles load failed — disabled with tier fallbacks');
88
+ _profilesCache = { tierProfiles: {}, modelOverrides: {}, enabled: false, tau: DEFAULT_TAU, weights: _defaultWeights() };
89
+ }
90
+ return _profilesCache;
91
+ }
92
+
93
+ // Exposed for tests (cache reset / profile injection).
94
+ function _resetProfilesCache() {
95
+ _profilesCache = null;
96
+ }
97
+
98
+ function _setProfilesForTests(profiles) {
99
+ const base = loadProfiles();
100
+ _profilesCache = {
101
+ tierProfiles: profiles?.tierProfiles ?? base.tierProfiles,
102
+ modelOverrides: profiles?.modelOverrides ?? base.modelOverrides,
103
+ enabled: profiles?.enabled ?? base.enabled,
104
+ tau: profiles?.tau ?? base.tau,
105
+ weights: profiles?.weights ?? base.weights,
106
+ };
107
+ return _profilesCache;
108
+ }
109
+
110
+ function _normalizeWeights(raw) {
111
+ const w = _defaultWeights();
112
+ if (!raw || typeof raw !== 'object') return w;
113
+ let touched = false;
114
+ for (const h of HEADS) {
115
+ const v = Number(raw[h]);
116
+ if (Number.isFinite(v) && v >= 0) {
117
+ w[h] = v;
118
+ touched = true;
119
+ }
120
+ }
121
+ if (!touched) return _defaultWeights();
122
+ const sum = Object.values(w).reduce((a, b) => a + b, 0);
123
+ if (sum <= 0) return _defaultWeights();
124
+ for (const h of HEADS) w[h] = w[h] / sum;
125
+ return w;
126
+ }
127
+
128
+ function getTau() {
129
+ return loadProfiles().tau;
130
+ }
131
+
132
+ function getWeights() {
133
+ return { ...loadProfiles().weights };
134
+ }
135
+
136
+ function isEnabled() {
137
+ return loadProfiles().enabled === true;
138
+ }
139
+
140
+ /**
141
+ * Resolve capabilities for one candidate. Precedence (first hit wins):
142
+ * 1. operator "provider:model" / "provider:*" override (manual, supreme,
143
+ * returned verbatim — not even the quant haircut applies)
144
+ * 2. seed snapshot / shipped seeds by normalized MODEL FAMILY
145
+ * (zai/baidu/ollama servings of glm-5.2 share one entry)
146
+ * 3. family-ladder heuristic (zero-network estimate, never frontier)
147
+ * 4. tier profile → tier-priority fallback (SIMPLE 0.2 … REASONING 0.9)
148
+ *
149
+ * Quantized self-hosted servings (Q4_K_M, gguf, …) take a small haircut at
150
+ * every level except 1: same brain, smaller body.
151
+ *
152
+ * @returns {{ caps, source }} — source is override|seed:snapshot|
153
+ * seed:shipped|family|tier|tier-fallback (telemetry provenance).
154
+ */
155
+ function resolveCapabilitiesWithSource({ provider, model, tier }) {
156
+ const { tierProfiles, modelOverrides } = loadProfiles();
157
+ const key = `${String(provider || '').toLowerCase()}:${String(model || '').toLowerCase()}`;
158
+ const wild = `${String(provider || '').toLowerCase()}:*`;
159
+ const pick = modelOverrides[key] || modelOverrides[wild];
160
+ if (pick) return { caps: { ...pick }, source: 'override' };
161
+
162
+ let family = 'unknown';
163
+ let quant = false;
164
+ try {
165
+ const fam = require('./capability-seeds/family');
166
+ ({ family, quant } = fam.normalizeFamily(provider, model));
167
+ } catch { /* family helpers unavailable — tier fallback below */ }
168
+
169
+ try {
170
+ const { resolveSeedCaps } = require('./capability-seeds/registry');
171
+ const seed = resolveSeedCaps(family);
172
+ if (seed) {
173
+ const caps = quant
174
+ ? require('./capability-seeds/family').applyQuantHaircut(seed.caps)
175
+ : { ...seed.caps };
176
+ return { caps, source: seed.source };
177
+ }
178
+ } catch { /* seed layers unavailable — keep falling through */ }
179
+
180
+ try {
181
+ const { heuristicCaps } = require('./capability-seeds/family-heuristics');
182
+ const heur = heuristicCaps(family);
183
+ if (heur) {
184
+ const caps = quant
185
+ ? require('./capability-seeds/family').applyQuantHaircut(heur)
186
+ : heur;
187
+ return { caps, source: 'family' };
188
+ }
189
+ } catch { /* heuristic unavailable — tier fallback below */ }
190
+
191
+ const tp = tierProfiles[tier];
192
+ if (tp) {
193
+ const caps = quant
194
+ ? require('./capability-seeds/family').applyQuantHaircut(tp)
195
+ : { ...tp };
196
+ return { caps, source: 'tier' };
197
+ }
198
+ const p = (TIER_PRIORITY[tier] || 1) / 4;
199
+ const caps = {};
200
+ for (const h of HEADS) caps[h] = Math.round(p * 1000) / 1000;
201
+ return { caps, source: 'tier-fallback' };
202
+ }
203
+
204
+ function resolveCapabilities(candidate) {
205
+ return resolveCapabilitiesWithSource(candidate).caps;
206
+ }
207
+
208
+ function shortfall(req, caps, weights = null) {
209
+ const w = weights || _defaultWeights();
210
+ let s = 0;
211
+ for (const h of HEADS) {
212
+ const r = Math.max(0, Math.min(1, Number(req?.[h]) || 0));
213
+ const c = Math.max(0, Math.min(1, Number(caps?.[h]) || 0));
214
+ s += (w[h] ?? 0) * Math.max(0, r - c);
215
+ }
216
+ return Math.round(s * 10000) / 10000;
217
+ }
218
+
219
+ function _costValue(c) {
220
+ const n = Number(c);
221
+ return Number.isFinite(n) && n >= 0 ? n : Number.POSITIVE_INFINITY;
222
+ }
223
+
224
+ /**
225
+ * @param {object} req — requirement vector {reasoning, codegen, debugging, tool_use} in [0,1]
226
+ * @param {Array<{provider, model, tier, cost?}>} candidates — catalog-constrained set
227
+ * (callers pass getAllConfiguredModels() + model-registry costs)
228
+ * @param {object} [opts] — { tau, weights }
229
+ * @returns {null | { selected, shortfalls: Array<{provider, model, tier, shortfall, cost, source}>, tau }}
230
+ */
231
+ function selectByShortfall(req, candidates, opts = {}) {
232
+ try {
233
+ if (!req || typeof req !== 'object') return null;
234
+ if (!Array.isArray(candidates) || candidates.length === 0) return null;
235
+ const tau = opts.tau ?? getTau();
236
+ const weights = opts.weights ?? getWeights();
237
+
238
+ const rows = candidates
239
+ .filter((c) => c && c.provider && c.model)
240
+ .map((c) => {
241
+ const { caps, source } = resolveCapabilitiesWithSource(c);
242
+ return {
243
+ provider: c.provider,
244
+ model: c.model,
245
+ tier: c.tier || 'MEDIUM',
246
+ cost: _costValue(c.cost),
247
+ shortfall: shortfall(req, caps, weights),
248
+ source,
249
+ };
250
+ });
251
+ if (rows.length === 0) return null;
252
+
253
+ const covering = rows.filter((r) => r.shortfall <= tau);
254
+ const pool = covering.length > 0 ? covering : rows;
255
+ pool.sort((a, b) => {
256
+ if (covering.length > 0) {
257
+ // Cheapest covering wins; cost tie (incl. all-unknown) breaks toward
258
+ // the LOWER tier — a covering lower tier is sufficient by definition,
259
+ // so prefer it over excess headroom (avoids over-provisioning).
260
+ if (a.cost !== b.cost) return a.cost - b.cost;
261
+ return (TIER_PRIORITY[a.tier] || 0) - (TIER_PRIORITY[b.tier] || 0);
262
+ }
263
+ // Nothing covers: minimal shortfall wins, tiebreak higher tier then cheaper.
264
+ if (a.shortfall !== b.shortfall) return a.shortfall - b.shortfall;
265
+ const tp = (TIER_PRIORITY[b.tier] || 0) - (TIER_PRIORITY[a.tier] || 0);
266
+ if (tp !== 0) return tp;
267
+ return a.cost - b.cost;
268
+ });
269
+
270
+ return { selected: pool[0], shortfalls: rows, tau };
271
+ } catch (err) {
272
+ logger.debug({ err: err.message }, '[Shortfall] select failed — failing open');
273
+ return null;
274
+ }
275
+ }
276
+
277
+ module.exports = {
278
+ HEADS,
279
+ DEFAULT_TAU,
280
+ loadProfiles,
281
+ _resetProfilesCache,
282
+ _setProfilesForTests,
283
+ getTau,
284
+ getWeights,
285
+ isEnabled,
286
+ resolveCapabilities,
287
+ resolveCapabilitiesWithSource,
288
+ shortfall,
289
+ selectByShortfall,
290
+ };
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Stuck-loop / no-progress detection (ROUTING-NOTES §4.10.5).
3
+ *
4
+ * The escalation ladder reacts to score drift, context overflow, risk
5
+ * keywords, and vision needs — but nothing watches for "the pinned model is
6
+ * thrashing": re-issuing the same tool call with the same input over and
7
+ * over, or repeating the same assistant text verbatim. A cheap-tier model
8
+ * stuck in that state burns turns indefinitely, because every frame of the
9
+ * loop is a mid-tool-exchange pin serve (which is unconditional — tool-call
10
+ * IDs aren't portable across providers, so switching mid-exchange would 400).
11
+ *
12
+ * The safe intervention is the one the pin path already uses for embedded
13
+ * text triggers: don't switch this turn — DROP THE PIN, so the next turn
14
+ * boundary re-routes fresh (and full routing, seeing the whole struggling
15
+ * conversation, escalates on its own signals).
16
+ *
17
+ * Detection is deliberately narrow to keep false positives near zero:
18
+ * - tool repetition: the last K assistant tool_use blocks are the SAME
19
+ * tool with the SAME input, K >= LYNKR_STUCK_TOOL_REPEATS (default 3).
20
+ * Agents legitimately re-run a tool (poll, retry-once); three identical
21
+ * consecutive calls is a loop.
22
+ * - text repetition: the last K assistant text blocks are identical after
23
+ * whitespace normalization, K >= LYNKR_STUCK_TEXT_REPEATS (default 3).
24
+ *
25
+ * Only the tail of the conversation is scanned (SCAN_WINDOW messages), so
26
+ * the check is O(1)-ish per request regardless of conversation length.
27
+ *
28
+ * @module routing/stuck-detector
29
+ */
30
+
31
+ const SCAN_WINDOW = 20;
32
+
33
+ function _toolRepeats() {
34
+ const n = Number.parseInt(process.env.LYNKR_STUCK_TOOL_REPEATS, 10);
35
+ return Number.isNaN(n) ? 3 : Math.max(2, n);
36
+ }
37
+
38
+ function _textRepeats() {
39
+ const n = Number.parseInt(process.env.LYNKR_STUCK_TEXT_REPEATS, 10);
40
+ return Number.isNaN(n) ? 3 : Math.max(2, n);
41
+ }
42
+
43
+ function _enabled() {
44
+ return process.env.LYNKR_STUCK_DETECTOR_ENABLED !== 'false';
45
+ }
46
+
47
+ /**
48
+ * Extract, oldest→newest, the assistant tool_use signatures and text blocks
49
+ * from the tail of the conversation.
50
+ */
51
+ function _assistantTail(messages) {
52
+ const tail = messages.slice(-SCAN_WINDOW);
53
+ const toolSigs = [];
54
+ const texts = [];
55
+ for (const msg of tail) {
56
+ if (msg?.role !== 'assistant') continue;
57
+ const content = Array.isArray(msg.content) ? msg.content : [];
58
+ for (const block of content) {
59
+ if (block?.type === 'tool_use') {
60
+ let inputSig;
61
+ try {
62
+ inputSig = JSON.stringify(block.input ?? null);
63
+ } catch {
64
+ inputSig = String(block.input);
65
+ }
66
+ toolSigs.push(`${block.name}::${inputSig}`);
67
+ } else if (block?.type === 'text' && typeof block.text === 'string') {
68
+ const normalized = block.text.replace(/\s+/g, ' ').trim();
69
+ if (normalized.length > 0) texts.push(normalized);
70
+ }
71
+ }
72
+ // String-content assistant messages count as text blocks too.
73
+ if (typeof msg.content === 'string') {
74
+ const normalized = msg.content.replace(/\s+/g, ' ').trim();
75
+ if (normalized.length > 0) texts.push(normalized);
76
+ }
77
+ }
78
+ return { toolSigs, texts };
79
+ }
80
+
81
+ function _trailingRun(items) {
82
+ if (items.length === 0) return 0;
83
+ const last = items[items.length - 1];
84
+ let run = 0;
85
+ for (let i = items.length - 1; i >= 0 && items[i] === last; i--) run++;
86
+ return run;
87
+ }
88
+
89
+ /**
90
+ * Detect a stuck loop in the conversation tail.
91
+ *
92
+ * @param {object} payload — request payload with .messages
93
+ * @returns {{ stuck: boolean, reason?: 'tool_repetition'|'text_repetition',
94
+ * repeats?: number, signature?: string }}
95
+ */
96
+ function detectStuckLoop(payload) {
97
+ if (!_enabled()) return { stuck: false };
98
+ const messages = payload?.messages;
99
+ if (!Array.isArray(messages) || messages.length < 4) return { stuck: false };
100
+
101
+ const { toolSigs, texts } = _assistantTail(messages);
102
+
103
+ const toolRun = _trailingRun(toolSigs);
104
+ if (toolRun >= _toolRepeats()) {
105
+ return {
106
+ stuck: true,
107
+ reason: 'tool_repetition',
108
+ repeats: toolRun,
109
+ // Truncated for logging — the full input may be huge or sensitive.
110
+ signature: toolSigs[toolSigs.length - 1].slice(0, 120),
111
+ };
112
+ }
113
+
114
+ const textRun = _trailingRun(texts);
115
+ if (textRun >= _textRepeats()) {
116
+ return {
117
+ stuck: true,
118
+ reason: 'text_repetition',
119
+ repeats: textRun,
120
+ signature: texts[texts.length - 1].slice(0, 120),
121
+ };
122
+ }
123
+
124
+ return { stuck: false };
125
+ }
126
+
127
+ module.exports = { detectStuckLoop };
@@ -175,6 +175,12 @@ function init() {
175
175
  // providers that report none) — "not measured" is distinct from 0.
176
176
  ["cache_read_tokens", "INTEGER"],
177
177
  ["cache_creation_tokens", "INTEGER"],
178
+ // Off-policy evaluation — the bandit's context vector (12-dim JSON
179
+ // array) for the row's decision. Required by the doubly-robust
180
+ // estimator's regression term r̂(x, a); IPS/SNIPS work without it.
181
+ // NULL on rows where the bandit didn't run (deterministic decisions)
182
+ // or recorded before this column existed.
183
+ ["context", "TEXT"],
178
184
  ];
179
185
  for (const [col, type] of additiveCols) {
180
186
  if (!existingCols.has(col)) {
@@ -237,7 +243,7 @@ function record(data) {
237
243
  retry_count, circuit_breaker_state, quality_score, tokens_per_second,
238
244
  cost_efficiency, request_text, response_text,
239
245
  base_tier, escalation_source, propensity, candidates, pinned, switch_reason,
240
- cache_decision, cache_read_tokens, cache_creation_tokens
246
+ cache_decision, cache_read_tokens, cache_creation_tokens, context
241
247
  ) VALUES (
242
248
  @request_id, @session_id, @timestamp, @complexity_score, @tier,
243
249
  @agentic_type, @tool_count, @input_tokens, @message_count, @request_type,
@@ -246,7 +252,7 @@ function record(data) {
246
252
  @retry_count, @circuit_breaker_state, @quality_score, @tokens_per_second,
247
253
  @cost_efficiency, @request_text, @response_text,
248
254
  @base_tier, @escalation_source, @propensity, @candidates, @pinned, @switch_reason,
249
- @cache_decision, @cache_read_tokens, @cache_creation_tokens
255
+ @cache_decision, @cache_read_tokens, @cache_creation_tokens, @context
250
256
  )`
251
257
  );
252
258
  if (!insert) return;
@@ -299,6 +305,9 @@ function record(data) {
299
305
  : JSON.stringify(data.cache_decision)),
300
306
  cache_read_tokens: data.cache_read_tokens ?? null,
301
307
  cache_creation_tokens: data.cache_creation_tokens ?? null,
308
+ context: data.context == null
309
+ ? null
310
+ : (typeof data.context === "string" ? data.context : JSON.stringify(data.context)),
302
311
  });
303
312
  } catch (err) {
304
313
  logger.debug({ err: err.message }, "Telemetry record failed");
package/src/server.js CHANGED
@@ -80,7 +80,11 @@ function createApp() {
80
80
 
81
81
  // Phase 6.1 — per-tenant routing policies (LYNKR-Tenant-Id header).
82
82
  // Runs before message handling so res.locals.tenantPolicy is populated.
83
- app.use('/v1/messages', tenantMiddleware);
83
+ // Must cover every AGENT_ENDPOINT, not just /v1/messages — mounting on a
84
+ // single path silently exempts OpenAI-compat clients from tenant policy
85
+ // (the same failure mode the comment above already warns about for other
86
+ // guards; see issue #100).
87
+ for (const p of AGENT_ENDPOINTS) app.use(p, tenantMiddleware);
84
88
 
85
89
  // Phase 6.2 — hierarchical budget enforcement (LYNKR_BUDGET_ENFORCER=false to disable).
86
90
  for (const p of AGENT_ENDPOINTS) app.use(p, budgetEnforcer);
@@ -105,7 +109,8 @@ function createApp() {
105
109
 
106
110
  app.get("/metrics/circuit-breakers", (req, res) => {
107
111
  const registry = getCircuitBreakerRegistry();
108
- res.json(registry.getAll());
112
+ const { getHealthProber } = require("./clients/health-probe");
113
+ res.json({ breakers: registry.getAll(), healthProbe: getHealthProber().getStatus() });
109
114
  });
110
115
 
111
116
  app.get("/metrics/load-shedding", (req, res) => {
@@ -124,13 +129,23 @@ function createApp() {
124
129
 
125
130
  app.get("/metrics/semantic-cache", (req, res) => {
126
131
  const { getSemanticCache, isSemanticCacheEnabled } = require("./cache/semantic");
132
+ const { getEmbeddingStatus } = require("./cache/embeddings");
127
133
  if (!isSemanticCacheEnabled()) {
128
134
  return res.json({ enabled: false, message: "Semantic cache not enabled" });
129
135
  }
130
136
  const cache = getSemanticCache();
131
- res.json({ enabled: true, ...cache.getStats() });
137
+ // embeddings.providerAvailable === false means matches are currently
138
+ // served from the non-semantic hash fallback — cache "hits" during a
139
+ // degraded window are approximate, not semantic.
140
+ res.json({ enabled: true, embeddings: getEmbeddingStatus(), ...cache.getStats() });
132
141
  });
133
142
 
143
+ // MCP broker — exposes configured MCP servers to other clients over HTTP.
144
+ // Off by default; requires LYNKR_MCP_BROKER_ENABLED + a bearer token.
145
+ // Mounted BEFORE the main router so /v1/mcp/* never falls through to the
146
+ // OpenAI-compat surface.
147
+ app.use(require('./api/mcp-broker'));
148
+
134
149
  app.use(router);
135
150
 
136
151
  app.use('/dashboard', require('./dashboard/router'));
@@ -181,6 +196,29 @@ async function start() {
181
196
 
182
197
  const app = createApp();
183
198
 
199
+ // Synthetic circuit-breaker health probing — recovers open circuits with a
200
+ // cheap background probe instead of letting the next live user request pay
201
+ // for testing a dead provider. Zero-cost while all breakers are closed.
202
+ try {
203
+ const { getHealthProber } = require("./clients/health-probe");
204
+ const prober = getHealthProber();
205
+ prober.start();
206
+ getShutdownManager().onShutdown(() => prober.stop());
207
+ } catch (err) {
208
+ logger.warn({ err: err.message }, "Health prober failed to start, circuit recovery falls back to live-request probing");
209
+ }
210
+
211
+ // OTel GenAI metrics export — no-op unless OTEL_EXPORTER_OTLP_ENDPOINT
212
+ // (or LYNKR_OTEL_ENDPOINT) is set. Zero-dependency OTLP/HTTP push.
213
+ try {
214
+ const { getOtelExporter } = require("./observability/otel");
215
+ const otel = getOtelExporter();
216
+ otel.start();
217
+ getShutdownManager().onShutdown(() => otel.stop());
218
+ } catch (err) {
219
+ logger.warn({ err: err.message }, "OTel exporter failed to start");
220
+ }
221
+
184
222
  // Wait for Ollama if it's the configured provider or referenced in tier config
185
223
  const provider = config.modelProvider?.type?.toLowerCase();
186
224
  if (provider === "ollama" || config.tiersReferenceOllama()) {
@@ -89,7 +89,11 @@ async function getFileContent(id) {
89
89
  async function deleteFile(id) {
90
90
  const entry = metadata.get(id);
91
91
  if (!entry) return false;
92
- try { await fsp.unlink(entry.storage_path); } catch {}
92
+ try {
93
+ await fsp.unlink(entry.storage_path);
94
+ } catch (err) {
95
+ logger.debug({ err: err.message, fileId: id }, "Best-effort unlink failed during file delete");
96
+ }
93
97
  metadata.delete(id);
94
98
  persistMetadata();
95
99
  return true;