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
@@ -40,14 +40,50 @@ function compressHistory(messages, options = {}) {
40
40
  keepRecentTurns: options.keepRecentTurns ?? config.historyCompression?.keepRecentTurns ?? 10,
41
41
  summarizeOlder: options.summarizeOlder ?? config.historyCompression?.summarizeOlder ?? true,
42
42
  enabled: options.enabled ?? config.historyCompression?.enabled ?? true,
43
+ // Model token budget (e.g. effectiveMax from the orchestrator). When set,
44
+ // the verbatim-recent window grows to USE the budget instead of a fixed
45
+ // message count. Live incident: a 935-message session against a 128k
46
+ // model kept only 10 recent messages (~56k tokens) while 108.8k tokens
47
+ // of budget were available — ~50k tokens of context thrown away, giving
48
+ // the model amnesia about its own recent edits.
49
+ budgetTokens: options.budgetTokens ?? null,
43
50
  };
44
51
 
45
52
  if (!opts.enabled) {
46
53
  return messages; // Return uncompressed if disabled
47
54
  }
48
55
 
56
+ // Recent-window size: fixed count by default; budget-driven when a model
57
+ // token budget is provided. RECENT_BUDGET_FRACTION reserves the rest of
58
+ // the budget for the summary block, system prompt, tools, and the
59
+ // response itself.
60
+ const RECENT_BUDGET_FRACTION = 0.6;
61
+ const CHARS_PER_TOKEN = 4;
62
+ let keepRecent = opts.keepRecentTurns;
63
+ if (Number.isFinite(opts.budgetTokens) && opts.budgetTokens > 0) {
64
+ const targetChars = opts.budgetTokens * CHARS_PER_TOKEN * RECENT_BUDGET_FRACTION;
65
+ let accumulated = 0;
66
+ let count = 0;
67
+ for (let i = messages.length - 1; i >= 0; i--) {
68
+ accumulated += estimateMessagesSize([messages[i]]);
69
+ if (accumulated > targetChars) break;
70
+ count++;
71
+ }
72
+ keepRecent = Math.max(opts.keepRecentTurns, count);
73
+ }
74
+
49
75
  // Calculate split point
50
- const splitIndex = Math.max(0, messages.length - opts.keepRecentTurns);
76
+ let splitIndex = Math.max(0, messages.length - keepRecent);
77
+
78
+ // Tool-exchange boundary alignment: never let the recent window START with
79
+ // a tool_result whose paired tool_use just got summarized away — providers
80
+ // reject orphaned tool_result blocks ("unexpected tool_use_id"). Pull the
81
+ // split earlier until the boundary is clean.
82
+ const startsWithToolResult = (msg) =>
83
+ Array.isArray(msg?.content) && msg.content.some((b) => b?.type === 'tool_result');
84
+ while (splitIndex > 0 && startsWithToolResult(messages[splitIndex])) {
85
+ splitIndex--;
86
+ }
51
87
 
