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
@@ -4,9 +4,24 @@
4
4
  * Injects `cache_control` breakpoints into requests for providers
5
5
  * that support explicit prompt caching (Anthropic, Bedrock, Vertex/Gemini).
6
6
  *
7
- * Strategy: "system_and_3" — places up to 4 breakpoints:
8
- * 1. System prompt (stable across turns — highest cache hit rate)
9
- * 2-4. Last 3 non-system messages (rolling window)
7
+ * Strategy: "stable_hierarchy" — up to 4 breakpoints ordered by stability
8
+ * (Phase 5, cache-aware routing):
9
+ * 1. Tools block — never moves (tools render before system in the
10
+ * provider's prefix, so this read point survives system edits)
11
+ * 2. System prompt — never moves
12
+ * 3. Frozen history boundary — advances only every K user turns
13
+ * (K = config.memory.distillation.refreshEveryTurns, default 5; shared
14
+ * with the distiller's freeze window). Deterministic from the message
15
+ * list, so consecutive requests inside a bucket mark the same bytes.
16
+ * 4. Rolling marker on the newest message — pays the 1.25x write on the
17
+ * per-turn delta once so the next turn reads it at 0.1x. Kept
18
+ * deliberately: dropping it would re-pay full input price on
19
+ * everything after the boundary every turn until the next refresh.
20
+ *
21
+ * The previous "system_and_3" strategy rolled breakpoints 2-4 across the
22
+ * last three messages; markers moved every turn, and history-rewriting
23
+ * layers (distiller) invalidated the prefix wholesale. Stability of the
24
+ * marked bytes is what compounds hits.
10
25
  *
11
26
  * Providers with automatic caching (OpenAI, DeepSeek) need no injection.
12
27
  *
@@ -17,12 +32,64 @@ const logger = require('../logger');
17
32
 
18
33
  const CACHE_MARKER = { type: 'ephemeral' };
19
34
  const MAX_BREAKPOINTS = 4;
35
+ const DEFAULT_BOUNDARY_EVERY_TURNS = 5;
36
+
37
+ function _boundaryEveryTurns() {
38
+ try {
39
+ const config = require('../config');
40
+ const k = config.memory?.distillation?.refreshEveryTurns;
41
+ return Number.isFinite(k) && k > 0 ? k : DEFAULT_BOUNDARY_EVERY_TURNS;
42
+ } catch {
43
+ return DEFAULT_BOUNDARY_EVERY_TURNS;
44
+ }
45
+ }
46
+
47
+ /** Mark the last content block of a message; converts string content. */
48
+ function _markMessage(msg) {
49
+ if (!msg) return false;
50
+ if (typeof msg.content === 'string') {
51
+ msg.content = [{
52
+ type: 'text',
53
+ text: msg.content,
54
+ cache_control: CACHE_MARKER,
55
+ }];
56
+ return true;
57
+ }
58
+ if (Array.isArray(msg.content) && msg.content.length > 0) {
59
+ const lastBlock = msg.content[msg.content.length - 1];
60
+ if (lastBlock && typeof lastBlock === 'object' && !lastBlock.cache_control) {
61
+ lastBlock.cache_control = CACHE_MARKER;
62
+ return true;
63
+ }
64
+ }
65
+ return false;
66
+ }
67
+
68
+ /**
69
+ * Index of the frozen-boundary message: the bucket-th user-role message,
70
+ * where bucket = floor(userTurns / K) * K. Deterministic in the message
71
+ * list, so every request inside a K-turn bucket marks the same message —
72
+ * the marked prefix bytes stay identical until the bucket advances.
73
+ *
74
+ * @returns {number} message index, or -1 when the conversation is too
75
+ * young (bucket < K) or the boundary can't be placed.
76
+ */
77
+ function _frozenBoundaryIndex(messages, everyTurns) {
78
+ if (!Array.isArray(messages) || messages.length === 0) return -1;
79
+ const userIdx = [];
80
+ for (let i = 0; i < messages.length; i++) {
81
+ if (messages[i]?.role === 'user') userIdx.push(i);
82
+ }
83
+ const bucket = Math.floor(userIdx.length / everyTurns) * everyTurns;
84
+ if (bucket < everyTurns) return -1;
85
+ return userIdx[bucket - 1];
86
+ }
20
87
 
21
88
  /**
22
89
  * Inject cache_control breakpoints into an Anthropic-format request body.
23
90
  * Mutates the body in-place for zero-copy performance.
24
91
  *
25
- * @param {Object} body - Request body with system and messages
92
+ * @param {Object} body - Request body with system, tools, and messages
26
93
  * @returns {number} Number of breakpoints injected
27
94
  */
28
95
  function injectAnthropicCacheBreakpoints(body) {
@@ -30,7 +97,16 @@ function injectAnthropicCacheBreakpoints(body) {
30
97
 
31
98
  let injected = 0;
32
99
 
33
- // Breakpoint 1: System prompt
100
+ // Breakpoint 1: tools block — most stable prefix region.
101
+ if (Array.isArray(body.tools) && body.tools.length > 0) {
102
+ const lastTool = body.tools[body.tools.length - 1];
103
+ if (lastTool && typeof lastTool === 'object' && !lastTool.cache_control) {
104
+ lastTool.cache_control = CACHE_MARKER;
105
+ injected++;
106
+ }
107
+ }
108
+
109
+ // Breakpoint 2: system prompt.
34
110
  if (body.system) {
35
111
  if (typeof body.system === 'string') {
36
112
  // Convert string system to array format for cache_control support
@@ -50,32 +126,19 @@ function injectAnthropicCacheBreakpoints(body) {
50
126
  }
51
127
  }
52
128
 
53
- // Breakpoints 2-4: Last 3 non-system messages
54
129
  if (Array.isArray(body.messages) && body.messages.length > 0) {
55
- const remaining = MAX_BREAKPOINTS - injected;
56
- const messagesToMark = Math.min(remaining, 3, body.messages.length);
57
-
58
- for (let i = 0; i < messagesToMark; i++) {
59
- const msgIdx = body.messages.length - 1 - i;
60
- const msg = body.messages[msgIdx];
61
- if (!msg) continue;
62
-
63
- if (typeof msg.content === 'string') {
64
- // Convert string content to array for cache_control
65
- msg.content = [{
66
- type: 'text',
67
- text: msg.content,
68
- cache_control: CACHE_MARKER,
69
- }];
70
- injected++;
71
- } else if (Array.isArray(msg.content) && msg.content.length > 0) {
72
- // Mark the last content block in this message
73
- const lastBlock = msg.content[msg.content.length - 1];
74
- if (lastBlock && typeof lastBlock === 'object' && !lastBlock.cache_control) {
75
- lastBlock.cache_control = CACHE_MARKER;
76
- injected++;
77
- }
78
- }
130
+ const lastIdx = body.messages.length - 1;
131
+
132
+ // Breakpoint 3: frozen history boundary (advances every K user turns).
133
+ const boundaryIdx = _frozenBoundaryIndex(body.messages, _boundaryEveryTurns());
134
+ if (boundaryIdx >= 0 && boundaryIdx < lastIdx && injected < MAX_BREAKPOINTS) {
135
+ if (_markMessage(body.messages[boundaryIdx])) injected++;
136
+ }
137
+
138
+ // Breakpoint 4: rolling marker on the newest message — caches this
139
+ // turn's delta so the next turn reads it instead of re-paying input.
140
+ if (injected < MAX_BREAKPOINTS) {
141
+ if (_markMessage(body.messages[lastIdx])) injected++;
79
142
  }
80
143
  }
81
144
 
@@ -809,6 +809,33 @@ var config = {
809
809
  extraction: {
810
810
  enabled: memoryExtractionEnabled,
811
811
  },
812
+ // TencentDB-Agent-Memory-inspired token optimization (L0-L3 pipeline).
813
+ // Fixed defaults by design — tune here, not via env.
814
+ distillation: {
815
+ enabled: true,
816
+ turnThreshold: 10, // distill once conversation reaches 10 user turns
817
+ keepRecentTurns: 3, // last 3 user turns stay verbatim
818
+ // Size-based rescue trigger: fires regardless of turn count once the
819
+ // history alone hits this many estimated tokens, so small-context
820
+ // local models (4k-8k) don't overflow before the turn threshold.
821
+ tokenThreshold: 3000,
822
+ // Cache-aware routing (Phase 5): once emitted, the distilled block is
823
+ // frozen and only re-distilled every this-many user turns, so the
824
+ // block stays byte-identical between refreshes and compounds provider
825
+ // prompt-cache hits instead of rewriting history on every request.
826
+ // The stable-breakpoint hierarchy in prompt-cache-injection.js shares
827
+ // this K for its frozen history boundary.
828
+ refreshEveryTurns: 5,
829
+ },
830
+ skills: {
831
+ enabled: true,
832
+ minSavings: 0.6, // compression outcome must save 60%+ to be cached
833
+ },
834
+ wiki: {
835
+ enabled: true,
836
+ minTokens: 500, // register blocks larger than ~500 tokens
837
+ similarityThreshold: 0.85,
838
+ },
812
839
  decay: {
813
840
  enabled: memoryDecayEnabled,
814
841
  halfLifeDays: Number.isNaN(memoryDecayHalfLifeDays) ? 30 : memoryDecayHalfLifeDays,
@@ -888,6 +915,41 @@ var config = {
888
915
  provider: headroomProvider,
889
916
  logLevel: headroomLogLevel,
890
917
  },
918
+ // TencentDB-Agent-Memory sidecar (memory-core + memory-hub, Docker Hub
919
+ // `agentmemory/*` images). Opt-in like Headroom; when enabled, `lynkr start`
920
+ // launches both containers. Their internal LLM calls default to routing
921
+ // back through Lynkr itself, so no extra API key is needed.
922
+ tencentdbMemory: {
923
+ enabled: process.env.TENCENTDB_MEMORY_ENABLED === "true",
924
+ docker: {
925
+ enabled: process.env.TENCENTDB_MEMORY_DOCKER_ENABLED !== "false", // default true when sidecar enabled
926
+ network: "tdai-memory-stack",
927
+ core: {
928
+ image: process.env.TENCENTDB_MEMORY_CORE_IMAGE ?? "agentmemory/memory-core:latest",
929
+ containerName: "tdai-memory-core",
930
+ port: Number.parseInt(process.env.TENCENTDB_MEMORY_CORE_PORT ?? "8420", 10),
931
+ volume: "tdai-memory-core-data",
932
+ },
933
+ hub: {
934
+ image: process.env.TENCENTDB_MEMORY_HUB_IMAGE ?? "agentmemory/memory-hub:latest",
935
+ containerName: "tdai-memory-hub",
936
+ panelPort: Number.parseInt(process.env.TENCENTDB_MEMORY_PANEL_PORT ?? "8125", 10),
937
+ knowledgePort: Number.parseInt(process.env.TENCENTDB_MEMORY_KNOWLEDGE_PORT ?? "8424", 10),
938
+ volume: "tdai-panel-data",
939
+ },
940
+ },
941
+ // LLM the memory services use for extraction/summarization/wiki ingest.
942
+ // Defaults route through Lynkr's own OpenAI-compatible endpoint (tier
943
+ // routing decides the actual model), so local Ollama setups run free.
944
+ llm: {
945
+ baseUrl: process.env.TENCENTDB_MEMORY_LLM_BASE_URL
946
+ ?? `http://host.docker.internal:${Number.isNaN(port) ? 8080 : port}/v1`,
947
+ apiKey: process.env.TENCENTDB_MEMORY_LLM_API_KEY ?? "lynkr-local",
948
+ model: process.env.TENCENTDB_MEMORY_LLM_MODEL ?? "auto",
949
+ protocol: process.env.TENCENTDB_MEMORY_LLM_PROTOCOL ?? "openai",
950
+ },
951
+ promptMode: process.env.TENCENTDB_MEMORY_PROMPT_MODE ?? "code", // code | chat
952
+ },
891
953
  security: {
892
954
  // Content filtering
893
955
  contentFilterEnabled: process.env.SECURITY_CONTENT_FILTER_ENABLED !== "false", // default true
@@ -949,6 +1011,21 @@ var config = {
949
1011
  // If all exit 0, short-circuit the request with zero LLM cost.
950
1012
  preflightEnabled: process.env.LYNKR_PREFLIGHT_ENABLED === 'true',
951
1013
  preflightTimeoutMs: Number(process.env.LYNKR_PREFLIGHT_TIMEOUT_MS) || 120000,
1014
+ // Cache-aware routing (Phases 1-3). Fixed defaults by design — tune
1015
+ // here, not via env (same policy as memory.distillation).
1016
+ cacheAware: {
1017
+ enabled: true,
1018
+ // Median remaining turns assumed when telemetry is too sparse to
1019
+ // estimate — conservative so a warm pin isn't dropped for a switch
1020
+ // that only pays off over a long horizon that may not happen.
1021
+ defaultRemainingTurns: 10,
1022
+ // Per-turn size assumptions when the payload gives no better signal.
1023
+ newTokensPerTurn: 2000,
1024
+ outputTokensPerTurn: 800,
1025
+ // Local models: switching costs prefill latency, not dollars. Hold
1026
+ // the pin while the warm prefix exceeds this many tokens.
1027
+ localMaxSwitchPrefixTokens: 16000,
1028
+ },
952
1029
  },
953
1030
 
954
1031
  // Model Tier Configuration (REQUIRED)
@@ -973,6 +1050,8 @@ var config = {
973
1050
  command: process.env.CODE_GRAPH_COMMAND || 'graphify',
974
1051
  workspace: process.env.CODE_GRAPH_WORKSPACE || process.cwd(),
975
1052
  timeout: parseInt(process.env.CODE_GRAPH_TIMEOUT, 10) || 10000,
1053
+ maxDepth: 2, // symbol dependency depth for graph queries
1054
+ maxFiles: 5, // max files returned as relevant context
976
1055
  },
977
1056
 
978
1057
  // Large payload optimization (skip cloning media blocks that get discarded)
@@ -13,6 +13,13 @@ const logger = require('../logger');
13
13
  const config = require('../config');
14
14
  const distill = require('./distill');
15
15
 
16
+ // Lazy-loaded so requiring this module doesn't pull in the SQLite store
17
+ let skillsCache = null;
18
+ function getSkillsCache() {
19
+ if (!skillsCache) skillsCache = require('../memory/skills-cache');
20
+ return skillsCache;
21
+ }
22
+
16
23
  /**
17
24
  * Compress conversation history to fit within token budget
18
25
  *
@@ -277,14 +284,26 @@ function compressToolResultBlock(block, options = {}) {
277
284
  tool_use_id: block.tool_use_id,
278
285
  };
279
286
 
280
- // Compress content using Distill when content is large enough to benefit
287
+ // Compress content using Distill when content is large enough to benefit.
288
+ // The skills cache remembers how each output shape compressed before:
289
+ // known-futile shapes skip dedup work, proven-compressible shapes get a
290
+ // tighter length budget.
291
+ const compressWithSkills = (text, previousResult) => {
292
+ const skills = getSkillsCache();
293
+ const hints = skills.getCompressionHints(text);
294
+ const result = distill.compressToolResult(text, {
295
+ previousResult,
296
+ maxLength: Math.floor(500 * hints.maxLengthFactor),
297
+ skipDedup: hints.skipDedup,
298
+ });
299
+ const savings = text.length > 0 ? 1 - result.text.length / text.length : 0;
300
+ skills.record(hints.signature, result.method, savings);
301
+ return result.text;
302
+ };
303
+
281
304
  if (typeof block.content === 'string') {
282
305
  if (block.content.length > 500) {
283
- const result = distill.compressToolResult(block.content, {
284
- previousResult: options.previousResult,
285
- maxLength: 500,
286
- });
287
- compressed.content = result.text;
306
+ compressed.content = compressWithSkills(block.content, options.previousResult);
288
307
  } else {
289
308
  compressed.content = block.content;
290
309
  }
@@ -292,14 +311,14 @@ function compressToolResultBlock(block, options = {}) {
292
311
  compressed.content = block.content.map(item => {
293
312
  if (typeof item === 'string') {
294
313
  if (item.length > 500) {
295
- return distill.compressToolResult(item, { maxLength: 500 }).text;
314
+ return compressWithSkills(item);
296
315
  }
297
316
  return item;
298
317
  } else if (item.type === 'text') {
299
318
  if (item.text && item.text.length > 500) {
300
319
  return {
301
320
  type: 'text',
302
- text: distill.compressToolResult(item.text, { maxLength: 500 }).text,
321
+ text: compressWithSkills(item.text),
303
322
  };
304
323
  }
305
324
  return item;
@@ -342,6 +342,7 @@ function deduplicateBlocks(blocks, options = {}) {
342
342
  * @param {Object} options
343
343
  * @param {string} options.previousResult - Previous tool result for delta rendering
344
344
  * @param {number} options.maxLength - Max output length (default 1000)
345
+ * @param {boolean} options.skipDedup - Skip section dedup (known-futile shapes)
345
346
  * @returns {Object} { text, method, stats }
346
347
  */
347
348
  function compressToolResult(text, options = {}) {
@@ -380,7 +381,7 @@ function compressToolResult(text, options = {}) {
380
381
  }
381
382
 
382
383
  // Step 3: Internal dedup — split into logical sections and dedup
383
- const sections = result.split(/\n{2,}/);
384
+ const sections = options.skipDedup ? [] : result.split(/\n{2,}/);
384
385
  if (sections.length > 3) {
385
386
  const { compressed, stats } = deduplicateBlocks(sections);
386
387
  if (stats.duplicatesRemoved > 0) {
@@ -205,7 +205,11 @@ function routing(req, res) {
205
205
  if (s) providerStats[p] = s;
206
206
  }
207
207
 
208
- res.json({ tierDefinitions: TIER_DEFINITIONS, accuracy, stats, providerStats, circuitBreakers: cbStates, window: win.label });
208
+ // Cache-aware routing (Phase 6): per-decision switch/hold economics,
209
+ // aggregated into "cache dollars saved by routing".
210
+ const cacheEconomics = telemetry.getCacheEconomics({ since });
211
+
212
+ res.json({ tierDefinitions: TIER_DEFINITIONS, accuracy, stats, providerStats, circuitBreakers: cbStates, cacheEconomics, window: win.label });
209
213
  } catch (e) {
210
214
  res.status(500).json({ error: 'routing_api_error', detail: e.message });
211
215
  }