lynkr 9.11.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.
@@ -16,6 +16,7 @@ const logger = require('../logger');
16
16
  const config = require('../config');
17
17
  const codeGraph = require('../tools/code-graph');
18
18
  const clientProfiles = require('./client-profiles');
19
+ const { compressMessages, shouldCompress } = require('./context-compressor');
19
20
 
20
21
  /**
21
22
  * Return the tools the user actually added beyond the client harness's
@@ -769,13 +770,31 @@ function getThreshold() {
769
770
  * @returns {Object} Complexity analysis result
770
771
  */
771
772
  async function analyzeComplexity(payload, options = {}) {
772
- const content = extractContent(payload);
773
- const messageCount = payload?.messages?.length ?? 0;
773
+ // Phase 0: Context Compression (TencentDB-inspired)
774
+ // Compress conversation before scoring to account for token reduction
775
+ let workingPayload = payload;
776
+ let compressionStats = null;
777
+
778
+ const useCompression = options.compression ?? config.routing?.compression ?? true;
779
+ if (useCompression && shouldCompress(payload)) {
780
+ const { compressed, stats } = compressMessages(payload.messages);
781
+ compressionStats = stats;
782
+ workingPayload = { ...payload, messages: compressed };
783
+
784
+ logger.debug({
785
+ reduction: stats.reduction,
786
+ originalTokens: stats.original,
787
+ compressedTokens: stats.compressed,
788
+ }, '[complexity] Context compressed');
789
+ }
790
+
791
+ const content = extractContent(workingPayload);
792
+ const messageCount = workingPayload?.messages?.length ?? 0;
774
793
  const useWeighted = options.weighted ?? config.routing?.weightedScoring ?? false;
775
794
 
776
795
  // Use weighted scoring if enabled
777
796
  if (useWeighted) {
778
- const weighted = calculateWeightedScore(payload, content);
797
+ const weighted = calculateWeightedScore(workingPayload, content);
779
798
  const threshold = getThreshold();
780
799
  const mode = config.smartToolSelection?.mode ?? 'heuristic';
781
800
 
@@ -807,6 +826,7 @@ async function analyzeComplexity(payload, options = {}) {
807
826
  forceReason: taskTypeResult.reason?.startsWith('force_') ? taskTypeResult.reason : null,
808
827
  content: content.slice(0, 100) + (content.length > 100 ? '...' : ''),
809
828
  graphSignals: null,
829
+ compression: compressionStats, // Add compression stats
810
830
  };
811
831
 
812
832
  // Phase 5: Structural Analysis (code-review-graph, optional)
@@ -841,9 +861,9 @@ async function analyzeComplexity(payload, options = {}) {
841
861
  return result;
842
862
  }
843
863
 
844
- // Standard scoring (original logic)
845
- const tokenScore = scoreTokens(payload);
846
- const toolScore = scoreTools(payload);
864
+ // Standard scoring (original logic) - use compressed payload
865
+ const tokenScore = scoreTokens(workingPayload);
866
+ const toolScore = scoreTools(workingPayload);
847
867
  const taskTypeResult = scoreTaskType(content);
848
868
  const codeComplexityResult = scoreCodeComplexity(content);
849
869
  const reasoningResult = scoreReasoning(content);
@@ -881,8 +901,8 @@ async function analyzeComplexity(payload, options = {}) {
881
901
  mode,
882
902
  recommendation,
883
903
  breakdown: {
884
- tokens: { score: tokenScore, estimated: estimateTokens(payload) },
885
- tools: { score: toolScore, count: _effectiveTools(payload).length },
904
+ tokens: { score: tokenScore, estimated: estimateTokens(workingPayload) },
905
+ tools: { score: toolScore, count: _effectiveTools(workingPayload).length },
886
906
  taskType: taskTypeResult,
887
907
  codeComplexity: codeComplexityResult,
888
908
  reasoning: reasoningResult,
@@ -890,11 +910,12 @@ async function analyzeComplexity(payload, options = {}) {
890
910
  },
891
911
  content: content.slice(0, 100) + (content.length > 100 ? '...' : ''),
892
912
  graphSignals: null,
913
+ compression: compressionStats, // Add compression stats
893
914
  };
894
915
 
895
916
  // Phase 5: Structural Analysis (code-review-graph, optional)
896
917
  try {
897
- const filePaths = extractFilePaths(payload);
918
+ const filePaths = extractFilePaths(workingPayload);
898
919
  const graphOpts = { filePaths, workspace: options?.workspace };
899
920
  const graphAvailable = await codeGraph.isAvailable(graphOpts);
900
921
  if (graphAvailable && filePaths.length > 0) {
@@ -170,6 +170,11 @@ function init() {
170
170
  // projectedSwitchCostUsd, projectedStaySavingsUsd,
171
171
  // expectedRemainingTurns} as JSON.
172
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"],
173
178
  ];
174
179
  for (const [col, type] of additiveCols) {
175
180
  if (!existingCols.has(col)) {
@@ -232,7 +237,7 @@ function record(data) {
232
237
  retry_count, circuit_breaker_state, quality_score, tokens_per_second,
233
238
  cost_efficiency, request_text, response_text,
234
239
  base_tier, escalation_source, propensity, candidates, pinned, switch_reason,
235
- cache_decision
240
+ cache_decision, cache_read_tokens, cache_creation_tokens
236
241
  ) VALUES (
237
242
  @request_id, @session_id, @timestamp, @complexity_score, @tier,
238
243
  @agentic_type, @tool_count, @input_tokens, @message_count, @request_type,
@@ -241,7 +246,7 @@ function record(data) {
241
246
  @retry_count, @circuit_breaker_state, @quality_score, @tokens_per_second,
242
247
  @cost_efficiency, @request_text, @response_text,
243
248
  @base_tier, @escalation_source, @propensity, @candidates, @pinned, @switch_reason,
244
- @cache_decision
249
+ @cache_decision, @cache_read_tokens, @cache_creation_tokens
245
250
  )`
246
251
  );
247
252
  if (!insert) return;
@@ -292,6 +297,8 @@ function record(data) {
292
297
  : (typeof data.cache_decision === "string"
293
298
  ? data.cache_decision
294
299
  : JSON.stringify(data.cache_decision)),
300
+ cache_read_tokens: data.cache_read_tokens ?? null,
301
+ cache_creation_tokens: data.cache_creation_tokens ?? null,
295
302
  });
296
303
  } catch (err) {
297
304
  logger.debug({ err: err.message }, "Telemetry record failed");
@@ -926,6 +933,200 @@ function getExpectedRemainingTurns(currentTurns = 0) {
926
933
  }
927
934
  }
928
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
+
929
1130
  module.exports = {
930
1131
  record,
931
1132
  query,
@@ -936,6 +1137,9 @@ module.exports = {
936
1137
  getQualityByTierAndType,
937
1138
  getExpectedRemainingTurns,
938
1139
  getCacheEconomics,
1140
+ getMeasuredCacheSavings,
1141
+ getSessionDetail,
1142
+ getAnalytics,
939
1143
  recordSavings,
940
1144
  getSavingsSummary,
941
1145
  cleanup,