lynkr 9.9.1 → 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/README.md +50 -9
- package/bin/cli.js +2 -0
- package/bin/lynkr-init.js +11 -12
- package/bin/lynkr-reset.js +71 -0
- package/config/model-tiers.json +199 -52
- package/package.json +3 -3
- package/public/dashboard.html +29 -0
- package/scripts/validate-difficulty-classifier.js +27 -6
- package/src/api/providers-handler.js +0 -1
- package/src/api/router.js +309 -160
- package/src/clients/databricks.js +215 -71
- 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 +82 -43
- 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 +202 -1237
- package/src/orchestrator/passthrough-stream.js +382 -0
- package/src/orchestrator/sse-transformer.js +408 -0
- package/src/prompts/system.js +20 -6
- package/src/routing/affinity-store.js +85 -4
- 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 +161 -17
- package/src/routing/index.js +196 -56
- package/src/routing/intent-score.js +20 -2
- package/src/routing/model-registry.js +35 -10
- package/src/routing/session-affinity.js +68 -1
- package/src/routing/side-channel-detector.js +103 -0
- package/src/routing/telemetry.js +139 -2
- package/src/routing/verifier.js +41 -2
- package/src/server.js +36 -49
- package/src/tools/code-graph.js +6 -4
- package/src/agents/context-manager.js +0 -236
- package/src/agents/decomposition/dispatcher.js +0 -185
- package/src/agents/decomposition/gate.js +0 -136
- package/src/agents/decomposition/index.js +0 -183
- package/src/agents/decomposition/model-call.js +0 -75
- package/src/agents/decomposition/planner.js +0 -223
- package/src/agents/decomposition/synthesizer.js +0 -89
- package/src/agents/decomposition/telemetry.js +0 -55
- package/src/agents/definitions/loader.js +0 -653
- package/src/agents/executor.js +0 -457
- package/src/agents/index.js +0 -165
- package/src/agents/parallel-coordinator.js +0 -68
- package/src/agents/reflector.js +0 -331
- package/src/agents/skillbook.js +0 -331
- package/src/agents/store.js +0 -259
- package/src/edits/index.js +0 -171
- package/src/indexer/babel-parser.js +0 -213
- package/src/indexer/index.js +0 -1629
- package/src/indexer/navigation/index.js +0 -32
- package/src/indexer/navigation/providers/treeSitter.js +0 -36
- package/src/indexer/parser.js +0 -443
- package/src/tasks/store.js +0 -349
- package/src/tests/coverage.js +0 -173
- package/src/tests/index.js +0 -171
- package/src/tests/store.js +0 -213
- package/src/tools/agent-task.js +0 -145
- package/src/tools/code-mode.js +0 -304
- package/src/tools/decompose.js +0 -91
- package/src/tools/edits.js +0 -94
- package/src/tools/execution.js +0 -171
- package/src/tools/git.js +0 -1346
- package/src/tools/index.js +0 -306
- package/src/tools/indexer.js +0 -360
- package/src/tools/lazy-loader.js +0 -366
- package/src/tools/mcp-remote.js +0 -88
- package/src/tools/mcp.js +0 -116
- package/src/tools/process.js +0 -167
- package/src/tools/smart-selection.js +0 -180
- package/src/tools/stubs.js +0 -55
- package/src/tools/tasks.js +0 -260
- package/src/tools/tests.js +0 -132
- package/src/tools/tinyfish.js +0 -358
- package/src/tools/truncate.js +0 -106
- package/src/tools/web-client.js +0 -71
- package/src/tools/web.js +0 -415
- package/src/tools/workspace.js +0 -204
package/src/config/index.js
CHANGED
|
@@ -179,14 +179,6 @@ if (!SUPPORTED_MODEL_PROVIDERS.has(rawFallbackProvider)) {
|
|
|
179
179
|
|
|
180
180
|
const fallbackProvider = rawFallbackProvider;
|
|
181
181
|
|
|
182
|
-
// Tool execution mode: server (default), client, or passthrough
|
|
183
|
-
const toolExecutionMode = (process.env.TOOL_EXECUTION_MODE ?? "server").toLowerCase();
|
|
184
|
-
if (!["server", "client", "passthrough"].includes(toolExecutionMode)) {
|
|
185
|
-
throw new Error(
|
|
186
|
-
"TOOL_EXECUTION_MODE must be one of: server, client, passthrough (default: server)"
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
182
|
// Memory system configuration (Titans-inspired long-term memory)
|
|
191
183
|
const memoryEnabled = process.env.MEMORY_ENABLED !== "false"; // default true
|
|
192
184
|
const memoryRetrievalLimit = Number.parseInt(process.env.MEMORY_RETRIEVAL_LIMIT ?? "5", 10);
|
|
@@ -201,7 +193,6 @@ const memoryDecayHalfLifeDays = Number.parseInt(process.env.MEMORY_DECAY_HALF_LI
|
|
|
201
193
|
|
|
202
194
|
// Token optimization settings
|
|
203
195
|
const tokenTrackingEnabled = process.env.TOKEN_TRACKING_ENABLED !== "false"; // default true
|
|
204
|
-
const toolTruncationEnabled = process.env.TOOL_TRUNCATION_ENABLED !== "false"; // default true
|
|
205
196
|
const memoryFormat = (process.env.MEMORY_FORMAT ?? "compact").toLowerCase();
|
|
206
197
|
const memoryDedupEnabled = process.env.MEMORY_DEDUP_ENABLED !== "false"; // default true
|
|
207
198
|
const memoryDedupLookback = Number.parseInt(process.env.MEMORY_DEDUP_LOOKBACK ?? "5", 10);
|
|
@@ -526,14 +517,9 @@ const testProfiles = parseJson(process.env.WORKSPACE_TEST_PROFILES ?? "", null);
|
|
|
526
517
|
|
|
527
518
|
// Agents configuration
|
|
528
519
|
const agentsEnabled = process.env.AGENTS_ENABLED === "true";
|
|
529
|
-
const agentsMaxConcurrent = Number.parseInt(process.env.AGENTS_MAX_CONCURRENT ?? "10", 10);
|
|
530
|
-
const agentsDefaultModel = process.env.AGENTS_DEFAULT_MODEL ?? "haiku";
|
|
531
|
-
const agentsMaxSteps = Number.parseInt(process.env.AGENTS_MAX_STEPS ?? "15", 10);
|
|
532
|
-
const agentsTimeout = Number.parseInt(process.env.AGENTS_TIMEOUT ?? "120000", 10);
|
|
533
520
|
|
|
534
521
|
// Task decomposition configuration (opt-in; builds on the agents subsystem).
|
|
535
522
|
// Single env toggle; everything else is hardcoded below.
|
|
536
|
-
const taskDecompositionEnabled = process.env.TASK_DECOMPOSITION_ENABLED === "true";
|
|
537
523
|
|
|
538
524
|
// Tier-aware fallback (escalate-then-demote) is always on (hardcoded).
|
|
539
525
|
|
|
@@ -667,7 +653,6 @@ var config = {
|
|
|
667
653
|
openRouterMaxToolsForRouting,
|
|
668
654
|
fallbackProvider,
|
|
669
655
|
},
|
|
670
|
-
toolExecutionMode,
|
|
671
656
|
toolResultCompression: {
|
|
672
657
|
enabled: true,
|
|
673
658
|
},
|
|
@@ -777,10 +762,6 @@ var config = {
|
|
|
777
762
|
manifestPath: sandboxManifestPath,
|
|
778
763
|
manifestDirs: sandboxManifestDirs,
|
|
779
764
|
},
|
|
780
|
-
codeMode: {
|
|
781
|
-
enabled: process.env.CODE_MODE_ENABLED === 'true',
|
|
782
|
-
toolListCacheTtl: parseInt(process.env.CODE_MODE_CACHE_TTL, 10) || 60_000,
|
|
783
|
-
},
|
|
784
765
|
},
|
|
785
766
|
promptCache: {
|
|
786
767
|
enabled: promptCacheEnabled,
|
|
@@ -794,25 +775,10 @@ var config = {
|
|
|
794
775
|
ttlMs: Number.parseInt(process.env.SEMANTIC_CACHE_TTL_MS ?? "300000", 10), // 5 minutes (was 1 hour)
|
|
795
776
|
},
|
|
796
777
|
agents: {
|
|
778
|
+
// Sole remaining knob: gates the agent-delegation instructions injected
|
|
779
|
+
// into the system prompt (the CLIENT's Task tool). Execution-related
|
|
780
|
+
// agent config died with server-mode tool execution.
|
|
797
781
|
enabled: agentsEnabled,
|
|
798
|
-
maxConcurrent: Number.isNaN(agentsMaxConcurrent) ? 10 : agentsMaxConcurrent,
|
|
799
|
-
defaultModel: agentsDefaultModel,
|
|
800
|
-
maxSteps: Number.isNaN(agentsMaxSteps) ? 15 : agentsMaxSteps,
|
|
801
|
-
timeout: Number.isNaN(agentsTimeout) ? 120000 : agentsTimeout,
|
|
802
|
-
},
|
|
803
|
-
taskDecomposition: {
|
|
804
|
-
enabled: taskDecompositionEnabled, // only env-driven knob (TASK_DECOMPOSITION_ENABLED)
|
|
805
|
-
// Hardcoded defaults — tune here if needed.
|
|
806
|
-
shadow: false,
|
|
807
|
-
planModel: "sonnet",
|
|
808
|
-
synthModel: "sonnet",
|
|
809
|
-
minConfidence: 0.5,
|
|
810
|
-
gate: {
|
|
811
|
-
minComplexity: 60,
|
|
812
|
-
minTokens: 3000,
|
|
813
|
-
maxSubtasks: 6,
|
|
814
|
-
minIndependentUnits: 2,
|
|
815
|
-
},
|
|
816
782
|
},
|
|
817
783
|
tierFallback: {
|
|
818
784
|
enabled: true, // always on (hardcoded): escalate-then-demote on provider failure; floor = SIMPLE
|
|
@@ -843,6 +809,33 @@ var config = {
|
|
|
843
809
|
extraction: {
|
|
844
810
|
enabled: memoryExtractionEnabled,
|
|
845
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
|
+
},
|
|
846
839
|
decay: {
|
|
847
840
|
enabled: memoryDecayEnabled,
|
|
848
841
|
halfLifeDays: Number.isNaN(memoryDecayHalfLifeDays) ? 30 : memoryDecayHalfLifeDays,
|
|
@@ -851,9 +844,6 @@ var config = {
|
|
|
851
844
|
tokenTracking: {
|
|
852
845
|
enabled: tokenTrackingEnabled,
|
|
853
846
|
},
|
|
854
|
-
toolTruncation: {
|
|
855
|
-
enabled: toolTruncationEnabled,
|
|
856
|
-
},
|
|
857
847
|
systemPrompt: {
|
|
858
848
|
mode: systemPromptMode,
|
|
859
849
|
toolDescriptions: toolDescriptions,
|
|
@@ -925,6 +915,41 @@ var config = {
|
|
|
925
915
|
provider: headroomProvider,
|
|
926
916
|
logLevel: headroomLogLevel,
|
|
927
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
|
+
},
|
|
928
953
|
security: {
|
|
929
954
|
// Content filtering
|
|
930
955
|
contentFilterEnabled: process.env.SECURITY_CONTENT_FILTER_ENABLED !== "false", // default true
|
|
@@ -986,6 +1011,21 @@ var config = {
|
|
|
986
1011
|
// If all exit 0, short-circuit the request with zero LLM cost.
|
|
987
1012
|
preflightEnabled: process.env.LYNKR_PREFLIGHT_ENABLED === 'true',
|
|
988
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
|
+
},
|
|
989
1029
|
},
|
|
990
1030
|
|
|
991
1031
|
// Model Tier Configuration (REQUIRED)
|
|
@@ -1010,6 +1050,8 @@ var config = {
|
|
|
1010
1050
|
command: process.env.CODE_GRAPH_COMMAND || 'graphify',
|
|
1011
1051
|
workspace: process.env.CODE_GRAPH_WORKSPACE || process.cwd(),
|
|
1012
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
|
|
1013
1055
|
},
|
|
1014
1056
|
|
|
1015
1057
|
// Large payload optimization (skip cloning media blocks that get discarded)
|
|
@@ -1092,9 +1134,6 @@ function reloadConfig() {
|
|
|
1092
1134
|
// Graphify
|
|
1093
1135
|
config.codeGraph.enabled = process.env.CODE_GRAPH_ENABLED === 'true';
|
|
1094
1136
|
|
|
1095
|
-
// Code Mode
|
|
1096
|
-
config.mcp.codeMode.enabled = process.env.CODE_MODE_ENABLED === 'true';
|
|
1097
|
-
|
|
1098
1137
|
// Log level
|
|
1099
1138
|
config.logger.level = process.env.LOG_LEVEL ?? "info";
|
|
1100
1139
|
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation Distiller — L0-L3 Pipeline (TencentDB-Agent-Memory inspired)
|
|
3
|
+
*
|
|
4
|
+
* Long conversations resend every turn on every request. Once a
|
|
5
|
+
* conversation reaches the turn threshold, this module replaces the older
|
|
6
|
+
* turns with one compact distilled block and keeps only the most recent
|
|
7
|
+
* turns verbatim:
|
|
8
|
+
*
|
|
9
|
+
* L0 — raw turns (the messages themselves, dropped after distillation)
|
|
10
|
+
* L1 — facts/decisions extracted from the dropped turns (heuristic)
|
|
11
|
+
* L2 — scenario summary: what was asked, done, and decided
|
|
12
|
+
* L3 — persona: durable user preferences pulled from the memory store
|
|
13
|
+
*
|
|
14
|
+
* Large repeated blocks inside the dropped turns are dereferenced through
|
|
15
|
+
* the wiki registry before summarization. All processing is local and
|
|
16
|
+
* synchronous — no LLM calls.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const store = require("./store");
|
|
20
|
+
const extractor = require("./extractor");
|
|
21
|
+
const wiki = require("./wiki");
|
|
22
|
+
const config = require("../config");
|
|
23
|
+
const logger = require("../logger");
|
|
24
|
+
|
|
25
|
+
const MAX_SCENARIO_POINTS = 12;
|
|
26
|
+
const MAX_PERSONA_ITEMS = 5;
|
|
27
|
+
const MAX_POINT_CHARS = 140;
|
|
28
|
+
const DEFAULT_REFRESH_EVERY_TURNS = 5;
|
|
29
|
+
const MAX_FROZEN_SESSIONS = 500;
|
|
30
|
+
|
|
31
|
+
function distillConfig() {
|
|
32
|
+
return config.memory?.distillation ?? {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Frozen distilled blocks (Phase 5, cache-aware routing).
|
|
37
|
+
//
|
|
38
|
+
// Re-distilling on every request rewrites the front of the conversation each
|
|
39
|
+
// turn, which invalidates the provider's prompt cache wholesale — on exactly
|
|
40
|
+
// the long sessions where caching matters most. Once emitted, a session's
|
|
41
|
+
// distilled block is frozen: the same block bytes and the same split point
|
|
42
|
+
// are served verbatim until K more user turns have accumulated
|
|
43
|
+
// (config.memory.distillation.refreshEveryTurns, default 5). Each refresh is
|
|
44
|
+
// then ONE deliberate, scheduled cache write instead of one per request.
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/** @type {Map<string, {frozenAtTurns:number, splitIdx:number, boundaryFp:string, distilledContent:string, stats:Object}>} */
|
|
48
|
+
const _frozen = new Map();
|
|
49
|
+
|
|
50
|
+
function _refreshEveryTurns() {
|
|
51
|
+
const k = distillConfig().refreshEveryTurns;
|
|
52
|
+
return Number.isFinite(k) && k > 0 ? k : DEFAULT_REFRESH_EVERY_TURNS;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Cheap byte-stability fingerprint for the frozen prefix: split position
|
|
57
|
+
* plus samples of the first and boundary messages. Detects client-side
|
|
58
|
+
* history rewrites (compaction, edits) that make the frozen block stale.
|
|
59
|
+
*/
|
|
60
|
+
function _boundaryFingerprint(messages, splitIdx) {
|
|
61
|
+
const sample = (m) => {
|
|
62
|
+
if (!m) return "?";
|
|
63
|
+
const c = typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? "");
|
|
64
|
+
return `${m.role}:${c.slice(0, 80)}`;
|
|
65
|
+
};
|
|
66
|
+
return `${splitIdx}|${sample(messages[0])}|${sample(messages[splitIdx - 1])}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function _rememberFrozen(sessionId, entry) {
|
|
70
|
+
if (!sessionId) return;
|
|
71
|
+
_frozen.delete(sessionId);
|
|
72
|
+
_frozen.set(sessionId, entry);
|
|
73
|
+
if (_frozen.size > MAX_FROZEN_SESSIONS) {
|
|
74
|
+
const oldest = _frozen.keys().next().value;
|
|
75
|
+
if (oldest !== undefined) _frozen.delete(oldest);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Test helper — drop all frozen blocks. */
|
|
80
|
+
function _clearFrozen() {
|
|
81
|
+
_frozen.clear();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A "real" user turn carries user-authored text — tool_result-only
|
|
86
|
+
* user-role messages are plumbing, not turns.
|
|
87
|
+
*/
|
|
88
|
+
function isRealUserTurn(msg) {
|
|
89
|
+
if (msg?.role !== "user") return false;
|
|
90
|
+
if (typeof msg.content === "string") return msg.content.trim().length > 0;
|
|
91
|
+
if (Array.isArray(msg.content)) {
|
|
92
|
+
return msg.content.some(b => b?.type === "text" && b.text?.trim());
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function realUserTurnIndices(messages) {
|
|
98
|
+
const indices = [];
|
|
99
|
+
for (let i = 0; i < messages.length; i++) {
|
|
100
|
+
if (isRealUserTurn(messages[i])) indices.push(i);
|
|
101
|
+
}
|
|
102
|
+
return indices;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const CHARS_PER_TOKEN = 4;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Rough token estimate for the message history (text + tool content).
|
|
109
|
+
*/
|
|
110
|
+
function estimateHistoryTokens(messages) {
|
|
111
|
+
let chars = 0;
|
|
112
|
+
for (const msg of messages) {
|
|
113
|
+
if (typeof msg.content === "string") {
|
|
114
|
+
chars += msg.content.length;
|
|
115
|
+
} else if (Array.isArray(msg.content)) {
|
|
116
|
+
for (const block of msg.content) {
|
|
117
|
+
if (block?.text) chars += block.text.length;
|
|
118
|
+
else if (typeof block?.content === "string") chars += block.content.length;
|
|
119
|
+
else if (Array.isArray(block?.content)) {
|
|
120
|
+
for (const item of block.content) {
|
|
121
|
+
chars += typeof item === "string" ? item.length : (item?.text?.length ?? 0);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (block?.input) chars += JSON.stringify(block.input).length;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return Math.ceil(chars / CHARS_PER_TOKEN);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Whether the conversation should be distilled. Two triggers:
|
|
133
|
+
* - turn count: conversation reached turnThreshold user turns, OR
|
|
134
|
+
* - size rescue: history alone exceeds tokenThreshold estimated tokens
|
|
135
|
+
* (protects small-context models that overflow long before the turn
|
|
136
|
+
* threshold — observed with 4k-context Ollama models dying at turn 8).
|
|
137
|
+
* Either way there must be at least one turn older than the keep window,
|
|
138
|
+
* or there is nothing to distill.
|
|
139
|
+
*/
|
|
140
|
+
function needsDistillation(messages) {
|
|
141
|
+
const cfg = distillConfig();
|
|
142
|
+
if (cfg.enabled === false) return false;
|
|
143
|
+
if (!messages?.length) return false;
|
|
144
|
+
|
|
145
|
+
const turns = realUserTurnIndices(messages).length;
|
|
146
|
+
const keepRecent = cfg.keepRecentTurns ?? 3;
|
|
147
|
+
if (turns <= keepRecent) return false;
|
|
148
|
+
|
|
149
|
+
if (turns >= (cfg.turnThreshold ?? 10)) return true;
|
|
150
|
+
|
|
151
|
+
const tokenThreshold = cfg.tokenThreshold ?? 3000;
|
|
152
|
+
return estimateHistoryTokens(messages) >= tokenThreshold;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function extractText(msg) {
|
|
156
|
+
if (typeof msg?.content === "string") return msg.content;
|
|
157
|
+
if (Array.isArray(msg?.content)) {
|
|
158
|
+
return msg.content
|
|
159
|
+
.filter(b => b?.type === "text" && b.text)
|
|
160
|
+
.map(b => b.text)
|
|
161
|
+
.join(" ");
|
|
162
|
+
}
|
|
163
|
+
return "";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function truncate(text, max = MAX_POINT_CHARS) {
|
|
167
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
168
|
+
return clean.length > max ? `${clean.slice(0, max)}…` : clean;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* L2 — scenario summary of the dropped turns: user asks, decisions and
|
|
173
|
+
* facts surfaced by the assistant, and tool usage counts.
|
|
174
|
+
*/
|
|
175
|
+
function buildScenario(oldMessages) {
|
|
176
|
+
const asks = [];
|
|
177
|
+
const toolCounts = new Map();
|
|
178
|
+
let assistantText = "";
|
|
179
|
+
|
|
180
|
+
for (const msg of oldMessages) {
|
|
181
|
+
if (isRealUserTurn(msg)) {
|
|
182
|
+
asks.push(truncate(extractText(msg), 100));
|
|
183
|
+
} else if (msg.role === "assistant") {
|
|
184
|
+
assistantText += `${extractText(msg)}\n`;
|
|
185
|
+
if (Array.isArray(msg.content)) {
|
|
186
|
+
for (const block of msg.content) {
|
|
187
|
+
if (block?.type === "tool_use" && block.name) {
|
|
188
|
+
toolCounts.set(block.name, (toolCounts.get(block.name) ?? 0) + 1);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// L1 — reuse the extraction patterns on the dropped assistant output
|
|
196
|
+
const decisions = extractor.extractByType(assistantText, "decision").slice(0, 4);
|
|
197
|
+
const facts = extractor.extractByType(assistantText, "fact").slice(0, 4);
|
|
198
|
+
|
|
199
|
+
const parts = [];
|
|
200
|
+
if (asks.length) {
|
|
201
|
+
const shown = asks.slice(-MAX_SCENARIO_POINTS);
|
|
202
|
+
const omitted = asks.length - shown.length;
|
|
203
|
+
parts.push(`Requests${omitted > 0 ? ` (${omitted} earlier omitted)` : ""}: ${shown.join(" → ")}`);
|
|
204
|
+
}
|
|
205
|
+
if (decisions.length) parts.push(`Decisions: ${decisions.map(d => truncate(d)).join("; ")}`);
|
|
206
|
+
if (facts.length) parts.push(`Facts: ${facts.map(f => truncate(f)).join("; ")}`);
|
|
207
|
+
if (toolCounts.size) {
|
|
208
|
+
const tools = Array.from(toolCounts.entries())
|
|
209
|
+
.sort((a, b) => b[1] - a[1])
|
|
210
|
+
.slice(0, 8)
|
|
211
|
+
.map(([name, count]) => (count > 1 ? `${name}×${count}` : name))
|
|
212
|
+
.join(", ");
|
|
213
|
+
parts.push(`Tools used: ${tools}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return parts.join("\n");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* L3 — persona line from durable preference memories (global + session).
|
|
221
|
+
*/
|
|
222
|
+
function buildPersona(sessionId) {
|
|
223
|
+
try {
|
|
224
|
+
const prefs = store
|
|
225
|
+
.getMemoriesByType("preference", 50)
|
|
226
|
+
.filter(m => m.sessionId === null || m.sessionId === sessionId)
|
|
227
|
+
.sort((a, b) => (b.importance ?? 0) - (a.importance ?? 0))
|
|
228
|
+
.slice(0, MAX_PERSONA_ITEMS);
|
|
229
|
+
|
|
230
|
+
if (!prefs.length) return "";
|
|
231
|
+
return prefs.map(m => truncate(m.content, 80)).join("; ");
|
|
232
|
+
} catch (err) {
|
|
233
|
+
logger.warn({ err, sessionId }, "[distiller] Persona build failed");
|
|
234
|
+
return "";
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Distill a long conversation: dereference repeated large blocks via wiki,
|
|
240
|
+
* summarize everything before the last keepRecentTurns user turns into one
|
|
241
|
+
* block, and keep the recent turns verbatim.
|
|
242
|
+
*
|
|
243
|
+
* The split lands on a real user turn boundary, so assistant tool_use /
|
|
244
|
+
* user tool_result pairs are never separated.
|
|
245
|
+
*
|
|
246
|
+
* @param {Array} messages - Full conversation
|
|
247
|
+
* @param {Object} options
|
|
248
|
+
* @param {string} options.sessionId
|
|
249
|
+
* @returns {{messages: Array, applied: boolean, stats: Object}}
|
|
250
|
+
*/
|
|
251
|
+
function distillMessages(messages, options = {}) {
|
|
252
|
+
const { sessionId = null } = options;
|
|
253
|
+
|
|
254
|
+
if (!needsDistillation(messages)) {
|
|
255
|
+
return { messages, applied: false, stats: {} };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const keepRecent = distillConfig().keepRecentTurns ?? 3;
|
|
259
|
+
const turnIndices = realUserTurnIndices(messages);
|
|
260
|
+
|
|
261
|
+
// Phase 5 freeze: serve the session's frozen block verbatim while it is
|
|
262
|
+
// still fresh (fewer than K user turns since it was built) and the
|
|
263
|
+
// history prefix it covers is byte-stable. The block and split point are
|
|
264
|
+
// identical across requests, so the provider prompt cache keeps hitting.
|
|
265
|
+
if (sessionId) {
|
|
266
|
+
const cached = _frozen.get(sessionId);
|
|
267
|
+
if (
|
|
268
|
+
cached
|
|
269
|
+
&& cached.splitIdx < messages.length
|
|
270
|
+
&& turnIndices.length - cached.frozenAtTurns < _refreshEveryTurns()
|
|
271
|
+
&& _boundaryFingerprint(messages, cached.splitIdx) === cached.boundaryFp
|
|
272
|
+
) {
|
|
273
|
+
return {
|
|
274
|
+
messages: [
|
|
275
|
+
{ role: "user", content: cached.distilledContent },
|
|
276
|
+
...messages.slice(cached.splitIdx),
|
|
277
|
+
],
|
|
278
|
+
applied: true,
|
|
279
|
+
stats: { ...cached.stats, fromFrozenCache: true },
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const splitIdx = turnIndices[Math.max(0, turnIndices.length - keepRecent)];
|
|
285
|
+
|
|
286
|
+
if (!splitIdx) {
|
|
287
|
+
return { messages, applied: false, stats: {} };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const oldMessages = messages.slice(0, splitIdx);
|
|
291
|
+
const recentMessages = messages.slice(splitIdx);
|
|
292
|
+
|
|
293
|
+
// Wiki pass over dropped turns: registers large blocks for cross-request
|
|
294
|
+
// dedup and shrinks repeats before the scenario is built
|
|
295
|
+
const { messages: dereferenced, stats: wikiStats } = wiki.dereferenceMessages(oldMessages);
|
|
296
|
+
|
|
297
|
+
const scenario = buildScenario(dereferenced);
|
|
298
|
+
const persona = buildPersona(sessionId);
|
|
299
|
+
|
|
300
|
+
const sections = [`[Distilled context — earlier ${turnIndices.length - keepRecent} turns compressed]`];
|
|
301
|
+
if (persona) sections.push(`User profile: ${persona}`);
|
|
302
|
+
if (scenario) sections.push(scenario);
|
|
303
|
+
|
|
304
|
+
const distilledBlock = {
|
|
305
|
+
role: "user",
|
|
306
|
+
content: sections.join("\n"),
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const originalChars = JSON.stringify(oldMessages).length;
|
|
310
|
+
const distilledChars = distilledBlock.content.length;
|
|
311
|
+
|
|
312
|
+
const stats = {
|
|
313
|
+
droppedMessages: oldMessages.length,
|
|
314
|
+
keptMessages: recentMessages.length,
|
|
315
|
+
originalChars,
|
|
316
|
+
distilledChars,
|
|
317
|
+
savingsPct: originalChars > 0
|
|
318
|
+
? (((originalChars - distilledChars) / originalChars) * 100).toFixed(1)
|
|
319
|
+
: "0.0",
|
|
320
|
+
wiki: wikiStats,
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
logger.debug({ sessionId, ...stats }, "[distiller] Conversation distilled");
|
|
324
|
+
|
|
325
|
+
// Freeze the block for the next K user turns (Phase 5). Keyed by the
|
|
326
|
+
// split fingerprint so a client-side history rewrite invalidates it.
|
|
327
|
+
_rememberFrozen(sessionId, {
|
|
328
|
+
frozenAtTurns: turnIndices.length,
|
|
329
|
+
splitIdx,
|
|
330
|
+
boundaryFp: _boundaryFingerprint(messages, splitIdx),
|
|
331
|
+
distilledContent: distilledBlock.content,
|
|
332
|
+
stats,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
messages: [distilledBlock, ...recentMessages],
|
|
337
|
+
applied: true,
|
|
338
|
+
stats,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
module.exports = {
|
|
343
|
+
needsDistillation,
|
|
344
|
+
distillMessages,
|
|
345
|
+
buildScenario,
|
|
346
|
+
buildPersona,
|
|
347
|
+
isRealUserTurn,
|
|
348
|
+
estimateHistoryTokens,
|
|
349
|
+
_clearFrozen,
|
|
350
|
+
};
|
package/src/memory/extractor.js
CHANGED
|
@@ -358,19 +358,11 @@ function calculateInitialImportance(type, surpriseScore) {
|
|
|
358
358
|
return Math.min(1.0, base + (surpriseScore * 0.3));
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
-
/**
|
|
362
|
-
* Parse entities from content
|
|
363
|
-
*/
|
|
364
|
-
function parseEntities(content) {
|
|
365
|
-
return extractEntities(content);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
361
|
module.exports = {
|
|
369
362
|
extractMemories,
|
|
370
363
|
extractContent,
|
|
371
364
|
extractByType,
|
|
372
365
|
extractEntities,
|
|
373
366
|
extractRelationships,
|
|
374
|
-
parseEntities,
|
|
375
367
|
classifyCategory,
|
|
376
368
|
};
|