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.
- package/README.md +5 -1
- package/bin/cli.js +14 -6
- package/bin/lynkr-desktop-token.js +108 -0
- package/bin/lynkr-init.js +13 -1
- package/bin/lynkr-restart.js +125 -0
- package/bin/opencode-setup.js +175 -0
- package/bin/run.js +92 -0
- package/config/model-capabilities.json +20 -0
- package/config/model-capability-seeds.json +75 -0
- package/config/model-tiers.json +13 -1
- package/package.json +16 -22
- package/scripts/claude-desktop.js +212 -0
- package/scripts/ope-report.js +78 -0
- package/scripts/seed-capabilities.js +230 -0
- package/src/api/claude-desktop-gateway.js +123 -0
- package/src/api/health.js +0 -1
- package/src/api/mcp-broker.js +149 -0
- package/src/api/middleware/budget-enforcer.js +77 -7
- package/src/api/middleware/budget.js +36 -2
- package/src/api/middleware/session.js +0 -0
- package/src/api/openai-router.js +210 -15
- package/src/api/providers-handler.js +16 -1
- package/src/api/router.js +73 -106
- package/src/auth-mode.js +40 -4
- package/src/budget/index.js +86 -0
- package/src/cache/embeddings.js +125 -35
- package/src/cache/onnx-embedder.js +92 -0
- package/src/cache/prompt.js +0 -7
- package/src/clients/bedrock-utils.js +2 -1
- package/src/clients/codex-process.js +1 -1
- package/src/clients/codex-utils.js +0 -2
- package/src/clients/databricks.js +337 -46
- package/src/clients/gpt-utils.js +84 -8
- package/src/clients/health-probe.js +157 -0
- package/src/clients/openai-format.js +73 -23
- package/src/clients/provider-capabilities.js +27 -2
- package/src/clients/resilience.js +0 -1
- package/src/clients/responses-format.js +0 -56
- package/src/clients/routing.js +1 -1
- package/src/config/index.js +13 -2
- package/src/context/budget.js +1 -1
- package/src/context/compression.js +127 -19
- package/src/context/tool-result-compressor.js +3 -5
- package/src/dashboard/api.js +1 -1
- package/src/logger/oversized-error-stream.js +1 -1
- package/src/memory/extractor.js +3 -3
- package/src/memory/search.js +2 -2
- package/src/memory/store.js +9 -24
- package/src/memory/tools.js +14 -0
- package/src/observability/metrics.js +52 -3
- package/src/observability/otel.js +180 -0
- package/src/orchestrator/azure-responses-sse.js +223 -0
- package/src/orchestrator/index.js +216 -212
- package/src/orchestrator/passthrough-stream.js +7 -1
- package/src/orchestrator/sse-transformer.js +130 -13
- package/src/policy/safe-commands.js +0 -1
- package/src/routing/bandit.js +39 -0
- package/src/routing/calibration.js +21 -1
- package/src/routing/capabilities.js +88 -0
- package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
- package/src/routing/capability-seeds/family-heuristics.js +77 -0
- package/src/routing/capability-seeds/family.js +105 -0
- package/src/routing/capability-seeds/models-dev.js +64 -0
- package/src/routing/capability-seeds/normalize.js +160 -0
- package/src/routing/capability-seeds/registry.js +125 -0
- package/src/routing/capability-seeds/sources.js +69 -0
- package/src/routing/capability-seeds/swebench.js +66 -0
- package/src/routing/client-profiles.js +26 -0
- package/src/routing/complexity-analyzer.js +5 -8
- package/src/routing/confidence-scorer.js +2 -2
- package/src/routing/cost-optimizer.js +1 -2
- package/src/routing/decide.js +149 -0
- package/src/routing/deescalator.js +38 -1
- package/src/routing/embedding-cache.js +0 -1
- package/src/routing/index.js +202 -86
- package/src/routing/intent-score.js +0 -1
- package/src/routing/knn-router.js +27 -5
- package/src/routing/latency-tracker.js +1 -1
- package/src/routing/model-registry.js +25 -0
- package/src/routing/model-slots.js +65 -0
- package/src/routing/model-tiers.js +2 -0
- package/src/routing/ope.js +255 -0
- package/src/routing/openai-model-slots.js +82 -0
- package/src/routing/regret-estimator.js +1 -1
- package/src/routing/shortfall.js +290 -0
- package/src/routing/stuck-detector.js +127 -0
- package/src/routing/telemetry.js +11 -2
- package/src/server.js +41 -3
- package/src/stores/file-store.js +5 -1
- package/src/tools/web-search-exec.js +182 -0
- package/src/training/trajectory-compressor.js +1 -1
- package/src/workers/pool.js +1 -1
- package/src/workers/worker.js +1 -1
- package/scripts/convert-routellm.py +0 -105
- package/src/diff/comments.js +0 -153
- 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
|
-
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
//
|
|
137
|
-
keyPoints.push(`User: ${content
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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
|
|
496
|
+
* @returns {Array<{id: string, name: string, input: Object}>}
|
|
389
497
|
*/
|
|
390
|
-
function
|
|
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
|
|
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+[
|
|
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 (/^[
|
|
961
|
+
if (/^[@#[]/.test(trimmed)) {
|
|
964
962
|
skeleton.push(line);
|
|
965
963
|
continue;
|
|
966
964
|
}
|
package/src/dashboard/api.js
CHANGED
|
@@ -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 [
|
|
69
|
+
for (const [_sessionId, { writeStream }] of sessionFiles.entries()) {
|
|
70
70
|
writeStream.end();
|
|
71
71
|
}
|
|
72
72
|
sessionFiles.clear();
|
package/src/memory/extractor.js
CHANGED
|
@@ -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_
|
|
30
|
-
/in\s+['"`]([A-Za-z0-9_
|
|
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_
|
|
36
|
+
/([A-Za-z0-9_./-]+)\s+(?:calls|invokes|references)\s+([A-Za-z0-9_./-]+)/gi,
|
|
37
37
|
],
|
|
38
38
|
};
|
|
39
39
|
|
package/src/memory/search.js
CHANGED
|
@@ -30,7 +30,7 @@ function sanitizeKeyword(keyword) {
|
|
|
30
30
|
|
|
31
31
|
// Remove ALL FTS5 special characters
|
|
32
32
|
const sanitized = keyword
|
|
33
|
-
.replace(/[*()
|
|
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(/[*()
|
|
100
|
+
cleaned = cleaned.replace(/[*()<>\-:[\]{}|^~,.;!?'"@#$%&+=/\\\\]/g, ' ');
|
|
101
101
|
cleaned = cleaned.replace(/\s+/g, ' ').trim();
|
|
102
102
|
|
|
103
103
|
if (!cleaned) {
|
package/src/memory/store.js
CHANGED
|
@@ -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 {
|
package/src/memory/tools.js
CHANGED
|
@@ -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 };
|