lynkr 7.2.5 → 8.0.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 (64) hide show
  1. package/README.md +2 -2
  2. package/config/model-tiers.json +89 -0
  3. package/docs/docs.html +1 -0
  4. package/docs/index.md +7 -0
  5. package/docs/toon-integration-spec.md +130 -0
  6. package/documentation/README.md +3 -2
  7. package/documentation/claude-code-cli.md +23 -16
  8. package/documentation/cursor-integration.md +17 -14
  9. package/documentation/docker.md +11 -4
  10. package/documentation/embeddings.md +7 -5
  11. package/documentation/faq.md +66 -12
  12. package/documentation/features.md +22 -15
  13. package/documentation/installation.md +66 -14
  14. package/documentation/production.md +43 -8
  15. package/documentation/providers.md +145 -42
  16. package/documentation/routing.md +476 -0
  17. package/documentation/token-optimization.md +7 -5
  18. package/documentation/troubleshooting.md +81 -5
  19. package/install.sh +6 -1
  20. package/package.json +4 -2
  21. package/scripts/setup.js +0 -1
  22. package/src/agents/executor.js +14 -6
  23. package/src/api/middleware/session.js +15 -2
  24. package/src/api/openai-router.js +130 -37
  25. package/src/api/providers-handler.js +15 -1
  26. package/src/api/router.js +107 -2
  27. package/src/budget/index.js +4 -3
  28. package/src/clients/databricks.js +431 -234
  29. package/src/clients/gpt-utils.js +181 -0
  30. package/src/clients/ollama-utils.js +66 -140
  31. package/src/clients/routing.js +0 -1
  32. package/src/clients/standard-tools.js +76 -3
  33. package/src/config/index.js +113 -35
  34. package/src/context/toon.js +173 -0
  35. package/src/logger/index.js +23 -0
  36. package/src/orchestrator/index.js +686 -211
  37. package/src/routing/agentic-detector.js +320 -0
  38. package/src/routing/complexity-analyzer.js +202 -2
  39. package/src/routing/cost-optimizer.js +305 -0
  40. package/src/routing/index.js +168 -159
  41. package/src/routing/model-tiers.js +365 -0
  42. package/src/server.js +2 -2
  43. package/src/sessions/cleanup.js +3 -3
  44. package/src/sessions/record.js +10 -1
  45. package/src/sessions/store.js +7 -2
  46. package/src/tools/agent-task.js +48 -1
  47. package/src/tools/index.js +15 -2
  48. package/te +11622 -0
  49. package/test/README.md +1 -1
  50. package/test/azure-openai-config.test.js +17 -8
  51. package/test/azure-openai-integration.test.js +7 -1
  52. package/test/azure-openai-routing.test.js +41 -43
  53. package/test/bedrock-integration.test.js +18 -32
  54. package/test/hybrid-routing-integration.test.js +35 -20
  55. package/test/hybrid-routing-performance.test.js +74 -64
  56. package/test/llamacpp-integration.test.js +28 -9
  57. package/test/lmstudio-integration.test.js +20 -8
  58. package/test/openai-integration.test.js +17 -20
  59. package/test/performance-tests.js +1 -1
  60. package/test/routing.test.js +65 -59
  61. package/test/toon-compression.test.js +131 -0
  62. package/CLAWROUTER_ROUTING_PLAN.md +0 -910
  63. package/ROUTER_COMPARISON.md +0 -173
  64. package/TIER_ROUTING_PLAN.md +0 -771
@@ -62,7 +62,7 @@ function resolveConfigPath(targetPath) {
62
62
  return path.resolve(normalised);
63
63
  }
64
64
 
65
- const SUPPORTED_MODEL_PROVIDERS = new Set(["databricks", "azure-anthropic", "ollama", "openrouter", "azure-openai", "openai", "llamacpp", "lmstudio", "bedrock", "zai", "vertex"]);
65
+ const SUPPORTED_MODEL_PROVIDERS = new Set(["databricks", "azure-anthropic", "ollama", "openrouter", "azure-openai", "openai", "llamacpp", "lmstudio", "bedrock", "zai", "vertex", "moonshot"]);
66
66
  const rawModelProvider = (process.env.MODEL_PROVIDER ?? "databricks").toLowerCase();
