lynkr 9.0.2 → 9.1.2

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.
@@ -0,0 +1,170 @@
1
+ const config = require('../config');
2
+ const telemetry = require('../routing/telemetry');
3
+ const { getUsage } = require('../usage/aggregator');
4
+ const metrics = require('../metrics');
5
+ const { getMetricsCollector } = require('../observability/metrics');
6
+ const { TIER_DEFINITIONS } = require('../routing/model-tiers');
7
+
8
+ function getConfiguredProviders() {
9
+ const c = config;
10
+ const providers = [];
11
+ const add = (name, type, ok) => ok && providers.push({ name, type });
12
+
13
+ add('databricks', 'cloud', c.databricks?.url && c.databricks?.apiKey);
14
+ add('azure-anthropic','cloud', c.azureAnthropic?.endpoint && c.azureAnthropic?.apiKey);
15
+ add('bedrock', 'cloud', c.bedrock?.apiKey);
16
+ add('openrouter', 'cloud', c.openrouter?.apiKey);
17
+ add('openai', 'cloud', c.openai?.apiKey);
18
+ add('azure-openai', 'cloud', c.azureOpenAI?.endpoint && c.azureOpenAI?.apiKey);
19
+ add('vertex', 'cloud', c.vertex?.projectId);
20
+ add('moonshot', 'cloud', c.moonshot?.apiKey);
21
+ add('ollama', 'local', c.ollama?.endpoint);
22
+ add('llamacpp', 'local', c.llamacpp?.endpoint);
23
+ add('lmstudio', 'local', c.lmstudio?.endpoint);
24
+
25
+ return providers;
26
+ }
27
+
28
+ // Noise provider names injected by unit tests — filter them out of UI
29
+ const TEST_PROVIDER_RE = /^(accuracy-|stats-|provider-stats-|roundtrip-|latency-)/;
30
+
31
+ // Find the widest window that has at least one row, so the UI never shows
32
+ // empty panels just because there were no requests in the last 24 hours.
33
+ function findActiveWindow() {
34
+ const newest = telemetry.query({ limit: 1 });
35
+ if (!newest.length) return { since: Date.now() - 86400000, label: '24h' };
36
+
37
+ const ageMs = Date.now() - newest[0].timestamp;
38
+ if (ageMs <= 86400000) return { since: Date.now() - 86400000, label: '24h' };
39
+ if (ageMs <= 7*86400000) return { since: Date.now() - 7*86400000, label: '7d' };
40
+ if (ageMs <= 30*86400000) return { since: Date.now() - 30*86400000, label: '30d' };
41
+ return { since: 0, label: 'all time' };
42
+ }
43
+
44
+ function getCircuitBreakerStates() {
45
+ try {
46
+ const { getCircuitBreakerRegistry } = require('../clients/circuit-breaker');
47
+ const reg = getCircuitBreakerRegistry();
48
+ return reg.getAll();
49
+ } catch {
50
+ return {};
51
+ }
52
+ }
53
+
54
+ // Group telemetry rows by calendar day (UTC), returning last `days` buckets
55
+ function dailyBreakdown(rows, days = 7) {
56
+ const now = Date.now();
57
+ const DAY = 86400000;
58
+ const result = [];
59
+
60
+ for (let i = days - 1; i >= 0; i--) {
61
+ const start = now - (i + 1) * DAY;
62
+ const end = now - i * DAY;
63
+ const bucket = rows.filter(r => r.timestamp >= start && r.timestamp < end);
64
+
65
+ const byTier = {};
66
+ let cost = 0;
67
+ for (const r of bucket) {
68
+ const t = r.tier || 'UNKNOWN';
69
+ byTier[t] = (byTier[t] || 0) + 1;
70
+ cost += Number(r.cost_usd) || 0;
71
+ }
72
+
73
+ result.push({
74
+ label: new Date(start).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
75
+ total: bucket.length,
76
+ byTier,
77
+ cost: Math.round(cost * 10000) / 10000,
78
+ });
79
+ }
80
+ return result;
81
+ }
82
+
83
+ function overview(req, res) {
84
+ const win = findActiveWindow();
85
+ const todayUsage = getUsage({ window: win.label === '24h' ? '1d' : win.label === 'all time' ? 'all' : win.label });
86
+ const recentRows = telemetry.query({ limit: 10 });
87
+ const todayStats = telemetry.getStats({ since: win.since });
88
+ const snap = metrics.snapshot();
89
+
90
+ res.json({
91
+ uptime: Math.floor(process.uptime()),
92
+ port: config.port,
93
+ version: process.env.npm_package_version || '9.0.2',
94
+ modelProvider: config.modelProvider?.type || 'unknown',
95
+ providers: getConfiguredProviders(),
96
+ statsWindow: win.label,
97
+ metrics: {
98
+ requestsTotal: snap.requestsTotal,
99
+ responsesSuccess: snap.responses?.success || 0,
100
+ responsesError: snap.responses?.error || 0,
101
+ },
102
+ today: {
103
+ requests: todayUsage.totals?.requests || 0,
104
+ totalTokens: todayUsage.totals?.totalTokens || 0,
105
+ cost: todayUsage.totals?.actualCost || 0,
106
+ saved: todayUsage.totals?.saved || 0,
107
+ savedPercent: todayUsage.totals?.savedPercent || 0,
108
+ },
109
+ stats: todayStats,
110
+ recentRequests: recentRows,
111
+ });
112
+ }
113
+
114
+ function usage(req, res) {
115
+ const window = req.query.window || '7d';
116
+ const provider = req.query.provider || undefined;
117
+ const model = req.query.model || undefined;
118
+
119
+ const data = getUsage({ window, provider, model });
120
+
121
+ // Add daily breakdown for chart (last 7 or 30 days depending on window)
122
+ const days = window === '1d' ? 1 : window === '30d' ? 30 : 7;
123
+ const since = window === 'all' ? 0 : Date.now() - days * 86400000;
124
+ const rawRows = since > 0
125
+ ? telemetry.query({ since, limit: 50000 })
126
+ : telemetry.query({ limit: 50000 });
127
+
128
+ data.daily = dailyBreakdown(rawRows, Math.min(days, 30));
129
+
130
+ res.json(data);
131
+ }
132
+
133
+ function routing(req, res) {
134
+ const win = findActiveWindow();
135
+ const { since } = win;
136
+
137
+ const accuracy = telemetry.getRoutingAccuracy({ since });
138
+ const stats = telemetry.getStats({ since });
139
+ const cbStates = getCircuitBreakerStates();
140
+
141
+ // Derive providers from actual DB rows — never miss a provider not in config
142
+ const dbRows = telemetry.query({ limit: 100000, since });
143
+ const dbProviders = [...new Set(
144
+ dbRows.map(r => r.provider).filter(p => p && !TEST_PROVIDER_RE.test(p))
145
+ )];
146
+
147
+ const providerStats = {};
148
+ for (const p of dbProviders) {
149
+ const s = telemetry.getProviderStats(p, { since });
150
+ if (s) providerStats[p] = s;
151
+ }
152
+
153
+ res.json({ tierDefinitions: TIER_DEFINITIONS, accuracy, stats, providerStats, circuitBreakers: cbStates, window: win.label });
154
+ }
155
+
156
+ function logs(req, res) {
157
+ const limit = Math.min(parseInt(req.query.limit || '100', 10), 500);
158
+ const filters = { limit };
159
+
160
+ if (req.query.provider) filters.provider = req.query.provider;
161
+ if (req.query.tier) filters.tier = req.query.tier;
162
+ if (req.query.since) filters.since = parseInt(req.query.since, 10);
163
+
164
+ let rows = telemetry.query(filters);
165
+ if (req.query.error === 'true') rows = rows.filter(r => r.error_type);
166
+
167
+ res.json(rows);
168
+ }
169
+
170
+ module.exports = { overview, usage, routing, logs };
@@ -0,0 +1,13 @@
1
+ const express = require('express');
2
+ const path = require('path');
3
+ const api = require('./api');
4
+
5
+ const router = express.Router();
6
+
7
+ router.get('/', (_req, res) => res.sendFile(path.join(__dirname, '../../public/dashboard.html')));
8
+ router.get('/api/overview', api.overview);
9
+ router.get('/api/usage', api.usage);
10
+ router.get('/api/routing', api.routing);
11
+ router.get('/api/logs', api.logs);
12
+
13
+ module.exports = router;
@@ -58,6 +58,7 @@ async function checkHealth() {
58
58
  return {
59
59
  available: data.headroom_loaded === true,
60
60
  status: data.status,
61
+ version: data.headroom_version,
61
62
  ccrEnabled: data.ccr_enabled,
62
63
  llmlinguaEnabled: data.llmlingua_enabled,
63
64
  entriesCached: data.entries_cached,
@@ -154,8 +155,10 @@ async function compressMessages(messages, tools = [], options = {}) {
154
155
  tokensBefore: result.stats?.tokens_before,
155
156
  tokensAfter: result.stats?.tokens_after,
156
157
  savingsPercent: result.stats?.savings_percent,
158
+ compressionRatio: result.stats?.compression_ratio,
157
159
  latencyMs: result.stats?.latency_ms,
158
160
  transforms: result.stats?.transforms_applied,
161
+ headroomVersion: result.stats?.headroom_version,
159
162
  },
160
163
  "Headroom compression applied"
161
164
  );
@@ -245,112 +248,6 @@ async function ccrRetrieve(hash, query = null, maxResults = 20) {
245
248
  }
246
249
  }
