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.
- package/README.md +46 -11
- package/bin/lynkr-init.js +7 -0
- package/bin/statusline.js +80 -0
- package/bin/wrap.js +10 -6
- package/config/model-tiers.json +5 -5
- package/package.json +9 -4
- package/public/dashboard.html +354 -1
- package/src/api/openai-router.js +120 -2
- package/src/api/router.js +34 -0
- package/src/clients/databricks.js +148 -66
- package/src/clients/gpt-utils.js +8 -1
- package/src/clients/ollama-utils.js +19 -10
- package/src/clients/prompt-cache-injection.js +93 -30
- package/src/config/index.js +79 -0
- package/src/context/compression.js +27 -8
- package/src/context/distill.js +2 -1
- package/src/dashboard/api.js +82 -2
- package/src/dashboard/recommendations/cache-weakspots.js +88 -0
- package/src/dashboard/recommendations/deadweight.js +82 -0
- package/src/dashboard/recommendations/downsize.js +109 -0
- package/src/dashboard/recommendations/index.js +106 -0
- package/src/dashboard/recommendations/side-requests.js +68 -0
- package/src/dashboard/recommendations/wilson.js +24 -0
- package/src/dashboard/router.js +5 -0
- package/src/memory/distiller.js +350 -0
- package/src/memory/extractor.js +0 -8
- package/src/memory/format.js +17 -2
- package/src/memory/index.js +10 -0
- package/src/memory/retriever.js +7 -2
- package/src/memory/skills-cache.js +180 -0
- package/src/memory/tencentdb-launcher.js +459 -0
- package/src/memory/wiki.js +171 -0
- package/src/orchestrator/index.js +85 -2
- package/src/orchestrator/sse-transformer.js +9 -3
- package/src/prompts/system.js +20 -6
- package/src/routing/affinity-store.js +68 -1
- package/src/routing/agentic-detector.js +9 -1
- package/src/routing/cache-economics.js +123 -0
- package/src/routing/cache-switch-cost.js +194 -0
- package/src/routing/calibration.js +8 -1
- package/src/routing/complexity-analyzer.js +37 -10
- package/src/routing/context-compressor.js +170 -0
- package/src/routing/difficulty-classifier.js +115 -13
- package/src/routing/index.js +161 -53
- package/src/routing/model-registry.js +35 -10
- package/src/routing/session-affinity.js +60 -0
- package/src/routing/telemetry.js +343 -2
- package/src/routing/verifier.js +41 -2
- package/src/server.js +36 -3
- package/src/tools/code-graph.js +6 -4
package/src/config/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
321
|
+
text: compressWithSkills(item.text),
|
|
303
322
|
};
|
|
304
323
|
}
|
|
305
324
|
return item;
|
package/src/context/distill.js
CHANGED
|
@@ -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) {
|
package/src/dashboard/api.js
CHANGED
|
@@ -205,7 +205,15 @@ function routing(req, res) {
|
|
|
205
205
|
if (s) providerStats[p] = s;
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
-
|
|
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
|
-
|
|
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 };
|