52
88
  if (splitIndex === 0) {
53
89
  // All messages are recent, no compression needed
@@ -126,31 +162,56 @@ function summarizeOldHistory(messages) {
126
162
  let hasUserInput = false;
127
163
  let hasAssistantOutput = false;
128
164
 
165
+ // Index tool_result blocks by tool_use_id so each tool_use below can carry
166
+ // a one-line recap of what it actually found. Previously this summary kept
167
+ // only the tool NAME ("Assistant used tools: bash") and threw away the
168
+ // result entirely — which meant a long tool-calling session forgot its own
169
+ // diagnosis every time it aged past the recent-turns window and had to
170
+ // re-derive it from scratch (live symptom: an agent stuck re-reading the
171
+ // same file and re-fetching the same issue instead of ever writing a fix).
172
+ const resultByToolUseId = new Map();
173
+ for (const msg of messages) {
174
+ if (!Array.isArray(msg.content)) continue;
175
+ for (const block of msg.content) {
176
+ if (block?.type === 'tool_result' && block.tool_use_id) {
177
+ resultByToolUseId.set(block.tool_use_id, extractResultSnippet(block));
178
+ }
179
+ }
180
+ }
181
+
129
182
  for (const msg of messages) {
130
183
  if (msg.role === 'user') {
131
184
  hasUserInput = true;
132
185
  const content = extractTextContent(msg);
133
- if (content.length < 200) {
134
- keyPoints.push(`User: ${content}`);
135
- } else {
136
- // Compress long user messages
137
- keyPoints.push(`User: ${content.substring(0, 150)}...`);
186
+ if (content) {
187
+ // compressText keeps head AND tail (unlike a flat substring(0, N)),
188
+ // which matters here too: the actionable part of a long user
189
+ // message is often at the end ("...so please fix X and open a PR").
190
+ keyPoints.push(`User: ${compressText(content, 200)}`);
138
191
  }
139
192
  } else if (msg.role === 'assistant') {
140
193
  hasAssistantOutput = true;
141
194
  const content = extractTextContent(msg);
142
195
 
143
- // Extract tool uses
144
- const toolUses = extractToolUses(msg);
145
- if (toolUses.length > 0) {
146
- keyPoints.push(`Assistant used tools: ${toolUses.join(', ')}`);
196
+ // Extract tool uses WITH their outcome, not just the tool name.
197
+ const toolUseBlocks = extractToolUseBlocks(msg);
198
+ for (const use of toolUseBlocks) {
199
+ const argsSnippet = summarizeToolArgs(use.input);
200
+ const resultSnippet = resultByToolUseId.get(use.id);
201
+ keyPoints.push(
202
+ resultSnippet
203
+ ? `Assistant called ${use.name}(${argsSnippet}) → ${resultSnippet}`
204
+ : `Assistant called ${use.name}(${argsSnippet})`
205
+ );
147
206
  }
148
207
 
149
- // Add assistant text if meaningful
150
- if (content.length > 20 && content.length < 200) {
151
- keyPoints.push(`Assistant: ${content}`);
152
- } else if (content.length >= 200) {
153
- keyPoints.push(`Assistant: ${content.substring(0, 150)}...`);
208
+ // Add assistant text if meaningful. compressText (head+tail) instead
209
+ // of a head-only substring(0, 150) — conclusions ("Cause: ...",
210
+ // "Findings: ...") are usually written at the END of a reasoning
211
+ // turn, so a head-only cut was silently discarding exactly the part
212
+ // that mattered for remembering what was already diagnosed.
213
+ if (content.length > 20) {
214
+ keyPoints.push(`Assistant: ${compressText(content, 300)}`);
154
215
  }
155
216
  }
156
217
  }
@@ -168,6 +229,51 @@ function summarizeOldHistory(messages) {
168
229
  };
169
230
  }
170
231
 
232
+ /**
233
+ * Short, single-line recap of a tool_result's content — used so the
234
+ * compacted history shows what a tool call actually found, not just that it
235
+ * was called. Reuses compressText's head+tail truncation.
236
+ *
237
+ * @param {Object} block - tool_result block
238
+ * @returns {string} snippet, or '' if the result had no usable text
239
+ */
240
+ function extractResultSnippet(block) {
241
+ const RESULT_SNIPPET_MAX = 160;
242
+ let text = '';
243
+ if (typeof block.content === 'string') {
244
+ text = block.content;
245
+ } else if (Array.isArray(block.content)) {
246
+ text = block.content
247
+ .filter(item => typeof item === 'string' || item?.type === 'text')
248
+ .map(item => (typeof item === 'string' ? item : item.text || ''))
249
+ .join(' ');
250
+ }
251
+ text = text.trim();
252
+ if (!text) return '';
253
+ const prefix = block.is_error ? 'error: ' : '';
254
+ return prefix + compressText(text, RESULT_SNIPPET_MAX);
255
+ }
256
+
257
+ /**
258
+ * Compact, single-line rendering of a tool_use's arguments for the summary
259
+ * (e.g. `Read(file_path=".../types.go", offset=390)`), not the full JSON.
260
+ *
261
+ * @param {Object} input - tool_use input object
262
+ * @returns {string}
263
+ */
264
+ function summarizeToolArgs(input) {
265
+ const ARGS_SNIPPET_MAX = 80;
266
+ if (!input || typeof input !== 'object') return '';
267
+ try {
268
+ const flat = Object.entries(input)
269
+ .map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
270
+ .join(', ');
271
+ return compressText(flat, ARGS_SNIPPET_MAX);
272
+ } catch {
273
+ return '';
274
+ }
275
+ }
276
+
171
277
  /**
172
278
  * Compress a single message
173
279
  *
@@ -382,17 +488,19 @@ function extractTextContent(message) {
382
488
  }
383
489
 
384
490
  /**
385
- * Extract tool names used in message
491
+ * Extract tool_use blocks (id/name/input) from a message — the fuller
492
+ * counterpart to extractToolUses(), which only returns names. Used by
493
+ * summarizeOldHistory() to pair each call with its outcome.
386
494
  *
387
495
  * @param {Object} message - Message object
388
- * @returns {Array} Tool names
496
+ * @returns {Array<{id: string, name: string, input: Object}>}
389
497
  */
390
- function extractToolUses(message) {
498
+ function extractToolUseBlocks(message) {
391
499
  if (!Array.isArray(message.content)) return [];
392
500
 
393
501
  return message.content
394
502
  .filter(block => block.type === 'tool_use')
395
- .map(block => block.name);
503
+ .map(block => ({ id: block.id, name: block.name, input: block.input }));
396
504
  }
397
505
 
398
506
  /**
@@ -696,7 +696,7 @@ function compressDirectoryListing(text, opts = {}) {
696
696
  // skips this check — real listings can contain names (spaces, unicode)
697
697
  // the shape regex would reject.
698
698
  if (!opts.trusted) {
699
- const pathLines = lines.filter(l => /^[.\w\/-]+\.\w+$/.test(l.trim()) || /^[.\w\/-]+\/$/.test(l.trim()) || /^[-drwx]{10}/.test(l.trim()));
699
+ const pathLines = lines.filter(l => /^[.\w/-]+\.\w+$/.test(l.trim()) || /^[.\w/-]+\/$/.test(l.trim()) || /^[-drwx]{10}/.test(l.trim()));
700
700
  if (pathLines.length < lines.length * 0.6) return null;
701
701
  }
702
702
 
@@ -823,7 +823,7 @@ function compressLintOutput(text, opts = {}) {
823
823
  for (const line of clean.split("\n")) {
824
824
  // ESLint stylish prints each file as a standalone path line above its
825
825
  // issue block — track it so issues group per file.
826
- if (/^\S+[\/\\]\S+\.\w+$/.test(line.trim()) && !line.includes(":")) {
826
+ if (/^\S+[/\\]\S+\.\w+$/.test(line.trim()) && !line.includes(":")) {
827
827
  currentFile = line.trim();
828
828
  continue;
829
829
  }
@@ -935,8 +935,6 @@ function compressLargeFile(text) {
935
935
 
936
936
  // Extract structural skeleton
937
937
  const skeleton = [];
938
- let inBlock = false;
939
- let braceDepth = 0;
940
938
 
941
939
  for (let i = 0; i < lines.length; i++) {
942
940
  const line = lines[i];
@@ -960,7 +958,7 @@ function compressLargeFile(text) {
960
958
  }
961
959
 
962
960
  // Keep decorators/attributes
963
- if (/^[@#\[]/.test(trimmed)) {
961
+ if (/^[@#[]/.test(trimmed)) {
964
962
  skeleton.push(line);
965
963
  continue;
966
964
  }
@@ -2,7 +2,6 @@ const config = require('../config');
2
2
  const telemetry = require('../routing/telemetry');
3
3
  const { getUsage } = require('../usage/aggregator');
4
4
  const metrics = require('../metrics');
5
- const { getMetricsCollector } = require('../observability/metrics');
6
5
  const { TIER_DEFINITIONS } = require('../routing/model-tiers');
7
6
 
8
7
  // Per-provider type + whether its credentials/endpoint are actually present.
@@ -18,6 +17,7 @@ function providerMeta() {
18
17
  'azure-openai': { type: 'cloud', configured: !!(c.azureOpenAI?.endpoint && c.azureOpenAI?.apiKey) },
19
18
  vertex: { type: 'cloud', configured: !!c.vertex?.projectId },
20
19
  moonshot: { type: 'cloud', configured: !!c.moonshot?.apiKey },
20
+ fireworks: { type: 'cloud', configured: !!c.fireworks?.apiKey },
21
21
  ollama: { type: 'local', configured: !!c.ollama?.endpoint },
22
22
  llamacpp: { type: 'local', configured: !!c.llamacpp?.endpoint },
23
23
  lmstudio: { type: 'local', configured: !!c.lmstudio?.endpoint },
@@ -66,7 +66,7 @@ function createOversizedErrorStream(config) {
66
66
  },
67
67
  final(callback) {
68
68
  // Close all open file handles when stream ends
69
- for (const [sessionId, { writeStream }] of sessionFiles.entries()) {
69
+ for (const [_sessionId, { writeStream }] of sessionFiles.entries()) {
70
70
  writeStream.end();
71
71
  }
72
72
  sessionFiles.clear();
@@ -26,14 +26,14 @@ const EXTRACTION_PATTERNS = {
26
26
  ],
27
27
 
28
28
  entity: [
29
- /(?:file|function|class|module|component|package|library)\s+['"`]?([A-Za-z0-9_./\-]+)['"`]?/gi,
30
- /in\s+['"`]([A-Za-z0-9_./\-]+\.(?:js|ts|py|java|go|rs|cpp|c|h))['"`]/gi,
29
+ /(?:file|function|class|module|component|package|library)\s+['"`]?([A-Za-z0-9_./-]+)['"`]?/gi,
30
+ /in\s+['"`]([A-Za-z0-9_./-]+\.(?:js|ts|py|java|go|rs|cpp|c|h))['"`]/gi,
31
31
  /`([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)`/g, // Code references
32
32
  ],
33
33
 
34
34
  relationship: [
35
35
  /([A-Za-z0-9_]+)\s+(?:depends on|imports|uses|extends|implements|inherits from)\s+([A-Za-z0-9_]+)/gi,
36
- /([A-Za-z0-9_./\-]+)\s+(?:calls|invokes|references)\s+([A-Za-z0-9_./\-]+)/gi,
36
+ /([A-Za-z0-9_./-]+)\s+(?:calls|invokes|references)\s+([A-Za-z0-9_./-]+)/gi,
37
37
  ],
38
38
  };
39
39
 
@@ -30,7 +30,7 @@ function sanitizeKeyword(keyword) {
30
30
 
31
31
  // Remove ALL FTS5 special characters
32
32
  const sanitized = keyword
33
- .replace(/[*()<>\-:\[\]{}|^~,.;!?'"@#$%&+=/\\\\]/g, ' ')
33
+ .replace(/[*()<>\-:[\]{}|^~,.;!?'"@#$%&+=/\\\\]/g, ' ')
34
34
  .replace(/\s+/g, ' ')
35
35
  .trim();
36
36
 
@@ -97,7 +97,7 @@ function prepareFTS5Query(query) {
97
97
  // - @ symbol → "syntax error near @"
98
98
  // - Quotes (") → can break string quoting
99
99
  // - Parentheses, brackets → break grouping syntax
100
- cleaned = cleaned.replace(/[*()<>\-:\[\]{}|^~,.;!?'"@#$%&+=/\\\\]/g, ' ');
100
+ cleaned = cleaned.replace(/[*()<>\-:[\]{}|^~,.;!?'"@#$%&+=/\\\\]/g, ' ');
101
101
  cleaned = cleaned.replace(/\s+/g, ' ').trim();
102
102
 
103
103
  if (!cleaned) {
@@ -122,12 +122,6 @@ const upsertEntityStmt = db.prepare(`
122
122
  properties = @properties
123
123
  `);
124
124
 
125
- const getEntityStmt = db.prepare(`
126
- SELECT id, entity_type, entity_name, first_seen_at, last_seen_at, occurrence_count, properties
127
- FROM memory_entities
128
- WHERE entity_type = ? AND entity_name = ?
129
- `);
130
-
131
125
  const getAllEntitiesStmt = db.prepare(`
132
126
  SELECT id, entity_type, entity_name, first_seen_at, last_seen_at, occurrence_count, properties
133
127
  FROM memory_entities
@@ -176,19 +170,6 @@ function toMemory(row) {
176
170
  };
177
171
  }
178
172
 
179
- function toEntity(row) {
180
- if (!row) return null;
181
- return {
182
- id: row.id,
183
- entityType: row.entity_type,
184
- entityName: row.entity_name,
185
- firstSeenAt: row.first_seen_at,
186
- lastSeenAt: row.last_seen_at,
187
- occurrenceCount: row.occurrence_count ?? 1,
188
- properties: parseJSON(row.properties, {}),
189
- };
190
- }
191
-
192
173
  /**
193
174
  * Create a new memory
194
175
  */
@@ -400,16 +381,20 @@ function trackEntity(options) {
400
381
  }
401
382
 
402
383
  /**
403
- * Get an entity
384
+ * Get an entity by name, optionally scoped to a type. Names can collide
385
+ * across types (e.g. a "file" and a "topic" both named "router"), so
386
+ * without a type filter the most recently seen entity wins,
387
+ * deterministically.
404
388
  */
405
- function getEntity(name) {
406
- // Since we only have the name, we need to search across all entity types
389
+ function getEntity(name, type = null) {
407
390
  const stmt = db.prepare(`
408
391
  SELECT id, entity_type, entity_name, first_seen_at, last_seen_at, occurrence_count, properties
409
392
  FROM memory_entities
410
- WHERE entity_name = ?
393
+ WHERE entity_name = ? ${type ? "AND entity_type = ?" : ""}
394
+ ORDER BY last_seen_at DESC
395
+ LIMIT 1
411
396
  `);
412
- const row = stmt.get(name);
397
+ const row = type ? stmt.get(name, type) : stmt.get(name);
413
398
  if (!row) return null;
414
399
 
415
400
  return {
@@ -340,6 +340,19 @@ async function memory_stats(args, context = {}) {
340
340
  };
341
341
  }
342
342
 
343
+ // Surface tracked entities — the extractor writes them on every
344
+ // conversation, but until this they were collected and never readable.
345
+ let entities = [];
346
+ try {
347
+ entities = store.getAllEntities(10).map(e => ({
348
+ name: e.name,
349
+ type: e.type,
350
+ count: e.count,
351
+ }));
352
+ } catch (err) {
353
+ logger.debug({ err: err.message }, 'Entity stats unavailable');
354
+ }
355
+
343
356
  return {
344
357
  ok: true,
345
358
  content: JSON.stringify({
@@ -350,6 +363,7 @@ async function memory_stats(args, context = {}) {
350
363
  recentCount: stats.recentCount,
351
364
  importantCount: stats.importantCount,
352
365
  sessionId: stats.sessionId || 'global',
366
+ topEntities: entities,
353
367
  }, null, 2),
354
368
  };
355
369
  } catch (err) {
@@ -1,6 +1,3 @@
1
- const config = require("../config");
2
- const logger = require("../logger");
3
-
4
1
  /**
5
2
  * High-performance metrics collection
6
3
  *
@@ -32,6 +29,20 @@ class MetricsCollector {
32
29
  this.budgetBlocks = 0;
33
30
  this.rateLimitBlocks = 0;
34
31
 
32
+ // Loop-guard interventions (cross-request tool dedup): warnings injected
33
+ // into model context and force-terminated turns. Non-zero numbers here
34
+ // mean Lynkr actively altered agent behavior — surfaced so operators
35
+ // don't need log forensics to discover it.
36
+ this.loopGuardWarnings = 0;
37
+ this.loopGuardTerminations = 0;
38
+
39
+ // History compression: how often and how hard conversations are being
40
+ // squeezed to fit the model's context window. A high maxPercentage means
41
+ // models are seeing a small fraction of their sessions.
42
+ this.historyCompressions = 0;
43
+ this.historyCompressionLastPct = 0;
44
+ this.historyCompressionMaxPct = 0;
45
+
35
46
  // API metrics
36
47
  this.databricksRequests = 0;
37
48
  this.databricksErrors = 0;
@@ -123,6 +134,28 @@ class MetricsCollector {
123
134
  this.rateLimitBlocks++;
124
135
  }
125
136
 
137
+ /**
138
+ * Record a loop-guard intervention.
139
+ * @param {'warn'|'terminate'} kind
140
+ */
141
+ recordLoopGuard(kind) {
142
+ // 'observed' is the only kind emitted since the guard went observe-only;
143
+ // 'terminate' is kept as a counter bucket for any historical dashboards.
144
+ if (kind === 'terminate') this.loopGuardTerminations++;
145
+ else this.loopGuardWarnings++;
146
+ }
147
+
148
+ /**
149
+ * Record a history-compression event.
150
+ * @param {number} percentage - percent of chars removed (0-100)
151
+ */
152
+ recordHistoryCompression(percentage) {
153
+ this.historyCompressions++;
154
+ const pct = Number(percentage) || 0;
155
+ this.historyCompressionLastPct = pct;
156
+ if (pct > this.historyCompressionMaxPct) this.historyCompressionMaxPct = pct;
157
+ }
158
+
126
159
  /**
127
160
  * Record Databricks API call
128
161
  */
@@ -225,6 +258,13 @@ class MetricsCollector {
225
258
  budget_blocks_total: this.budgetBlocks,
226
259
  rate_limit_blocks_total: this.rateLimitBlocks,
227
260
 
261
+ // Loop guard + history compression (context interventions)
262
+ loop_guard_warnings_total: this.loopGuardWarnings,
263
+ loop_guard_terminations_total: this.loopGuardTerminations,
264
+ history_compressions_total: this.historyCompressions,
265
+ history_compression_last_pct: this.historyCompressionLastPct,
266
+ history_compression_max_pct: this.historyCompressionMaxPct,
267
+
228
268
  // API
229
269
  databricks_requests_total: this.databricksRequests,
230
270
  databricks_errors_total: this.databricksErrors,
@@ -335,6 +375,15 @@ class MetricsCollector {
335
375
  metric("http_request_duration_ms", "summary", "HTTP request latency in ms", metrics.latency_ms.p95, { quantile: "0.95" });
336
376
  metric("http_request_duration_ms", "summary", "HTTP request latency in ms", metrics.latency_ms.p99, { quantile: "0.99" });
337
377
 
378
+ // OTel GenAI semantic-convention aliases (dots → underscores, per
379
+ // Prometheus conversion rules). Same underlying counters as above,
380
+ // named so semconv-aware dashboards/tooling find them.
381
+ metric("gen_ai_client_token_usage_total", "counter", "GenAI tokens consumed (OTel semconv)", metrics.tokens_input_total, { gen_ai_token_type: "input" });
382
+ metric("gen_ai_client_token_usage_total", "counter", "GenAI tokens consumed (OTel semconv)", metrics.tokens_output_total, { gen_ai_token_type: "output" });
383
+ metric("gen_ai_client_operation_duration_ms", "summary", "GenAI operation duration (OTel semconv)", metrics.latency_ms.median, { quantile: "0.5" });
384
+ metric("gen_ai_client_operation_duration_ms", "summary", "GenAI operation duration (OTel semconv)", metrics.latency_ms.p95, { quantile: "0.95" });
385
+ metric("gen_ai_client_operation_duration_ms", "summary", "GenAI operation duration (OTel semconv)", metrics.latency_ms.p99, { quantile: "0.99" });
386
+
338
387
  return lines.join("\n");
339
388
  }
340
389
 
@@ -0,0 +1,180 @@
1
+ /**
2
+ * OpenTelemetry GenAI metrics export (ROUTING-NOTES §1 gap audit).
3
+ *
4
+ * Lynkr already tracks every number the OTel GenAI semantic conventions
5
+ * care about — this module gives them semconv-aligned names and an export
6
+ * path, WITHOUT adding the @opentelemetry/* dependency tree: OTLP/HTTP is
7
+ * just JSON over POST, and the payload below follows the OTLP 1.x
8
+ * ExportMetricsServiceRequest shape that any collector accepts.
9
+ *
10
+ * Enabled when OTEL_EXPORTER_OTLP_ENDPOINT (the standard env var) or
11
+ * LYNKR_OTEL_ENDPOINT is set. Pushes every LYNKR_OTEL_EXPORT_INTERVAL_MS
12
+ * (default 60s) to `<endpoint>/v1/metrics`. Optional headers via the
13
+ * standard OTEL_EXPORTER_OTLP_HEADERS ("key=value,key2=value2").
14
+ *
15
+ * Emitted metrics (GenAI semconv where one exists, lynkr.* where not):
16
+ * gen_ai.client.token.usage sum, attr gen_ai.token.type=input|output
17
+ * gen_ai.client.operation.duration gauge (p50/p95/p99, unit ms)
18
+ * lynkr.cost.usd sum
19
+ * lynkr.requests sum (+ lynkr.request.errors)
20
+ *
21
+ * @module observability/otel
22
+ */
23
+
24
+ const logger = require('../logger');
25
+ const { getMetricsCollector } = require('./metrics');
26
+
27
+ const EXPORT_INTERVAL_MS = Number.parseInt(process.env.LYNKR_OTEL_EXPORT_INTERVAL_MS, 10) || 60_000;
28
+
29
+ function _endpoint() {
30
+ const raw = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || process.env.LYNKR_OTEL_ENDPOINT || null;
31
+ if (!raw) return null;
32
+ return raw.replace(/\/+$/, '');
33
+ }
34
+
35
+ function _headers() {
36
+ const headers = { 'Content-Type': 'application/json' };
37
+ const raw = process.env.OTEL_EXPORTER_OTLP_HEADERS;
38
+ if (raw) {
39
+ for (const pair of raw.split(',')) {
40
+ const idx = pair.indexOf('=');
41
+ if (idx > 0) headers[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
42
+ }
43
+ }
44
+ return headers;
45
+ }
46
+
47
+ function _sumPoint(value, attributes, startNanos, nowNanos) {
48
+ return {
49
+ attributes,
50
+ startTimeUnixNano: startNanos,
51
+ timeUnixNano: nowNanos,
52
+ asDouble: value,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Build an OTLP/HTTP ExportMetricsServiceRequest from the collector's
58
+ * current snapshot. Exposed for tests.
59
+ */
60
+ function buildOtlpPayload(metrics, { startTimeMs, nowMs }) {
61
+ const startNanos = String(startTimeMs * 1e6);
62
+ const nowNanos = String(nowMs * 1e6);
63
+ const attr = (key, value) => ({ key, value: { stringValue: value } });
64
+
65
+ const sums = (name, unit, points) => ({
66
+ name,
67
+ unit,
68
+ sum: {
69
+ aggregationTemporality: 2, // CUMULATIVE
70
+ isMonotonic: true,
71
+ dataPoints: points,
72
+ },
73
+ });
74
+
75
+ const gauge = (name, unit, points) => ({ name, unit, gauge: { dataPoints: points } });
76
+
77
+ return {
78
+ resourceMetrics: [{
79
+ resource: {
80
+ attributes: [
81
+ attr('service.name', 'lynkr'),
82
+ attr('service.version', process.env.npm_package_version || 'unknown'),
83
+ ],
84
+ },
85
+ scopeMetrics: [{
86
+ scope: { name: 'lynkr.observability', version: '1' },
87
+ metrics: [
88
+ sums('gen_ai.client.token.usage', '{token}', [
89
+ _sumPoint(metrics.tokens_input_total, [attr('gen_ai.token.type', 'input')], startNanos, nowNanos),
90
+ _sumPoint(metrics.tokens_output_total, [attr('gen_ai.token.type', 'output')], startNanos, nowNanos),
91
+ ]),
92
+ gauge('gen_ai.client.operation.duration', 'ms', [
93
+ { attributes: [attr('quantile', '0.5')], timeUnixNano: nowNanos, asDouble: metrics.latency_ms.median },
94
+ { attributes: [attr('quantile', '0.95')], timeUnixNano: nowNanos, asDouble: metrics.latency_ms.p95 },
95
+ { attributes: [attr('quantile', '0.99')], timeUnixNano: nowNanos, asDouble: metrics.latency_ms.p99 },
96
+ ]),
97
+ sums('lynkr.cost.usd', 'usd', [
98
+ _sumPoint(metrics.cost_usd_total, [], startNanos, nowNanos),
99
+ ]),
100
+ sums('lynkr.requests', '{request}', [
101
+ _sumPoint(metrics.requests_total, [], startNanos, nowNanos),
102
+ ]),
103
+ sums('lynkr.request.errors', '{request}', [
104
+ _sumPoint(metrics.requests_errors_total, [], startNanos, nowNanos),
105
+ ]),
106
+ ],
107
+ }],
108
+ }],
109
+ };
110
+ }
111
+
112
+ class OtelExporter {
113
+ constructor() {
114
+ this.timer = null;
115
+ this.startTimeMs = Date.now();
116
+ this.stats = { exports: 0, failures: 0, lastExportAt: null };
117
+ this._warnedOnce = false;
118
+ }
119
+
120
+ start() {
121
+ const endpoint = _endpoint();
122
+ if (!endpoint || this.timer) return;
123
+ this.timer = setInterval(() => {
124
+ this.exportOnce().catch(() => {});
125
+ }, EXPORT_INTERVAL_MS);
126
+ this.timer.unref?.();
127
+ logger.info({ endpoint, intervalMs: EXPORT_INTERVAL_MS }, '[Otel] GenAI metrics export started');
128
+ }
129
+
130
+ stop() {
131
+ if (this.timer) {
132
+ clearInterval(this.timer);
133
+ this.timer = null;
134
+ }
135
+ }
136
+
137
+ async exportOnce() {
138
+ const endpoint = _endpoint();
139
+ if (!endpoint) return false;
140
+ const metrics = getMetricsCollector().getMetrics();
141
+ const payload = buildOtlpPayload(metrics, { startTimeMs: this.startTimeMs, nowMs: Date.now() });
142
+ try {
143
+ const response = await fetch(`${endpoint}/v1/metrics`, {
144
+ method: 'POST',
145
+ headers: _headers(),
146
+ body: JSON.stringify(payload),
147
+ signal: AbortSignal.timeout(10_000),
148
+ });
149
+ if (!response.ok) throw new Error(`OTLP export → ${response.status}`);
150
+ await response.arrayBuffer().catch(() => {});
151
+ this.stats.exports += 1;
152
+ this.stats.lastExportAt = Date.now();
153
+ this._warnedOnce = false;
154
+ return true;
155
+ } catch (err) {
156
+ this.stats.failures += 1;
157
+ // Warn once per failure streak, then stay quiet — a down collector
158
+ // must not spam logs at every interval.
159
+ if (!this._warnedOnce) {
160
+ this._warnedOnce = true;
161
+ logger.warn({ endpoint, error: err.message }, '[Otel] metrics export failing (will keep retrying quietly)');
162
+ }
163
+ return false;
164
+ }
165
+ }
166
+
167
+ getStatus() {
168
+ return { enabled: !!_endpoint(), running: !!this.timer, intervalMs: EXPORT_INTERVAL_MS, ...this.stats };
169
+ }
170
+ }
171
+
172
+ let exporter = null;
173
+
174
+ /** @returns {OtelExporter} singleton */
175
+ function getOtelExporter() {
176
+ if (!exporter) exporter = new OtelExporter();
177
+ return exporter;
178
+ }
179
+
180
+ module.exports = { getOtelExporter, buildOtlpPayload };