67
67
 
68
68
  // Validate MODEL_PROVIDER early with a clear error message
@@ -132,6 +132,11 @@ const zaiApiKey = process.env.ZAI_API_KEY?.trim() || null;
132
132
  const zaiEndpoint = process.env.ZAI_ENDPOINT?.trim() || "https://api.z.ai/api/anthropic/v1/messages";
133
133
  const zaiModel = process.env.ZAI_MODEL?.trim() || "GLM-4.7";
134
134
 
135
+ // Moonshot AI (Kimi) configuration - OpenAI-compatible API
136
+ const moonshotApiKey = process.env.MOONSHOT_API_KEY?.trim() || null;
137
+ const moonshotEndpoint = process.env.MOONSHOT_ENDPOINT?.trim() || "https://api.moonshot.ai/v1/chat/completions";
138
+ const moonshotModel = process.env.MOONSHOT_MODEL?.trim() || "kimi-k2-turbo-preview";
139
+
135
140
  // Vertex AI (Google Gemini) configuration
136
141
  const vertexApiKey = process.env.VERTEX_API_KEY?.trim() || process.env.GOOGLE_API_KEY?.trim() || null;
137
142
  const vertexModel = process.env.VERTEX_MODEL?.trim() || "gemini-2.0-flash";
@@ -144,8 +149,7 @@ const suggestionModeModel = (process.env.SUGGESTION_MODE_MODEL ?? "default").tri
144
149
  const hotReloadEnabled = process.env.HOT_RELOAD_ENABLED !== "false"; // default true
145
150
  const hotReloadDebounceMs = Number.parseInt(process.env.HOT_RELOAD_DEBOUNCE_MS ?? "1000", 10);
146
151
 
147
- // Hybrid routing configuration
148
- const preferOllama = process.env.PREFER_OLLAMA === "true";
152
+ // Routing configuration
149
153
  const fallbackEnabled = process.env.FALLBACK_ENABLED !== "false"; // default true
