wolverine-ai 1.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.
- package/PLATFORM.md +442 -0
- package/README.md +475 -0
- package/SERVER_BEST_PRACTICES.md +62 -0
- package/TELEMETRY.md +108 -0
- package/bin/wolverine.js +95 -0
- package/examples/01-basic-typo.js +31 -0
- package/examples/02-multi-file/routes/users.js +15 -0
- package/examples/02-multi-file/server.js +25 -0
- package/examples/03-syntax-error.js +23 -0
- package/examples/04-secret-leak.js +14 -0
- package/examples/05-expired-key.js +27 -0
- package/examples/06-json-config/config.json +13 -0
- package/examples/06-json-config/server.js +28 -0
- package/examples/07-rate-limit-loop.js +11 -0
- package/examples/08-sandbox-escape.js +20 -0
- package/examples/buggy-server.js +39 -0
- package/examples/demos/01-basic-typo/index.js +20 -0
- package/examples/demos/01-basic-typo/routes/api.js +13 -0
- package/examples/demos/01-basic-typo/routes/health.js +4 -0
- package/examples/demos/02-multi-file/index.js +24 -0
- package/examples/demos/02-multi-file/routes/api.js +13 -0
- package/examples/demos/02-multi-file/routes/health.js +4 -0
- package/examples/demos/03-syntax-error/index.js +18 -0
- package/examples/demos/04-secret-leak/index.js +16 -0
- package/examples/demos/05-expired-key/index.js +21 -0
- package/examples/demos/06-json-config/config.json +9 -0
- package/examples/demos/06-json-config/index.js +20 -0
- package/examples/demos/07-null-crash/index.js +16 -0
- package/examples/run-demo.js +110 -0
- package/package.json +67 -0
- package/server/config/settings.json +62 -0
- package/server/index.js +33 -0
- package/server/routes/api.js +12 -0
- package/server/routes/health.js +16 -0
- package/server/routes/time.js +12 -0
- package/src/agent/agent-engine.js +727 -0
- package/src/agent/goal-loop.js +140 -0
- package/src/agent/research-agent.js +120 -0
- package/src/agent/sub-agents.js +176 -0
- package/src/backup/backup-manager.js +321 -0
- package/src/brain/brain.js +315 -0
- package/src/brain/embedder.js +131 -0
- package/src/brain/function-map.js +263 -0
- package/src/brain/vector-store.js +267 -0
- package/src/core/ai-client.js +387 -0
- package/src/core/cluster-manager.js +144 -0
- package/src/core/config.js +89 -0
- package/src/core/error-parser.js +87 -0
- package/src/core/health-monitor.js +129 -0
- package/src/core/models.js +132 -0
- package/src/core/patcher.js +55 -0
- package/src/core/runner.js +464 -0
- package/src/core/system-info.js +141 -0
- package/src/core/verifier.js +146 -0
- package/src/core/wolverine.js +290 -0
- package/src/dashboard/server.js +1332 -0
- package/src/index.js +94 -0
- package/src/logger/event-logger.js +237 -0
- package/src/logger/pricing.js +96 -0
- package/src/logger/repair-history.js +109 -0
- package/src/logger/token-tracker.js +277 -0
- package/src/mcp/mcp-client.js +224 -0
- package/src/mcp/mcp-registry.js +228 -0
- package/src/mcp/mcp-security.js +152 -0
- package/src/monitor/perf-monitor.js +300 -0
- package/src/monitor/process-monitor.js +231 -0
- package/src/monitor/route-prober.js +191 -0
- package/src/notifications/notifier.js +227 -0
- package/src/platform/heartbeat.js +93 -0
- package/src/platform/queue.js +53 -0
- package/src/platform/register.js +64 -0
- package/src/platform/telemetry.js +76 -0
- package/src/security/admin-auth.js +150 -0
- package/src/security/injection-detector.js +174 -0
- package/src/security/rate-limiter.js +152 -0
- package/src/security/sandbox.js +128 -0
- package/src/security/secret-redactor.js +217 -0
- package/src/skills/skill-registry.js +129 -0
- package/src/skills/sql.js +375 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
const OpenAI = require("openai");
|
|
2
|
+
const { getModel } = require("./models");
|
|
3
|
+
|
|
4
|
+
let client = null;
|
|
5
|
+
let _tracker = null;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Set the global token tracker. Called once from runner on startup.
|
|
9
|
+
*/
|
|
10
|
+
function setTokenTracker(tracker) { _tracker = tracker; }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extract token counts from any OpenAI response usage object.
|
|
14
|
+
*/
|
|
15
|
+
function _extractTokens(usage) {
|
|
16
|
+
if (!usage) return { input: 0, output: 0 };
|
|
17
|
+
return {
|
|
18
|
+
input: usage.prompt_tokens || usage.input_tokens || 0,
|
|
19
|
+
output: usage.completion_tokens || usage.output_tokens || 0,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Track a call if tracker is set.
|
|
25
|
+
*/
|
|
26
|
+
function _track(model, category, usage, tool) {
|
|
27
|
+
if (!_tracker) return;
|
|
28
|
+
const { input, output } = _extractTokens(usage);
|
|
29
|
+
_tracker.record(model, category, input, output, tool);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getClient() {
|
|
33
|
+
if (!client) {
|
|
34
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
35
|
+
if (!apiKey) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
"OPENAI_API_KEY is not set. Add it to .env.local or set it as an environment variable."
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
client = new OpenAI({ apiKey });
|
|
41
|
+
}
|
|
42
|
+
return client;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Detect if a model uses the Responses API vs Chat Completions.
|
|
47
|
+
* Codex models and some newer models use /v1/responses.
|
|
48
|
+
*/
|
|
49
|
+
function isResponsesModel(model) {
|
|
50
|
+
return /codex/i.test(model);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Detect if a model uses internal reasoning tokens (o-series, gpt-5-nano, etc.)
|
|
55
|
+
* These models need higher token limits because reasoning consumes most of the budget.
|
|
56
|
+
*/
|
|
57
|
+
function isReasoningModel(model) {
|
|
58
|
+
return /^o[1-9]|^gpt-5-nano|^gpt-5\.4-nano/.test(model);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build the token limit param for Chat Completions API.
|
|
63
|
+
* Reasoning models get 4x the limit to accommodate thinking tokens.
|
|
64
|
+
*/
|
|
65
|
+
function tokenParam(model, limit) {
|
|
66
|
+
// Reasoning models need headroom for chain-of-thought
|
|
67
|
+
const effectiveLimit = isReasoningModel(model) ? Math.max(limit * 4, 4096) : limit;
|
|
68
|
+
|
|
69
|
+
if (isResponsesModel(model)) {
|
|
70
|
+
return { max_output_tokens: effectiveLimit };
|
|
71
|
+
}
|
|
72
|
+
const usesNewParam = /^(o[1-9]|gpt-5|gpt-4o)/.test(model) || model.includes("nano");
|
|
73
|
+
if (usesNewParam) {
|
|
74
|
+
return { max_completion_tokens: effectiveLimit };
|
|
75
|
+
}
|
|
76
|
+
return { max_tokens: limit };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Unified AI call — automatically routes to Responses API or Chat Completions
|
|
81
|
+
* based on the model name.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} params
|
|
84
|
+
* @param {string} params.model - Model name
|
|
85
|
+
* @param {string} params.systemPrompt - System/instructions prompt
|
|
86
|
+
* @param {string} params.userPrompt - User message
|
|
87
|
+
* @param {number} params.maxTokens - Max response tokens
|
|
88
|
+
* @param {Array} params.tools - Tool definitions (optional)
|
|
89
|
+
* @param {string} params.toolChoice - Tool choice strategy (optional)
|
|
90
|
+
* @returns {{ content: string, toolCalls: Array|null, usage: object }}
|
|
91
|
+
*/
|
|
92
|
+
async function aiCall({ model, systemPrompt, userPrompt, maxTokens = 2048, tools, toolChoice, category = "chat", tool }) {
|
|
93
|
+
const openai = getClient();
|
|
94
|
+
|
|
95
|
+
let result;
|
|
96
|
+
if (isResponsesModel(model)) {
|
|
97
|
+
result = await _responsesCall(openai, { model, systemPrompt, userPrompt, maxTokens, tools });
|
|
98
|
+
} else {
|
|
99
|
+
result = await _chatCall(openai, { model, systemPrompt, userPrompt, maxTokens, tools, toolChoice });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_track(model, category, result.usage, tool);
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Responses API call — for codex and responses-only models.
|
|
108
|
+
*/
|
|
109
|
+
async function _responsesCall(openai, { model, systemPrompt, userPrompt, maxTokens, tools }) {
|
|
110
|
+
const params = {
|
|
111
|
+
model,
|
|
112
|
+
input: [
|
|
113
|
+
...(systemPrompt ? [{ role: "developer", content: systemPrompt }] : []),
|
|
114
|
+
{ role: "user", content: userPrompt },
|
|
115
|
+
],
|
|
116
|
+
max_output_tokens: maxTokens,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Convert chat-style tools to responses-style tools
|
|
120
|
+
if (tools && tools.length > 0) {
|
|
121
|
+
params.tools = tools.map(t => {
|
|
122
|
+
if (t.type === "function" && t.function) {
|
|
123
|
+
// Chat Completions style → Responses style
|
|
124
|
+
return {
|
|
125
|
+
type: "function",
|
|
126
|
+
name: t.function.name,
|
|
127
|
+
description: t.function.description,
|
|
128
|
+
parameters: t.function.parameters,
|
|
129
|
+
strict: true,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return t;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const response = await openai.responses.create(params);
|
|
137
|
+
|
|
138
|
+
// Extract text content and tool calls from response output
|
|
139
|
+
let content = "";
|
|
140
|
+
let toolCalls = null;
|
|
141
|
+
|
|
142
|
+
if (response.output) {
|
|
143
|
+
for (const item of response.output) {
|
|
144
|
+
if (item.type === "message" && item.content) {
|
|
145
|
+
for (const block of item.content) {
|
|
146
|
+
if (block.type === "output_text") {
|
|
147
|
+
content += block.text;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} else if (item.type === "function_call") {
|
|
151
|
+
if (!toolCalls) toolCalls = [];
|
|
152
|
+
toolCalls.push({
|
|
153
|
+
id: item.call_id || item.id,
|
|
154
|
+
type: "function",
|
|
155
|
+
function: {
|
|
156
|
+
name: item.name,
|
|
157
|
+
arguments: item.arguments,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Fallback: some responses have output_text directly
|
|
165
|
+
if (!content && response.output_text) {
|
|
166
|
+
content = response.output_text;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
content: content.trim(),
|
|
171
|
+
toolCalls,
|
|
172
|
+
usage: response.usage || {},
|
|
173
|
+
_raw: response,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Chat Completions API call — for standard chat models.
|
|
179
|
+
*/
|
|
180
|
+
async function _chatCall(openai, { model, systemPrompt, userPrompt, maxTokens, tools, toolChoice }) {
|
|
181
|
+
const messages = [];
|
|
182
|
+
if (systemPrompt) messages.push({ role: "system", content: systemPrompt });
|
|
183
|
+
messages.push({ role: "user", content: userPrompt });
|
|
184
|
+
|
|
185
|
+
// Some models (gpt-5-nano, o-series) don't support temperature
|
|
186
|
+
const noTemp = /^(o[1-9]|gpt-5)/.test(model);
|
|
187
|
+
|
|
188
|
+
const params = {
|
|
189
|
+
model,
|
|
190
|
+
messages,
|
|
191
|
+
...(!noTemp ? { temperature: 0 } : {}),
|
|
192
|
+
...tokenParam(model, maxTokens),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
if (tools && tools.length > 0) {
|
|
196
|
+
params.tools = tools;
|
|
197
|
+
params.tool_choice = toolChoice || "auto";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const response = await openai.chat.completions.create(params);
|
|
201
|
+
const choice = response.choices[0];
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
content: (choice.message.content || "").trim(),
|
|
205
|
+
toolCalls: choice.message.tool_calls || null,
|
|
206
|
+
usage: response.usage || {},
|
|
207
|
+
_raw: response,
|
|
208
|
+
_message: choice.message,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Build a Responses API conversation continuation with tool results.
|
|
214
|
+
* For multi-turn agent loops with codex models.
|
|
215
|
+
*/
|
|
216
|
+
async function aiCallWithHistory({ model, messages, tools, maxTokens = 4096, category = "chat", tool }) {
|
|
217
|
+
const openai = getClient();
|
|
218
|
+
|
|
219
|
+
let result;
|
|
220
|
+
if (isResponsesModel(model)) {
|
|
221
|
+
result = await _responsesCallWithHistory(openai, { model, messages, tools, maxTokens });
|
|
222
|
+
} else {
|
|
223
|
+
result = await _chatCallWithHistory(openai, { model, messages, tools, maxTokens });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
_track(model, category, result.usage, tool);
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function _responsesCallWithHistory(openai, { model, messages, tools, maxTokens }) {
|
|
231
|
+
// Convert chat-style messages to responses input format
|
|
232
|
+
const input = messages.map(msg => {
|
|
233
|
+
if (msg.role === "system") {
|
|
234
|
+
return { role: "developer", content: msg.content };
|
|
235
|
+
}
|
|
236
|
+
if (msg.role === "tool") {
|
|
237
|
+
return {
|
|
238
|
+
type: "function_call_output",
|
|
239
|
+
call_id: msg.tool_call_id,
|
|
240
|
+
output: msg.content,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (msg.role === "assistant" && msg.tool_calls) {
|
|
244
|
+
// Emit function_call items for each tool call
|
|
245
|
+
return msg.tool_calls.map(tc => ({
|
|
246
|
+
type: "function_call",
|
|
247
|
+
call_id: tc.id,
|
|
248
|
+
name: tc.function.name,
|
|
249
|
+
arguments: tc.function.arguments,
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
if (msg.role === "assistant") {
|
|
253
|
+
return { role: "assistant", content: msg.content || "" };
|
|
254
|
+
}
|
|
255
|
+
return { role: msg.role, content: msg.content };
|
|
256
|
+
}).flat();
|
|
257
|
+
|
|
258
|
+
const params = {
|
|
259
|
+
model,
|
|
260
|
+
input,
|
|
261
|
+
max_output_tokens: maxTokens,
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
if (tools && tools.length > 0) {
|
|
265
|
+
params.tools = tools.map(t => {
|
|
266
|
+
if (t.type === "function" && t.function) {
|
|
267
|
+
return {
|
|
268
|
+
type: "function",
|
|
269
|
+
name: t.function.name,
|
|
270
|
+
description: t.function.description,
|
|
271
|
+
parameters: t.function.parameters,
|
|
272
|
+
strict: true,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
return t;
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const response = await openai.responses.create(params);
|
|
280
|
+
|
|
281
|
+
// Build a chat-compatible response
|
|
282
|
+
let content = "";
|
|
283
|
+
let toolCalls = null;
|
|
284
|
+
|
|
285
|
+
if (response.output) {
|
|
286
|
+
for (const item of response.output) {
|
|
287
|
+
if (item.type === "message" && item.content) {
|
|
288
|
+
for (const block of item.content) {
|
|
289
|
+
if (block.type === "output_text") content += block.text;
|
|
290
|
+
}
|
|
291
|
+
} else if (item.type === "function_call") {
|
|
292
|
+
if (!toolCalls) toolCalls = [];
|
|
293
|
+
toolCalls.push({
|
|
294
|
+
id: item.call_id || item.id,
|
|
295
|
+
type: "function",
|
|
296
|
+
function: {
|
|
297
|
+
name: item.name,
|
|
298
|
+
arguments: item.arguments,
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!content && response.output_text) content = response.output_text;
|
|
306
|
+
|
|
307
|
+
// Return in chat-compatible format so the agent engine doesn't need to change
|
|
308
|
+
const message = { role: "assistant", content: content.trim() || null };
|
|
309
|
+
if (toolCalls) message.tool_calls = toolCalls;
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
choices: [{ message }],
|
|
313
|
+
usage: response.usage || {},
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function _chatCallWithHistory(openai, { model, messages, tools, maxTokens }) {
|
|
318
|
+
const noTemp = /^(o[1-9]|gpt-5)/.test(model);
|
|
319
|
+
const params = {
|
|
320
|
+
model,
|
|
321
|
+
messages,
|
|
322
|
+
...(!noTemp ? { temperature: 0 } : {}),
|
|
323
|
+
...tokenParam(model, maxTokens),
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
if (tools && tools.length > 0) {
|
|
327
|
+
params.tools = tools;
|
|
328
|
+
params.tool_choice = "auto";
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return openai.chat.completions.create(params);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Send an error context to OpenAI and get a repair patch back.
|
|
336
|
+
* Uses CODING_MODEL — routes to correct API automatically.
|
|
337
|
+
*/
|
|
338
|
+
async function requestRepair({ filePath, sourceCode, errorMessage, stackTrace }) {
|
|
339
|
+
const model = getModel("coding");
|
|
340
|
+
|
|
341
|
+
const systemPrompt = "You are a Node.js debugging expert. Respond with ONLY valid JSON, no markdown fences.";
|
|
342
|
+
const userPrompt = `A server crashed with the following error. Analyze and produce a fix.
|
|
343
|
+
|
|
344
|
+
## File: ${filePath}
|
|
345
|
+
|
|
346
|
+
\`\`\`javascript
|
|
347
|
+
${sourceCode}
|
|
348
|
+
\`\`\`
|
|
349
|
+
|
|
350
|
+
## Error Message
|
|
351
|
+
\`\`\`
|
|
352
|
+
${errorMessage}
|
|
353
|
+
\`\`\`
|
|
354
|
+
|
|
355
|
+
## Stack Trace
|
|
356
|
+
\`\`\`
|
|
357
|
+
${stackTrace}
|
|
358
|
+
\`\`\`
|
|
359
|
+
|
|
360
|
+
## Instructions
|
|
361
|
+
1. Identify the root cause of the error.
|
|
362
|
+
2. Produce a minimal fix — change only what is necessary.
|
|
363
|
+
3. Respond with ONLY valid JSON in this exact format:
|
|
364
|
+
|
|
365
|
+
{
|
|
366
|
+
"explanation": "Brief explanation of what went wrong and what the fix does",
|
|
367
|
+
"changes": [
|
|
368
|
+
{
|
|
369
|
+
"file": "the file path",
|
|
370
|
+
"old": "the exact lines to replace (copy verbatim from the source)",
|
|
371
|
+
"new": "the replacement lines"
|
|
372
|
+
}
|
|
373
|
+
]
|
|
374
|
+
}`;
|
|
375
|
+
|
|
376
|
+
const result = await aiCall({ model, systemPrompt, userPrompt, maxTokens: 2048, category: "heal" });
|
|
377
|
+
const content = result.content;
|
|
378
|
+
const cleaned = content.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "");
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
return JSON.parse(cleaned);
|
|
382
|
+
} catch (parseErr) {
|
|
383
|
+
throw new Error(`Failed to parse AI response as JSON.\nRaw response:\n${content}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
module.exports = { requestRepair, getClient, tokenParam, aiCall, aiCallWithHistory, isResponsesModel, setTokenTracker };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
const cluster = require("cluster");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const chalk = require("chalk");
|
|
4
|
+
const { detect, logSystemInfo } = require("./system-info");
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Cluster Manager — auto-scaling worker management.
|
|
8
|
+
*
|
|
9
|
+
* Detects machine capabilities and forks the optimal number of workers.
|
|
10
|
+
* Each worker runs a wolverine instance managing the server process.
|
|
11
|
+
* The master manages workers, restarts crashed ones, and balances load.
|
|
12
|
+
*
|
|
13
|
+
* Modes:
|
|
14
|
+
* - single: 1 worker (small machines, dev, containers)
|
|
15
|
+
* - auto: detect cores → fork optimal workers
|
|
16
|
+
* - fixed: user specifies exact worker count
|
|
17
|
+
*
|
|
18
|
+
* Load balancing uses Node's built-in round-robin (default on Linux)
|
|
19
|
+
* or OS-level distribution (Windows).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
class ClusterManager {
|
|
23
|
+
constructor(options = {}) {
|
|
24
|
+
this.scriptPath = options.scriptPath;
|
|
25
|
+
this.mode = options.mode || "auto"; // single, auto, fixed
|
|
26
|
+
this.fixedWorkers = options.workers || 0;
|
|
27
|
+
this.logger = options.logger;
|
|
28
|
+
|
|
29
|
+
this._systemInfo = null;
|
|
30
|
+
this._workerCount = 0;
|
|
31
|
+
this._workers = new Map(); // pid → { id, startTime, restarts }
|
|
32
|
+
this._shuttingDown = false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Initialize — detect system, decide worker count, fork if needed.
|
|
37
|
+
* Returns true if this process is the master, false if it's a worker.
|
|
38
|
+
*/
|
|
39
|
+
init() {
|
|
40
|
+
this._systemInfo = detect();
|
|
41
|
+
|
|
42
|
+
if (this.mode === "single" || !cluster.isPrimary) {
|
|
43
|
+
return { isMaster: cluster.isPrimary, workers: 1, systemInfo: this._systemInfo };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Determine worker count
|
|
47
|
+
if (this.mode === "fixed" && this.fixedWorkers > 0) {
|
|
48
|
+
this._workerCount = this.fixedWorkers;
|
|
49
|
+
} else {
|
|
50
|
+
this._workerCount = this._systemInfo.recommended.workers;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Single core / small machine — don't cluster
|
|
54
|
+
if (this._workerCount <= 1) {
|
|
55
|
+
return { isMaster: true, workers: 1, systemInfo: this._systemInfo, clustered: false };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log(chalk.blue.bold("\n 🔄 Cluster Mode"));
|
|
59
|
+
logSystemInfo(this._systemInfo);
|
|
60
|
+
console.log(chalk.blue(` 🔄 Forking ${this._workerCount} workers...\n`));
|
|
61
|
+
|
|
62
|
+
// Fork workers
|
|
63
|
+
for (let i = 0; i < this._workerCount; i++) {
|
|
64
|
+
this._forkWorker();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Handle worker exits
|
|
68
|
+
cluster.on("exit", (worker, code, signal) => {
|
|
69
|
+
if (this._shuttingDown) return;
|
|
70
|
+
|
|
71
|
+
const info = this._workers.get(worker.process.pid);
|
|
72
|
+
const restarts = info ? info.restarts : 0;
|
|
73
|
+
|
|
74
|
+
console.log(chalk.yellow(` 🔄 Worker ${worker.process.pid} died (code: ${code}, signal: ${signal})`));
|
|
75
|
+
|
|
76
|
+
if (this.logger) {
|
|
77
|
+
this.logger.warn("cluster.worker_died", `Worker ${worker.process.pid} died`, {
|
|
78
|
+
pid: worker.process.pid, code, signal, restarts,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this._workers.delete(worker.process.pid);
|
|
83
|
+
|
|
84
|
+
// Respawn with backoff if crashing repeatedly
|
|
85
|
+
if (restarts < 5) {
|
|
86
|
+
const delay = Math.min(1000 * Math.pow(2, restarts), 30000);
|
|
87
|
+
console.log(chalk.gray(` 🔄 Respawning in ${delay / 1000}s (restart #${restarts + 1})`));
|
|
88
|
+
setTimeout(() => this._forkWorker(restarts + 1), delay);
|
|
89
|
+
} else {
|
|
90
|
+
console.log(chalk.red(` 🔄 Worker exceeded max restarts (5) — not respawning`));
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Graceful shutdown
|
|
95
|
+
const shutdown = () => {
|
|
96
|
+
this._shuttingDown = true;
|
|
97
|
+
console.log(chalk.yellow("\n 🔄 Shutting down cluster..."));
|
|
98
|
+
for (const [pid] of this._workers) {
|
|
99
|
+
try { process.kill(pid, "SIGTERM"); } catch {}
|
|
100
|
+
}
|
|
101
|
+
setTimeout(() => process.exit(0), 5000);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
process.on("SIGINT", shutdown);
|
|
105
|
+
process.on("SIGTERM", shutdown);
|
|
106
|
+
|
|
107
|
+
return { isMaster: true, workers: this._workerCount, systemInfo: this._systemInfo, clustered: true };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get cluster status for dashboard.
|
|
112
|
+
*/
|
|
113
|
+
getStatus() {
|
|
114
|
+
return {
|
|
115
|
+
clustered: this._workerCount > 1,
|
|
116
|
+
mode: this.mode,
|
|
117
|
+
totalWorkers: this._workerCount,
|
|
118
|
+
activeWorkers: this._workers.size,
|
|
119
|
+
workers: Array.from(this._workers.entries()).map(([pid, info]) => ({
|
|
120
|
+
pid,
|
|
121
|
+
id: info.id,
|
|
122
|
+
uptime: Math.round((Date.now() - info.startTime) / 1000),
|
|
123
|
+
restarts: info.restarts,
|
|
124
|
+
})),
|
|
125
|
+
system: this._systemInfo,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
_forkWorker(restarts = 0) {
|
|
130
|
+
const worker = cluster.fork({
|
|
131
|
+
WOLVERINE_WORKER_ID: this._workers.size + 1,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
this._workers.set(worker.process.pid, {
|
|
135
|
+
id: this._workers.size + 1,
|
|
136
|
+
startTime: Date.now(),
|
|
137
|
+
restarts,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
console.log(chalk.green(` 🔄 Worker ${worker.process.pid} started`));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { ClusterManager };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Config Loader — reads wolverine.config.js, falls back to env vars.
|
|
6
|
+
*
|
|
7
|
+
* Priority:
|
|
8
|
+
* 1. Environment variables (highest — for CI/Docker overrides)
|
|
9
|
+
* 2. wolverine.config.js (project settings)
|
|
10
|
+
* 3. Hardcoded defaults (lowest)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
let _config = null;
|
|
14
|
+
|
|
15
|
+
function loadConfig() {
|
|
16
|
+
if (_config) return _config;
|
|
17
|
+
|
|
18
|
+
// Load from server/config/settings.json
|
|
19
|
+
const configPath = path.join(process.cwd(), "server", "config", "settings.json");
|
|
20
|
+
let fileConfig = {};
|
|
21
|
+
if (fs.existsSync(configPath)) {
|
|
22
|
+
try {
|
|
23
|
+
fileConfig = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.log(` ⚠️ Failed to load server/config/settings.json: ${err.message}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_config = {
|
|
30
|
+
models: {
|
|
31
|
+
reasoning: process.env.REASONING_MODEL || fileConfig.models?.reasoning || "gpt-4o",
|
|
32
|
+
coding: process.env.CODING_MODEL || fileConfig.models?.coding || "gpt-4o",
|
|
33
|
+
chat: process.env.CHAT_MODEL || fileConfig.models?.chat || "gpt-4o-mini",
|
|
34
|
+
tool: process.env.TOOL_MODEL || fileConfig.models?.tool || "gpt-4o-mini",
|
|
35
|
+
classifier: process.env.CLASSIFIER_MODEL || fileConfig.models?.classifier || "gpt-4o-mini",
|
|
36
|
+
audit: process.env.AUDIT_MODEL || fileConfig.models?.audit || "gpt-4o-mini",
|
|
37
|
+
compacting: process.env.COMPACTING_MODEL || fileConfig.models?.compacting || "gpt-4o-mini",
|
|
38
|
+
research: process.env.RESEARCH_MODEL || fileConfig.models?.research || "gpt-4o",
|
|
39
|
+
embedding: process.env.TEXT_EMBEDDING_MODEL || fileConfig.models?.embedding || "text-embedding-3-small",
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
server: {
|
|
43
|
+
port: parseInt(process.env.PORT, 10) || fileConfig.server?.port || 3000,
|
|
44
|
+
maxRetries: parseInt(process.env.WOLVERINE_MAX_RETRIES, 10) || fileConfig.server?.maxRetries || 3,
|
|
45
|
+
maxMemoryMB: parseInt(process.env.WOLVERINE_MAX_MEMORY_MB, 10) || fileConfig.server?.maxMemoryMB || 512,
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
telemetry: {
|
|
49
|
+
enabled: process.env.WOLVERINE_TELEMETRY !== "false" && (fileConfig.telemetry?.enabled !== false),
|
|
50
|
+
heartbeatIntervalMs: parseInt(process.env.WOLVERINE_HEARTBEAT_INTERVAL_MS, 10) || fileConfig.telemetry?.heartbeatIntervalMs || 60000,
|
|
51
|
+
platformUrl: process.env.WOLVERINE_PLATFORM_URL || fileConfig.telemetry?.platformUrl || "https://api.wolverinenode.xyz",
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
rateLimiting: {
|
|
55
|
+
maxCallsPerWindow: parseInt(process.env.WOLVERINE_RATE_MAX_CALLS, 10) || fileConfig.rateLimiting?.maxCallsPerWindow || 10,
|
|
56
|
+
windowMs: parseInt(process.env.WOLVERINE_RATE_WINDOW_MS, 10) || fileConfig.rateLimiting?.windowMs || 600000,
|
|
57
|
+
minGapMs: parseInt(process.env.WOLVERINE_RATE_MIN_GAP_MS, 10) || fileConfig.rateLimiting?.minGapMs || 5000,
|
|
58
|
+
maxTokensPerHour: parseInt(process.env.WOLVERINE_RATE_MAX_TOKENS_HOUR, 10) || fileConfig.rateLimiting?.maxTokensPerHour || 100000,
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
healthCheck: {
|
|
62
|
+
intervalMs: parseInt(process.env.WOLVERINE_HEALTH_INTERVAL_MS, 10) || fileConfig.healthCheck?.intervalMs || 15000,
|
|
63
|
+
timeoutMs: parseInt(process.env.WOLVERINE_HEALTH_TIMEOUT_MS, 10) || fileConfig.healthCheck?.timeoutMs || 5000,
|
|
64
|
+
failThreshold: parseInt(process.env.WOLVERINE_HEALTH_FAIL_THRESHOLD, 10) || fileConfig.healthCheck?.failThreshold || 3,
|
|
65
|
+
startDelayMs: parseInt(process.env.WOLVERINE_HEALTH_START_DELAY_MS, 10) || fileConfig.healthCheck?.startDelayMs || 10000,
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
dashboard: {
|
|
69
|
+
port: parseInt(process.env.WOLVERINE_DASHBOARD_PORT, 10) || fileConfig.dashboard?.port || null,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return _config;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get a config value by dot path: getConfig("models.reasoning")
|
|
78
|
+
*/
|
|
79
|
+
function getConfig(dotPath) {
|
|
80
|
+
const config = loadConfig();
|
|
81
|
+
return dotPath.split(".").reduce((obj, key) => obj?.[key], config);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Reset config cache (for testing).
|
|
86
|
+
*/
|
|
87
|
+
function resetConfig() { _config = null; }
|
|
88
|
+
|
|
89
|
+
module.exports = { loadConfig, getConfig, resetConfig };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse a Node.js error/stack trace string and extract the relevant file + line info.
|
|
5
|
+
* Returns { filePath, line, column, errorMessage, stackTrace }
|
|
6
|
+
*/
|
|
7
|
+
function parseError(stderr) {
|
|
8
|
+
const lines = stderr.split("\n").map(l => l.replace(/\r$/, ""));
|
|
9
|
+
|
|
10
|
+
// Find the error message line (usually contains "Error:" or "TypeError:" etc.)
|
|
11
|
+
let errorMessage = "";
|
|
12
|
+
let stackTrace = stderr;
|
|
13
|
+
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
if (/^\w*Error:/.test(line.trim()) || /^\w*TypeError:/.test(line.trim())) {
|
|
16
|
+
errorMessage = line.trim();
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!errorMessage) {
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
const trimmed = line.trim();
|
|
24
|
+
if (trimmed && !trimmed.startsWith("at ")) {
|
|
25
|
+
errorMessage = trimmed;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let filePath = null;
|
|
31
|
+
let line = null;
|
|
32
|
+
let column = null;
|
|
33
|
+
|
|
34
|
+
// 1. First line of stderr — Node prints "filepath.js:line" for syntax/reference errors
|
|
35
|
+
const firstLineMatch = lines[0] && lines[0].match(/^(.+\.[a-zA-Z]+):(\d+)$/);
|
|
36
|
+
if (firstLineMatch) {
|
|
37
|
+
filePath = firstLineMatch[1];
|
|
38
|
+
line = parseInt(firstLineMatch[2], 10);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 2. Try to find a user-land file in the stack trace (not node_modules, not node: internal)
|
|
42
|
+
const stackFrames = stderr.match(/at\s+.+\(.+:\d+:\d+\)/g) || [];
|
|
43
|
+
let foundUserFile = false;
|
|
44
|
+
for (const stackLine of stackFrames) {
|
|
45
|
+
const match = stackLine.match(/\(([^)]+):(\d+):(\d+)\)/);
|
|
46
|
+
if (match) {
|
|
47
|
+
const candidate = match[1];
|
|
48
|
+
if (!candidate.includes("node_modules") &&
|
|
49
|
+
!candidate.includes("node:") &&
|
|
50
|
+
!candidate.startsWith("internal/")) {
|
|
51
|
+
filePath = candidate;
|
|
52
|
+
line = parseInt(match[2], 10);
|
|
53
|
+
column = parseInt(match[3], 10);
|
|
54
|
+
foundUserFile = true;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 3. If no user-land file in stack, try the generic stack trace patterns
|
|
61
|
+
// BUT only if we didn't already have a file from the first line
|
|
62
|
+
if (!foundUserFile && !filePath) {
|
|
63
|
+
const fileMatch = stderr.match(/\(([^)]+):(\d+):(\d+)\)/) ||
|
|
64
|
+
stderr.match(/at\s+([^\s(]+):(\d+):(\d+)/);
|
|
65
|
+
if (fileMatch) {
|
|
66
|
+
filePath = fileMatch[1];
|
|
67
|
+
line = parseInt(fileMatch[2], 10);
|
|
68
|
+
column = parseInt(fileMatch[3], 10);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Normalize Git Bash paths on Windows: /c/Users/... → C:/Users/...
|
|
73
|
+
if (filePath) {
|
|
74
|
+
filePath = filePath.replace(/^\/([a-zA-Z])\//, "$1:/");
|
|
75
|
+
filePath = path.resolve(filePath);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
filePath,
|
|
80
|
+
line,
|
|
81
|
+
column,
|
|
82
|
+
errorMessage,
|
|
83
|
+
stackTrace,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { parseError };
|