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.
Files changed (93) hide show
  1. package/README.md +50 -9
  2. package/bin/cli.js +2 -0
  3. package/bin/lynkr-init.js +11 -12
  4. package/bin/lynkr-reset.js +71 -0
  5. package/config/model-tiers.json +199 -52
  6. package/package.json +3 -3
  7. package/public/dashboard.html +29 -0
  8. package/scripts/validate-difficulty-classifier.js +27 -6
  9. package/src/api/providers-handler.js +0 -1
  10. package/src/api/router.js +309 -160
  11. package/src/clients/databricks.js +215 -71
  12. package/src/clients/gpt-utils.js +8 -1
  13. package/src/clients/ollama-utils.js +19 -10
  14. package/src/clients/prompt-cache-injection.js +93 -30
  15. package/src/config/index.js +82 -43
  16. package/src/context/compression.js +27 -8
  17. package/src/context/distill.js +2 -1
  18. package/src/dashboard/api.js +5 -1
  19. package/src/memory/distiller.js +350 -0
  20. package/src/memory/extractor.js +0 -8
  21. package/src/memory/format.js +17 -2
  22. package/src/memory/index.js +10 -0
  23. package/src/memory/retriever.js +7 -2
  24. package/src/memory/skills-cache.js +180 -0
  25. package/src/memory/tencentdb-launcher.js +459 -0
  26. package/src/memory/wiki.js +171 -0
  27. package/src/orchestrator/index.js +202 -1237
  28. package/src/orchestrator/passthrough-stream.js +382 -0
  29. package/src/orchestrator/sse-transformer.js +408 -0
  30. package/src/prompts/system.js +20 -6
  31. package/src/routing/affinity-store.js +85 -4
  32. package/src/routing/agentic-detector.js +9 -1
  33. package/src/routing/cache-economics.js +123 -0
  34. package/src/routing/cache-switch-cost.js +194 -0
  35. package/src/routing/calibration.js +8 -1
  36. package/src/routing/complexity-analyzer.js +7 -1
  37. package/src/routing/context-compressor.js +170 -0
  38. package/src/routing/difficulty-classifier.js +161 -17
  39. package/src/routing/index.js +196 -56
  40. package/src/routing/intent-score.js +20 -2
  41. package/src/routing/model-registry.js +35 -10
  42. package/src/routing/session-affinity.js +68 -1
  43. package/src/routing/side-channel-detector.js +103 -0
  44. package/src/routing/telemetry.js +139 -2
  45. package/src/routing/verifier.js +41 -2
  46. package/src/server.js +36 -49
  47. package/src/tools/code-graph.js +6 -4
  48. package/src/agents/context-manager.js +0 -236
  49. package/src/agents/decomposition/dispatcher.js +0 -185
  50. package/src/agents/decomposition/gate.js +0 -136
  51. package/src/agents/decomposition/index.js +0 -183
  52. package/src/agents/decomposition/model-call.js +0 -75
  53. package/src/agents/decomposition/planner.js +0 -223
  54. package/src/agents/decomposition/synthesizer.js +0 -89
  55. package/src/agents/decomposition/telemetry.js +0 -55
  56. package/src/agents/definitions/loader.js +0 -653
  57. package/src/agents/executor.js +0 -457
  58. package/src/agents/index.js +0 -165
  59. package/src/agents/parallel-coordinator.js +0 -68
  60. package/src/agents/reflector.js +0 -331
  61. package/src/agents/skillbook.js +0 -331
  62. package/src/agents/store.js +0 -259
  63. package/src/edits/index.js +0 -171
  64. package/src/indexer/babel-parser.js +0 -213
  65. package/src/indexer/index.js +0 -1629
  66. package/src/indexer/navigation/index.js +0 -32
  67. package/src/indexer/navigation/providers/treeSitter.js +0 -36
  68. package/src/indexer/parser.js +0 -443
  69. package/src/tasks/store.js +0 -349
  70. package/src/tests/coverage.js +0 -173
  71. package/src/tests/index.js +0 -171
  72. package/src/tests/store.js +0 -213
  73. package/src/tools/agent-task.js +0 -145
  74. package/src/tools/code-mode.js +0 -304
  75. package/src/tools/decompose.js +0 -91
  76. package/src/tools/edits.js +0 -94
  77. package/src/tools/execution.js +0 -171
  78. package/src/tools/git.js +0 -1346
  79. package/src/tools/index.js +0 -306
  80. package/src/tools/indexer.js +0 -360
  81. package/src/tools/lazy-loader.js +0 -366
  82. package/src/tools/mcp-remote.js +0 -88
  83. package/src/tools/mcp.js +0 -116
  84. package/src/tools/process.js +0 -167
  85. package/src/tools/smart-selection.js +0 -180
  86. package/src/tools/stubs.js +0 -55
  87. package/src/tools/tasks.js +0 -260
  88. package/src/tools/tests.js +0 -132
  89. package/src/tools/tinyfish.js +0 -358
  90. package/src/tools/truncate.js +0 -106
  91. package/src/tools/web-client.js +0 -71
  92. package/src/tools/web.js +0 -415
  93. package/src/tools/workspace.js +0 -204
@@ -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
- 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
  }
@@ -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
+ };
@@ -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
  };