lynkr 9.10.0 → 9.11.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 (38) hide show
  1. package/bin/lynkr-init.js +7 -0
  2. package/config/model-tiers.json +5 -5
  3. package/package.json +3 -3
  4. package/public/dashboard.html +29 -0
  5. package/src/api/router.js +34 -0
  6. package/src/clients/databricks.js +120 -65
  7. package/src/clients/gpt-utils.js +8 -1
  8. package/src/clients/ollama-utils.js +19 -10
  9. package/src/clients/prompt-cache-injection.js +93 -30
  10. package/src/config/index.js +79 -0
  11. package/src/context/compression.js +27 -8
  12. package/src/context/distill.js +2 -1
  13. package/src/dashboard/api.js +5 -1
  14. package/src/memory/distiller.js +350 -0
  15. package/src/memory/extractor.js +0 -8
  16. package/src/memory/format.js +17 -2
  17. package/src/memory/index.js +10 -0
  18. package/src/memory/retriever.js +7 -2
  19. package/src/memory/skills-cache.js +180 -0
  20. package/src/memory/tencentdb-launcher.js +459 -0
  21. package/src/memory/wiki.js +171 -0
  22. package/src/orchestrator/index.js +85 -2
  23. package/src/prompts/system.js +20 -6
  24. package/src/routing/affinity-store.js +68 -1
  25. package/src/routing/agentic-detector.js +9 -1
  26. package/src/routing/cache-economics.js +123 -0
  27. package/src/routing/cache-switch-cost.js +194 -0
  28. package/src/routing/calibration.js +8 -1
  29. package/src/routing/complexity-analyzer.js +7 -1
  30. package/src/routing/context-compressor.js +170 -0
  31. package/src/routing/difficulty-classifier.js +115 -13
  32. package/src/routing/index.js +161 -53
  33. package/src/routing/model-registry.js +35 -10
  34. package/src/routing/session-affinity.js +60 -0
  35. package/src/routing/telemetry.js +139 -2
  36. package/src/routing/verifier.js +41 -2
  37. package/src/server.js +36 -3
  38. package/src/tools/code-graph.js +6 -4
@@ -165,6 +165,11 @@ 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"],
168
173
  ];