247
250
 
248
- /**
249
- * Track compression for proactive CCR expansion
250
- */
251
- async function ccrTrack(hashKey, turnNumber, toolName, sample) {
252
- const headroomConfig = getConfig();
253
-
254
- if (!isEnabled()) {
255
- return { tracked: false };
256
- }
257
-
258
- try {
259
- const params = new URLSearchParams({
260
- hash_key: hashKey,
261
- turn_number: String(turnNumber),
262
- tool_name: toolName,
263
- sample: sample.substring(0, 500),
264
- });
265
-
266
- const response = await fetch(`${headroomConfig.endpoint}/ccr/track?${params}`, {
267
- method: "POST",
268
- signal: AbortSignal.timeout(2000),
269
- });
270
-
271
- if (response.ok) {
272
- return await response.json();
273
- }
274
- return { tracked: false };
275
- } catch (err) {
276
- logger.debug({ error: err.message }, "CCR tracking failed");
277
- return { tracked: false };
278
- }
279
- }
280
-
281
- /**
282
- * Analyze query for proactive CCR expansion
283
- */
284
- async function ccrAnalyze(query, turnNumber) {
285
- const headroomConfig = getConfig();
286
-
287
- if (!isEnabled()) {
288
- return { expansions: [] };
289
- }
290
-
291
- try {
292
- const response = await fetch(`${headroomConfig.endpoint}/ccr/analyze`, {
293
- method: "POST",
294
- headers: { "Content-Type": "application/json" },
295
- body: JSON.stringify({ query, turn_number: turnNumber }),
296
- signal: AbortSignal.timeout(2000),
297
- });
298
-
299
- if (response.ok) {
300
- return await response.json();
301
- }
302
- return { expansions: [] };
303
- } catch (err) {
304
- logger.debug({ error: err.message }, "CCR analysis failed");
305
- return { expansions: [] };
306
- }
307
- }
308
-
309
- /**
310
- * Compress text using LLMLingua-2 ML compression
311
- * (Optional - requires LLMLingua enabled in sidecar)
312
- */
313
- async function llmlinguaCompress(text, targetRatio = 0.5, forceTokens = null) {
314
- const headroomConfig = getConfig();
315
-
316
- if (!isEnabled()) {
317
- return { success: false, error: "Headroom disabled" };
318
- }
319
-
320
- try {
321
- const params = new URLSearchParams({
322
- text,
323
- target_ratio: String(targetRatio),
324
- });
325
-
326
- if (forceTokens && Array.isArray(forceTokens)) {
327
- params.append("force_tokens", JSON.stringify(forceTokens));
328
- }
329
-
330
- const response = await fetch(`${headroomConfig.endpoint}/compress/llmlingua?${params}`, {
331
- method: "POST",
332
- signal: AbortSignal.timeout(30000), // LLMLingua can be slow
333
- });
334
-
335
- if (!response.ok) {
336
- const error = await response.text();
337
- return { success: false, error };
338
- }
339
-
340
- const result = await response.json();
341
- return {
342
- success: true,
343
- compressed: result.compressed,
344
- originalTokens: result.original_tokens,
345
- compressedTokens: result.compressed_tokens,
346
- ratio: result.ratio,
347
- };
348
- } catch (err) {
349
- logger.error({ error: err.message }, "LLMLingua compression failed");
350
- return { success: false, error: err.message };
351
- }
352
- }
353
-
354
251
  /**
355
252
  * Get client-side metrics
356
253
  */
