lynkr 8.0.1 → 9.0.2

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 (55) hide show
  1. package/README.md +238 -315
  2. package/bin/cli.js +16 -3
  3. package/index.js +7 -3
  4. package/install.sh +3 -3
  5. package/lynkr-skill.tar.gz +0 -0
  6. package/native/Cargo.toml +26 -0
  7. package/native/index.js +29 -0
  8. package/native/lynkr-native.node +0 -0
  9. package/native/src/lib.rs +321 -0
  10. package/package.json +8 -6
  11. package/src/api/files-multipart.js +30 -0
  12. package/src/api/files-router.js +81 -0
  13. package/src/api/openai-router.js +379 -308
  14. package/src/api/providers-handler.js +171 -3
  15. package/src/api/router.js +109 -5
  16. package/src/cache/prompt.js +13 -0
  17. package/src/clients/circuit-breaker.js +10 -247
  18. package/src/clients/codex-process.js +342 -0
  19. package/src/clients/codex-utils.js +143 -0
  20. package/src/clients/databricks.js +243 -76
  21. package/src/clients/ollama-utils.js +21 -17
  22. package/src/clients/openai-format.js +20 -6
  23. package/src/clients/openrouter-utils.js +42 -37
  24. package/src/clients/prompt-cache-injection.js +140 -0
  25. package/src/clients/provider-capabilities.js +41 -0
  26. package/src/clients/resilience.js +540 -0
  27. package/src/clients/responses-format.js +8 -7
  28. package/src/clients/retry.js +22 -167
  29. package/src/clients/standard-tools.js +1 -1
  30. package/src/clients/xml-tool-extractor.js +307 -0
  31. package/src/cluster.js +82 -0
  32. package/src/config/index.js +66 -0
  33. package/src/context/compression.js +42 -9
  34. package/src/context/distill.js +507 -0
  35. package/src/context/tool-result-compressor.js +563 -0
  36. package/src/memory/extractor.js +22 -0
  37. package/src/orchestrator/index.js +147 -205
  38. package/src/routing/complexity-analyzer.js +258 -5
  39. package/src/routing/index.js +15 -34
  40. package/src/routing/latency-tracker.js +148 -0
  41. package/src/routing/model-tiers.js +2 -0
  42. package/src/routing/quality-scorer.js +113 -0
  43. package/src/routing/telemetry.js +502 -0
  44. package/src/server.js +23 -0
  45. package/src/stores/file-store.js +69 -0
  46. package/src/stores/response-store.js +25 -0
  47. package/src/tools/code-graph.js +538 -0
  48. package/src/tools/code-mode.js +304 -0
  49. package/src/tools/index.js +1 -1
  50. package/src/tools/lazy-loader.js +11 -0
  51. package/src/tools/mcp-remote.js +7 -0
  52. package/src/tools/smart-selection.js +11 -0
  53. package/src/tools/web.js +1 -1
  54. package/src/utils/payload.js +206 -0
  55. package/src/utils/perf-timer.js +80 -0
@@ -1,200 +1,55 @@
1
- const logger = require("../logger");
2
-
3
1
  /**
4
- * Retry configuration for API calls
2
+ * Retry logic for API calls — backed by Cockatiel
3
+ *
4
+ * This module re-exports the Cockatiel-backed retry adapter from resilience.js
5
+ * while preserving all original exports for consumers.
5
6
  */
7
+ const { withCockatielRetry, DEFAULT_RETRY_CONFIG } = require("./resilience");
8
+
6
9
  const DEFAULT_CONFIG = {
7
- maxRetries: 3,
8
- initialDelay: 1000, // 1 second
9
- maxDelay: 30000, // 30 seconds
10
- backoffMultiplier: 2,
11
- jitterFactor: 0.1, // 10% jitter
12
- retryableStatuses: [429, 500, 502, 503, 504],
13
- retryableErrors: ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ENETUNREACH', 'ECONNREFUSED'],
10
+ maxRetries: DEFAULT_RETRY_CONFIG.maxRetries,
11
+ initialDelay: DEFAULT_RETRY_CONFIG.initialDelay,
12
+ maxDelay: DEFAULT_RETRY_CONFIG.maxDelay,
13
+ backoffMultiplier: DEFAULT_RETRY_CONFIG.backoffMultiplier,
14
+ jitterFactor: DEFAULT_RETRY_CONFIG.jitterFactor,
15
+ retryableStatuses: DEFAULT_RETRY_CONFIG.retryableStatuses,
16
+ retryableErrors: DEFAULT_RETRY_CONFIG.retryableErrors,
14
17
  };
