lynkr 9.10.0 → 9.12.0

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 (50) hide show
  1. package/README.md +46 -11
  2. package/bin/lynkr-init.js +7 -0
  3. package/bin/statusline.js +80 -0
  4. package/bin/wrap.js +10 -6
  5. package/config/model-tiers.json +5 -5
  6. package/package.json +9 -4
  7. package/public/dashboard.html +354 -1
  8. package/src/api/openai-router.js +120 -2
  9. package/src/api/router.js +34 -0
  10. package/src/clients/databricks.js +148 -66
  11. package/src/clients/gpt-utils.js +8 -1
  12. package/src/clients/ollama-utils.js +19 -10
  13. package/src/clients/prompt-cache-injection.js +93 -30
  14. package/src/config/index.js +79 -0
  15. package/src/context/compression.js +27 -8
  16. package/src/context/distill.js +2 -1
  17. package/src/dashboard/api.js +82 -2
  18. package/src/dashboard/recommendations/cache-weakspots.js +88 -0
  19. package/src/dashboard/recommendations/deadweight.js +82 -0
  20. package/src/dashboard/recommendations/downsize.js +109 -0
  21. package/src/dashboard/recommendations/index.js +106 -0
  22. package/src/dashboard/recommendations/side-requests.js +68 -0
  23. package/src/dashboard/recommendations/wilson.js +24 -0
  24. package/src/dashboard/router.js +5 -0
  25. package/src/memory/distiller.js +350 -0
  26. package/src/memory/extractor.js +0 -8
  27. package/src/memory/format.js +17 -2
  28. package/src/memory/index.js +10 -0
  29. package/src/memory/retriever.js +7 -2
  30. package/src/memory/skills-cache.js +180 -0
  31. package/src/memory/tencentdb-launcher.js +459 -0
  32. package/src/memory/wiki.js +171 -0
  33. package/src/orchestrator/index.js +85 -2
  34. package/src/orchestrator/sse-transformer.js +9 -3
  35. package/src/prompts/system.js +20 -6
  36. package/src/routing/affinity-store.js +68 -1
  37. package/src/routing/agentic-detector.js +9 -1
  38. package/src/routing/cache-economics.js +123 -0
  39. package/src/routing/cache-switch-cost.js +194 -0
  40. package/src/routing/calibration.js +8 -1
  41. package/src/routing/complexity-analyzer.js +37 -10
  42. package/src/routing/context-compressor.js +170 -0
  43. package/src/routing/difficulty-classifier.js +115 -13
  44. package/src/routing/index.js +161 -53
  45. package/src/routing/model-registry.js +35 -10
  46. package/src/routing/session-affinity.js +60 -0
  47. package/src/routing/telemetry.js +343 -2
  48. package/src/routing/verifier.js +41 -2
  49. package/src/server.js +36 -3
  50. package/src/tools/code-graph.js +6 -4
