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/routing/telemetry.js
CHANGED
|
@@ -165,6 +165,16 @@ function init() {
|
|
|
165
165
|
["candidates", "TEXT"],
|
|
166
166
|
["pinned", "INTEGER DEFAULT 0"],
|
|
167
167
|
["switch_reason", "TEXT"],
|
|
168
|
+
// Phase 6 (cache-aware routing) — per-decision cache economics
|
|
169
|
+
// receipt: {decision, reason, warmPrefixTokens, breakEvenTurns,
|
|
170
|
+
// projectedSwitchCostUsd, projectedStaySavingsUsd,
|
|
171
|
+
// expectedRemainingTurns} as JSON.
|
|
172
|
+
["cache_decision", "TEXT"],
|
|
173
|
+
// Lens dashboard — per-request cache counters from the provider's
|
|
174
|
+
// usage payload. NULL on rows recorded before capture began (or by
|
|
175
|
+
// providers that report none) — "not measured" is distinct from 0.
|
|
176
|
+
["cache_read_tokens", "INTEGER"],
|
|
177
|
+
["cache_creation_tokens", "INTEGER"],
|
|
168
178
|
];
|
|
169
179
|
for (const [col, type] of additiveCols) {
|
|
170
180
|
if (!existingCols.has(col)) {
|
|
@@ -226,7 +236,8 @@ function record(data) {
|
|
|
226
236
|
latency_ms, status_code, error_type, cost_usd, tool_calls_made,
|
|
227
237
|
retry_count, circuit_breaker_state, quality_score, tokens_per_second,
|
|
228
238
|
cost_efficiency, request_text, response_text,
|
|
229
|
-
base_tier, escalation_source, propensity, candidates, pinned, switch_reason
|
|
239
|
+
base_tier, escalation_source, propensity, candidates, pinned, switch_reason,
|
|
240
|
+
cache_decision, cache_read_tokens, cache_creation_tokens
|
|
230
241
|
) VALUES (
|
|
231
242
|
@request_id, @session_id, @timestamp, @complexity_score, @tier,
|
|
232
243
|
@agentic_type, @tool_count, @input_tokens, @message_count, @request_type,
|
|
@@ -234,7 +245,8 @@ function record(data) {
|
|
|
234
245
|
@latency_ms, @status_code, @error_type, @cost_usd, @tool_calls_made,
|
|
235
246
|
@retry_count, @circuit_breaker_state, @quality_score, @tokens_per_second,
|
|
236
247
|
@cost_efficiency, @request_text, @response_text,
|
|
237
|
-
@base_tier, @escalation_source, @propensity, @candidates, @pinned, @switch_reason
|
|
248
|
+
@base_tier, @escalation_source, @propensity, @candidates, @pinned, @switch_reason,
|
|
249
|
+
@cache_decision, @cache_read_tokens, @cache_creation_tokens
|
|
238
250
|
)`
|
|
239
251
|
);
|
|
240
252
|
if (!insert) return;
|
|
@@ -280,6 +292,13 @@ function record(data) {
|
|
|
280
292
|
candidates: candidatesJson,
|
|
281
293
|
pinned: data.pinned ? 1 : 0,
|
|
282
294
|
switch_reason: data.switch_reason ?? null,
|
|
295
|
+
cache_decision: data.cache_decision == null
|
|
296
|
+
? null
|
|
297
|
+
: (typeof data.cache_decision === "string"
|
|
298
|
+
? data.cache_decision
|
|
299
|
+
: JSON.stringify(data.cache_decision)),
|
|
300
|
+
cache_read_tokens: data.cache_read_tokens ?? null,
|
|
301
|
+
cache_creation_tokens: data.cache_creation_tokens ?? null,
|
|
283
302
|
});
|
|
284
303
|
} catch (err) {
|
|
285
304
|
logger.debug({ err: err.message }, "Telemetry record failed");
|
|
@@ -791,6 +810,323 @@ function _resetForTests() {
|
|
|
791
810
|
_testDbDisabled = false;
|
|
792
811
|
}
|
|
793
812
|
|
|
813
|
+
// Cache-aware routing (Phase 6) — "cache dollars saved / burned by routing".
|
|
814
|
+
//
|
|
815
|
+
// Every gated switch/hold decision carries a receipt. Aggregating them
|
|
816
|
+
// answers the public question "does model routing break prefix caching?"
|
|
817
|
+
// with a dollar figure:
|
|
818
|
+
// - a HOLD avoided a switch whose one-time cache write exceeded its
|
|
819
|
+
// projected per-turn savings over the expected horizon
|
|
820
|
+
// (saved = switchOnce − savings × remainingTurns)
|
|
821
|
+
// - a SWITCH cleared break-even; its projected net gain is
|
|
822
|
+
// (savings × remainingTurns − switchOnce)
|
|
823
|
+
/**
|
|
824
|
+
* @param {Object} [opts]
|
|
825
|
+
* @param {number} [opts.since] - default: last 7 days.
|
|
826
|
+
* @returns {{decisions:number, holds:number, switches:number,
|
|
827
|
+
* dollarsSavedByHolds:number, dollarsSavedBySwitches:number,
|
|
828
|
+
* totalDollarsSaved:number, byReason:Object}|null}
|
|
829
|
+
*/
|
|
830
|
+
function getCacheEconomics(opts = {}) {
|
|
831
|
+
if (!init()) return null;
|
|
832
|
+
const since = opts.since ?? Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
833
|
+
try {
|
|
834
|
+
const rows = db
|
|
835
|
+
.prepare(
|
|
836
|
+
`SELECT cache_decision FROM routing_telemetry
|
|
837
|
+
WHERE timestamp > ? AND cache_decision IS NOT NULL
|
|
838
|
+
LIMIT 20000`
|
|
839
|
+
)
|
|
840
|
+
.all(since);
|
|
841
|
+
|
|
842
|
+
const out = {
|
|
843
|
+
decisions: 0,
|
|
844
|
+
holds: 0,
|
|
845
|
+
switches: 0,
|
|
846
|
+
dollarsSavedByHolds: 0,
|
|
847
|
+
dollarsSavedBySwitches: 0,
|
|
848
|
+
totalDollarsSaved: 0,
|
|
849
|
+
byReason: {},
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
for (const row of rows) {
|
|
853
|
+
let d;
|
|
854
|
+
try {
|
|
855
|
+
d = JSON.parse(row.cache_decision);
|
|
856
|
+
} catch {
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
if (!d || typeof d !== "object") continue;
|
|
860
|
+
out.decisions++;
|
|
861
|
+
out.byReason[d.reason ?? "unknown"] = (out.byReason[d.reason ?? "unknown"] ?? 0) + 1;
|
|
862
|
+
|
|
863
|
+
const savings = typeof d.projectedStaySavingsUsd === "number" ? d.projectedStaySavingsUsd : null;
|
|
864
|
+
const once = typeof d.projectedSwitchCostUsd === "number" ? d.projectedSwitchCostUsd : null;
|
|
865
|
+
const horizon = typeof d.expectedRemainingTurns === "number" ? d.expectedRemainingTurns : null;
|
|
866
|
+
|
|
867
|
+
if (d.decision === "hold") {
|
|
868
|
+
out.holds++;
|
|
869
|
+
if (savings != null && once != null && horizon != null) {
|
|
870
|
+
out.dollarsSavedByHolds += Math.max(0, once - savings * horizon);
|
|
871
|
+
}
|
|
872
|
+
} else if (d.decision === "switch") {
|
|
873
|
+
out.switches++;
|
|
874
|
+
if (savings != null && once != null && horizon != null) {
|
|
875
|
+
out.dollarsSavedBySwitches += Math.max(0, savings * horizon - once);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
out.totalDollarsSaved = out.dollarsSavedByHolds + out.dollarsSavedBySwitches;
|
|
881
|
+
return out;
|
|
882
|
+
} catch (err) {
|
|
883
|
+
logger.debug({ err: err.message }, "Telemetry getCacheEconomics failed");
|
|
884
|
+
return null;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Cache-aware routing (Phase 3) — expected remaining turns.
|
|
889
|
+
//
|
|
890
|
+
// Conditional median: given a session has already reached `currentTurns`
|
|
891
|
+
// messages, how many MORE messages do comparable sessions run? Uses the max
|
|
892
|
+
// message_count each recent session reached. Conditional (len > current)
|
|
893
|
+
// rather than a global median because long sessions systematically outlive
|
|
894
|
+
// the average — exactly the sessions whose warm prefixes are worth pricing.
|
|
895
|
+
const _remainingTurnsCache = new Map();
|
|
896
|
+
const REMAINING_TURNS_CACHE_TTL_MS = 60 * 1000;
|
|
897
|
+
const REMAINING_TURNS_MIN_SESSIONS = 20;
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* @param {number} currentTurns - message_count the session has reached now.
|
|
901
|
+
* @returns {number|null} median remaining messages, or null when telemetry
|
|
902
|
+
* is too sparse (< 20 comparable sessions in the last 7 days).
|
|
903
|
+
*/
|
|
904
|
+
function getExpectedRemainingTurns(currentTurns = 0) {
|
|
905
|
+
if (!init()) return null;
|
|
906
|
+
const bucket = Math.max(0, Math.floor((Number(currentTurns) || 0) / 5) * 5);
|
|
907
|
+
const cached = _remainingTurnsCache.get(bucket);
|
|
908
|
+
if (cached && Date.now() - cached.ts < REMAINING_TURNS_CACHE_TTL_MS) {
|
|
909
|
+
return cached.value;
|
|
910
|
+
}
|
|
911
|
+
try {
|
|
912
|
+
const since = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
913
|
+
const rows = db
|
|
914
|
+
.prepare(
|
|
915
|
+
`SELECT MAX(message_count) AS len
|
|
916
|
+
FROM routing_telemetry
|
|
917
|
+
WHERE timestamp > ? AND session_id IS NOT NULL AND message_count IS NOT NULL
|
|
918
|
+
GROUP BY session_id
|
|
919
|
+
HAVING len > ?
|
|
920
|
+
LIMIT 5000`
|
|
921
|
+
)
|
|
922
|
+
.all(since, bucket);
|
|
923
|
+
let value = null;
|
|
924
|
+
if (rows.length >= REMAINING_TURNS_MIN_SESSIONS) {
|
|
925
|
+
const remaining = rows.map((r) => r.len - bucket).sort((a, b) => a - b);
|
|
926
|
+
value = remaining[Math.floor(remaining.length / 2)];
|
|
927
|
+
}
|
|
928
|
+
_remainingTurnsCache.set(bucket, { value, ts: Date.now() });
|
|
929
|
+
return value;
|
|
930
|
+
} catch (err) {
|
|
931
|
+
logger.debug({ err: err.message }, "Telemetry getExpectedRemainingTurns failed");
|
|
932
|
+
return null;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// ---------------------------------------------------------------------------
|
|
937
|
+
// Lens dashboard queries
|
|
938
|
+
// ---------------------------------------------------------------------------
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* MEASURED cache savings — backward-looking, auditable against the bill:
|
|
942
|
+
* for rows that carried cache counters, what did cache reads actually save
|
|
943
|
+
* vs. paying full input price? Distinct from the projected figures in
|
|
944
|
+
* getCacheEconomics. Also reports the hit ratio per (provider, model).
|
|
945
|
+
*
|
|
946
|
+
* @param {Object} [opts] {since}
|
|
947
|
+
* @returns {{measuredSavedUsd:number, rowsMeasured:number, perModel:Array}|null}
|
|
948
|
+
*/
|
|
949
|
+
function getMeasuredCacheSavings(opts = {}) {
|
|
950
|
+
if (!init()) return null;
|
|
951
|
+
const since = opts.since ?? Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
952
|
+
try {
|
|
953
|
+
const rows = db
|
|
954
|
+
.prepare(
|
|
955
|
+
`SELECT provider, model, COUNT(*) n,
|
|
956
|
+
SUM(COALESCE(cache_read_tokens,0)) rd,
|
|
957
|
+
SUM(COALESCE(cache_creation_tokens,0)) cr,
|
|
958
|
+
SUM(COALESCE(input_tokens,0)) inp
|
|
959
|
+
FROM routing_telemetry
|
|
960
|
+
WHERE timestamp > ? AND cache_read_tokens IS NOT NULL
|
|
961
|
+
GROUP BY provider, model`
|
|
962
|
+
)
|
|
963
|
+
.all(since);
|
|
964
|
+
const { resolveCacheEconomics } = require("./cache-economics");
|
|
965
|
+
let saved = 0;
|
|
966
|
+
let measured = 0;
|
|
967
|
+
const perModel = [];
|
|
968
|
+
for (const r of rows) {
|
|
969
|
+
measured += r.n;
|
|
970
|
+
const econ = resolveCacheEconomics(r.provider, r.model);
|
|
971
|
+
const savedUsd = econ.unknownPricing
|
|
972
|
+
? null
|
|
973
|
+
: (r.rd * Math.max(0, econ.inputPerM - econ.cacheReadPerM)) / 1_000_000;
|
|
974
|
+
if (savedUsd != null) saved += savedUsd;
|
|
975
|
+
const total = r.rd + r.cr + r.inp;
|
|
976
|
+
perModel.push({
|
|
977
|
+
provider: r.provider,
|
|
978
|
+
model: r.model,
|
|
979
|
+
requests: r.n,
|
|
980
|
+
cacheReadTokens: r.rd,
|
|
981
|
+
hitRatio: total > 0 ? r.rd / total : null,
|
|
982
|
+
savedUsd,
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
perModel.sort((a, b) => (a.hitRatio ?? 1) - (b.hitRatio ?? 1));
|
|
986
|
+
return { measuredSavedUsd: saved, rowsMeasured: measured, perModel };
|
|
987
|
+
} catch (err) {
|
|
988
|
+
logger.debug({ err: err.message }, "Telemetry getMeasuredCacheSavings failed");
|
|
989
|
+
return null;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Session drill-down: per-model mix, context-growth series, tool totals.
|
|
995
|
+
* @param {string} sessionId
|
|
996
|
+
*/
|
|
997
|
+
function getSessionDetail(sessionId) {
|
|
998
|
+
if (!sessionId || !init()) return null;
|
|
999
|
+
try {
|
|
1000
|
+
const rows = db
|
|
1001
|
+
.prepare(
|
|
1002
|
+
`SELECT timestamp, provider, model, tier, routing_method, input_tokens,
|
|
1003
|
+
output_tokens, cost_usd, latency_ms, tool_count, tool_calls_made,
|
|
1004
|
+
cache_read_tokens, cache_creation_tokens, status_code, error_type,
|
|
1005
|
+
pinned, switch_reason, message_count
|
|
1006
|
+
FROM routing_telemetry WHERE session_id = ? ORDER BY timestamp ASC LIMIT 2000`
|
|
1007
|
+
)
|
|
1008
|
+
.all(sessionId);
|
|
1009
|
+
if (!rows.length) return null;
|
|
1010
|
+
|
|
1011
|
+
const perModel = new Map();
|
|
1012
|
+
let toolCalls = 0;
|
|
1013
|
+
let cost = 0;
|
|
1014
|
+
for (const r of rows) {
|
|
1015
|
+
const key = `${r.provider}:${r.model}`;
|
|
1016
|
+
const m = perModel.get(key) ?? {
|
|
1017
|
+
provider: r.provider, model: r.model, requests: 0,
|
|
1018
|
+
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, costUsd: 0,
|
|
1019
|
+
};
|
|
1020
|
+
m.requests++;
|
|
1021
|
+
m.inputTokens += r.input_tokens ?? 0;
|
|
1022
|
+
m.outputTokens += r.output_tokens ?? 0;
|
|
1023
|
+
m.cacheReadTokens += r.cache_read_tokens ?? 0;
|
|
1024
|
+
m.costUsd += r.cost_usd ?? 0;
|
|
1025
|
+
perModel.set(key, m);
|
|
1026
|
+
toolCalls += r.tool_calls_made ?? 0;
|
|
1027
|
+
cost += r.cost_usd ?? 0;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
return {
|
|
1031
|
+
sessionId,
|
|
1032
|
+
requests: rows.length,
|
|
1033
|
+
totalCostUsd: cost,
|
|
1034
|
+
toolCalls,
|
|
1035
|
+
firstSeen: rows[0].timestamp,
|
|
1036
|
+
lastSeen: rows[rows.length - 1].timestamp,
|
|
1037
|
+
models: [...perModel.values()].sort((a, b) => b.costUsd - a.costUsd),
|
|
1038
|
+
// Input (context) tokens per call over the session — the distiller
|
|
1039
|
+
// cliff and frozen-block plateau are visible here.
|
|
1040
|
+
contextSeries: rows.map((r) => ({
|
|
1041
|
+
t: r.timestamp,
|
|
1042
|
+
inputTokens: r.input_tokens ?? 0,
|
|
1043
|
+
cacheReadTokens: r.cache_read_tokens ?? null,
|
|
1044
|
+
tier: r.tier,
|
|
1045
|
+
model: r.model,
|
|
1046
|
+
})),
|
|
1047
|
+
rows,
|
|
1048
|
+
};
|
|
1049
|
+
} catch (err) {
|
|
1050
|
+
logger.debug({ err: err.message }, "Telemetry getSessionDetail failed");
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Pivot explorer — every metric/dimension is whitelisted; params never reach
|
|
1056
|
+
// SQL as strings.
|
|
1057
|
+
const ANALYTICS_METRICS = {
|
|
1058
|
+
spend: "SUM(COALESCE(cost_usd,0))",
|
|
1059
|
+
tokens: "SUM(COALESCE(input_tokens,0)+COALESCE(output_tokens,0))",
|
|
1060
|
+
requests: "COUNT(*)",
|
|
1061
|
+
sessions: "COUNT(DISTINCT session_id)",
|
|
1062
|
+
};
|
|
1063
|
+
const ANALYTICS_DIMS = {
|
|
1064
|
+
provider: "provider",
|
|
1065
|
+
model: "model",
|
|
1066
|
+
tier: "tier",
|
|
1067
|
+
request_type: "request_type",
|
|
1068
|
+
routing_method: "routing_method",
|
|
1069
|
+
switch_reason: "switch_reason",
|
|
1070
|
+
day: "date(timestamp/1000, 'unixepoch')",
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Generic Metric × Dimension (× Stack) pivot with KPI row and
|
|
1075
|
+
* period-over-period deltas (suppressed when the prior window is thin).
|
|
1076
|
+
*
|
|
1077
|
+
* @param {Object} [opts] {metric, by, stack, since, until}
|
|
1078
|
+
*/
|
|
1079
|
+
function getAnalytics(opts = {}) {
|
|
1080
|
+
if (!init()) return null;
|
|
1081
|
+
const metricExpr = ANALYTICS_METRICS[opts.metric] ?? ANALYTICS_METRICS.spend;
|
|
1082
|
+
const metric = ANALYTICS_METRICS[opts.metric] ? opts.metric : "spend";
|
|
1083
|
+
const byExpr = ANALYTICS_DIMS[opts.by] ?? ANALYTICS_DIMS.provider;
|
|
1084
|
+
const by = ANALYTICS_DIMS[opts.by] ? opts.by : "provider";
|
|
1085
|
+
const stackExpr = opts.stack && ANALYTICS_DIMS[opts.stack] ? ANALYTICS_DIMS[opts.stack] : null;
|
|
1086
|
+
const until = opts.until ?? Date.now();
|
|
1087
|
+
const since = opts.since ?? until - 7 * 24 * 60 * 60 * 1000;
|
|
1088
|
+
|
|
1089
|
+
try {
|
|
1090
|
+
const groupCols = stackExpr ? `${byExpr} AS dim, ${stackExpr} AS stack` : `${byExpr} AS dim`;
|
|
1091
|
+
const groupBy = stackExpr ? "GROUP BY dim, stack" : "GROUP BY dim";
|
|
1092
|
+
const rows = db
|
|
1093
|
+
.prepare(
|
|
1094
|
+
`SELECT ${groupCols}, ${metricExpr} AS value
|
|
1095
|
+
FROM routing_telemetry
|
|
1096
|
+
WHERE timestamp BETWEEN ? AND ?
|
|
1097
|
+
${groupBy} ORDER BY value DESC LIMIT 500`
|
|
1098
|
+
)
|
|
1099
|
+
.all(since, until);
|
|
1100
|
+
|
|
1101
|
+
const kpiRow = (lo, hi) =>
|
|
1102
|
+
db
|
|
1103
|
+
.prepare(
|
|
1104
|
+
`SELECT SUM(COALESCE(cost_usd,0)) spend,
|
|
1105
|
+
SUM(COALESCE(input_tokens,0)+COALESCE(output_tokens,0)) tokens,
|
|
1106
|
+
COUNT(*) requests,
|
|
1107
|
+
COUNT(DISTINCT session_id) sessions
|
|
1108
|
+
FROM routing_telemetry WHERE timestamp BETWEEN ? AND ?`
|
|
1109
|
+
)
|
|
1110
|
+
.get(lo, hi);
|
|
1111
|
+
const kpis = kpiRow(since, until);
|
|
1112
|
+
const prev = kpiRow(since - (until - since), since);
|
|
1113
|
+
// Thin prior window → deltas are noise, suppress rather than mislead.
|
|
1114
|
+
const kpiDeltas = (prev?.requests ?? 0) >= 10
|
|
1115
|
+
? {
|
|
1116
|
+
spend: kpis.spend - (prev.spend ?? 0),
|
|
1117
|
+
tokens: kpis.tokens - (prev.tokens ?? 0),
|
|
1118
|
+
requests: kpis.requests - prev.requests,
|
|
1119
|
+
sessions: kpis.sessions - (prev.sessions ?? 0),
|
|
1120
|
+
}
|
|
1121
|
+
: null;
|
|
1122
|
+
|
|
1123
|
+
return { metric, by, stack: stackExpr ? opts.stack : null, since, until, rows, kpis, kpiDeltas };
|
|
1124
|
+
} catch (err) {
|
|
1125
|
+
logger.debug({ err: err.message }, "Telemetry getAnalytics failed");
|
|
1126
|
+
return null;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
794
1130
|
module.exports = {
|
|
795
1131
|
record,
|
|
796
1132
|
query,
|
|
@@ -799,6 +1135,11 @@ module.exports = {
|
|
|
799
1135
|
getRoutingAccuracy,
|
|
800
1136
|
getEscalationStats,
|
|
801
1137
|
getQualityByTierAndType,
|
|
1138
|
+
getExpectedRemainingTurns,
|
|
1139
|
+
getCacheEconomics,
|
|
1140
|
+
getMeasuredCacheSavings,
|
|
1141
|
+
getSessionDetail,
|
|
1142
|
+
getAnalytics,
|
|
802
1143
|
recordSavings,
|
|
803
1144
|
getSavingsSummary,
|
|
804
1145
|
cleanup,
|
package/src/routing/verifier.js
CHANGED
|
@@ -204,6 +204,28 @@ function contentScore(userText, answerText, responseBody) {
|
|
|
204
204
|
return Math.max(0, Math.min(100, score));
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Caveman detection
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
// context/caveman.js appends a brevity instruction tagged with this literal
|
|
212
|
+
// marker whenever CAVEMAN is enabled. Presence in the outbound system prompt
|
|
213
|
+
// is a reliable, config-independent signal that the model was told to be
|
|
214
|
+
// terse — so the length-based effort penalty in contentScore() is a false
|
|
215
|
+
// negative here (short answer = compliant, not lazy) and must not fire.
|
|
216
|
+
const CAVEMAN_MARKER = "[brevity]";
|
|
217
|
+
|
|
218
|
+
function _cavemanActive(payload) {
|
|
219
|
+
const s = payload?.system;
|
|
220
|
+
if (typeof s === "string") return s.includes(CAVEMAN_MARKER);
|
|
221
|
+
if (Array.isArray(s)) {
|
|
222
|
+
for (const b of s) {
|
|
223
|
+
if (typeof b?.text === "string" && b.text.includes(CAVEMAN_MARKER)) return true;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
207
229
|
// ---------------------------------------------------------------------------
|
|
208
230
|
// Public API
|
|
209
231
|
// ---------------------------------------------------------------------------
|
|
@@ -240,11 +262,26 @@ function verify({ payload, responseBody } = {}) {
|
|
|
240
262
|
].filter(Boolean);
|
|
241
263
|
|
|
242
264
|
const score = contentScore(userText, answerText, responseBody);
|
|
243
|
-
|
|
265
|
+
const cavemanActive = _cavemanActive(payload);
|
|
266
|
+
// When caveman brevity is active the model was explicitly told to be
|
|
267
|
+
// terse; a short answer is compliant, not lazy, so the length-driven
|
|
268
|
+
// low-score check would misfire and thrash the cascade. Structural
|
|
269
|
+
// checks above (language drift, degeneration, truncation, malformed
|
|
270
|
+
// tools, echo) still run — those catch real failures regardless.
|
|
271
|
+
if (
|
|
272
|
+
reasons.length === 0 &&
|
|
273
|
+
score < CONTENT_SCORE_FAIL_THRESHOLD &&
|
|
274
|
+
!cavemanActive
|
|
275
|
+
) {
|
|
244
276
|
reasons.push(`low-content-score (${score} < ${CONTENT_SCORE_FAIL_THRESHOLD})`);
|
|
245
277
|
}
|
|
246
278
|
|
|
247
|
-
return {
|
|
279
|
+
return {
|
|
280
|
+
verdict: reasons.length ? 'fail' : 'pass',
|
|
281
|
+
score,
|
|
282
|
+
reasons,
|
|
283
|
+
cavemanActive,
|
|
284
|
+
};
|
|
248
285
|
} catch (err) {
|
|
249
286
|
// Fail-open: a broken verifier must never block serving.
|
|
250
287
|
logger.debug({ err: err.message }, '[Verifier] error — failing open');
|
|
@@ -263,5 +300,7 @@ module.exports = {
|
|
|
263
300
|
checkMalformedToolCalls,
|
|
264
301
|
checkEmptyOrEcho,
|
|
265
302
|
contentScore,
|
|
303
|
+
_cavemanActive,
|
|
304
|
+
CAVEMAN_MARKER,
|
|
266
305
|
},
|
|
267
306
|
};
|
package/src/server.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const express = require("express");
|
|
2
|
-
const compression = require("compression");
|
|
3
2
|
const config = require("./config");
|
|
4
3
|
const loggingMiddleware = require("./api/middleware/logging");
|
|
5
4
|
const router = require("./api/router");
|
|
@@ -19,7 +18,7 @@ const metrics = require("./metrics");
|
|
|
19
18
|
const logger = require("./logger");
|
|
20
19
|
const { initialiseMcp } = require("./mcp");
|
|
21
20
|
const { initConfigWatcher, getConfigWatcher } = require("./config/watcher");
|
|
22
|
-
const { initializeHeadroom, shutdownHeadroom
|
|
21
|
+
const { initializeHeadroom, shutdownHeadroom } = require("./headroom");
|
|
23
22
|
const { getWorkerPool, isWorkerPoolReady } = require("./workers/pool");
|
|
24
23
|
const { waitForOllama } = require("./clients/ollama-startup");
|
|
25
24
|
|
|
@@ -196,10 +195,44 @@ async function start() {
|
|
|
196
195
|
}
|
|
197
196
|
}
|
|
198
197
|
|
|
199
|
-
const server = app.listen(config.port, () => {
|
|
198
|
+
const server = app.listen(config.port, (err) => {
|
|
199
|
+
// Express 5 invokes this callback on bind errors too (it wires the
|
|
200
|
+
// callback to the server 'error' event) — without the guard, a failed
|
|
201
|
+
// bind prints "listening" right before the EADDRINUSE failure.
|
|
202
|
+
if (err) return;
|
|
200
203
|
console.log(`Claude→Databricks proxy listening on http://localhost:${config.port}`);
|
|
201
204
|
});
|
|
202
205
|
|
|
206
|
+
// Surface bind failures to the caller. Without this, EADDRINUSE from a
|
|
207
|
+
// stale instance is swallowed, and `lynkr wrap`'s readiness probe then
|
|
208
|
+
// gets answered by the OLD server still holding the port — the new
|
|
209
|
+
// session silently runs against stale code.
|
|
210
|
+
await new Promise((resolve, reject) => {
|
|
211
|
+
server.once("listening", resolve);
|
|
212
|
+
server.once("error", reject);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// TencentDB-Agent-Memory sidecar — non-blocking (first start pulls two
|
|
216
|
+
// Docker images). Launched after listen so the proxy is usable immediately;
|
|
217
|
+
// the memory stack's LLM calls route back through Lynkr, which is why
|
|
218
|
+
// Lynkr must already be accepting requests when the containers come up.
|
|
219
|
+
if (config.tencentdbMemory?.enabled) {
|
|
220
|
+
(async () => {
|
|
221
|
+
try {
|
|
222
|
+
const tencentdbLauncher = require("./memory/tencentdb-launcher");
|
|
223
|
+
const result = await tencentdbLauncher.ensureRunning();
|
|
224
|
+
if (result.started) {
|
|
225
|
+
logger.info(result.endpoints, "[tdai] TencentDB Agent Memory stack ready");
|
|
226
|
+
console.log(`TencentDB Agent Memory ready — Panel UI: ${result.endpoints.panel}`);
|
|
227
|
+
} else {
|
|
228
|
+
logger.debug({ reason: result.reason }, "[tdai] Sidecar not started");
|
|
229
|
+
}
|
|
230
|
+
} catch (err) {
|
|
231
|
+
logger.warn({ err }, "[tdai] TencentDB memory sidecar failed to start — continuing without it");
|
|
232
|
+
}
|
|
233
|
+
})();
|
|
234
|
+
}
|
|
235
|
+
|
|
203
236
|
// Classifier bootstrap check — non-blocking, log-only.
|
|
204
237
|
// Detects ollama + confirms the classifier model is pulled. Never auto-
|
|
205
238
|
// installs (that's `lynkr init`'s job); warns and lets scoring fall back
|
package/src/tools/code-graph.js
CHANGED
|
@@ -313,9 +313,10 @@ async function getBlastRadius(filePaths, options = {}) {
|
|
|
313
313
|
if (cached) return cached;
|
|
314
314
|
|
|
315
315
|
// Query neighbors for each file to estimate blast radius
|
|
316
|
+
const depth = String(config.codeGraph?.maxDepth ?? 2);
|
|
316
317
|
const result = await execGraph(
|
|
317
318
|
"query",
|
|
318
|
-
["get_neighbors", "--files", ...filePaths, "--depth",
|
|
319
|
+
["get_neighbors", "--files", ...filePaths, "--depth", depth, "--json"],
|
|
319
320
|
ws
|
|
320
321
|
);
|
|
321
322
|
if (!result) return null;
|
|
@@ -364,11 +365,11 @@ async function getBlastRadius(filePaths, options = {}) {
|
|
|
364
365
|
* Uses Graphify's BFS-based query to find related nodes.
|
|
365
366
|
*
|
|
366
367
|
* @param {string[]} filePaths — seed file paths
|
|
367
|
-
* @param {number} [maxFiles
|
|
368
|
+
* @param {number} [maxFiles] — maximum files to return (default from config.codeGraph.maxFiles)
|
|
368
369
|
* @param {CodeGraphOptions} [options]
|
|
369
370
|
* @returns {Promise<string[]|null>}
|
|
370
371
|
*/
|
|
371
|
-
async function getRelevantContext(filePaths, maxFiles =
|
|
372
|
+
async function getRelevantContext(filePaths, maxFiles = config.codeGraph?.maxFiles ?? 5, options = {}) {
|
|
372
373
|
if (!Array.isArray(filePaths) || filePaths.length === 0) return null;
|
|
373
374
|
|
|
374
375
|
const ws = resolveWorkspace({ ...options, filePaths });
|
|
@@ -419,8 +420,9 @@ async function getComplexitySignals(filePaths, options = {}) {
|
|
|
419
420
|
if (cached) return cached;
|
|
420
421
|
|
|
421
422
|
// Run parallel queries: neighbors (blast radius) + god_nodes + graph_stats
|
|
423
|
+
const signalDepth = String(config.codeGraph?.maxDepth ?? 2);
|
|
422
424
|
const [neighborsResult, godNodesResult, statsResult] = await Promise.all([
|
|
423
|
-
execGraph("query", ["get_neighbors", "--files", ...filePaths, "--depth",
|
|
425
|
+
execGraph("query", ["get_neighbors", "--files", ...filePaths, "--depth", signalDepth, "--json"], ws),
|
|
424
426
|
execGraph("query", ["god_nodes", "--json"], ws),
|
|
425
427
|
execGraph("query", ["graph_stats", "--json"], ws),
|
|
426
428
|
]);
|