@@ -424,9 +321,6 @@ module.exports = {
424
321
  checkHealth,
425
322
  compressMessages,
426
323
  ccrRetrieve,
427
- ccrTrack,
428
- ccrAnalyze,
429
- llmlinguaCompress,
430
324
  getMetrics,
431
325
  getServerMetrics,
432
326
  getCombinedMetrics,
@@ -125,20 +125,6 @@ class HeadroomManager {
125
125
  return client.ccrRetrieve(hash, query, maxResults);
126
126
  }
127
127
 
128
- /**
129
- * Track compression for proactive expansion
130
- */
131
- async ccrTrack(hashKey, turnNumber, toolName, sample) {
132
- return client.ccrTrack(hashKey, turnNumber, toolName, sample);
133
- }
134
-
135
- /**
136
- * Analyze query for proactive CCR expansion
137
- */
138
- async ccrAnalyze(query, turnNumber) {
139
- return client.ccrAnalyze(query, turnNumber);
140
- }
141
-
142
128
  /**
143
129
  * Check if Headroom is enabled
144
130
  */
@@ -258,58 +258,8 @@ function searchMemories(options) {
258
258
  }
259
259
  }
260
260
 
261
- /**
262
-
263
- * Search with keyword expansion (UPDATED - now uses sanitized keywords)
264
- =======
265
- * Prepare FTS5 query - handle special characters and phrases
266
- */
267
- function prepareFTS5Query(query) {
268
- // FTS5 special characters: " * ( ) < > - : AND OR NOT
269
- // Strategy: Strip XML/HTML tags, then sanitize remaining text
270
- let cleaned = query.trim();
271
-
272
- // Step 1: Remove XML/HTML tags (common in error messages)
273
- // Matches: <tag>, </tag>, <tag attr="value">
274
- cleaned = cleaned.replace(/<[^>]+>/g, ' ');
275
-
276
- // Step 2: Remove excess whitespace from tag removal
277
- cleaned = cleaned.replace(/\s+/g, ' ').trim();
278
-
279
- if (!cleaned) {
280
- // Query was all tags, return safe fallback
281
- return '"empty query"';
282
- }
283
-
284
- // Step 3: Check if query contains FTS5 operators (AND, OR, NOT)
285
- const hasFTS5Operators = /\b(AND|OR|NOT)\b/i.test(cleaned);
286
-
287
- // Step 4: ENHANCED - Remove ALL special characters that could break FTS5
288
- // Keep only: letters, numbers, spaces
289
- // Remove: * ( ) < > - : [ ] | , + = ? ! ; / \ @ # $ % ^ & { }
290
- cleaned = cleaned.replace(/[*()<>\-:\[\]|,+=?!;\/\\@#$%^&{}]/g, ' ');
291
- cleaned = cleaned.replace(/\s+/g, ' ').trim();
292
-
293
- // Step 5: Escape double quotes (FTS5 uses "" for literal quote)
294
- cleaned = cleaned.replace(/"/g, '""');
295
-
296
- // Step 6: Additional safety - remove any remaining non-alphanumeric except spaces
297
- cleaned = cleaned.replace(/[^\w\s""]/g, ' ');
298
- cleaned = cleaned.replace(/\s+/g, ' ').trim();
299
-
300
- // Step 7: Wrap in quotes for phrase search (safest approach)
301
- if (!hasFTS5Operators) {
302
- // Treat as literal phrase search
303
- cleaned = `"${cleaned}"`;
304
- }
305
-
306
- // If query has FTS5 operators, let FTS5 parse them (advanced users)
307
- return cleaned;
308
- }
309
-
310
261
  /**
311
262
  * Search with keyword expansion (extract key terms)
312
-
313
263
  */
314
264
  function searchWithExpansion(options) {
315
265
  const { query, limit = 10 } = options;
@@ -17,6 +17,7 @@ const { compressMessages: headroomCompress, isEnabled: isHeadroomEnabled } = req
17
17
  const { createAuditLogger } = require("../logger/audit-logger");
18
18
  const { getResolvedIp, runWithDnsContext } = require("../clients/dns-logger");
19
19
  const { getShuttingDown } = require("../api/health");
20
+ const { tryPreflight, buildSatisfiedResponse: buildPreflightResponse } = require("./preflight");
20
21
  const crypto = require("crypto");
21
22
  const { asyncClone, asyncTransform, getPoolStats } = require("../workers/helpers");
22
23
  const { getSemanticCache, isSemanticCacheEnabled } = require("../cache/semantic");
@@ -1383,7 +1384,9 @@ function sanitizePayload(payload) {
1383
1384
  clean.tools = selectedTools.length > 0 ? selectedTools : undefined;
1384
1385
  }
1385
1386
 
1386
- clean.stream = payload?.stream ?? false;
1387
+ // Always false: the agent loop needs buffered JSON to parse tool calls.
1388
+ // Lynkr synthesises SSE back to the client from the buffered response.
1389
+ clean.stream = false;
1387
1390
 
1388
1391
  if (
1389
1392
  config.modelProvider?.type === "azure-anthropic" &&
@@ -1799,9 +1802,11 @@ async function runAgentLoop({
1799
1802
  }
1800
1803
  }
1801
1804
 
1802
- // Inject tool termination instructions for non-Claude models
1803
- // This helps models know when to stop calling tools and provide a text response
1804
- if (steps === 1 && providerType !== 'databricks' && providerType !== 'azure-anthropic') {
1805
+ const hasRequestTools = Array.isArray(cleanPayload.tools) && cleanPayload.tools.length > 0;
1806
+ // Inject tool termination instructions for non-Claude models only when tools
1807
+ // are actually in the request. Injecting when there are no tools confuses models
1808
+ // like MiniMax into hallucinating tool_use blocks spontaneously.
1809
+ if (steps === 1 && hasRequestTools && providerType !== 'databricks' && providerType !== 'azure-anthropic') {
1805
1810
  const toolTerminationInstruction = `
1806
1811
 
1807
1812
  IMPORTANT TOOL USAGE RULES:
@@ -1815,6 +1820,13 @@ IMPORTANT TOOL USAGE RULES:
1815
1820
  logger.debug({ sessionId: session?.id ?? null }, 'Tool termination instructions injected for non-Claude model');
1816
1821
  }
1817
1822
 
1823
+ // When no tools are in the request, explicitly forbid tool_use output for
1824
+ // Ollama models that have been trained on Claude Code data and tend to emit
1825
+ // tool_use blocks spontaneously (e.g. minimax-m2.5:cloud calling Write).
1826
+ if (steps === 1 && !hasRequestTools && providerType === 'ollama') {
1827
+ cleanPayload.system = (cleanPayload.system || '') + '\n\nCRITICAL: You have NO tools available. Do NOT generate tool_use, function_call, or code_execution blocks. Output ONLY text content directly.';
1828
+ }
1829
+
1818
1830
  // Compute model-aware token budget thresholds
1819
1831
  const registry = getModelRegistrySync();
1820
1832
  const modelInfo = registry.getCost(requestedModel);
@@ -2210,7 +2222,30 @@ IMPORTANT TOOL USAGE RULES:
2210
2222
  noToolInjection: !!cleanPayload._noToolInjection,
2211
2223
  }, "Dropped hallucinated tool calls (no tools were sent to model)");
2212
2224
  toolCalls = [];
2213
- // If there's also no text content, treat as empty response (handled below)
2225
+
2226
+ // Check if there is any text content alongside the hallucinated tool calls.
2227
+ // If not, the response is effectively empty. Inject a redirect message so the
2228
+ // model outputs the artifact directly instead of looping tool-call attempts.
2229
+ const hasTextContent = isAnthropicFormat
2230
+ ? (databricksResponse.json?.content ?? []).some(b => b?.type === "text" && String(b.text || "").trim().length > 0)
2231
+ : (typeof message.content === "string" && message.content.trim().length > 0);
2232
+
2233
+ if (!hasTextContent && steps < settings.maxSteps - 1) {
2234
+ logger.info({
2235
+ sessionId: session?.id ?? null,
2236
+ step: steps,
2237
+ }, "Hallucinated tool calls with no text content — injecting redirect to force direct output");
2238
+
2239
+ // Push a phantom assistant turn (thinking only, no tool_use) then a user
2240
+ // redirect message so the model outputs the artifact directly.
2241
+ const redirectUser = {
2242
+ role: "user",
2243
+ content: "You don't have any tools available in this context. Please output the result directly as an <artifact identifier=\"design.html\" type=\"text/html\" title=\"Design\"> block containing complete HTML. Do not attempt to call any tools.",
2244
+ };
2245
+ cleanPayload.messages.push(redirectUser);
2246
+ steps++;
2247
+ continue;
2248
+ }
2214
2249
  }
2215
2250
 
2216
2251
  if (toolCalls.length > 0) {
@@ -3689,6 +3724,28 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
3689
3724
  };
3690
3725
  }
3691
3726
 
3727
+ // === PREFLIGHT CHECK ===
3728
+ // If the request supplied preflight_commands and they all pass in
3729
+ // the workspace, the work is already done — short-circuit with a
3730
+ // synthetic response and never touch the model. No-op when the
3731
+ // feature is disabled or the request didn't opt in.
3732
+ const preflightResult = tryPreflight({ payload, cwd });
3733
+ if (preflightResult?.satisfied) {
3734
+ logger.info({
3735
+ commands: preflightResult.results.length,
3736
+ reason: preflightResult.reason,
3737
+ }, '[Preflight] Satisfied — skipping model call');
3738
+ return buildPreflightResponse({
3739
+ model: requestedModel,
3740
+ preflightResult,
3741
+ });
3742
+ }
3743
+ if (preflightResult && !preflightResult.satisfied) {
3744
+ logger.debug({
3745
+ failedCommand: preflightResult.failedCommand,
3746
+ }, '[Preflight] Not satisfied — proceeding with model call');
3747
+ }
3748
+
3692
3749
  // === TOOL LOOP GUARD (EARLY CHECK) ===
3693
3750
  // Check BEFORE sanitization since sanitizePayload removes conversation history
3694
3751
  // All providers use threshold 2 to catch loops early