lynkr 7.2.5 → 8.0.1

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 (124) hide show
  1. package/README.md +3 -3
  2. package/config/model-tiers.json +89 -0
  3. package/install.sh +6 -1
  4. package/package.json +4 -2
  5. package/scripts/setup.js +0 -1
  6. package/src/agents/executor.js +14 -6
  7. package/src/api/middleware/session.js +15 -2
  8. package/src/api/openai-router.js +162 -37
  9. package/src/api/providers-handler.js +15 -1
  10. package/src/api/router.js +107 -2
  11. package/src/budget/index.js +4 -3
  12. package/src/clients/databricks.js +431 -234
  13. package/src/clients/gpt-utils.js +181 -0
  14. package/src/clients/ollama-utils.js +66 -140
  15. package/src/clients/routing.js +0 -1
  16. package/src/clients/standard-tools.js +99 -3
  17. package/src/config/index.js +133 -35
  18. package/src/context/toon.js +173 -0
  19. package/src/logger/index.js +23 -0
  20. package/src/orchestrator/index.js +688 -213
  21. package/src/routing/agentic-detector.js +320 -0
  22. package/src/routing/complexity-analyzer.js +202 -2
  23. package/src/routing/cost-optimizer.js +305 -0
  24. package/src/routing/index.js +168 -159
  25. package/src/routing/model-tiers.js +365 -0
  26. package/src/server.js +4 -14
  27. package/src/sessions/cleanup.js +3 -3
  28. package/src/sessions/record.js +10 -1
  29. package/src/sessions/store.js +7 -2
  30. package/src/tools/agent-task.js +48 -1
  31. package/src/tools/index.js +19 -2
  32. package/src/tools/lazy-loader.js +7 -0
  33. package/src/tools/tinyfish.js +358 -0
  34. package/src/tools/truncate.js +1 -0
  35. package/.github/FUNDING.yml +0 -15
  36. package/.github/workflows/README.md +0 -215
  37. package/.github/workflows/ci.yml +0 -69
  38. package/.github/workflows/index.yml +0 -62
  39. package/.github/workflows/web-tools-tests.yml +0 -56
  40. package/CITATIONS.bib +0 -6
  41. package/CLAWROUTER_ROUTING_PLAN.md +0 -910
  42. package/DEPLOYMENT.md +0 -1001
  43. package/LYNKR-TUI-PLAN.md +0 -984
  44. package/PERFORMANCE-REPORT.md +0 -866
  45. package/PLAN-per-client-model-routing.md +0 -252
  46. package/ROUTER_COMPARISON.md +0 -173
  47. package/TIER_ROUTING_PLAN.md +0 -771
  48. package/docs/42642f749da6234f41b6b425c3bb07c9.txt +0 -1
  49. package/docs/BingSiteAuth.xml +0 -4
  50. package/docs/docs-style.css +0 -478
  51. package/docs/docs.html +0 -197
  52. package/docs/google5be250e608e6da39.html +0 -1
  53. package/docs/index.html +0 -577
  54. package/docs/index.md +0 -577
  55. package/docs/robots.txt +0 -4
  56. package/docs/sitemap.xml +0 -44
  57. package/docs/style.css +0 -1223
  58. package/documentation/README.md +0 -100
  59. package/documentation/api.md +0 -806
  60. package/documentation/claude-code-cli.md +0 -672
  61. package/documentation/codex-cli.md +0 -397
  62. package/documentation/contributing.md +0 -571
  63. package/documentation/cursor-integration.md +0 -731
  64. package/documentation/docker.md +0 -867
  65. package/documentation/embeddings.md +0 -760
  66. package/documentation/faq.md +0 -659
  67. package/documentation/features.md +0 -396
  68. package/documentation/headroom.md +0 -519
  69. package/documentation/installation.md +0 -706
  70. package/documentation/memory-system.md +0 -476
  71. package/documentation/production.md +0 -601
  72. package/documentation/providers.md +0 -906
  73. package/documentation/testing.md +0 -629
  74. package/documentation/token-optimization.md +0 -323
  75. package/documentation/tools.md +0 -697
  76. package/documentation/troubleshooting.md +0 -893
  77. package/final-test.js +0 -33
  78. package/headroom-sidecar/config.py +0 -93
  79. package/headroom-sidecar/requirements.txt +0 -14
  80. package/headroom-sidecar/server.py +0 -451
  81. package/monitor-agents.sh +0 -31
  82. package/scripts/audit-log-reader.js +0 -399
  83. package/scripts/compact-dictionary.js +0 -204
  84. package/scripts/test-deduplication.js +0 -448
  85. package/src/db/database.sqlite +0 -0
  86. package/test/README.md +0 -212
  87. package/test/azure-openai-config.test.js +0 -204
  88. package/test/azure-openai-error-resilience.test.js +0 -238
  89. package/test/azure-openai-format-conversion.test.js +0 -354
  90. package/test/azure-openai-integration.test.js +0 -281
  91. package/test/azure-openai-routing.test.js +0 -177
  92. package/test/azure-openai-streaming.test.js +0 -171
  93. package/test/bedrock-integration.test.js +0 -471
  94. package/test/comprehensive-test-suite.js +0 -928
  95. package/test/config-validation.test.js +0 -207
  96. package/test/cursor-integration.test.js +0 -484
  97. package/test/format-conversion.test.js +0 -578
  98. package/test/hybrid-routing-integration.test.js +0 -254
  99. package/test/hybrid-routing-performance.test.js +0 -418
  100. package/test/llamacpp-integration.test.js +0 -863
  101. package/test/lmstudio-integration.test.js +0 -335
  102. package/test/memory/extractor.test.js +0 -398
  103. package/test/memory/retriever.test.js +0 -613
  104. package/test/memory/retriever.test.js.bak +0 -585
  105. package/test/memory/search.test.js +0 -537
  106. package/test/memory/search.test.js.bak +0 -389
  107. package/test/memory/store.test.js +0 -344
  108. package/test/memory/store.test.js.bak +0 -312
  109. package/test/memory/surprise.test.js +0 -300
  110. package/test/memory-performance.test.js +0 -472
  111. package/test/openai-integration.test.js +0 -686
  112. package/test/openrouter-error-resilience.test.js +0 -418
  113. package/test/passthrough-mode.test.js +0 -385
  114. package/test/performance-benchmark.js +0 -351
  115. package/test/performance-tests.js +0 -528
  116. package/test/routing.test.js +0 -219
  117. package/test/web-tools.test.js +0 -329
  118. package/test-agents-simple.js +0 -43
  119. package/test-cli-connection.sh +0 -33
  120. package/test-learning-unit.js +0 -126
  121. package/test-learning.js +0 -112
  122. package/test-parallel-agents.sh +0 -124
  123. package/test-parallel-direct.js +0 -155
  124. package/test-subagents.sh +0 -117
