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.
- package/bin/lynkr-init.js +7 -0
- package/config/model-tiers.json +5 -5
- package/package.json +3 -3
- package/public/dashboard.html +29 -0
- package/src/api/router.js +34 -0
- package/src/clients/databricks.js +120 -65
- 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 +5 -1
- 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/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 +7 -1
- 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 +139 -2
- package/src/routing/verifier.js +41 -2
- package/src/server.js +36 -3
- 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: "
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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:
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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,11 @@ 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
|
+
|
|
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
|
}
|