lynkr 9.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.
- package/README.md +70 -21
- package/bin/cli.js +16 -3
- package/index.js +7 -3
- package/install.sh +3 -3
- package/lynkr-skill.tar.gz +0 -0
- package/native/Cargo.toml +26 -0
- package/native/index.js +29 -0
- package/native/lynkr-native.node +0 -0
- package/native/src/lib.rs +321 -0
- package/package.json +6 -5
- package/src/api/files-multipart.js +30 -0
- package/src/api/files-router.js +81 -0
- package/src/api/openai-router.js +352 -300
- package/src/api/router.js +100 -3
- package/src/cache/prompt.js +13 -0
- package/src/clients/databricks.js +33 -13
- package/src/clients/ollama-utils.js +21 -17
- package/src/clients/openai-format.js +20 -6
- package/src/clients/openrouter-utils.js +42 -37
- package/src/clients/prompt-cache-injection.js +140 -0
- package/src/clients/provider-capabilities.js +41 -0
- package/src/clients/responses-format.js +8 -7
- package/src/clients/standard-tools.js +1 -1
- package/src/clients/xml-tool-extractor.js +307 -0
- package/src/cluster.js +82 -0
- package/src/config/index.js +9 -0
- package/src/context/distill.js +15 -0
- package/src/context/tool-result-compressor.js +563 -0
- package/src/memory/extractor.js +22 -0
- package/src/orchestrator/index.js +101 -199
- package/src/routing/index.js +3 -32
- package/src/routing/telemetry.js +40 -2
- package/src/server.js +12 -0
- package/src/stores/file-store.js +69 -0
- package/src/stores/response-store.js +25 -0
- package/src/tools/index.js +1 -1
- package/src/tools/web.js +1 -1
- package/.lynkr/telemetry.db +0 -0
- package/.lynkr/telemetry.db-shm +0 -0
- package/.lynkr/telemetry.db-wal +0 -0
|
@@ -19,6 +19,7 @@ const logger = require("../logger");
|
|
|
19
19
|
function mapClientToolToLynkr(clientToolName) {
|
|
20
20
|
const reverseMapping = {
|
|
21
21
|
// ============== CODEX CLI ==============
|
|
22
|
+
"shell": "Bash",
|
|
22
23
|
"shell_command": "Bash",
|
|
23
24
|
"read_file": "Read",
|
|
24
25
|
"write_file": "Write",
|
|
@@ -140,13 +141,13 @@ function convertResponsesToChat(responsesRequest) {
|
|
|
140
141
|
|
|
141
142
|
// Handle function_call (tool calls - convert to assistant with tool_calls)
|
|
142
143
|
if (msg.type === 'function_call') {
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
144
|
+
// Keep the client's original tool name (e.g., "shell", "read_file")
|
|
145
|
+
// so it matches the tool definitions injected in the Responses endpoint.
|
|
146
|
+
// Mapping to Lynkr names here would cause a mismatch with
|
|
147
|
+
// client-named tool definitions sent to the model.
|
|
146
148
|
logger.debug({
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}, "Mapping client tool name to Lynkr");
|
|
149
|
+
toolName: msg.name
|
|
150
|
+
}, "Preserving client tool name in function_call");
|
|
150
151
|
|
|
151
152
|
return {
|
|
152
153
|
role: 'assistant',
|
|
@@ -155,7 +156,7 @@ function convertResponsesToChat(responsesRequest) {
|
|
|
155
156
|
id: msg.call_id || msg.id,
|
|
156
157
|
type: 'function',
|
|
157
158
|
function: {
|
|
158
|
-
name:
|
|
159
|
+
name: msg.name,
|
|
159
160
|
arguments: typeof msg.arguments === 'string' ? msg.arguments : JSON.stringify(msg.arguments || {})
|
|
160
161
|
}
|
|
161
162
|
}]
|
|
@@ -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: ["
|
|
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 };
|
package/src/config/index.js
CHANGED
|
@@ -638,6 +638,9 @@ var config = {
|
|
|
638
638
|
fallbackProvider,
|
|
639
639
|
},
|
|
640
640
|
toolExecutionMode,
|
|
641
|
+
toolResultCompression: {
|
|
642
|
+
enabled: true,
|
|
643
|
+
},
|
|
641
644
|
server: {
|
|
642
645
|
jsonLimit: process.env.REQUEST_JSON_LIMIT ?? "1gb",
|
|
643
646
|
},
|
|
@@ -929,6 +932,12 @@ var config = {
|
|
|
929
932
|
REASONING: process.env.TIER_REASONING?.trim() || null,
|
|
930
933
|
},
|
|
931
934
|
|
|
935
|
+
// Cluster mode (multi-core scaling for 50+ concurrent users)
|
|
936
|
+
cluster: {
|
|
937
|
+
enabled: process.env.CLUSTER_ENABLED === 'true',
|
|
938
|
+
workers: process.env.CLUSTER_WORKERS || 'auto',
|
|
939
|
+
},
|
|
940
|
+
|
|
932
941
|
// Graphify knowledge graph integration (structural analysis)
|
|
933
942
|
codeGraph: {
|
|
934
943
|
enabled: process.env.CODE_GRAPH_ENABLED === 'true',
|
package/src/context/distill.js
CHANGED
|
@@ -71,9 +71,19 @@ function jaccardSimilarity(setA, setB) {
|
|
|
71
71
|
return union === 0 ? 0 : intersection / union;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// Try to load native Rust implementation (3.7x faster for 100+ line blocks)
|
|
75
|
+
let nativeSimilarity = null;
|
|
76
|
+
try {
|
|
77
|
+
const native = require('../../native');
|
|
78
|
+
if (native.available && native.structuralSimilarity) {
|
|
79
|
+
nativeSimilarity = native.structuralSimilarity;
|
|
80
|
+
}
|
|
81
|
+
} catch { /* native module not available — use JS */ }
|
|
82
|
+
|
|
74
83
|
/**
|
|
75
84
|
* Compute structural similarity between two text blocks.
|
|
76
85
|
* Uses normalized line signatures + Jaccard index.
|
|
86
|
+
* Delegates to Rust native when available (3.7x faster).
|
|
77
87
|
*
|
|
78
88
|
* @param {string} a - First text
|
|
79
89
|
* @param {string} b - Second text
|
|
@@ -83,6 +93,11 @@ function structuralSimilarity(a, b) {
|
|
|
83
93
|
if (!a && !b) return 1;
|
|
84
94
|
if (!a || !b) return 0;
|
|
85
95
|
|
|
96
|
+
// Use Rust for large inputs where the speedup offsets Napi boundary cost
|
|
97
|
+
if (nativeSimilarity && a.length + b.length > 500) {
|
|
98
|
+
return nativeSimilarity(a, b);
|
|
99
|
+
}
|
|
100
|
+
|
|
86
101
|
const sigA = extractSignature(a);
|
|
87
102
|
const sigB = extractSignature(b);
|
|
88
103
|
|