@@ -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
 
@@ -385,6 +397,14 @@ const webFetchBodyPreviewMax = Number.parseInt(process.env.WEB_FETCH_BODY_PREVIE
385
397
  const webSearchRetryEnabled = process.env.WEB_SEARCH_RETRY_ENABLED !== "false"; // default true
386
398
  const webSearchMaxRetries = Number.parseInt(process.env.WEB_SEARCH_MAX_RETRIES ?? "2", 10);
387
399
 
400
+ // TinyFish AI Browser Automation configuration
401
+ const tinyfishApiKey = process.env.TINYFISH_API_KEY?.trim() || null;
402
+ const tinyfishEndpoint = process.env.TINYFISH_ENDPOINT?.trim() || "https://agent.tinyfish.ai/v1/automation/run-sse";
403
+ const tinyfishBrowserProfile = process.env.TINYFISH_BROWSER_PROFILE?.trim() || "lite";
404
+ const tinyfishTimeoutMs = parseInt(process.env.TINYFISH_TIMEOUT_MS ?? "120000", 10);
405
+ const tinyfishProxyEnabled = process.env.TINYFISH_PROXY_ENABLED === "true";
406
+ const tinyfishProxyCountry = process.env.TINYFISH_PROXY_COUNTRY?.trim() || "US";
407
+
388
408
  const policyMaxSteps = Number.parseInt(process.env.POLICY_MAX_STEPS ?? "8", 10);
389
409
  const policyMaxToolCalls = Number.parseInt(process.env.POLICY_MAX_TOOL_CALLS ?? "12", 10);
390
410
  const policyToolLoopThreshold = Number.parseInt(process.env.POLICY_TOOL_LOOP_THRESHOLD ?? "10", 10);
@@ -593,6 +613,11 @@ var config = {
593
613
  apiKey: vertexApiKey,
594
614
  model: vertexModel,
595
615
  },
616
+ moonshot: {
617
+ apiKey: moonshotApiKey,
618
+ endpoint: moonshotEndpoint,
619
+ model: moonshotModel,
620
+ },
596
621
  hotReload: {
597
622
  enabled: hotReloadEnabled,
598
623
  debounceMs: Number.isNaN(hotReloadDebounceMs) ? 1000 : hotReloadDebounceMs,
@@ -601,8 +626,6 @@ var config = {
601
626
  type: modelProvider,
602
627
  defaultModel,
603
628
  suggestionModeModel,
604
- // Hybrid routing settings
605
- preferOllama,
606
629
  fallbackEnabled,
607
630
  ollamaMaxToolsForRouting,
608
631
  openRouterMaxToolsForRouting,
@@ -620,6 +643,13 @@ var config = {
620
643
  },
621
644
  logger: {
622
645
  level: process.env.LOG_LEVEL ?? "info",
646
+ file: {
647
+ enabled: process.env.LOG_FILE_ENABLED === "true",
648
+ path: process.env.LOG_FILE_PATH ?? path.join(process.cwd(), "logs", "lynkr.log"),
649
+ level: process.env.LOG_FILE_LEVEL ?? "debug", // File captures everything
650
+ frequency: process.env.LOG_FILE_FREQUENCY ?? "daily", // daily | hourly | <milliseconds>
651
+ maxFiles: parseInt(process.env.LOG_FILE_MAX_FILES ?? "14", 10),
652
+ },
623
653
  },
624
654
  sessionStore: {
625
655
  dbPath: sessionDbPath,
@@ -638,6 +668,14 @@ var config = {
638
668
  retryEnabled: webSearchRetryEnabled,
639
669
  maxRetries: Number.isNaN(webSearchMaxRetries) ? 2 : webSearchMaxRetries,
640
670
  },
671
+ tinyfish: {
672
+ apiKey: tinyfishApiKey,
673
+ endpoint: tinyfishEndpoint,
674
+ browserProfile: tinyfishBrowserProfile,
675
+ timeoutMs: Number.isNaN(tinyfishTimeoutMs) ? 120000 : tinyfishTimeoutMs,
676
+ proxyEnabled: tinyfishProxyEnabled,
677
+ proxyCountry: tinyfishProxyCountry,
678
+ },
641
679
  policy: {
642
680
  maxStepsPerTurn: Number.isNaN(policyMaxSteps) ? 8 : policyMaxSteps,
643
681
  maxToolCallsPerTurn: Number.isNaN(policyMaxToolCalls) ? 12 : policyMaxToolCalls,
@@ -704,8 +742,8 @@ var config = {
704
742
  semanticCache: {
705
743
  enabled: process.env.SEMANTIC_CACHE_ENABLED !== 'false', // Disable via env if needed
706
744
  similarityThreshold: parseFloat(process.env.SEMANTIC_CACHE_THRESHOLD || '0.95'), // Higher threshold
707
- maxEntries: 500,
708
- ttlMs: 3600000, // 1 hour
745
+ maxEntries: Number.parseInt(process.env.SEMANTIC_CACHE_MAX_ENTRIES ?? "50", 10), // Reduced from 500 to prevent memory bloat
746
+ ttlMs: Number.parseInt(process.env.SEMANTIC_CACHE_TTL_MS ?? "300000", 10), // 5 minutes (was 1 hour)
709
747
  },
710
748
  agents: {
711
749
  enabled: agentsEnabled,
@@ -765,6 +803,12 @@ var config = {
765
803
  max: tokenBudgetMax,
766
804
  enforcement: tokenBudgetEnforcement,
767
805
  },
806
+ toon: {
807
+ enabled: toonEnabled,
808
+ minBytes: Number.isNaN(toonMinBytes) ? 4096 : toonMinBytes,
809
+ failOpen: toonFailOpen,
810
+ logStats: toonLogStats,
811
+ },
768
812
  smartToolSelection: {
769
813
  enabled: true, // HARDCODED - always enabled
770
814
  mode: smartToolSelectionMode,
@@ -857,6 +901,23 @@ var config = {
857
901
  taskTimeoutMs: Number.isNaN(workerTaskTimeoutMs) ? 5000 : workerTaskTimeoutMs,
858
902
  offloadThresholdBytes: Number.isNaN(workerOffloadThresholdBytes) ? 10000 : workerOffloadThresholdBytes,
859
903
  },
904
+
905
+ // Intelligent Routing
906
+ routing: {
907
+ weightedScoring: true,
908
+ costOptimization: true,
909
+ agenticDetection: true,
910
+ },
911
+
912
+ // Model Tier Configuration (REQUIRED)
913
+ // Format: TIER_<LEVEL>=provider:model (e.g., TIER_SIMPLE=ollama:llama3.2)
914
+ modelTiers: {
915
+ enabled: true,
916
+ SIMPLE: process.env.TIER_SIMPLE?.trim() || null,
917
+ MEDIUM: process.env.TIER_MEDIUM?.trim() || null,
918
+ COMPLEX: process.env.TIER_COMPLEX?.trim() || null,
919
+ REASONING: process.env.TIER_REASONING?.trim() || null,
920
+ },
860
921
  };
861
922
 
862
923
  /**
@@ -881,17 +942,28 @@ function reloadConfig() {
881
942
  config.zai.model = process.env.ZAI_MODEL?.trim() || "GLM-4.7";
882
943
  config.vertex.apiKey = process.env.VERTEX_API_KEY?.trim() || process.env.GOOGLE_API_KEY?.trim() || null;
883
944
  config.vertex.model = process.env.VERTEX_MODEL?.trim() || "gemini-2.0-flash";
945
+ config.moonshot.apiKey = process.env.MOONSHOT_API_KEY?.trim() || null;
946
+ config.moonshot.model = process.env.MOONSHOT_MODEL?.trim() || "kimi-k2-turbo-preview";
884
947
 
885
948
  // Model provider settings
886
949
  const newProvider = (process.env.MODEL_PROVIDER ?? "databricks").toLowerCase();
887
950
  if (SUPPORTED_MODEL_PROVIDERS.has(newProvider)) {
888
951
  config.modelProvider.type = newProvider;
889
952
  }
890
- config.modelProvider.preferOllama = process.env.PREFER_OLLAMA === "true";
891
953
  config.modelProvider.fallbackEnabled = process.env.FALLBACK_ENABLED !== "false";
892
954
  config.modelProvider.fallbackProvider = (process.env.FALLBACK_PROVIDER ?? "databricks").toLowerCase();
893
955
  config.modelProvider.suggestionModeModel = (process.env.SUGGESTION_MODE_MODEL ?? "default").trim();
894
956
 
957
+ // TinyFish config reload
958
+ config.tinyfish.apiKey = process.env.TINYFISH_API_KEY?.trim() || null;
959
+ config.tinyfish.browserProfile = process.env.TINYFISH_BROWSER_PROFILE?.trim() || "lite";
960
+
961
+ config.toon.enabled = process.env.TOON_ENABLED === "true";
962
+ const newToonMinBytes = Number.parseInt(process.env.TOON_MIN_BYTES ?? "4096", 10);
963
+ config.toon.minBytes = Number.isNaN(newToonMinBytes) ? 4096 : newToonMinBytes;
964
+ config.toon.failOpen = process.env.TOON_FAIL_OPEN !== "false";
965
+ config.toon.logStats = process.env.TOON_LOG_STATS !== "false";
966
+
895
967
  // Log level
896
968
  config.logger.level = process.env.LOG_LEVEL ?? "info";
897
969
 
@@ -902,4 +974,30 @@ function reloadConfig() {
902
974
  // Make config mutable for hot reload
903
975
  config.reloadConfig = reloadConfig;
904
976
 
977
+ /**
978
+ * Check if any TIER_* value references Ollama (starts with "ollama:")
979
+ * Used by server.js to decide whether to wait for Ollama at startup.
980
+ */
981
+ config.tiersReferenceOllama = function tiersReferenceOllama() {
982
+ const tiers = config.modelTiers;
983
+ if (!tiers?.enabled) return false;
984
+ return [tiers.SIMPLE, tiers.MEDIUM, tiers.COMPLEX, tiers.REASONING]
985
+ .some(v => typeof v === 'string' && v.startsWith('ollama:'));
986
+ };
987
+
988
+ // Validate TIER_* configuration (warn if missing, don't crash)
989
+ const missingTiers = [];
990
+ if (!config.modelTiers.SIMPLE) missingTiers.push('TIER_SIMPLE');
991
+ if (!config.modelTiers.MEDIUM) missingTiers.push('TIER_MEDIUM');
992
+ if (!config.modelTiers.COMPLEX) missingTiers.push('TIER_COMPLEX');
993
+ if (!config.modelTiers.REASONING) missingTiers.push('TIER_REASONING');
994
+
995
+ if (missingTiers.length > 0) {
996
+ config.modelTiers.enabled = false;
997
+ console.warn(
998
+ `[WARN] Missing tier configuration: ${missingTiers.join(', ')} — tiered routing disabled.\n` +
999
+ ` Set TIER_<LEVEL>=provider:model to enable (e.g., TIER_SIMPLE=ollama:llama3.2)`
1000
+ );
1001
+ }
1002
+
905
1003
  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({