wolverine-ai 1.5.0 → 1.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wolverine-ai",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "Self-healing Node.js server framework powered by AI. Catches crashes, diagnoses errors, generates fixes, verifies, and restarts — automatically.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -47,6 +47,12 @@
47
47
  "startDelayMs": 10000
48
48
  },
49
49
 
50
+ "errorMonitor": {
51
+ "defaultThreshold": 3,
52
+ "windowMs": 30000,
53
+ "cooldownMs": 60000
54
+ },
55
+
50
56
  "dashboard": {},
51
57
 
52
58
  "cors": {
package/server/index.js CHANGED
@@ -19,10 +19,38 @@ fastify.setNotFoundHandler((req, reply) => {
19
19
  reply.code(404).send({ error: "Not found", path: req.url });
20
20
  });
21
21
 
22
- // Error handler
22
+ // Error handler — reports to Wolverine parent via IPC for auto-healing
23
23
  fastify.setErrorHandler((err, req, reply) => {
24
24
  console.error(`[ERROR] ${err.message}`);
25
- reply.code(500).send({ error: "Internal server error" });
25
+ reply.code(500).send({ error: err.message });
26
+
27
+ // Report to Wolverine via IPC (if running under wolverine)
28
+ if (typeof process.send === "function") {
29
+ try {
30
+ // Extract file/line from stack trace
31
+ let file = null, line = null;
32
+ if (err.stack) {
33
+ const frames = err.stack.split("\n");
34
+ for (const frame of frames) {
35
+ const m = frame.match(/\(([^)]+):(\d+):(\d+)\)/) || frame.match(/at\s+([^\s(]+):(\d+):(\d+)/);
36
+ if (m && !m[1].includes("node_modules") && !m[1].includes("node:")) {
37
+ file = m[1]; line = parseInt(m[2], 10); break;
38
+ }
39
+ }
40
+ }
41
+ process.send({
42
+ type: "route_error",
43
+ path: req.url,
44
+ method: req.method,
45
+ statusCode: 500,
46
+ message: err.message,
47
+ stack: err.stack,
48
+ file,
49
+ line,
50
+ timestamp: Date.now(),
51
+ });
52
+ } catch (_) { /* IPC send failed — non-fatal */ }
53
+ }
26
54
  });
27
55
 
28
56
  fastify.listen({ port: PORT, host: "0.0.0.0" }, (err) => {
@@ -335,7 +335,7 @@ async function _chatCallWithHistory(openai, { model, messages, tools, maxTokens
335
335
  * Send an error context to OpenAI and get a repair patch back.
336
336
  * Uses CODING_MODEL — routes to correct API automatically.
337
337
  */
338
- async function requestRepair({ filePath, sourceCode, errorMessage, stackTrace }) {
338
+ async function requestRepair({ filePath, sourceCode, backupSourceCode, errorMessage, stackTrace }) {
339
339
  const model = getModel("coding");
340
340
 
341
341
  const systemPrompt = "You are a Node.js debugging expert. Respond with ONLY valid JSON, no markdown fences.";
@@ -357,7 +357,7 @@ ${errorMessage}
357
357
  ${stackTrace}
358
358
  \`\`\`
359
359
 
360
- ## Instructions
360
+ ${backupSourceCode ? `## Last Known Working Version\n\`\`\`javascript\n${backupSourceCode}\n\`\`\`\n\nCompare the current broken code with this working version. If the broken code added something that doesn't work, REVERT that addition rather than patching around it.\n` : ""}## Instructions
361
361
  1. Identify the root cause of the error.
362
362
  2. Not all errors are code bugs. Choose the correct fix type:
363
363
  - "Cannot find module 'X'" (not starting with ./ or ../) = missing npm package → use "commands" to npm install
@@ -1,55 +1,54 @@
1
1
  /**
2
2
  * Error Hook — preloaded into the child server process via --require.
3
3
  *
4
- * Patches Fastify and Express error handlers to report caught errors
5
- * back to the Wolverine parent process via IPC. This enables healing
6
- * of 500 errors that don't crash the process.
4
+ * Safety net: patches Fastify and Express to report caught errors
5
+ * via IPC to the Wolverine parent process. Works even if the user's
6
+ * server code doesn't call process.send() in its error handler.
7
7
  *
8
8
  * How it works:
9
- * 1. Runner spawns child with: node --require ./src/core/error-hook.js server/index.js
10
- * 2. This file hooks into Module._load to intercept fastify/express creation
11
- * 3. When a framework instance is created, we add an error handler that sends IPC messages
12
- * 4. Parent's ErrorMonitor receives the messages and triggers heal after threshold
9
+ * 1. Runner spawns: node --require error-hook.js server/index.js
10
+ * 2. This file intercepts require("fastify") and require("express")
11
+ * 3. Wraps the constructor to add an onError hook (Fastify) or
12
+ * error middleware (Express) that sends IPC messages
13
+ * 4. Parent's ErrorMonitor receives messages and triggers heal
13
14
  *
14
- * Zero changes to user's server code.
15
+ * If the server already reports errors via process.send(), the hook
16
+ * deduplicates by checking a timestamp flag on the error object.
15
17
  */
16
18
 
17
19
  const Module = require("module");
18
20
  const originalLoad = Module._load;
19
21
 
20
- let _hooked = false;
22
+ let _fastifyHooked = false;
23
+ let _expressHooked = false;
21
24
 
22
25
  Module._load = function (request, parent, isMain) {
23
26
  const result = originalLoad.apply(this, arguments);
24
27
 
25
28
  // Hook Fastify
26
- if (request === "fastify" && typeof result === "function" && !_hooked) {
29
+ if (request === "fastify" && typeof result === "function" && !_fastifyHooked) {
30
+ _fastifyHooked = true;
27
31
  const originalFastify = result;
28
32
  const wrapped = function (...args) {
29
33
  const instance = originalFastify(...args);
30
34
  _hookFastify(instance);
31
35
  return instance;
32
36
  };
33
- // Preserve all properties (fastify.default, etc.)
34
- Object.keys(originalFastify).forEach((key) => {
35
- wrapped[key] = originalFastify[key];
36
- });
37
- _hooked = true;
37
+ Object.keys(originalFastify).forEach((key) => { wrapped[key] = originalFastify[key]; });
38
+ wrapped.default = wrapped; // ESM compat
38
39
  return wrapped;
39
40
  }
40
41
 
41
42
  // Hook Express
42
- if (request === "express" && typeof result === "function" && !_hooked) {
43
+ if (request === "express" && typeof result === "function" && !_expressHooked) {
44
+ _expressHooked = true;
43
45
  const originalExpress = result;
44
46
  const wrapped = function (...args) {
45
47
  const app = originalExpress(...args);
46
48
  _hookExpress(app);
47
49
  return app;
48
50
  };
49
- Object.keys(originalExpress).forEach((key) => {
50
- wrapped[key] = originalExpress[key];
51
- });
52
- _hooked = true;
51
+ Object.keys(originalExpress).forEach((key) => { wrapped[key] = originalExpress[key]; });
53
52
  return wrapped;
54
53
  }
55
54
 
@@ -57,55 +56,51 @@ Module._load = function (request, parent, isMain) {
57
56
  };
58
57
 
59
58
  function _hookFastify(fastify) {
60
- // Use onReady to add hooks after all plugins are loaded
61
- fastify.addHook("onReady", function (done) {
62
- // Add a global error handler that reports to parent
63
- fastify.addHook("onError", function (request, reply, error, done) {
59
+ // Wrap setErrorHandler so our IPC reporting runs BEFORE the user's handler
60
+ const origSetError = fastify.setErrorHandler;
61
+ fastify.setErrorHandler = function (userHandler) {
62
+ return origSetError.call(this, function (error, request, reply) {
64
63
  _reportError(request.url, request.method, error);
65
- done();
64
+ return userHandler.call(this, error, request, reply);
66
65
  });
67
- done();
68
- });
66
+ };
69
67
 
70
- // Also intercept the setErrorHandler if user sets one
71
- const originalSetError = fastify.setErrorHandler.bind(fastify);
72
- fastify.setErrorHandler = function (handler) {
73
- return originalSetError(function (error, request, reply) {
68
+ // Also add onError hook as a fallback (fires even if no custom error handler)
69
+ try {
70
+ fastify.addHook("onError", function (request, reply, error, done) {
74
71
  _reportError(request.url, request.method, error);
75
- return handler(error, request, reply);
72
+ done();
76
73
  });
77
- };
74
+ } catch { /* addHook may fail if server is already started */ }
78
75
  }
79
76
 
80
77
  function _hookExpress(app) {
81
- // For Express, we monkey-patch app.use to detect error middleware
82
- // and also add our own at the end via a delayed hook
83
- const originalListen = app.listen.bind(app);
78
+ // Wrap app.listen to inject error middleware AFTER all user middleware
79
+ const originalListen = app.listen;
84
80
  app.listen = function (...args) {
85
- // Add our error handler AFTER all user middleware
86
- app.use(function wolverineErrorHandler(err, req, res, next) {
81
+ app.use(function _wolverineErrorHook(err, req, res, next) {
87
82
  _reportError(req.originalUrl || req.url, req.method, err);
88
83
  next(err);
89
84
  });
90
- return originalListen(...args);
85
+ return originalListen.apply(this, args);
91
86
  };
92
87
  }
93
88
 
89
+ // Dedup: skip if error was already reported in the same tick
90
+ const _reported = new WeakSet();
91
+
94
92
  function _reportError(url, method, error) {
95
- if (!process.send) return; // No IPC channel — not spawned by wolverine
93
+ if (typeof process.send !== "function") return;
94
+ if (!error || _reported.has(error)) return;
95
+ _reported.add(error);
96
96
 
97
97
  try {
98
- // Extract file/line from stack trace
99
- let file = null;
100
- let line = null;
101
- if (error && error.stack) {
102
- const stackLines = error.stack.split("\n");
103
- for (const sl of stackLines) {
104
- const match = sl.match(/\(([^)]+):(\d+):(\d+)\)/) || sl.match(/at\s+([^\s(]+):(\d+):(\d+)/);
105
- if (match && !match[1].includes("node_modules") && !match[1].includes("node:")) {
106
- file = match[1];
107
- line = parseInt(match[2], 10);
108
- break;
98
+ let file = null, line = null;
99
+ if (error.stack) {
100
+ for (const frame of error.stack.split("\n")) {
101
+ const m = frame.match(/\(([^)]+):(\d+):(\d+)\)/) || frame.match(/at\s+([^\s(]+):(\d+):(\d+)/);
102
+ if (m && !m[1].includes("node_modules") && !m[1].includes("node:")) {
103
+ file = m[1]; line = parseInt(m[2], 10); break;
109
104
  }
110
105
  }
111
106
  }
@@ -115,13 +110,11 @@ function _reportError(url, method, error) {
115
110
  path: url,
116
111
  method: method || "GET",
117
112
  statusCode: 500,
118
- message: error?.message || "Unknown error",
119
- stack: error?.stack?.slice(0, 2000) || "",
113
+ message: error.message || "Unknown error",
114
+ stack: (error.stack || "").slice(0, 2000),
120
115
  file,
121
116
  line,
122
117
  timestamp: Date.now(),
123
118
  });
124
- } catch {
125
- // Silently fail — don't break the server for IPC issues
126
- }
119
+ } catch { /* IPC send failed — non-fatal */ }
127
120
  }
@@ -139,6 +139,7 @@ class WolverineRunner {
139
139
  this._stabilityTimer = null;
140
140
  this._stderrBuffer = "";
141
141
  this._healInProgress = false;
142
+ this._healStatus = null; // { active, file, error, phase, startedAt, iteration }
142
143
  }
143
144
 
144
145
  async start() {
@@ -410,6 +411,7 @@ class WolverineRunner {
410
411
  async _healAndRestart() {
411
412
  if (this._healInProgress) return;
412
413
  this._healInProgress = true;
414
+ this._healStatus = { active: true, error: this._stderrBuffer.slice(0, 200), phase: "diagnosing", startedAt: Date.now() };
413
415
 
414
416
  try {
415
417
  const result = await heal({
@@ -429,14 +431,23 @@ class WolverineRunner {
429
431
 
430
432
  if (result.healed) {
431
433
  this._lastBackupId = result.backupId;
432
- const mode = result.mode === "agent" ? "multi-file agent" : "fast path";
434
+ this.retryCount = 0; // Fresh start after successful heal
435
+ const mode = result.mode === "agent" ? "multi-file agent" : result.mode || "fast path";
433
436
  console.log(chalk.green(`\n🐺 Wolverine healed the error via ${mode}! Restarting...\n`));
434
437
 
435
438
  if (result.agentStats) {
436
439
  console.log(chalk.gray(` Agent stats: ${result.agentStats.turns} turns, ${result.agentStats.tokens} tokens, ${result.agentStats.filesModified.length} files modified`));
437
440
  }
438
441
 
442
+ // Broadcast heal success to dashboard SSE
443
+ if (this.logger) {
444
+ this.logger.info("heal.success", `Healed via ${mode}: ${result.explanation?.slice(0, 100)}`, {
445
+ mode, file: result.agentStats?.filesModified?.[0], duration: Date.now() - (this._healStatus?.startedAt || Date.now()),
446
+ });
447
+ }
448
+
439
449
  this._healInProgress = false;
450
+ this._healStatus = null;
440
451
  this._spawn();
441
452
  } else {
442
453
  console.log(chalk.red(`\n🐺 Wolverine could not heal: ${result.explanation}`));
@@ -479,6 +490,7 @@ class WolverineRunner {
479
490
  this._healInProgress = true;
480
491
 
481
492
  console.log(chalk.yellow(`\n🐺 Wolverine healing caught error on ${routePath}...`));
493
+ this._healStatus = { active: true, route: routePath, error: errorDetails?.message?.slice(0, 200), phase: "diagnosing", startedAt: Date.now() };
482
494
  this.logger.info("heal.error_monitor", `Healing caught 500 on ${routePath}`, { route: routePath });
483
495
 
484
496
  // Build a synthetic stderr from the error details
@@ -506,16 +518,28 @@ class WolverineRunner {
506
518
 
507
519
  if (result.healed) {
508
520
  console.log(chalk.green(`\n🐺 Wolverine healed ${routePath} via ${result.mode}! Restarting...\n`));
521
+ this.retryCount = 0; // Fresh start after successful heal
509
522
  this.errorMonitor.clearRoute(routePath);
523
+
524
+ // Broadcast heal success to dashboard SSE
525
+ if (this.logger) {
526
+ this.logger.info("heal.success", `Healed ${routePath} via ${result.mode}: ${result.explanation?.slice(0, 100)}`, {
527
+ mode: result.mode, route: routePath, duration: Date.now() - (this._healStatus?.startedAt || Date.now()),
528
+ });
529
+ }
530
+
510
531
  this._healInProgress = false;
532
+ this._healStatus = null;
511
533
  this.restart();
512
534
  } else {
513
535
  console.log(chalk.red(`\n🐺 Could not heal ${routePath}: ${result.explanation}`));
514
536
  this._healInProgress = false;
537
+ this._healStatus = null;
515
538
  }
516
539
  } catch (err) {
517
540
  console.log(chalk.red(`\n🐺 Error during heal: ${err.message}`));
518
541
  this._healInProgress = false;
542
+ this._healStatus = null;
519
543
  }
520
544
  }
521
545
 
@@ -83,16 +83,22 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
83
83
  let openaiClient = null;
84
84
  try { openaiClient = getClient(); } catch { /* will fail later */ }
85
85
 
86
- const injectionResult = await detectInjection(parsed.errorMessage, parsed.stackTrace, { openaiClient });
87
-
88
- if (logger) logger.info(EVENT_TYPES.HEAL_INJECTION_SCAN, `Injection scan: ${injectionResult.safe ? "clean" : "BLOCKED"}`, injectionResult);
89
-
90
- if (!injectionResult.safe) {
91
- console.log(chalk.red(" 🚨 BLOCKED: Potential prompt injection detected."));
92
- if (logger) logger.critical(EVENT_TYPES.SECURITY_INJECTION_DETECTED, "Injection detected — repair blocked", injectionResult);
93
- return { healed: false, explanation: "Prompt injection detected repair blocked" };
86
+ // Skip injection scan on empty/trivial stderr (prevents false positives on clean restarts)
87
+ const stderrContent = (parsed.errorMessage || "").trim() + (parsed.stackTrace || "").trim();
88
+ if (stderrContent.length < 20) {
89
+ console.log(chalk.gray(" 🛡️ Stderr too short for injection scan — skipping"));
90
+ } else {
91
+ const injectionResult = await detectInjection(parsed.errorMessage, parsed.stackTrace, { openaiClient });
92
+
93
+ if (logger) logger.info(EVENT_TYPES.HEAL_INJECTION_SCAN, `Injection scan: ${injectionResult.safe ? "clean" : "BLOCKED"}`, injectionResult);
94
+
95
+ if (!injectionResult.safe) {
96
+ console.log(chalk.red(" 🚨 BLOCKED: Potential prompt injection detected."));
97
+ if (logger) logger.critical(EVENT_TYPES.SECURITY_INJECTION_DETECTED, "Injection detected — repair blocked", injectionResult);
98
+ return { healed: false, explanation: "Prompt injection detected — repair blocked" };
99
+ }
100
+ console.log(chalk.green(" ✅ Clean — no injection detected."));
94
101
  }
95
- console.log(chalk.green(" ✅ Clean — no injection detected."));
96
102
 
97
103
  // 4b. Check if this is a human-required issue (expired keys, billing, etc.)
98
104
  if (notifier) {
@@ -128,6 +134,26 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
128
134
  // 5. Read the source file (if available) + get brain context
129
135
  const sourceCode = hasFile ? sandbox.readFile(parsed.filePath) : "";
130
136
 
137
+ // 5b. Get last known good version from backup (helps AI revert vs patch)
138
+ let backupSourceCode = "";
139
+ if (hasFile && backupManager) {
140
+ try {
141
+ const fs = require("fs");
142
+ const path = require("path");
143
+ const stableBackups = backupManager.getAll().filter(b => b.status === "stable" || b.status === "verified");
144
+ if (stableBackups.length > 0) {
145
+ const latest = stableBackups[stableBackups.length - 1];
146
+ const relPath = path.relative(cwd, parsed.filePath).replace(/[/\\]/g, "__");
147
+ const backupFile = path.join(cwd, ".wolverine", "backups", latest.id, relPath);
148
+ if (fs.existsSync(backupFile)) {
149
+ backupSourceCode = fs.readFileSync(backupFile, "utf-8");
150
+ if (backupSourceCode === sourceCode) backupSourceCode = ""; // Same — no useful diff
151
+ else console.log(chalk.gray(` 📋 Found last known good version (backup ${latest.id})`));
152
+ }
153
+ }
154
+ } catch { /* non-fatal */ }
155
+ }
156
+
131
157
  let brainContext = "";
132
158
  // Inject relevant skill context (claw-code: pre-enrich prompt with matched tools)
133
159
  if (skills) {
@@ -183,7 +209,7 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
183
209
  console.log(chalk.yellow(` 🧠 Fast path (${getModel("coding")})...`));
184
210
  try {
185
211
  const repair = await requestRepair({
186
- filePath: parsed.filePath, sourceCode,
212
+ filePath: parsed.filePath, sourceCode, backupSourceCode,
187
213
  errorMessage: parsed.errorMessage, stackTrace: parsed.stackTrace,
188
214
  });
189
215
  rateLimiter.record(errorSignature);
@@ -243,7 +269,7 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
243
269
  const agentResult = await agent.run({
244
270
  errorMessage: parsed.errorMessage, stackTrace: parsed.stackTrace,
245
271
  primaryFile: parsed.filePath, sourceCode,
246
- brainContext: fullContext,
272
+ brainContext: fullContext + (backupSourceCode ? `\n\nLAST KNOWN WORKING VERSION of ${parsed.filePath}:\n${backupSourceCode}\nIf the broken code added something that doesn't work, revert it rather than patching around it.` : ""),
247
273
  });
248
274
  rateLimiter.record(errorSignature, agentResult.totalTokens);
249
275
 
@@ -871,6 +871,7 @@ ${context ? "\nBrain:\n" + context : ""}`,
871
871
  backups: this.backupManager ? this.backupManager.getStats() : {},
872
872
  health: this.healthMonitor ? this.healthMonitor.getStats() : {},
873
873
  errorMonitor: this.errorMonitor ? this.errorMonitor.getStats() : {},
874
+ heal: this.runner ? this.runner._healStatus : null,
874
875
  }));
875
876
  }
876
877