@@ -809,6 +809,33 @@ var config = {
809
809
  extraction: {
810
810
  enabled: memoryExtractionEnabled,
811
811
  },
812
+ // TencentDB-Agent-Memory-inspired token optimization (L0-L3 pipeline).
813
+ // Fixed defaults by design — tune here, not via env.
814
+ distillation: {
815
+ enabled: true,
816
+ turnThreshold: 10, // distill once conversation reaches 10 user turns
817
+ keepRecentTurns: 3, // last 3 user turns stay verbatim
818
+ // Size-based rescue trigger: fires regardless of turn count once the
819
+ // history alone hits this many estimated tokens, so small-context
820
+ // local models (4k-8k) don't overflow before the turn threshold.
821
+ tokenThreshold: 3000,
822
+ // Cache-aware routing (Phase 5): once emitted, the distilled block is
823
+ // frozen and only re-distilled every this-many user turns, so the
824
+ // block stays byte-identical between refreshes and compounds provider
825
+ // prompt-cache hits instead of rewriting history on every request.
826
+ // The stable-breakpoint hierarchy in prompt-cache-injection.js shares
827
+ // this K for its frozen history boundary.
828
+ refreshEveryTurns: 5,
829
+ },
830
+ skills: {
831
+ enabled: true,
832
+ minSavings: 0.6, // compression outcome must save 60%+ to be cached
833
+ },
834
+ wiki: {
835
+ enabled: true,
836
+ minTokens: 500, // register blocks larger than ~500 tokens
837
+ similarityThreshold: 0.85,
838
+ },
812
839
  decay: {
813
840
  enabled: memoryDecayEnabled,
814
841
  halfLifeDays: Number.isNaN(memoryDecayHalfLifeDays) ? 30 : memoryDecayHalfLifeDays,
@@ -888,6 +915,41 @@ var config = {
888
915
  provider: headroomProvider,
889
916
  logLevel: headroomLogLevel,
890
917
  },
918
+ // TencentDB-Agent-Memory sidecar (memory-core + memory-hub, Docker Hub
919
+ // `agentmemory/*` images). Opt-in like Headroom; when enabled, `lynkr start`
920
+ // launches both containers. Their internal LLM calls default to routing
921
+ // back through Lynkr itself, so no extra API key is needed.
922
+ tencentdbMemory: {
923
+ enabled: process.env.TENCENTDB_MEMORY_ENABLED === "true",
924
+ docker: {
925
+ enabled: process.env.TENCENTDB_MEMORY_DOCKER_ENABLED !== "false", // default true when sidecar enabled
926
+ network: "tdai-memory-stack",
927
+ core: {
928
+ image: process.env.TENCENTDB_MEMORY_CORE_IMAGE ?? "agentmemory/memory-core:latest",
929
+ containerName: "tdai-memory-core",
930
+ port: Number.parseInt(process.env.TENCENTDB_MEMORY_CORE_PORT ?? "8420", 10),
931
+ volume: "tdai-memory-core-data",
932
+ },
933
+ hub: {
934
+ image: process.env.TENCENTDB_MEMORY_HUB_IMAGE ?? "agentmemory/memory-hub:latest",
935
+ containerName: "tdai-memory-hub",
936
+ panelPort: Number.parseInt(process.env.TENCENTDB_MEMORY_PANEL_PORT ?? "8125", 10),
937
+ knowledgePort: Number.parseInt(process.env.TENCENTDB_MEMORY_KNOWLEDGE_PORT ?? "8424", 10),
938
+ volume: "tdai-panel-data",
939
+ },
940
+ },
941
+ // LLM the memory services use for extraction/summarization/wiki ingest.
942
+ // Defaults route through Lynkr's own OpenAI-compatible endpoint (tier
943
+ // routing decides the actual model), so local Ollama setups run free.
944
+ llm: {
945
+ baseUrl: process.env.TENCENTDB_MEMORY_LLM_BASE_URL
946
+ ?? `http://host.docker.internal:${Number.isNaN(port) ? 8080 : port}/v1`,
947
+ apiKey: process.env.TENCENTDB_MEMORY_LLM_API_KEY ?? "lynkr-local",
948
+ model: process.env.TENCENTDB_MEMORY_LLM_MODEL ?? "auto",
949
+ protocol: process.env.TENCENTDB_MEMORY_LLM_PROTOCOL ?? "openai",
950
+ },
951
+ promptMode: process.env.TENCENTDB_MEMORY_PROMPT_MODE ?? "code", // code | chat
952
+ },
891
953
  security: {
892
954
  // Content filtering
893
955
  contentFilterEnabled: process.env.SECURITY_CONTENT_FILTER_ENABLED !== "false", // default true
@@ -949,6 +1011,21 @@ var config = {
949
1011
  // If all exit 0, short-circuit the request with zero LLM cost.
950
1012
  preflightEnabled: process.env.LYNKR_PREFLIGHT_ENABLED === 'true',
951
1013
  preflightTimeoutMs: Number(process.env.LYNKR_PREFLIGHT_TIMEOUT_MS) || 120000,
1014
+ // Cache-aware routing (Phases 1-3). Fixed defaults by design — tune
1015
+ // here, not via env (same policy as memory.distillation).
1016
+ cacheAware: {
1017
+ enabled: true,
1018
+ // Median remaining turns assumed when telemetry is too sparse to
1019
+ // estimate — conservative so a warm pin isn't dropped for a switch
1020
+ // that only pays off over a long horizon that may not happen.
1021
+ defaultRemainingTurns: 10,
1022
+ // Per-turn size assumptions when the payload gives no better signal.
1023
+ newTokensPerTurn: 2000,
1024
+ outputTokensPerTurn: 800,
1025
+ // Local models: switching costs prefill latency, not dollars. Hold
1026
+ // the pin while the warm prefix exceeds this many tokens.
1027
+ localMaxSwitchPrefixTokens: 16000,
1028
+ },
952
1029
  },
953
1030
 
954
1031
  // Model Tier Configuration (REQUIRED)
@@ -973,6 +1050,8 @@ var config = {
973
1050
  command: process.env.CODE_GRAPH_COMMAND || 'graphify',
974
1051
  workspace: process.env.CODE_GRAPH_WORKSPACE || process.cwd(),
975
1052
  timeout: parseInt(process.env.CODE_GRAPH_TIMEOUT, 10) || 10000,
1053
+ maxDepth: 2, // symbol dependency depth for graph queries
1054
+ maxFiles: 5, // max files returned as relevant context
976
1055
  },
977
1056
 
978
1057
  // Large payload optimization (skip cloning media blocks that get discarded)
@@ -13,6 +13,13 @@ const logger = require('../logger');
13
13
  const config = require('../config');
14
14
  const distill = require('./distill');
15
15
 
16
+ // Lazy-loaded so requiring this module doesn't pull in the SQLite store
17
+ let skillsCache = null;
18
+ function getSkillsCache() {
19
+ if (!skillsCache) skillsCache = require('../memory/skills-cache');
20
+ return skillsCache;
21
+ }
22
+
16
23
  /**
17
24
  * Compress conversation history to fit within token budget
18
25
  *
@@ -277,14 +284,26 @@ function compressToolResultBlock(block, options = {}) {
277
284
  tool_use_id: block.tool_use_id,
278
285
  };
279
286
 
280
- // Compress content using Distill when content is large enough to benefit
287
+ // Compress content using Distill when content is large enough to benefit.
288
+ // The skills cache remembers how each output shape compressed before:
289
+ // known-futile shapes skip dedup work, proven-compressible shapes get a
290
+ // tighter length budget.
291
+ const compressWithSkills = (text, previousResult) => {
292
+ const skills = getSkillsCache();
293
+ const hints = skills.getCompressionHints(text);
294
+ const result = distill.compressToolResult(text, {
295
+ previousResult,
296
+ maxLength: Math.floor(500 * hints.maxLengthFactor),
297
+ skipDedup: hints.skipDedup,
298
+ });
299
+ const savings = text.length > 0 ? 1 - result.text.length / text.length : 0;
300
+ skills.record(hints.signature, result.method, savings);
301
+ return result.text;
302
+ };
303
+
281
304
  if (typeof block.content === 'string') {
282
305
  if (block.content.length > 500) {
283
- const result = distill.compressToolResult(block.content, {
284
- previousResult: options.previousResult,
285
- maxLength: 500,
286
- });
287
- compressed.content = result.text;
306
+ compressed.content = compressWithSkills(block.content, options.previousResult);
288
307
  } else {
289
308
  compressed.content = block.content;
290
309
  }
@@ -292,14 +311,14 @@ function compressToolResultBlock(block, options = {}) {
292
311
  compressed.content = block.content.map(item => {
293
312
  if (typeof item === 'string') {
294
313
  if (item.length > 500) {
295
- return distill.compressToolResult(item, { maxLength: 500 }).text;
314
+ return compressWithSkills(item);
296
315
  }
297
316
  return item;
298
317
  } else if (item.type === 'text') {
299
318
  if (item.text && item.text.length > 500) {
300
319
  return {
301
320
  type: 'text',
302
- text: distill.compressToolResult(item.text, { maxLength: 500 }).text,
321
+ text: compressWithSkills(item.text),
303
322
  };
304
323
  }
305
324
  return item;
@@ -342,6 +342,7 @@ function deduplicateBlocks(blocks, options = {}) {
342
342
  * @param {Object} options
343
343
  * @param {string} options.previousResult - Previous tool result for delta rendering
344
344
  * @param {number} options.maxLength - Max output length (default 1000)
345
+ * @param {boolean} options.skipDedup - Skip section dedup (known-futile shapes)
345
346
  * @returns {Object} { text, method, stats }
346
347
  */
347
348
  function compressToolResult(text, options = {}) {
@@ -380,7 +381,7 @@ function compressToolResult(text, options = {}) {
380
381
  }
381
382
 
382
383
  // Step 3: Internal dedup — split into logical sections and dedup
383
- const sections = result.split(/\n{2,}/);
384
+ const sections = options.skipDedup ? [] : result.split(/\n{2,}/);
384
385
  if (sections.length > 3) {
385
386
  const { compressed, stats } = deduplicateBlocks(sections);
386
387
  if (stats.duplicatesRemoved > 0) {
@@ -205,7 +205,15 @@ function routing(req, res) {
205
205
  if (s) providerStats[p] = s;
206
206
  }
207
207
 
208
- res.json({ tierDefinitions: TIER_DEFINITIONS, accuracy, stats, providerStats, circuitBreakers: cbStates, window: win.label });
208
+ // Cache-aware routing (Phase 6): per-decision switch/hold economics,
209
+ // aggregated into "cache dollars saved by routing".
210
+ const cacheEconomics = telemetry.getCacheEconomics({ since });
211
+ // Lens money-framing: the MEASURED companion — cache reads actually
212
+ // served this window, auditable against the bill (vs the projected
213
+ // figures above). null until cache counters have accrued.
214
+ const cacheMeasured = telemetry.getMeasuredCacheSavings({ since });
215
+
216
+ res.json({ tierDefinitions: TIER_DEFINITIONS, accuracy, stats, providerStats, circuitBreakers: cbStates, cacheEconomics, cacheMeasured, window: win.label });
209
217
  } catch (e) {
210
218
  res.status(500).json({ error: 'routing_api_error', detail: e.message });
211
219
  }
@@ -229,4 +237,76 @@ function logs(req, res) {
229
237
  }
230
238
  }
231
239
 
232
- module.exports = { overview, usage, routing, logs };
240
+ /* ── Lens endpoints (feature/lens-dashboard) ─────────────────────────── */
241
+
242
+ // Recommendations — analyzer findings ranked by past overspend.
243
+ function recommendations(req, res) {
244
+ try {
245
+ const engine = require('./recommendations');
246
+ const windowMs = req.query.window === '30d' ? 30 * 86400000
247
+ : req.query.window === '24h' ? 86400000
248
+ : 7 * 86400000;
249
+ res.json(engine.run({ windowMs, force: req.query.force === 'true' }));
250
+ } catch (e) {
251
+ res.status(500).json({ error: 'recommendations_api_error', detail: e.message });
252
+ }
253
+ }
254
+
255
+ // Session drill-down: per-model mix + context-growth series + raw rows.
256
+ function sessionDetail(req, res) {
257
+ try {
258
+ const detail = telemetry.getSessionDetail(req.params.id);
259
+ if (!detail) return res.status(404).json({ error: 'session_not_found' });
260
+ res.json(detail);
261
+ } catch (e) {
262
+ res.status(500).json({ error: 'session_api_error', detail: e.message });
263
+ }
264
+ }
265
+
266
+ // Pivot explorer: metric × dimension (× stack), whitelisted server-side.
267
+ function analytics(req, res) {
268
+ try {
269
+ const days = Math.min(90, Math.max(1, parseInt(req.query.days || '7', 10) || 7));
270
+ const data = telemetry.getAnalytics({
271
+ metric: req.query.metric,
272
+ by: req.query.by,
273
+ stack: req.query.stack || null,
274
+ since: Date.now() - days * 86400000,
275
+ });
276
+ if (!data) return res.status(503).json({ error: 'telemetry_unavailable' });
277
+ res.json(data);
278
+ } catch (e) {
279
+ res.status(500).json({ error: 'analytics_api_error', detail: e.message });
280
+ }
281
+ }
282
+
283
+ // Statusline: one cheap snapshot for the zero-token status line — last
284
+ // routed request, today's spend, measured cache re-read share.
285
+ function statusline(req, res) {
286
+ try {
287
+ const last = telemetry.query({ limit: 1 })[0] ?? null;
288
+ const since = Date.now() - 86400000;
289
+ const db = telemetry.getDb();
290
+ let today = null;
291
+ if (db) {
292
+ today = db.prepare(
293
+ `SELECT SUM(COALESCE(cost_usd,0)) spend,
294
+ SUM(COALESCE(cache_read_tokens,0)) rd,
295
+ SUM(COALESCE(cache_read_tokens,0)+COALESCE(cache_creation_tokens,0)+COALESCE(input_tokens,0)) total
296
+ FROM routing_telemetry WHERE timestamp > ?`
297
+ ).get(since);
298
+ }
299
+ res.json({
300
+ last: last && {
301
+ tier: last.tier, provider: last.provider, model: last.model,
302
+ pinned: !!last.pinned, at: last.timestamp,
303
+ },
304
+ todaySpendUsd: today?.spend ?? null,
305
+ cacheReadPct: today && today.total > 0 ? Math.round((today.rd / today.total) * 100) : null,
306
+ });
307
+ } catch (e) {
308
+ res.status(500).json({ error: 'statusline_api_error', detail: e.message });
309
+ }
310
+ }
311
+
312
+ module.exports = { overview, usage, routing, logs, recommendations, sessionDetail, analytics, statusline };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * cache-weakspots — prompt-cache hit ratio per (provider, model), worst first.
3
+ *
4
+ * Reads the per-request cache counters (cache_read_tokens /
5
+ * cache_creation_tokens). Rows recorded before capture began are NULL and
6
+ * excluded — when nothing has been measured yet the finding says so
7
+ * explicitly instead of reporting a fake all-clear.
8
+ */
9
+
10
+ const MIN_REQUESTS = 5;
11
+ const WEAK_RATIO = 0.5;
12
+
13
+ module.exports = {
14
+ id: 'cache-weakspots',
15
+ title: 'Weak prompt-cache hit ratios',
16
+
17
+ analyze({ db, since }) {
18
+ const rows = db
19
+ .prepare(
20
+ `SELECT provider, model, COUNT(*) n,
21
+ SUM(COALESCE(cache_read_tokens,0)) rd,
22
+ SUM(COALESCE(cache_creation_tokens,0)) cr,
23
+ SUM(COALESCE(input_tokens,0)) inp
24
+ FROM routing_telemetry
25
+ WHERE timestamp > ? AND cache_read_tokens IS NOT NULL
26
+ GROUP BY provider, model
27
+ HAVING COUNT(*) >= ${MIN_REQUESTS}`
28
+ )
29
+ .all(since);
30
+
31
+ if (!rows.length) {
32
+ return {
33
+ id: this.id, title: this.title, mechanism: 'info', state: 'not_measured',
34
+ framing: 'usd', pastOverspendUsd: null, pastOverspendTokens: null,
35
+ stats: [], evidence: { columns: [], rows: [] }, fix: null,
36
+ caveat: null,
37
+ estimateBasis: 'Cache counters are captured from this version onward — data accrues as requests flow. An un-run measurement is not an all-clear.',
38
+ };
39
+ }
40
+
41
+ const { resolveCacheEconomics } = require('../../routing/cache-economics');
42
+ const weak = [];
43
+ let usd = 0;
44
+ for (const r of rows) {
45
+ const total = r.rd + r.cr + r.inp;
46
+ const ratio = total > 0 ? r.rd / total : 0;
47
+ if (ratio >= WEAK_RATIO) continue;
48
+ const econ = resolveCacheEconomics(r.provider, r.model);
49
+ // Ceiling: what the uncached input cost beyond cache-read price.
50
+ const missedUsd = econ.unknownPricing || econ.mechanism === 'local'
51
+ ? null
52
+ : (r.inp * Math.max(0, econ.inputPerM - econ.cacheReadPerM)) / 1_000_000;
53
+ if (missedUsd != null) usd += missedUsd;
54
+ weak.push([r.provider, r.model, r.n, `${Math.round(ratio * 100)}%`, r.inp, missedUsd ?? '(local/unknown)']);
55
+ }
56
+
57
+ if (!weak.length) {
58
+ return {
59
+ id: this.id, title: this.title, mechanism: 'info', state: 'clean',
60
+ framing: 'usd', pastOverspendUsd: 0, pastOverspendTokens: null,
61
+ stats: [{ label: 'models measured', value: rows.length }],
62
+ evidence: { columns: [], rows: [] }, fix: null, caveat: null,
63
+ estimateBasis: `Hit ratio = cache reads ÷ (reads + writes + uncached input); weak = below ${WEAK_RATIO * 100}%.`,
64
+ };
65
+ }
66
+
67
+ return {
68
+ id: this.id,
69
+ title: this.title,
70
+ mechanism: 'info',
71
+ state: 'actionable',
72
+ framing: 'usd',
73
+ pastOverspendUsd: usd,
74
+ pastOverspendTokens: null,
75
+ stats: [
76
+ { label: 'weak (provider, model) pairs', value: weak.length },
77
+ { label: 'models measured', value: rows.length },
78
+ ],
79
+ evidence: {
80
+ columns: ['provider', 'model', 'requests', 'hit ratio', 'uncached input tokens', 'ceiling $'],
81
+ rows: weak.sort((a, b) => parseFloat(a[3]) - parseFloat(b[3])),
82
+ },
83
+ fix: 'Low ratios usually mean an unstable prefix: per-request bytes in the system prompt, client-side history rewrites, or conversations too short to cache. Check the silent-invalidator list and whether the distiller freeze covers these sessions.',
84
+ caveat: 'The dollar figure is a CEILING — it prices every uncached input token at the read discount, which assumes perfect cacheability. Real recovery is lower.',
85
+ estimateBasis: 'Uncached input tokens × (input price − cache-read price) for pairs below a 50% hit ratio, this window. NULL-counter rows (pre-capture) excluded.',
86
+ };
87
+ },
88
+ };
@@ -0,0 +1,82 @@
1
+ /**
2
+ * deadweight — tool schemas carried on every request but never called.
3
+ *
4
+ * Telemetry stores per-request tool COUNTS, not names, so the detectable
5
+ * unit is the session: sessions whose every request shipped a tool loadout
6
+ * and whose tool_calls_made never left zero paid schema rent for nothing.
7
+ * (Per-tool attribution needs tool names in telemetry — future work.)
8
+ */
9
+
10
+ const EST_TOKENS_PER_TOOL = 150; // typical JSON schema; stated in estimateBasis
11
+ const MIN_REQUESTS = 3;
12
+
13
+ module.exports = {
14
+ id: 'deadweight',
15
+ title: 'Tool schemas carried but never used',
16
+
17
+ analyze({ db, since }) {
18
+ const rows = db
19
+ .prepare(
20
+ `SELECT session_id,
21
+ COUNT(*) reqs,
22
+ AVG(COALESCE(tool_count,0)) avg_tools,
23
+ MAX(model) model,
24
+ MAX(provider) provider,
25
+ SUM(COALESCE(cost_usd,0)) session_cost
26
+ FROM routing_telemetry
27
+ WHERE timestamp > ? AND session_id IS NOT NULL AND COALESCE(tool_count,0) > 0
28
+ GROUP BY session_id
29
+ HAVING SUM(COALESCE(tool_calls_made,0)) = 0 AND COUNT(*) >= ${MIN_REQUESTS}
30
+ ORDER BY reqs * avg_tools DESC
31
+ LIMIT 50`
32
+ )
33
+ .all(since);
34
+
35
+ if (!rows.length) {
36
+ return {
37
+ id: this.id, title: this.title, mechanism: 'snippet', state: 'clean',
38
+ framing: 'usd', pastOverspendUsd: 0, pastOverspendTokens: 0,
39
+ stats: [], evidence: { columns: [], rows: [] },
40
+ fix: null, caveat: null,
41
+ estimateBasis: 'Sessions with >=3 requests, a tool loadout on every request, and zero tool calls.',
42
+ };
43
+ }
44
+
45
+ const { resolveCacheEconomics } = require('../../routing/cache-economics');
46
+ let tokens = 0;
47
+ let usd = 0;
48
+ const evidence = [];
49
+ for (const r of rows) {
50
+ const wastedTokens = Math.round(r.avg_tools * EST_TOKENS_PER_TOOL * r.reqs);
51
+ tokens += wastedTokens;
52
+ const econ = resolveCacheEconomics(r.provider, r.model);
53
+ const rowUsd = econ.unknownPricing ? null : (wastedTokens * econ.inputPerM) / 1_000_000;
54
+ if (rowUsd != null) usd += rowUsd;
55
+ evidence.push([
56
+ r.session_id, r.reqs, Math.round(r.avg_tools), wastedTokens,
57
+ rowUsd != null ? rowUsd : '(price unknown)',
58
+ ]);
59
+ }
60
+
61
+ return {
62
+ id: this.id,
63
+ title: this.title,
64
+ mechanism: 'snippet',
65
+ state: 'actionable',
66
+ framing: 'usd',
67
+ pastOverspendUsd: usd,
68
+ pastOverspendTokens: tokens,
69
+ stats: [
70
+ { label: 'sessions affected', value: rows.length },
71
+ { label: 'est. schema tokens re-sent', value: tokens },
72
+ ],
73
+ evidence: {
74
+ columns: ['session', 'requests', 'avg tools', 'est. wasted tokens', 'est. $'],
75
+ rows: evidence,
76
+ },
77
+ fix: 'These sessions never called a tool — their MCP servers/tool registrations paid schema rent on every request. Trim unused MCP servers from the client config, or scope them per-project.',
78
+ caveat: 'Schema size is estimated at ~150 tokens per tool; telemetry stores tool counts, not names, so attribution is per-session rather than per-tool.',
79
+ estimateBasis: 'avg tools × ~150 est. tokens/schema × requests per session, priced at each session\'s model input rate. Sessions with any tool call are excluded entirely.',
80
+ };
81
+ },
82
+ };
@@ -0,0 +1,109 @@
1
+ /**
2
+ * downsize — request types where the tier below has statistically proven
3
+ * itself, Wilson-bounded.
4
+ *
5
+ * The de-escalator's live rule uses raw averages (>=30 rows, avg quality
6
+ * >=70). This analyzer applies the stricter test TokenJam-bench uses for
7
+ * verdicts: the WILSON LOWER BOUND of the lower tier's success rate must
8
+ * clear the bar, so 21-of-30 (lower bound ~0.52) stays "unproven" while
9
+ * 140-of-170 (~0.76) is a verdict. Success = quality >= 70 and no error.
10
+ *
11
+ * No quality-equivalence claim is made — the verdict is "the lower tier's
12
+ * measured floor clears the bar on this request type", nothing more.
13
+ */
14
+
15
+ const { wilsonLowerBound } = require('./wilson');
16
+
17
+ const TIER_ORDER = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
18
+ const MIN_SAMPLES = 20;
19
+ const SUCCESS_QUALITY = 70;
20
+ const WILSON_BAR = 0.7;
21
+ const EVIDENCE_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; // evidence looks back further than the spend window
22
+
23
+ module.exports = {
24
+ id: 'downsize',
25
+ title: 'Tiers that could step down (Wilson-proven)',
26
+
27
+ analyze({ db, since, until }) {
28
+ // Lower-tier track record per request_type over 30 days.
29
+ const evidence = db
30
+ .prepare(
31
+ `SELECT tier, request_type,
32
+ COUNT(*) n,
33
+ SUM(CASE WHEN COALESCE(quality_score,0) >= ${SUCCESS_QUALITY} AND error_type IS NULL THEN 1 ELSE 0 END) k,
34
+ AVG(COALESCE(cost_usd,0)) avg_cost
35
+ FROM routing_telemetry
36
+ WHERE timestamp > ? AND tier IS NOT NULL AND request_type IS NOT NULL
37
+ GROUP BY tier, request_type`
38
+ )
39
+ .all(until - EVIDENCE_WINDOW_MS);
40
+
41
+ const byKey = new Map(evidence.map((r) => [`${r.tier}::${r.request_type}`, r]));
42
+
43
+ // Spend at each upper tier per request_type, this window.
44
+ const upperSpend = db
45
+ .prepare(
46
+ `SELECT tier, request_type, COUNT(*) n, SUM(COALESCE(cost_usd,0)) cost, AVG(COALESCE(cost_usd,0)) avg_cost
47
+ FROM routing_telemetry
48
+ WHERE timestamp > ? AND tier IS NOT NULL AND request_type IS NOT NULL
49
+ GROUP BY tier, request_type`
50
+ )
51
+ .all(since);
52
+
53
+ const rows = [];
54
+ let usd = 0;
55
+ let anyEvidence = false;
56
+ for (const u of upperSpend) {
57
+ const idx = TIER_ORDER.indexOf(u.tier);
58
+ if (idx <= 0) continue;
59
+ const lower = byKey.get(`${TIER_ORDER[idx - 1]}::${u.request_type}`);
60
+ if (!lower) continue;
61
+ anyEvidence = true;
62
+ if (lower.n < MIN_SAMPLES) continue;
63
+ const bound = wilsonLowerBound(lower.k, lower.n);
64
+ if (bound < WILSON_BAR) continue;
65
+ // Empirical per-request price delta between the two tiers on the SAME
66
+ // request type — measured, not modeled.
67
+ const delta = Math.max(0, (u.avg_cost ?? 0) - (lower.avg_cost ?? 0)) * u.n;
68
+ usd += delta;
69
+ rows.push([
70
+ u.request_type, u.tier, `${TIER_ORDER[idx - 1]}`,
71
+ `${lower.k}/${lower.n}`, bound.toFixed(2), u.n, delta,
72
+ ]);
73
+ }
74
+
75
+ if (!rows.length) {
76
+ return {
77
+ id: this.id, title: this.title, mechanism: 'info',
78
+ state: anyEvidence ? 'clean' : 'not_measured',
79
+ framing: 'usd', pastOverspendUsd: anyEvidence ? 0 : null, pastOverspendTokens: null,
80
+ stats: [],
81
+ evidence: { columns: [], rows: [] }, fix: null, caveat: null,
82
+ estimateBasis: anyEvidence
83
+ ? `No (tier, request_type) pair has a lower tier whose Wilson lower bound clears ${WILSON_BAR} on >=${MIN_SAMPLES} samples yet.`
84
+ : 'Needs quality-scored telemetry with request_type populated — accrues as traffic flows (request_type capture is recent).',
85
+ };
86
+ }
87
+
88
+ rows.sort((a, b) => b[6] - a[6]);
89
+ return {
90
+ id: this.id,
91
+ title: this.title,
92
+ mechanism: 'info',
93
+ state: 'actionable',
94
+ framing: 'usd',
95
+ pastOverspendUsd: usd,
96
+ pastOverspendTokens: null,
97
+ stats: [
98
+ { label: 'proven (request_type, tier) pairs', value: rows.length },
99
+ ],
100
+ evidence: {
101
+ columns: ['request_type', 'served tier', 'proven lower tier', 'lower success', 'Wilson lower bound', 'requests this window', 'measured delta $'],
102
+ rows,
103
+ },
104
+ fix: 'The live de-escalator will demote these automatically as its own thresholds clear; to act sooner, adjust the TIER_* mapping for these request types or lower the de-escalator sample bar deliberately.',
105
+ caveat: 'Verdict is "the lower tier\'s measured success floor clears the bar" — not quality equivalence. Success = quality >= 70 with no error.',
106
+ estimateBasis: 'Per-request cost delta between tiers measured on the SAME request_type (30-day evidence window), × this window\'s upper-tier request count. Wilson 95% lower bound.',
107
+ };
108
+ },
109
+ };
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Lens recommendations engine.
3
+ *
4
+ * Registry-driven analyzers over routing telemetry that answer "what should
5
+ * the operator change", TokenJam-style, with three disciplines borrowed
6
+ * deliberately:
7
+ *
8
+ * - PAST OVERSPEND, never projected savings: every dollar figure is
9
+ * backward-looking so the operator can check it against a bill already
10
+ * paid. Ceilings are labeled as ceilings in estimateBasis.
11
+ * - THREE-STATE HONESTY: 'actionable' (evidence found), 'clean' (ran,
12
+ * found nothing), 'not_measured' (data doesn't exist yet on this
13
+ * install). An un-run scan is not an all-clear.
14
+ * - MECHANISM badges: 'auto' (Lynkr could act), 'snippet' (client-side
15
+ * fix the operator applies), 'info' (diagnosis only).
16
+ *
17
+ * Analyzer contract: analyze({db, since, until}) -> finding
18
+ * {id, title, mechanism, state, framing: 'usd'|'tokens',
19
+ * pastOverspendUsd|null, pastOverspendTokens|null, stats: [{label,value}],
20
+ * evidence: {columns:[], rows:[][]}, fix, caveat, estimateBasis}
21
+ *
22
+ * The analyzers' overspend bases are disjoint by construction (dead tool
23
+ * schemas / harness side traffic / uncached input / tier price deltas), so
24
+ * the total is a plain sum — revisit if an analyzer is added whose basis
25
+ * overlaps an existing one.
26
+ *
27
+ * @module dashboard/recommendations
28
+ */
29
+
30
+ const logger = require('../../logger');
31
+ const telemetry = require('../../routing/telemetry');
32
+
33
+ const ANALYZERS = [
34
+ require('./deadweight'),
35
+ require('./side-requests'),
36
+ require('./cache-weakspots'),
37
+ require('./downsize'),
38
+ ];
39
+
40
+ const DEFAULT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
41
+ const RESULT_TTL_MS = 45 * 1000;
42
+
43
+ let _cached = null;
44
+
45
+ /**
46
+ * Run all analyzers. Cached for 45s — findings shift slowly and the
47
+ * dashboard polls every 30s.
48
+ *
49
+ * @param {Object} [opts] {windowMs, force}
50
+ */
51
+ function run(opts = {}) {
52
+ const windowMs = opts.windowMs ?? DEFAULT_WINDOW_MS;
53
+ if (!opts.force && _cached && Date.now() - _cached.computedAt < RESULT_TTL_MS
54
+ && _cached.windowMs === windowMs) {
55
+ return _cached;
56
+ }
57
+
58
+ const db = telemetry.getDb();
59
+ const until = Date.now();
60
+ const since = until - windowMs;
61
+
62
+ const findings = [];
63
+ for (const analyzer of ANALYZERS) {
64
+ try {
65
+ const finding = db
66
+ ? analyzer.analyze({ db, since, until })
67
+ : { id: analyzer.id, title: analyzer.title, state: 'not_measured', reason: 'telemetry unavailable' };
68
+ if (finding) findings.push(finding);
69
+ } catch (err) {
70
+ logger.debug({ analyzer: analyzer.id, err: err.message }, '[Recommendations] analyzer failed');
71
+ findings.push({ id: analyzer.id, title: analyzer.title, state: 'not_measured', reason: err.message });
72
+ }
73
+ }
74
+
75
+ // Rank by dollars, biggest first; token-framed findings after usd ones.
76
+ findings.sort((a, b) => (b.pastOverspendUsd ?? -1) - (a.pastOverspendUsd ?? -1));
77
+
78
+ let spendUsd = 0;
79
+ try {
80
+ if (db) {
81
+ spendUsd = db
82
+ .prepare('SELECT SUM(COALESCE(cost_usd,0)) s FROM routing_telemetry WHERE timestamp > ?')
83
+ .get(since)?.s ?? 0;
84
+ }
85
+ } catch { /* spend share stays 0 */ }
86
+
87
+ const totalRecoverableUsd = findings
88
+ .filter((f) => f.state === 'actionable' && typeof f.pastOverspendUsd === 'number')
89
+ .reduce((acc, f) => acc + f.pastOverspendUsd, 0);
90
+
91
+ _cached = {
92
+ computedAt: Date.now(),
93
+ windowMs,
94
+ spendUsd,
95
+ totalRecoverableUsd,
96
+ findings,
97
+ };
98
+ return _cached;
99
+ }
100
+
101
+ /** Test helper — drop the memoized result. */
102
+ function _clearCacheForTests() {
103
+ _cached = null;
104
+ }
105
+
106
+ module.exports = { run, ANALYZERS, _clearCacheForTests };