15
18
 
16
19
  /**
17
- * Add jitter to prevent thundering herd
18
- */
19
- function addJitter(delay, jitterFactor) {
20
- const jitter = delay * jitterFactor * (Math.random() * 2 - 1);
21
- return Math.max(0, delay + jitter);
22
- }
23
-
24
- /**
25
- * Calculate delay with exponential backoff
20
+ * Calculate delay with exponential backoff (preserved for any direct callers)
26
21
  */
27
22
  function calculateDelay(attempt, config) {
28
23
  const baseDelay = config.initialDelay * Math.pow(config.backoffMultiplier, attempt);
29
24
  const cappedDelay = Math.min(baseDelay, config.maxDelay);
30
- return addJitter(cappedDelay, config.jitterFactor);
25
+ const jitter = cappedDelay * config.jitterFactor * (Math.random() * 2 - 1);
26
+ return Math.max(0, cappedDelay + jitter);
31
27
  }
32
28
 
33
29
  /**
34
- * Check if error/response is retryable
30
+ * Check if error/response is retryable (preserved for any direct callers)
35
31
  */
36
32
  function isRetryable(error, response, config) {
37
- // Check response status codes
38
33
  if (response && config.retryableStatuses.includes(response.status)) {
39
34
  return true;
40
35
  }
41
-
42
- // Check error codes
43
36
  if (error && error.code && config.retryableErrors.includes(error.code)) {
44
37
  return true;
45
38
  }
46
-
47
- // Check nested cause (Node undici wraps connection errors as TypeError)
48
39
  if (error && error.cause?.code && config.retryableErrors.includes(error.cause.code)) {
49
40
  return true;
50
41
  }
51
-
52
- // Check for network errors
53
- if (error && (error.name === 'FetchError' || error.name === 'AbortError')) {
42
+ if (error && (error.name === "FetchError" || error.name === "AbortError")) {
54
43
  return true;
55
44
  }
56
-
57
45
  return false;
58
46
  }
59
47
 
60
48
  /**
61
- * Detect if this is a cold start (longer than expected response time)
49
+ * Detect if this is a cold start
62
50
  */
63
51
  function detectColdStart(startTime, endTime, threshold = 5000) {
64
- const duration = endTime - startTime;
65
- return duration > threshold;
66
- }
67
-
68
- /**
69
- * Execute function with retry logic
70
- */
71
- async function withRetry(fn, options = {}) {
72
- const config = { ...DEFAULT_CONFIG, ...options };
73
- let lastError;
74
- let lastResponse;
75
-
76
- for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
77
- const startTime = Date.now();
78
-
79
- try {
80
- const result = await fn(attempt);
81
- const endTime = Date.now();
82
-
83
- // Detect cold starts for monitoring
84
- if (detectColdStart(startTime, endTime)) {
85
- logger.warn({
86
- attempt,
87
- duration: endTime - startTime,
88
- }, 'Potential cold start detected');
89
- }
90
-
91
- // Check if response indicates we should retry
92
- if (result && isRetryable(null, result, config) && attempt < config.maxRetries) {
93
- lastResponse = result;
94
-
95
- // Special handling for 429 (rate limiting)
96
- if (result.status === 429) {
97
- // Check for Retry-After header
98
- const retryAfter = result.headers?.get?.('retry-after');
99
- let delay;
100
-
101
- if (retryAfter) {
102
- // Retry-After can be in seconds or a date
103
- const retryAfterNum = parseInt(retryAfter, 10);
104
- if (!isNaN(retryAfterNum)) {
105
- delay = retryAfterNum * 1000; // Convert to ms
106
- } else {
107
- const retryAfterDate = new Date(retryAfter);
108
- delay = retryAfterDate.getTime() - Date.now();
109
- }
110
- } else {
111
- // Use exponential backoff with longer delays for rate limiting
112
- delay = calculateDelay(attempt, {
113
- ...config,
114
- initialDelay: 2000, // Start at 2s for rate limits
115
- maxDelay: 60000, // Up to 1 minute
116
- });
117
- }
118
-
119
- logger.warn({
120
- attempt,
121
- delay,
122
- retryAfter: retryAfter || 'not specified',
123
- }, 'Rate limited (429), retrying after delay');
124
-
125
- await sleep(delay);
126
- continue;
127
- }
128
-
129
- // Regular retry with exponential backoff
130
- const delay = calculateDelay(attempt, config);
131
- logger.warn({
132
- attempt,
133
- status: result.status,
134
- delay,
135
- }, 'Request failed, retrying with backoff');
136
-
137
- await sleep(delay);
138
- continue;
139
- }
140
-
141
- // Success or non-retryable error
142
- return result;
143
-
144
- } catch (error) {
145
- lastError = error;
146
- const endTime = Date.now();
147
-
148
- // Check if cold start
149
- if (detectColdStart(startTime, endTime)) {
150
- logger.warn({
151
- attempt,
152
- duration: endTime - startTime,
153
- error: error.message,
154
- }, 'Potential cold start with error detected');
155
- }
156
-
157
- // Check if we should retry
158
- if (isRetryable(error, null, config) && attempt < config.maxRetries) {
159
- const delay = calculateDelay(attempt, config);
160
- logger.warn({
161
- attempt,
162
- error: error.message,
163
- code: error.code,
164
- delay,
165
- }, 'Request error, retrying with backoff');
166
-
167
- await sleep(delay);
168
- continue;
169
- }
170
-
171
- // Not retryable or out of retries
172
- throw error;
173
- }
174
- }
175
-
176
- // Max retries exceeded
177
- if (lastError) {
178
- lastError.message = `Max retries (${config.maxRetries}) exceeded: ${lastError.message}`;
179
- throw lastError;
180
- }
181
-
182
- if (lastResponse) {
183
- logger.error({
184
- status: lastResponse.status,
185
- maxRetries: config.maxRetries,
186
- }, 'Max retries exceeded');
187
- return lastResponse;
188
- }
189
-
190
- throw new Error('Retry logic failed unexpectedly');
191
- }
192
-
193
- /**
194
- * Sleep helper
195
- */
196
- function sleep(ms) {
197
- return new Promise(resolve => setTimeout(resolve, ms));
52
+ return (endTime - startTime) > threshold;
198
53
  }
