lynkr 9.14.2 → 9.14.7

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 (61) 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 +10 -4
  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/mcp-broker.js +149 -0
  17. package/src/api/middleware/budget-enforcer.js +77 -7
  18. package/src/api/middleware/budget.js +36 -2
  19. package/src/api/openai-router.js +125 -4
  20. package/src/api/providers-handler.js +16 -0
  21. package/src/api/router.js +67 -103
  22. package/src/auth-mode.js +40 -4
  23. package/src/budget/index.js +86 -0
  24. package/src/cache/embeddings.js +125 -35
  25. package/src/cache/onnx-embedder.js +92 -0
  26. package/src/clients/databricks.js +285 -21
  27. package/src/clients/gpt-utils.js +83 -7
  28. package/src/clients/health-probe.js +157 -0
  29. package/src/clients/provider-capabilities.js +27 -0
  30. package/src/config/index.js +13 -1
  31. package/src/context/compression.js +127 -19
  32. package/src/dashboard/api.js +1 -0
  33. package/src/observability/metrics.js +52 -0
  34. package/src/observability/otel.js +180 -0
  35. package/src/orchestrator/azure-responses-sse.js +223 -0
  36. package/src/orchestrator/index.js +219 -136
  37. package/src/orchestrator/passthrough-stream.js +7 -1
  38. package/src/orchestrator/sse-transformer.js +62 -3
  39. package/src/routing/bandit.js +39 -0
  40. package/src/routing/capabilities.js +88 -0
  41. package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
  42. package/src/routing/capability-seeds/family-heuristics.js +77 -0
  43. package/src/routing/capability-seeds/family.js +105 -0
  44. package/src/routing/capability-seeds/models-dev.js +64 -0
  45. package/src/routing/capability-seeds/normalize.js +160 -0
  46. package/src/routing/capability-seeds/registry.js +125 -0
  47. package/src/routing/capability-seeds/sources.js +69 -0
  48. package/src/routing/capability-seeds/swebench.js +66 -0
  49. package/src/routing/decide.js +149 -0
  50. package/src/routing/deescalator.js +37 -0
  51. package/src/routing/index.js +202 -86
  52. package/src/routing/model-registry.js +25 -0
  53. package/src/routing/model-slots.js +65 -0
  54. package/src/routing/model-tiers.js +2 -0
  55. package/src/routing/ope.js +255 -0
  56. package/src/routing/openai-model-slots.js +82 -0
  57. package/src/routing/shortfall.js +290 -0
  58. package/src/routing/stuck-detector.js +127 -0
  59. package/src/routing/telemetry.js +11 -2
  60. package/src/server.js +41 -3
  61. package/src/tools/web-search-exec.js +182 -0