150
154
  const ollamaMaxToolsForRouting = Number.parseInt(
151
155
  process.env.OLLAMA_MAX_TOOLS_FOR_ROUTING ?? "3",
@@ -204,6 +208,12 @@ const tokenBudgetWarning = Number.parseInt(process.env.TOKEN_BUDGET_WARNING ?? "
204
208
  const tokenBudgetMax = Number.parseInt(process.env.TOKEN_BUDGET_MAX ?? "180000", 10);
205
209
  const tokenBudgetEnforcement = process.env.TOKEN_BUDGET_ENFORCEMENT !== "false"; // default true
206
210
 
211
+ // TOON payload compression (opt-in)
212
+ const toonEnabled = process.env.TOON_ENABLED === "true"; // default false
213
+ const toonMinBytes = Number.parseInt(process.env.TOON_MIN_BYTES ?? "4096", 10);
214
+ const toonFailOpen = process.env.TOON_FAIL_OPEN !== "false"; // default true
215
+ const toonLogStats = process.env.TOON_LOG_STATS !== "false"; // default true
216
+
207
217
  // Smart tool selection configuration (always enabled)
208
218
  const smartToolSelectionMode = (process.env.SMART_TOOL_SELECTION_MODE ?? "heuristic").toLowerCase();
209
219
  const smartToolSelectionTokenBudget = Number.parseInt(
@@ -305,37 +315,39 @@ if (modelProvider === "bedrock" && !bedrockApiKey) {
305
315
  );
306
316
  }
307
317
 
308
- // Validate hybrid routing configuration
309
- if (preferOllama) {
310
- if (!ollamaEndpoint) {
311
- throw new Error("PREFER_OLLAMA is set but OLLAMA_ENDPOINT is not configured");
312
- }
313
- if (fallbackEnabled && !SUPPORTED_MODEL_PROVIDERS.has(fallbackProvider)) {
314
- throw new Error(
315
- `FALLBACK_PROVIDER must be one of: ${Array.from(SUPPORTED_MODEL_PROVIDERS).join(", ")}`
316
- );
317
- }
318
+ // Deprecation warning for PREFER_OLLAMA
319
+ if (process.env.PREFER_OLLAMA) {
320
+ console.warn('[DEPRECATION] PREFER_OLLAMA is removed. Use TIER_* env vars for routing. See documentation/routing.md');
321
+ }
318
322
 
319
- // Prevent local providers from being used as fallback (they can fail just like Ollama)
323
+ // Warn about misconfigured fallback provider (only when tier routing is active,
324
+ // since that's the only path that triggers provider fallback)
325
+ const tiersConfigured = !!(
326
+ process.env.TIER_SIMPLE?.trim() &&
327
+ process.env.TIER_MEDIUM?.trim() &&
328
+ process.env.TIER_COMPLEX?.trim() &&
329
+ process.env.TIER_REASONING?.trim()
330
+ );
331
+ if (fallbackEnabled && tiersConfigured) {
320
332
  const localProviders = ["ollama", "llamacpp", "lmstudio"];
321
- if (fallbackEnabled && localProviders.includes(fallbackProvider)) {
333
+ if (localProviders.includes(fallbackProvider)) {
322
334
  throw new Error(`FALLBACK_PROVIDER cannot be '${fallbackProvider}' (local providers should not be fallbacks). Use cloud providers: databricks, azure-anthropic, azure-openai, openrouter, openai, bedrock`);
323
335
  }
324
-
325
- // Ensure fallback provider is properly configured (only if fallback is enabled)
326
- if (fallbackEnabled) {
327
- if (fallbackProvider === "databricks" && (!rawBaseUrl || !apiKey)) {
328
- throw new Error("FALLBACK_PROVIDER is set to 'databricks' but DATABRICKS_API_BASE and DATABRICKS_API_KEY are not configured. Please set these environment variables or choose a different fallback provider.");
329
- }
330
- if (fallbackProvider === "azure-anthropic" && (!azureAnthropicEndpoint || !azureAnthropicApiKey)) {
331
- throw new Error("FALLBACK_PROVIDER is set to 'azure-anthropic' but AZURE_ANTHROPIC_ENDPOINT and AZURE_ANTHROPIC_API_KEY are not configured. Please set these environment variables or choose a different fallback provider.");
332
- }
333
- if (fallbackProvider === "azure-openai" && (!azureOpenAIEndpoint || !azureOpenAIApiKey)) {
334
- throw new Error("FALLBACK_PROVIDER is set to 'azure-openai' but AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY are not configured. Please set these environment variables or choose a different fallback provider.");
335
- }
336
- if (fallbackProvider === "bedrock" && !bedrockApiKey) {
337
- throw new Error("FALLBACK_PROVIDER is set to 'bedrock' but AWS_BEDROCK_API_KEY is not configured. Please set this environment variable or choose a different fallback provider.");
338
- }
336
+ let fallbackMisconfigured = false;
337
+ if (fallbackProvider === "databricks" && (!rawBaseUrl || !apiKey)) {
338
+ fallbackMisconfigured = true;
339
+ }
340
+ if (fallbackProvider === "azure-anthropic" && (!azureAnthropicEndpoint || !azureAnthropicApiKey)) {
341
+ fallbackMisconfigured = true;
342
+ }
343
+ if (fallbackProvider === "azure-openai" && (!azureOpenAIEndpoint || !azureOpenAIApiKey)) {
344
+ fallbackMisconfigured = true;
345
+ }
346
+ if (fallbackProvider === "bedrock" && !bedrockApiKey) {
347
+ fallbackMisconfigured = true;
348
+ }
349
+ if (fallbackMisconfigured) {
350
+ console.warn(`[WARN] FALLBACK_PROVIDER='${fallbackProvider}' is enabled but missing credentials. Fallback will not work until configured.`);
339
351
  }
340
352
  }
341
353
 
@@ -593,6 +605,11 @@ var config = {
593
605
  apiKey: vertexApiKey,
594
606
  model: vertexModel,
595
607
  },
608
+ moonshot: {
609
+ apiKey: moonshotApiKey,
610
+ endpoint: moonshotEndpoint,
611
+ model: moonshotModel,
612
+ },
596
613
  hotReload: {
597
614
  enabled: hotReloadEnabled,
598
615
  debounceMs: Number.isNaN(hotReloadDebounceMs) ? 1000 : hotReloadDebounceMs,
@@ -601,8 +618,6 @@ var config = {
601
618
  type: modelProvider,
602
619
  defaultModel,
603
620
  suggestionModeModel,
604
- // Hybrid routing settings
605
- preferOllama,
606
621
  fallbackEnabled,
607
622
  ollamaMaxToolsForRouting,
608
623
  openRouterMaxToolsForRouting,
@@ -620,6 +635,13 @@ var config = {
620
635
  },
621
636
  logger: {
622
637
  level: process.env.LOG_LEVEL ?? "info",
638
+ file: {
639
+ enabled: process.env.LOG_FILE_ENABLED === "true",
640
+ path: process.env.LOG_FILE_PATH ?? path.join(process.cwd(), "logs", "lynkr.log"),
641
+ level: process.env.LOG_FILE_LEVEL ?? "debug", // File captures everything
642
+ frequency: process.env.LOG_FILE_FREQUENCY ?? "daily", // daily | hourly | <milliseconds>
643
+ maxFiles: parseInt(process.env.LOG_FILE_MAX_FILES ?? "14", 10),
644
+ },
623
645
  },
624
646
  sessionStore: {
625
647
  dbPath: sessionDbPath,
@@ -704,8 +726,8 @@ var config = {
704
726
  semanticCache: {
705
727
  enabled: process.env.SEMANTIC_CACHE_ENABLED !== 'false', // Disable via env if needed
706
728
  similarityThreshold: parseFloat(process.env.SEMANTIC_CACHE_THRESHOLD || '0.95'), // Higher threshold
707
- maxEntries: 500,
708
- ttlMs: 3600000, // 1 hour
729
+ maxEntries: Number.parseInt(process.env.SEMANTIC_CACHE_MAX_ENTRIES ?? "50", 10), // Reduced from 500 to prevent memory bloat
730
+ ttlMs: Number.parseInt(process.env.SEMANTIC_CACHE_TTL_MS ?? "300000", 10), // 5 minutes (was 1 hour)
709
731
  },
710
732
  agents: {
711
733
  enabled: agentsEnabled,
@@ -765,6 +787,12 @@ var config = {
765
787
  max: tokenBudgetMax,
766
788
  enforcement: tokenBudgetEnforcement,
767
789
  },
790
+ toon: {
791
+ enabled: toonEnabled,
792
+ minBytes: Number.isNaN(toonMinBytes) ? 4096 : toonMinBytes,
793
+ failOpen: toonFailOpen,
794
+ logStats: toonLogStats,
795
+ },
768
796
  smartToolSelection: {
769
797
  enabled: true, // HARDCODED - always enabled
770
798
  mode: smartToolSelectionMode,
@@ -857,6 +885,23 @@ var config = {
857
885
  taskTimeoutMs: Number.isNaN(workerTaskTimeoutMs) ? 5000 : workerTaskTimeoutMs,
858
886
  offloadThresholdBytes: Number.isNaN(workerOffloadThresholdBytes) ? 10000 : workerOffloadThresholdBytes,
859
887
  },
888
+
889
+ // Intelligent Routing
890
+ routing: {
891
+ weightedScoring: true,
892
+ costOptimization: true,
893
+ agenticDetection: true,
894
+ },
895
+
896
+ // Model Tier Configuration (REQUIRED)
897
+ // Format: TIER_<LEVEL>=provider:model (e.g., TIER_SIMPLE=ollama:llama3.2)
898
+ modelTiers: {
899
+ enabled: true,
900
+ SIMPLE: process.env.TIER_SIMPLE?.trim() || null,
901
+ MEDIUM: process.env.TIER_MEDIUM?.trim() || null,
902
+ COMPLEX: process.env.TIER_COMPLEX?.trim() || null,
903
+ REASONING: process.env.TIER_REASONING?.trim() || null,
904
+ },
860
905
  };
861
906
 
862
907
  /**
@@ -881,17 +926,24 @@ function reloadConfig() {
881
926
  config.zai.model = process.env.ZAI_MODEL?.trim() || "GLM-4.7";
882
927
  config.vertex.apiKey = process.env.VERTEX_API_KEY?.trim() || process.env.GOOGLE_API_KEY?.trim() || null;
883
928
  config.vertex.model = process.env.VERTEX_MODEL?.trim() || "gemini-2.0-flash";
929
+ config.moonshot.apiKey = process.env.MOONSHOT_API_KEY?.trim() || null;
930
+ config.moonshot.model = process.env.MOONSHOT_MODEL?.trim() || "kimi-k2-turbo-preview";
884
931
 
885
932
  // Model provider settings
886
933
  const newProvider = (process.env.MODEL_PROVIDER ?? "databricks").toLowerCase();
887
934
  if (SUPPORTED_MODEL_PROVIDERS.has(newProvider)) {
888
935
  config.modelProvider.type = newProvider;
889
936
  }
890
- config.modelProvider.preferOllama = process.env.PREFER_OLLAMA === "true";
891
937
  config.modelProvider.fallbackEnabled = process.env.FALLBACK_ENABLED !== "false";
892
938
  config.modelProvider.fallbackProvider = (process.env.FALLBACK_PROVIDER ?? "databricks").toLowerCase();
893
939
  config.modelProvider.suggestionModeModel = (process.env.SUGGESTION_MODE_MODEL ?? "default").trim();
894
940
 
941
+ config.toon.enabled = process.env.TOON_ENABLED === "true";
942
+ const newToonMinBytes = Number.parseInt(process.env.TOON_MIN_BYTES ?? "4096", 10);
943
+ config.toon.minBytes = Number.isNaN(newToonMinBytes) ? 4096 : newToonMinBytes;
944
+ config.toon.failOpen = process.env.TOON_FAIL_OPEN !== "false";
945
+ config.toon.logStats = process.env.TOON_LOG_STATS !== "false";
946
+
895
947
  // Log level
896
948
  config.logger.level = process.env.LOG_LEVEL ?? "info";
897
949
 
@@ -902,4 +954,30 @@ function reloadConfig() {
902
954
  // Make config mutable for hot reload
903
955
  config.reloadConfig = reloadConfig;
904
956
 
957
+ /**
958
+ * Check if any TIER_* value references Ollama (starts with "ollama:")
959
+ * Used by server.js to decide whether to wait for Ollama at startup.
960
+ */
961
+ config.tiersReferenceOllama = function tiersReferenceOllama() {
962
+ const tiers = config.modelTiers;
963
+ if (!tiers?.enabled) return false;
964
+ return [tiers.SIMPLE, tiers.MEDIUM, tiers.COMPLEX, tiers.REASONING]
965
+ .some(v => typeof v === 'string' && v.startsWith('ollama:'));
966
+ };
967
+
968
+ // Validate TIER_* configuration (warn if missing, don't crash)
969
+ const missingTiers = [];
970
+ if (!config.modelTiers.SIMPLE) missingTiers.push('TIER_SIMPLE');
971
+ if (!config.modelTiers.MEDIUM) missingTiers.push('TIER_MEDIUM');
972
+ if (!config.modelTiers.COMPLEX) missingTiers.push('TIER_COMPLEX');
973
+ if (!config.modelTiers.REASONING) missingTiers.push('TIER_REASONING');
974
+
975
+ if (missingTiers.length > 0) {
976
+ config.modelTiers.enabled = false;
977
+ console.warn(
978
+ `[WARN] Missing tier configuration: ${missingTiers.join(', ')} — tiered routing disabled.\n` +
979
+ ` Set TIER_<LEVEL>=provider:model to enable (e.g., TIER_SIMPLE=ollama:llama3.2)`
980
+ );
981
+ }
982
+
905
983
  module.exports = config;
@@ -0,0 +1,173 @@
1
+ const logger = require("../logger");
2
+
3
+ let cachedEncode;
4
+ let cachedLoadError;
5
+ let warnedMissingDependency = false;
6
+
7
+ function normaliseSettings(settings = {}) {
8
+ const minBytesRaw =
9
+ typeof settings.minBytes === "number" ? settings.minBytes : Number.parseInt(settings.minBytes ?? "4096", 10);
10
+ return {
11
+ enabled: settings.enabled === true,
12
+ minBytes: Number.isFinite(minBytesRaw) && minBytesRaw > 0 ? minBytesRaw : 4096,
13
+ failOpen: settings.failOpen !== false,
14
+ logStats: settings.logStats !== false,
15
+ };
16
+ }
17
+
18
+ function resolveEncodeFn(overrideEncode) {
19
+ if (typeof overrideEncode === "function") return overrideEncode;
20
+ if (cachedEncode !== undefined) return cachedEncode;
21
+ try {
22
+ const toon = require("@toon-format/toon");
23
+ cachedEncode = typeof toon?.encode === "function" ? toon.encode : null;
24
+ cachedLoadError = cachedEncode ? null : new Error("Missing encode() export from @toon-format/toon");
25
+ } catch (err) {
26
+ cachedEncode = null;
27
+ cachedLoadError = err;
28
+ }
29
+ return cachedEncode;
30
+ }
31
+
32
+ function looksLikeJsonObjectOrArray(text) {
33
+ if (typeof text !== "string") return false;
34
+ const trimmed = text.trim();
35
+ if (trimmed.length < 2) return false;
36
+ return (
37
+ (trimmed.startsWith("{") && trimmed.endsWith("}")) ||
38
+ (trimmed.startsWith("[") && trimmed.endsWith("]"))
39
+ );
40
+ }
41
+
42
+ function safeJsonParse(text) {
43
+ try {
44
+ return JSON.parse(text);
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function toToonString(encodeFn, value) {
51
+ const encoded = encodeFn(value);
52
+ if (typeof encoded === "string") return encoded;
53
+ if (encoded && typeof encoded[Symbol.iterator] === "function") {
54
+ return Array.from(encoded).join("\n");
55
+ }
56
+ return "";
57
+ }
58
+
59
+ function compressStringContent(content, cfg, encodeFn, stats) {
60
+ if (typeof content !== "string") return content;
61
+
62
+ const originalBytes = Buffer.byteLength(content, "utf8");
63
+ if (originalBytes < cfg.minBytes) {
64
+ stats.skippedBySize += 1;
65
+ return content;
66
+ }
67
+
68
+ stats.candidateCount += 1;
69
+ if (!looksLikeJsonObjectOrArray(content)) {
70
+ stats.skippedByShape += 1;
71
+ return content;
72
+ }
73
+
74
+ const parsed = safeJsonParse(content);
75
+ if (!parsed || typeof parsed !== "object") {
76
+ stats.skippedByParse += 1;
77
+ return content;
78
+ }
79
+
80
+ const toonText = toToonString(encodeFn, parsed);
81
+ if (typeof toonText !== "string" || toonText.trim().length === 0) {
82
+ return content;
83
+ }
84
+
85
+ const compressedBytes = Buffer.byteLength(toonText, "utf8");
86
+ stats.convertedCount += 1;
87
+ stats.originalBytes += originalBytes;
88
+ stats.compressedBytes += compressedBytes;
89
+ return toonText;
90
+ }
91
+
92
+ function applyToonCompression(payload, settings = {}, options = {}) {
93
+ const cfg = normaliseSettings(settings);
94
+ const stats = {
95
+ enabled: cfg.enabled,
96
+ available: true,
97
+ convertedCount: 0,
98
+ candidateCount: 0,
99
+ skippedBySize: 0,
100
+ skippedByShape: 0,
101
+ skippedByParse: 0,
102
+ failureCount: 0,
103
+ originalBytes: 0,
104
+ compressedBytes: 0,
105
+ };
106
+
107
+ if (!cfg.enabled) return { payload, stats };
108
+ if (!payload || !Array.isArray(payload.messages) || payload.messages.length === 0) {
109
+ return { payload, stats };
110
+ }
111
+
112
+ const encodeFn = resolveEncodeFn(options.encode);
113
+ if (typeof encodeFn !== "function") {
114
+ stats.available = false;
115
+ const err = cachedLoadError ?? new Error("TOON encoder unavailable");
116
+ if (!cfg.failOpen) throw err;
117
+ if (!warnedMissingDependency) {
118
+ logger.warn(
119
+ { error: err.message },
120
+ "TOON enabled but encoder dependency is unavailable; falling back to JSON",
121
+ );
122
+ warnedMissingDependency = true;
123
+ }
124
+ return { payload, stats };
125
+ }
126
+
127
+ for (const message of payload.messages) {
128
+ if (!message || typeof message !== "object") continue;
129
+ if (message.role === "tool") continue; // Never mutate machine-executed protocol payloads
130
+ try {
131
+ if (typeof message.content === "string") {
132
+ message.content = compressStringContent(message.content, cfg, encodeFn, stats);
133
+ continue;
134
+ }
135
+
136
+ if (!Array.isArray(message.content)) continue;
137
+ for (const block of message.content) {
138
+ if (!block || typeof block !== "object") continue;
139
+
140
+ // Keep protocol blocks untouched. Only compress user-language text fields.
141
+ if (block.type === "text" && typeof block.text === "string") {
142
+ block.text = compressStringContent(block.text, cfg, encodeFn, stats);
143
+ continue;
144
+ }
145
+
146
+ if (block.type === "input_text" && typeof block.input_text === "string") {
147
+ block.input_text = compressStringContent(block.input_text, cfg, encodeFn, stats);
148
+ }
149
+ }
150
+ } catch (err) {
151
+ stats.failureCount += 1;
152
+ if (!cfg.failOpen) throw err;
153
+ }
154
+ }
155
+
156
+ if (cfg.logStats && stats.convertedCount > 0) {
157
+ logger.info(
158
+ {
159
+ convertedCount: stats.convertedCount,
160
+ candidateCount: stats.candidateCount,
161
+ originalBytes: stats.originalBytes,
162
+ compressedBytes: stats.compressedBytes,
163
+ },
164
+ "TOON compression applied to message context",
165
+ );
166
+ }
167
+
168
+ return { payload, stats };
169
+ }
170
+
171
+ module.exports = {
172
+ applyToonCompression,
173
+ };
@@ -1,4 +1,6 @@
1
1
  const pino = require("pino");
2
+ const fs = require("fs");
3
+ const path = require("path");
2
4
  const config = require("../config");
3
5
  const { createOversizedErrorStream } = require("./oversized-error-stream");
4
6
 
@@ -64,6 +66,27 @@ streams.push({
64
66
  : process.stdout,
65
67
  });
66
68
 
69
+ // File rotation stream (if enabled via LOG_FILE_ENABLED=true)
70
+ if (config.logger.file?.enabled) {
71
+ const fileConfig = config.logger.file;
72
+ // Ensure log directory exists
73
+ const logDir = path.dirname(fileConfig.path);
74
+ fs.mkdirSync(logDir, { recursive: true });
75
+
76
+ streams.push({
77
+ level: fileConfig.level,
78
+ stream: pino.transport({
79
+ target: "pino-roll",
80
+ options: {
81
+ file: fileConfig.path,
82
+ frequency: fileConfig.frequency,
83
+ limit: { count: fileConfig.maxFiles },
84
+ mkdir: true,
85
+ },
86
+ }),
87
+ });
88
+ }
89
+
67
90
  // Oversized error stream (if enabled)
68
91
  if (config.oversizedErrorLogging?.enabled) {
69
92
  streams.push({