199
54
 
200
55
  /**
@@ -202,12 +57,12 @@ function sleep(ms) {
202
57
  */
203
58
  function createRetryWrapper(fn, defaultOptions = {}) {
204
59
  return async function (...args) {
205
- return withRetry(() => fn(...args), defaultOptions);
60
+ return withCockatielRetry(() => fn(...args), defaultOptions);
206
61
  };
207
62
  }
208
63
 
209
64
  module.exports = {
210
- withRetry,
65
+ withRetry: withCockatielRetry,
211
66
  createRetryWrapper,
212
67
  calculateDelay,
213
68
  isRetryable,
@@ -275,7 +275,7 @@ EXAMPLE: User says "explore this project" → Call Task with subagent_type="Expl
275
275
  description: "Optional model override. Default is appropriate for each agent type."
276
276
  }
277
277
  },
278
- required: ["description", "prompt", "subagent_type"]
278
+ required: ["prompt"]
279
279
  }
280
280
  },
281
281
  {
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Universal Tool Call Extractor
3
+ *
4
+ * Extracts tool calls embedded as raw text (XML, JSON, custom tokens)
5
+ * from LLM output. Covers Minimax, Hermes/Qwen, Qwen3-Coder, GLM,
6
+ * Llama, Mistral, DeepSeek, GPT-OSS, and generic formats.
7
+ *
8
+ * @module clients/xml-tool-extractor
9
+ */
10
+
11
+ const logger = require("../logger");
12
+
13
+ let callCounter = 0;
14
+ function nextId() {
15
+ return `call_extracted_${Date.now()}_${callCounter++}`;
16
+ }
17
+
18
+ function tryParseJSON(str) {
19
+ try { return JSON.parse(str.trim()); } catch { return null; }
20
+ }
21
+
22
+ // ── Individual extractors ────────────────────────────────────────────
23
+
24
+ /** 1. Minimax: <invoke name="X"><parameter name="K">V</parameter></invoke> */
25
+ function extractMinimax(text) {
26
+ const calls = [];
27
+ const re = /<invoke\s+name="([^"]+)">([\s\S]*?)<\/invoke>/g;
28
+ const paramRe = /<parameter\s+name="([^"]+)">([\s\S]*?)<\/parameter>/g;
29
+ let m;
30
+ while ((m = re.exec(text)) !== null) {
31
+ const name = m[1];
32
+ const body = m[2];
33
+ const args = {};
34
+ let pm;
35
+ while ((pm = paramRe.exec(body)) !== null) {
36
+ let val = pm[2].trim();
37
+ const parsed = tryParseJSON(val);
38
+ args[pm[1]] = parsed !== null ? parsed : val;
39
+ }
40
+ paramRe.lastIndex = 0;
41
+ calls.push({ name, arguments: args, _match: m[0] });
42
+ }
43
+ // Also strip wrapper tags
44
+ let cleaned = text;
45
+ if (calls.length > 0) {
46
+ for (const c of calls) cleaned = cleaned.replace(c._match, "");
47
+ cleaned = cleaned.replace(/<\/?minimax:tool_call>/g, "");
48
+ }
49
+ return { calls, cleaned };
50
+ }
51
+
52
+ /** 2. GLM: <tool_call>func_name <arg_key>k</arg_key> <arg_value>v</arg_value></tool_call> */
53
+ function extractGLM(text) {
54
+ const calls = [];
55
+ const re = /<tool_call>([\s\S]*?)<\/tool_call>/g;
56
+ let m;
57
+ while ((m = re.exec(text)) !== null) {
58
+ const body = m[1].trim();
59
+ // Check if it's GLM style (has <arg_key> tags)
60
+ if (!body.includes("<arg_key>")) continue;
61
+ const nameMatch = body.match(/^(\S+)/);
62
+ if (!nameMatch) continue;
63
+ const name = nameMatch[1];
64
+ const args = {};
65
+ const kvRe = /<arg_key>([\s\S]*?)<\/arg_key>\s*<arg_value>([\s\S]*?)<\/arg_value>/g;
66
+ let kv;
67
+ while ((kv = kvRe.exec(body)) !== null) {
68
+ let val = kv[2].trim();
69
+ const parsed = tryParseJSON(val);
70
+ args[kv[1].trim()] = parsed !== null ? parsed : val;
71
+ }
72
+ calls.push({ name, arguments: args, _match: m[0] });
73
+ }
74
+ let cleaned = text;
75
+ for (const c of calls) cleaned = cleaned.replace(c._match, "");
76
+ return { calls, cleaned };
77
+ }
78
+
79
+ /** 3. Hermes/Qwen JSON: <tool_call>{"name":"X","arguments":{...}}</tool_call> */
80
+ function extractHermesQwen(text) {
81
+ const calls = [];
82
+ const re = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;
83
+ let m;
84
+ while ((m = re.exec(text)) !== null) {
85
+ const body = m[1].trim();
86
+ // Skip GLM format (handled above)
87
+ if (body.includes("<arg_key>")) continue;
88
+ // Skip Qwen3-Coder XML format
89
+ if (body.includes("<tool_name>")) continue;
90
+ const json = tryParseJSON(body);
91
+ if (json && (json.name || json.function)) {
92
+ const name = json.name || json.function?.name || json.function || "unknown";
93
+ const args = json.arguments || json.parameters || json.params || {};
94
+ calls.push({ name, arguments: typeof args === "string" ? tryParseJSON(args) || {} : args, _match: m[0] });
95
+ }
96
+ }
97
+ let cleaned = text;
98
+ for (const c of calls) cleaned = cleaned.replace(c._match, "");
99
+ return { calls, cleaned };
100
+ }
101
+
102
+ /** 4. Qwen3-Coder: <tool_call><tool_name>X</tool_name><parameter name="K">V</parameter></tool_call> */
103
+ function extractQwenCoder(text) {
104
+ const calls = [];
105
+ const re = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;
106
+ let m;
107
+ while ((m = re.exec(text)) !== null) {
108
+ const body = m[1].trim();
109
+ if (!body.includes("<tool_name>")) continue;
110
+ const nameMatch = body.match(/<tool_name>([\s\S]*?)<\/tool_name>/);
111
+ if (!nameMatch) continue;
112
+ const name = nameMatch[1].trim();
113
+ const args = {};
114
+ const paramRe = /<parameter\s+name="([^"]+)">([\s\S]*?)<\/parameter>/g;
115
+ let pm;
116
+ while ((pm = paramRe.exec(body)) !== null) {
117
+ let val = pm[2].trim();
118
+ const parsed = tryParseJSON(val);
119
+ args[pm[1]] = parsed !== null ? parsed : val;
120
+ }
121
+ calls.push({ name, arguments: args, _match: m[0] });
122
+ }
123
+ let cleaned = text;
124
+ for (const c of calls) cleaned = cleaned.replace(c._match, "");
125
+ return { calls, cleaned };
126
+ }
127
+
128
+ /** 5. DeepSeek: <|tool▁call▁begin|>function<|tool▁sep|>name\n```json\n{...}\n```\n<|tool▁call▁end|> */
129
+ function extractDeepSeek(text) {
130
+ const calls = [];
131
+ // Match both Unicode and ASCII approximations
132
+ const re = /(?:<|tool▁call▁begin|>|<\|tool_call_begin\|>|<\|tool_call_start\|>)\s*(?:function)?\s*(?:<|tool▁sep|>|<\|tool_sep\|>)?\s*(\S+)\s*```(?:json)?\s*([\s\S]*?)```\s*(?:<|tool▁call▁end|>|<\|tool_call_end\|>)/g;
133
+ let m;
134
+ while ((m = re.exec(text)) !== null) {
135
+ const name = m[1].trim();
136
+ const json = tryParseJSON(m[2]);
137
+ if (name) {
138
+ calls.push({ name, arguments: json || {}, _match: m[0] });
139
+ }
140
+ }
141
+ let cleaned = text;
142
+ for (const c of calls) cleaned = cleaned.replace(c._match, "");
143
+ return { calls, cleaned };
144
+ }
145
+
146
+ /** 6. Mistral: [TOOL_CALLS] [{"name":"X","arguments":{...}}] */
147
+ function extractMistral(text) {
148
+ const calls = [];
149
+ const re = /\[TOOL_CALLS\]\s*(\[[\s\S]*?\])/g;
150
+ let m;
151
+ while ((m = re.exec(text)) !== null) {
152
+ const arr = tryParseJSON(m[1]);
153
+ if (Array.isArray(arr)) {
154
+ for (const item of arr) {
155
+ if (item.name || item.function) {
156
+ const name = item.name || item.function?.name || "unknown";
157
+ const args = item.arguments || item.parameters || {};
158
+ calls.push({ name, arguments: typeof args === "string" ? tryParseJSON(args) || {} : args, _match: m[0] });
159
+ }
160
+ }
161
+ }
162
+ }
163
+ let cleaned = text;
164
+ for (const c of calls) cleaned = cleaned.replace(c._match, "");
165
+ return { calls, cleaned };
166
+ }
167
+
168
+ /** 7. Llama python_tag: <|python_tag|>{"name":"X","arguments":{...}} */
169
+ function extractLlamaPythonTag(text) {
170
+ const calls = [];
171
+ const re = /<\|python_tag\|>\s*(\{[\s\S]*?\})(?:<\|eom_id\|>|<\|eot_id\|>|\s*$)/g;
172
+ let m;
173
+ while ((m = re.exec(text)) !== null) {
174
+ const json = tryParseJSON(m[1]);
175
+ if (json && (json.name || json.function)) {
176
+ const name = json.name || json.function || "unknown";
177
+ const args = json.arguments || json.parameters || {};
178
+ calls.push({ name, arguments: typeof args === "string" ? tryParseJSON(args) || {} : args, _match: m[0] });
179
+ }
180
+ }
181
+ let cleaned = text;
182
+ for (const c of calls) cleaned = cleaned.replace(c._match, "");
183
+ return { calls, cleaned };
184
+ }
185
+
186
+ /** 8. GPT-OSS Harmony: <|call|>name(key=value, ...) */
187
+ function extractGptOss(text) {
188
+ const calls = [];
189
+ const re = /<\|call\|>\s*(\w+)\(([^)]*)\)/g;
190
+ let m;
191
+ while ((m = re.exec(text)) !== null) {
192
+ const name = m[1];
193
+ const argsStr = m[2].trim();
194
+ const args = {};
195
+ if (argsStr) {
196
+ // Parse key=value pairs
197
+ const kvRe = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))/g;
198
+ let kv;
199
+ while ((kv = kvRe.exec(argsStr)) !== null) {
200
+ args[kv[1]] = kv[2] ?? kv[3] ?? kv[4];
201
+ }
202
+ }
203
+ calls.push({ name, arguments: args, _match: m[0] });
204
+ }
205
+ let cleaned = text;
206
+ for (const c of calls) cleaned = cleaned.replace(c._match, "");
207
+ return { calls, cleaned };
208
+ }
209
+
210
+ /** 9. Generic <function_call>{...}</function_call> */
211
+ function extractGenericFunctionCall(text) {
212
+ const calls = [];
213
+ const re = /<function_call>\s*([\s\S]*?)\s*<\/function_call>/g;
214
+ let m;
215
+ while ((m = re.exec(text)) !== null) {
216
+ const json = tryParseJSON(m[1]);
217
+ if (json && (json.name || json.function)) {
218
+ const name = json.name || json.function?.name || json.function || "unknown";
219
+ const args = json.arguments || json.parameters || {};
220
+ calls.push({ name, arguments: typeof args === "string" ? tryParseJSON(args) || {} : args, _match: m[0] });
221
+ }
222
+ }
223
+ let cleaned = text;
224
+ for (const c of calls) cleaned = cleaned.replace(c._match, "");
225
+ return { calls, cleaned };
226
+ }
227
+
228
+ /** 10. Raw JSON fallback: text starts with {"name":"...","arguments":{...}} */
229
+ function extractRawJSON(text) {
230
+ const trimmed = text.trim();
231
+ if (!trimmed.startsWith("{")) return { calls: [], cleaned: text };
232
+ const json = tryParseJSON(trimmed);
233
+ if (!json || typeof json !== "object") return { calls: [], cleaned: text };
234
+ if (!json.name && !json.function) return { calls: [], cleaned: text };
235
+ const name = json.name || json.function?.name || json.function || "unknown";
236
+ const args = json.arguments || json.parameters || {};
237
+ return {
238
+ calls: [{ name, arguments: typeof args === "string" ? tryParseJSON(args) || {} : args, _match: trimmed }],
239
+ cleaned: "",
240
+ };
241
+ }
242
+
243
+ // ── Main entry point ─────────────────────────────────────────────────
244
+
245
+ const EXTRACTORS = [
246
+ { name: "minimax", fn: extractMinimax },
247
+ { name: "glm", fn: extractGLM },
248
+ { name: "hermes_qwen", fn: extractHermesQwen },
249
+ { name: "qwen_coder", fn: extractQwenCoder },
250
+ { name: "deepseek", fn: extractDeepSeek },
251
+ { name: "mistral", fn: extractMistral },
252
+ { name: "llama_python", fn: extractLlamaPythonTag },
253
+ { name: "gpt_oss", fn: extractGptOss },
254
+ { name: "generic_function_call", fn: extractGenericFunctionCall },
255
+ { name: "raw_json", fn: extractRawJSON },
256
+ ];
257
+
258
+ /**
259
+ * Extract tool calls from model text output.
260
+ * Tries all known patterns (most specific → most generic).
261
+ * Returns on first extractor that finds tool calls.
262
+ *
263
+ * @param {string} text - Raw model text content
264
+ * @returns {{ toolCalls: Array, cleanedText: string|null }}
265
+ */
266
+ function extractToolCallsFromText(text) {
267
+ if (!text || typeof text !== "string") {
268
+ return { toolCalls: [], cleanedText: text };
269
+ }
270
+
271
+ for (const { name: extractorName, fn } of EXTRACTORS) {
272
+ try {
273
+ const { calls, cleaned } = fn(text);
274
+ if (calls.length > 0) {
275
+ const toolCalls = calls.map((c) => ({
276
+ id: nextId(),
277
+ type: "function",
278
+ function: {
279
+ name: c.name,
280
+ arguments: typeof c.arguments === "string" ? c.arguments : JSON.stringify(c.arguments),
281
+ },
282
+ }));
283
+
284
+ // Clean up stray tokens
285
+ let cleanedText = (cleaned || "")
286
+ .replace(/<\|eom_id\|>/g, "")
287
+ .replace(/<\|eot_id\|>/g, "")
288
+ .replace(/<\|end\|>/g, "")
289
+ .trim() || null;
290
+
291
+ logger.info({
292
+ extractor: extractorName,
293
+ toolCount: toolCalls.length,
294
+ tools: toolCalls.map((t) => t.function.name),
295
+ }, "Extracted tool calls from model text output");
296
+
297
+ return { toolCalls, cleanedText };
298
+ }
299
+ } catch (err) {
300
+ logger.debug({ extractor: extractorName, error: err.message }, "Tool call extractor failed, trying next");
301
+ }
302
+ }
303
+
304
+ return { toolCalls: [], cleanedText: text };
305
+ }
306
+
307
+ module.exports = { extractToolCallsFromText };
package/src/cluster.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Cluster Mode — Multi-Core Scaling
3
+ *
4
+ * Forks one worker per CPU core. Each worker runs a full Lynkr
5
+ * instance with its own Express server, event loop, and connection pool.
6
+ *
7
+ * Enable: CLUSTER_ENABLED=true (default: false for dev, recommended for prod)
8
+ * Workers: CLUSTER_WORKERS=auto (default) or a number
9
+ *
10
+ * Architecture:
11
+ * Primary process → forks N workers → each worker calls start()
12
+ * Primary handles: signal forwarding, worker respawning, health monitoring
13
+ * Workers handle: HTTP requests, LLM proxying, tool execution
14
+ *
15
+ * Shared state considerations:
16
+ * - SQLite: WAL mode supports concurrent readers across processes
17
+ * - In-memory caches (prompt, circuit breaker): per-worker (not shared)
18
+ * - Rate limiting: per-worker (sessions are sticky via round-robin)
19
+ *
20
+ * @module cluster
21
+ */
22
+
23
+ const cluster = require('node:cluster');
24
+ const os = require('node:os');
25
+
26
+ const WORKER_COUNT = (() => {
27
+ const env = process.env.CLUSTER_WORKERS;
28
+ if (!env || env === 'auto') return Math.max(os.cpus().length - 1, 1);
29
+ const n = parseInt(env, 10);
30
+ return Number.isNaN(n) || n < 1 ? Math.max(os.cpus().length - 1, 1) : n;
31
+ })();
32
+
33
+ function startCluster() {
34
+ if (cluster.isPrimary) {
35
+ console.log(`[cluster] Primary ${process.pid} starting ${WORKER_COUNT} workers`);
36
+
37
+ // Fork workers
38
+ for (let i = 0; i < WORKER_COUNT; i++) {
39
+ cluster.fork();
40
+ }
41
+
42
+ // Respawn crashed workers
43
+ cluster.on('exit', (worker, code, signal) => {
44
+ if (signal) {
45
+ console.log(`[cluster] Worker ${worker.process.pid} killed by signal ${signal}`);
46
+ } else if (code !== 0) {
47
+ console.log(`[cluster] Worker ${worker.process.pid} exited with code ${code}, respawning...`);
48
+ cluster.fork();
49
+ } else {
50
+ console.log(`[cluster] Worker ${worker.process.pid} exited cleanly`);
51
+ }
52
+ });
53
+
54
+ // Forward SIGTERM/SIGINT to all workers for graceful shutdown
55
+ const shutdown = (sig) => {
56
+ console.log(`[cluster] Primary received ${sig}, shutting down workers...`);
57
+ for (const id in cluster.workers) {
58
+ cluster.workers[id].process.kill(sig);
59
+ }
60
+ // Give workers 10s to drain, then force exit
61
+ setTimeout(() => {
62
+ console.log('[cluster] Force exit after 10s drain timeout');
63
+ process.exit(0);
64
+ }, 10000).unref();
65
+ };
66
+
67
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
68
+ process.on('SIGINT', () => shutdown('SIGINT'));
69
+
70
+ // Log worker status
71
+ cluster.on('online', (worker) => {
72
+ console.log(`[cluster] Worker ${worker.process.pid} online`);
73
+ });
74
+
75
+ } else {
76
+ // Worker process — start the normal Lynkr server
77
+ const { start } = require('./server');
78
+ start();
79
+ }
80
+ }
81
+
82
+ module.exports = { startCluster, WORKER_COUNT };