@@ -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()) {
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Server-side web_search / web_fetch execution — narrowly scoped resurrection.
3
+ *
4
+ * Context: src/tools/web.js used to implement this (SearXNG-backed search,
5
+ * host-allowlisted fetch) and was deleted 2026-07-22 in commit b32e988,
6
+ * "Remove server-mode tool execution: Lynkr always forwards tool calls to
7
+ * the client." That was a deliberate architecture pivot, not a bug — Lynkr
8
+ * stopped executing ANY tool itself.
9
+ *
10
+ * This does NOT reintroduce general server-mode tool execution. It only
11
+ * auto-resolves `web_search`/`web_fetch` calls, and ONLY when the calling
12
+ * client is unrecognized by src/routing/client-profiles.js's detectClient()
13
+ * — i.e. Lynkr has no signal the caller can fulfill the call itself.
14
+ * Known harnesses (Claude Code / Claude Desktop's shared agent-sdk — both
15
+ * present as `claude-cli/...`, Cursor, goose, Codex) already execute these
16
+ * client-side today and are never routed through this module; see the call
17
+ * site in src/orchestrator/index.js's runAgentLoop.
18
+ *
19
+ * @module tools/web-search-exec
20
+ */
21
+
22
+ const config = require("../config");
23
+ const logger = require("../logger");
24
+
25
+ const AUTO_RESOLVABLE_NAMES = new Set(["web_search", "websearch", "web_fetch", "webfetch"]);
26
+
27
+ /**
28
+ * @param {string} name
29
+ * @returns {boolean}
30
+ */
31
+ function isAutoResolvable(name) {
32
+ return AUTO_RESOLVABLE_NAMES.has(String(name || "").toLowerCase());
33
+ }
34
+
35
+ /**
36
+ * True only when every call in this batch is one we can resolve ourselves —
37
+ * a mixed batch (one auto-resolvable + one arbitrary client tool) falls
38
+ * through to the normal "forward to client" path untouched, since we can't
39
+ * partially resolve a batch the model expects answered together.
40
+ *
41
+ * @param {Array} toolCalls - normalized {id, function:{name}} or {name} calls
42
+ * @returns {boolean}
43
+ */
44
+ function canAutoResolveAll(toolCalls) {
45
+ return Array.isArray(toolCalls) && toolCalls.length > 0
46
+ && toolCalls.every((tc) => isAutoResolvable(tc?.function?.name ?? tc?.name));
47
+ }
48
+
49
+ function isHostAllowed(urlStr) {
50
+ if (config.webSearch?.allowAllHosts) return true;
51
+ try {
52
+ const host = new URL(urlStr).hostname.toLowerCase();
53
+ const allowed = config.webSearch?.allowedHosts;
54
+ return Array.isArray(allowed) && allowed.includes(host);
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ async function _withTimeout(fn, timeoutMs) {
61
+ const controller = new AbortController();
62
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
63
+ try {
64
+ return await fn(controller.signal);
65
+ } finally {
66
+ clearTimeout(timer);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * SearXNG-backed search. Retries transient failures per config.webSearch
72
+ * (retryEnabled/maxRetries) — mirrors the retry policy the deleted web.js
73
+ * had, since a single dropped connection to a local sidecar shouldn't fail
74
+ * the whole tool call.
75
+ *
76
+ * @param {string} query
77
+ * @returns {Promise<Object>} { query, results: [{title,url,snippet}] } or { error }
78
+ */
79
+ async function searchWeb(query) {
80
+ const endpoint = config.webSearch?.endpoint || "http://localhost:8888/search";
81
+ const timeoutMs = config.webSearch?.timeoutMs || 10000;
82
+ const maxRetries = config.webSearch?.retryEnabled ? (config.webSearch?.maxRetries ?? 2) : 0;
83
+ const url = `${endpoint}?q=${encodeURIComponent(query || "")}&format=json`;
84
+
85
+ let lastErr = null;
86
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
87
+ try {
88
+ const res = await _withTimeout(
89
+ (signal) => fetch(url, { signal }),
90
+ timeoutMs,
91
+ );
92
+ if (!res.ok) {
93
+ lastErr = `search backend returned HTTP ${res.status}`;
94
+ continue;
95
+ }
96
+ const json = await res.json();
97
+ const results = (Array.isArray(json.results) ? json.results : [])
98
+ .slice(0, 8)
99
+ .map((r) => ({ title: r.title, url: r.url, snippet: r.content }));
100
+ return { query, results };
101
+ } catch (err) {
102
+ lastErr = err.message;
103
+ }
104
+ }
105
+ logger.warn({ query, endpoint, err: lastErr }, "[web-search-exec] search failed after retries");
106
+ return { query, error: `search failed: ${lastErr}` };
107
+ }
108
+
109
+ /**
110
+ * Generic URL fetch, host-allowlisted per config.webSearch (allowAllHosts /
111
+ * allowedHosts), truncated per config.webSearch.bodyPreviewMax.
112
+ *
113
+ * @param {string} targetUrl
114
+ * @returns {Promise<Object>}
115
+ */
116
+ async function fetchUrl(targetUrl) {
117
+ if (!targetUrl) return { error: "no url provided" };
118
+ if (!isHostAllowed(targetUrl)) {
119
+ logger.warn({ url: targetUrl }, "[web-search-exec] fetch blocked — host not allowed");
120
+ return { url: targetUrl, error: "host not in allowedHosts (WEB_SEARCH_ALLOW_ALL=false)" };
121
+ }
122
+ const maxPreview = config.webSearch?.bodyPreviewMax || 10000;
123
+ const timeoutMs = config.webSearch?.timeoutMs || 10000;
124
+ try {
125
+ const res = await _withTimeout(
126
+ (signal) => fetch(targetUrl, { signal }),
127
+ timeoutMs,
128
+ );
129
+ const text = await res.text();
130
+ return {
131
+ url: targetUrl,
132
+ status: res.status,
133
+ content: text.slice(0, maxPreview),
134
+ truncated: text.length > maxPreview,
135
+ };
136
+ } catch (err) {
137
+ logger.warn({ url: targetUrl, err: err.message }, "[web-search-exec] fetch failed");
138
+ return { url: targetUrl, error: `fetch failed: ${err.message}` };
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Execute every call in this (pre-checked via canAutoResolveAll) batch and
144
+ * append the matching assistant tool_use + user tool_result turns to
145
+ * `messages`, mirroring exactly what a real client round-trip would send
146
+ * back — so the next agent-loop iteration behaves identically to a normal
147
+ * client-fulfilled tool exchange, and the client never sees the exchange.
148
+ *
149
+ * @param {Array} toolCalls
150
+ * @param {Array} messages - mutated in place (push only, never rewritten)
151
+ */
152
+ async function autoResolve(toolCalls, messages) {
153
+ const toolUseBlocks = [];
154
+ const toolResultBlocks = [];
155
+
156
+ for (const tc of toolCalls) {
157
+ const rawName = tc.function?.name ?? tc.name ?? "";
158
+ const name = String(rawName).toLowerCase();
159
+ let input = {};
160
+ try {
161
+ const raw = tc.function?.arguments ?? tc.input ?? {};
162
+ input = typeof raw === "string" ? JSON.parse(raw) : (raw || {});
163
+ } catch { /* leave input empty — result will just reflect an empty query/url */ }
164
+
165
+ toolUseBlocks.push({ type: "tool_use", id: tc.id, name: rawName, input });
166
+
167
+ const result = (name === "web_search" || name === "websearch")
168
+ ? await searchWeb(input.query || input.q || "")
169
+ : await fetchUrl(input.url || "");
170
+
171
+ toolResultBlocks.push({
172
+ type: "tool_result",
173
+ tool_use_id: tc.id,
174
+ content: JSON.stringify(result),
175
+ });
176
+ }
177
+
178
+ messages.push({ role: "assistant", content: toolUseBlocks });
179
+ messages.push({ role: "user", content: toolResultBlocks });
180
+ }
181
+
182
+ module.exports = { canAutoResolveAll, autoResolve, searchWeb, fetchUrl, isAutoResolvable };