169
174
  for (const [col, type] of additiveCols) {
170
175
  if (!existingCols.has(col)) {
@@ -226,7 +231,8 @@ function record(data) {
226
231
  latency_ms, status_code, error_type, cost_usd, tool_calls_made,
227
232
  retry_count, circuit_breaker_state, quality_score, tokens_per_second,
228
233
  cost_efficiency, request_text, response_text,
229
- base_tier, escalation_source, propensity, candidates, pinned, switch_reason
234
+ base_tier, escalation_source, propensity, candidates, pinned, switch_reason,
235
+ cache_decision
230
236
  ) VALUES (
231
237
  @request_id, @session_id, @timestamp, @complexity_score, @tier,
232
238
  @agentic_type, @tool_count, @input_tokens, @message_count, @request_type,
@@ -234,7 +240,8 @@ function record(data) {
234
240
  @latency_ms, @status_code, @error_type, @cost_usd, @tool_calls_made,
235
241
  @retry_count, @circuit_breaker_state, @quality_score, @tokens_per_second,
236
242
  @cost_efficiency, @request_text, @response_text,
237
- @base_tier, @escalation_source, @propensity, @candidates, @pinned, @switch_reason
243
+ @base_tier, @escalation_source, @propensity, @candidates, @pinned, @switch_reason,
244
+ @cache_decision
238
245
  )`
239
246
  );
240
247
  if (!insert) return;
@@ -280,6 +287,11 @@ function record(data) {
280
287
  candidates: candidatesJson,
281
288
  pinned: data.pinned ? 1 : 0,
282
289
  switch_reason: data.switch_reason ?? null,
290
+ cache_decision: data.cache_decision == null
291
+ ? null
292
+ : (typeof data.cache_decision === "string"
293
+ ? data.cache_decision
294
+ : JSON.stringify(data.cache_decision)),
283
295
  });
284
296
  } catch (err) {
285
297
  logger.debug({ err: err.message }, "Telemetry record failed");
@@ -791,6 +803,129 @@ function _resetForTests() {
791
803
  _testDbDisabled = false;
792
804
  }
793
805
 
806
+ // Cache-aware routing (Phase 6) — "cache dollars saved / burned by routing".
807
+ //
808
+ // Every gated switch/hold decision carries a receipt. Aggregating them
809
+ // answers the public question "does model routing break prefix caching?"
810
+ // with a dollar figure:
811
+ // - a HOLD avoided a switch whose one-time cache write exceeded its
812
+ // projected per-turn savings over the expected horizon
813
+ // (saved = switchOnce − savings × remainingTurns)
814
+ // - a SWITCH cleared break-even; its projected net gain is
815
+ // (savings × remainingTurns − switchOnce)
816
+ /**
817
+ * @param {Object} [opts]
818
+ * @param {number} [opts.since] - default: last 7 days.
819
+ * @returns {{decisions:number, holds:number, switches:number,
820
+ * dollarsSavedByHolds:number, dollarsSavedBySwitches:number,
821
+ * totalDollarsSaved:number, byReason:Object}|null}
822
+ */
823
+ function getCacheEconomics(opts = {}) {
824
+ if (!init()) return null;
825
+ const since = opts.since ?? Date.now() - 7 * 24 * 60 * 60 * 1000;
826
+ try {
827
+ const rows = db
828
+ .prepare(
829
+ `SELECT cache_decision FROM routing_telemetry
830
+ WHERE timestamp > ? AND cache_decision IS NOT NULL
831
+ LIMIT 20000`
832
+ )
833
+ .all(since);
834
+
835
+ const out = {
836
+ decisions: 0,
837
+ holds: 0,
838
+ switches: 0,
839
+ dollarsSavedByHolds: 0,
840
+ dollarsSavedBySwitches: 0,
841
+ totalDollarsSaved: 0,
842
+ byReason: {},
843
+ };
844
+
845
+ for (const row of rows) {
846
+ let d;
847
+ try {
848
+ d = JSON.parse(row.cache_decision);
849
+ } catch {
850
+ continue;
851
+ }
852
+ if (!d || typeof d !== "object") continue;
853
+ out.decisions++;
854
+ out.byReason[d.reason ?? "unknown"] = (out.byReason[d.reason ?? "unknown"] ?? 0) + 1;
855
+
856
+ const savings = typeof d.projectedStaySavingsUsd === "number" ? d.projectedStaySavingsUsd : null;
857
+ const once = typeof d.projectedSwitchCostUsd === "number" ? d.projectedSwitchCostUsd : null;
858
+ const horizon = typeof d.expectedRemainingTurns === "number" ? d.expectedRemainingTurns : null;
859
+
860
+ if (d.decision === "hold") {
861
+ out.holds++;
862
+ if (savings != null && once != null && horizon != null) {
863
+ out.dollarsSavedByHolds += Math.max(0, once - savings * horizon);
864
+ }
865
+ } else if (d.decision === "switch") {
866
+ out.switches++;
867
+ if (savings != null && once != null && horizon != null) {
868
+ out.dollarsSavedBySwitches += Math.max(0, savings * horizon - once);
869
+ }
870
+ }
871
+ }
872
+
873
+ out.totalDollarsSaved = out.dollarsSavedByHolds + out.dollarsSavedBySwitches;
874
+ return out;
875
+ } catch (err) {
876
+ logger.debug({ err: err.message }, "Telemetry getCacheEconomics failed");
877
+ return null;
878
+ }
879
+ }
880
+
881
+ // Cache-aware routing (Phase 3) — expected remaining turns.
882
+ //
883
+ // Conditional median: given a session has already reached `currentTurns`
884
+ // messages, how many MORE messages do comparable sessions run? Uses the max
885
+ // message_count each recent session reached. Conditional (len > current)
886
+ // rather than a global median because long sessions systematically outlive
887
+ // the average — exactly the sessions whose warm prefixes are worth pricing.
888
+ const _remainingTurnsCache = new Map();
889
+ const REMAINING_TURNS_CACHE_TTL_MS = 60 * 1000;
890
+ const REMAINING_TURNS_MIN_SESSIONS = 20;
891
+
892
+ /**
893
+ * @param {number} currentTurns - message_count the session has reached now.
894
+ * @returns {number|null} median remaining messages, or null when telemetry
895
+ * is too sparse (< 20 comparable sessions in the last 7 days).
896
+ */
897
+ function getExpectedRemainingTurns(currentTurns = 0) {
898
+ if (!init()) return null;
899
+ const bucket = Math.max(0, Math.floor((Number(currentTurns) || 0) / 5) * 5);
900
+ const cached = _remainingTurnsCache.get(bucket);
901
+ if (cached && Date.now() - cached.ts < REMAINING_TURNS_CACHE_TTL_MS) {
902
+ return cached.value;
903
+ }
904
+ try {
905
+ const since = Date.now() - 7 * 24 * 60 * 60 * 1000;
906
+ const rows = db
907
+ .prepare(
908
+ `SELECT MAX(message_count) AS len
909
+ FROM routing_telemetry
910
+ WHERE timestamp > ? AND session_id IS NOT NULL AND message_count IS NOT NULL
911
+ GROUP BY session_id
912
+ HAVING len > ?
913
+ LIMIT 5000`
914
+ )
915
+ .all(since, bucket);
916
+ let value = null;
917
+ if (rows.length >= REMAINING_TURNS_MIN_SESSIONS) {
918
+ const remaining = rows.map((r) => r.len - bucket).sort((a, b) => a - b);
919
+ value = remaining[Math.floor(remaining.length / 2)];
920
+ }
921
+ _remainingTurnsCache.set(bucket, { value, ts: Date.now() });
922
+ return value;
923
+ } catch (err) {
924
+ logger.debug({ err: err.message }, "Telemetry getExpectedRemainingTurns failed");
925
+ return null;
926
+ }
927
+ }
928
+
794
929
  module.exports = {
795
930
  record,
796
931
  query,
@@ -799,6 +934,8 @@ module.exports = {
799
934
  getRoutingAccuracy,
800
935
  getEscalationStats,
801
936
  getQualityByTierAndType,
937
+ getExpectedRemainingTurns,
938
+ getCacheEconomics,
802
939
  recordSavings,
803
940
  getSavingsSummary,
804
941
  cleanup,
@@ -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
- if (reasons.length === 0 && score < CONTENT_SCORE_FAIL_THRESHOLD) {
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 { verdict: reasons.length ? 'fail' : 'pass', score, reasons };
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, getHeadroomManager } = require("./headroom");
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
@@ -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", "2", "--json"],
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=20] — maximum files to return
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 = 20, options = {}) {
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", "2", "--json"], ws),
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
  ]);