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.
Files changed (79) hide show
  1. package/PLATFORM.md +442 -0
  2. package/README.md +475 -0
  3. package/SERVER_BEST_PRACTICES.md +62 -0
  4. package/TELEMETRY.md +108 -0
  5. package/bin/wolverine.js +95 -0
  6. package/examples/01-basic-typo.js +31 -0
  7. package/examples/02-multi-file/routes/users.js +15 -0
  8. package/examples/02-multi-file/server.js +25 -0
  9. package/examples/03-syntax-error.js +23 -0
  10. package/examples/04-secret-leak.js +14 -0
  11. package/examples/05-expired-key.js +27 -0
  12. package/examples/06-json-config/config.json +13 -0
  13. package/examples/06-json-config/server.js +28 -0
  14. package/examples/07-rate-limit-loop.js +11 -0
  15. package/examples/08-sandbox-escape.js +20 -0
  16. package/examples/buggy-server.js +39 -0
  17. package/examples/demos/01-basic-typo/index.js +20 -0
  18. package/examples/demos/01-basic-typo/routes/api.js +13 -0
  19. package/examples/demos/01-basic-typo/routes/health.js +4 -0
  20. package/examples/demos/02-multi-file/index.js +24 -0
  21. package/examples/demos/02-multi-file/routes/api.js +13 -0
  22. package/examples/demos/02-multi-file/routes/health.js +4 -0
  23. package/examples/demos/03-syntax-error/index.js +18 -0
  24. package/examples/demos/04-secret-leak/index.js +16 -0
  25. package/examples/demos/05-expired-key/index.js +21 -0
  26. package/examples/demos/06-json-config/config.json +9 -0
  27. package/examples/demos/06-json-config/index.js +20 -0
  28. package/examples/demos/07-null-crash/index.js +16 -0
  29. package/examples/run-demo.js +110 -0
  30. package/package.json +67 -0
  31. package/server/config/settings.json +62 -0
  32. package/server/index.js +33 -0
  33. package/server/routes/api.js +12 -0
  34. package/server/routes/health.js +16 -0
  35. package/server/routes/time.js +12 -0
  36. package/src/agent/agent-engine.js +727 -0
  37. package/src/agent/goal-loop.js +140 -0
  38. package/src/agent/research-agent.js +120 -0
  39. package/src/agent/sub-agents.js +176 -0
  40. package/src/backup/backup-manager.js +321 -0
  41. package/src/brain/brain.js +315 -0
  42. package/src/brain/embedder.js +131 -0
  43. package/src/brain/function-map.js +263 -0
  44. package/src/brain/vector-store.js +267 -0
  45. package/src/core/ai-client.js +387 -0
  46. package/src/core/cluster-manager.js +144 -0
  47. package/src/core/config.js +89 -0
  48. package/src/core/error-parser.js +87 -0
  49. package/src/core/health-monitor.js +129 -0
  50. package/src/core/models.js +132 -0
  51. package/src/core/patcher.js +55 -0
  52. package/src/core/runner.js +464 -0
  53. package/src/core/system-info.js +141 -0
  54. package/src/core/verifier.js +146 -0
  55. package/src/core/wolverine.js +290 -0
  56. package/src/dashboard/server.js +1332 -0
  57. package/src/index.js +94 -0
  58. package/src/logger/event-logger.js +237 -0
  59. package/src/logger/pricing.js +96 -0
  60. package/src/logger/repair-history.js +109 -0
  61. package/src/logger/token-tracker.js +277 -0
  62. package/src/mcp/mcp-client.js +224 -0
  63. package/src/mcp/mcp-registry.js +228 -0
  64. package/src/mcp/mcp-security.js +152 -0
  65. package/src/monitor/perf-monitor.js +300 -0
  66. package/src/monitor/process-monitor.js +231 -0
  67. package/src/monitor/route-prober.js +191 -0
  68. package/src/notifications/notifier.js +227 -0
  69. package/src/platform/heartbeat.js +93 -0
  70. package/src/platform/queue.js +53 -0
  71. package/src/platform/register.js +64 -0
  72. package/src/platform/telemetry.js +76 -0
  73. package/src/security/admin-auth.js +150 -0
  74. package/src/security/injection-detector.js +174 -0
  75. package/src/security/rate-limiter.js +152 -0
  76. package/src/security/sandbox.js +128 -0
  77. package/src/security/secret-redactor.js +217 -0
  78. package/src/skills/skill-registry.js +129 -0
  79. package/src/skills/sql.js +375 -0
@@ -0,0 +1,231 @@
1
+ const { execSync } = require("child_process");
2
+ const chalk = require("chalk");
3
+
4
+ /**
5
+ * Process Monitor — heartbeat, memory leak detection, frozen state detection.
6
+ *
7
+ * Tracks the child process health:
8
+ * - Memory usage (RSS, heap) with leak detection
9
+ * - CPU usage percentage
10
+ * - Heartbeat (is the process alive?)
11
+ * - Frozen detection (process alive but not responding)
12
+ *
13
+ * Triggers restart when:
14
+ * - Memory exceeds threshold (configurable, default 512MB)
15
+ * - Memory growing consistently for N samples (leak detection)
16
+ * - Process not responding to health checks (frozen)
17
+ */
18
+
19
+ class ProcessMonitor {
20
+ constructor(options = {}) {
21
+ this.logger = options.logger;
22
+ this.pid = null;
23
+
24
+ // Thresholds
25
+ this.maxMemoryMB = options.maxMemoryMB || parseInt(process.env.WOLVERINE_MAX_MEMORY_MB, 10) || 512;
26
+ this.leakSamples = options.leakSamples || 10; // consecutive growing samples = leak
27
+ this.sampleIntervalMs = options.sampleIntervalMs || 10000; // sample every 10s
28
+
29
+ // State
30
+ this._samples = []; // { timestamp, rss, heap, cpu }
31
+ this._timer = null;
32
+ this._running = false;
33
+ this._onRestart = null;
34
+ this._consecutiveGrowth = 0;
35
+
36
+ // Analytics aggregates
37
+ this._peakMemory = 0;
38
+ this._avgMemory = 0;
39
+ this._sampleCount = 0;
40
+ this._cpuSamples = [];
41
+ this._lastCpuUsage = null;
42
+ this._lastCpuTime = null;
43
+ }
44
+
45
+ /**
46
+ * Start monitoring a process.
47
+ * @param {number} pid — the child process PID
48
+ * @param {function} onRestart — callback when restart is needed
49
+ */
50
+ start(pid, onRestart) {
51
+ this.pid = pid;
52
+ this._onRestart = onRestart;
53
+ this._running = true;
54
+ this._consecutiveGrowth = 0;
55
+ this._samples = [];
56
+ this._lastCpuUsage = null;
57
+ this._lastCpuTime = null;
58
+
59
+ this._timer = setInterval(() => this._sample(), this.sampleIntervalMs);
60
+ }
61
+
62
+ stop() {
63
+ this._running = false;
64
+ if (this._timer) {
65
+ clearInterval(this._timer);
66
+ this._timer = null;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Reset for new process (after restart).
72
+ */
73
+ reset(newPid) {
74
+ this.pid = newPid;
75
+ this._consecutiveGrowth = 0;
76
+ this._samples = [];
77
+ this._lastCpuUsage = null;
78
+ this._lastCpuTime = null;
79
+ }
80
+
81
+ /**
82
+ * Get current analytics snapshot.
83
+ */
84
+ getMetrics() {
85
+ const latest = this._samples.length > 0 ? this._samples[this._samples.length - 1] : null;
86
+ const avgCpu = this._cpuSamples.length > 0
87
+ ? Math.round(this._cpuSamples.reduce((a, b) => a + b, 0) / this._cpuSamples.length)
88
+ : 0;
89
+
90
+ return {
91
+ pid: this.pid,
92
+ alive: this._isAlive(),
93
+ current: latest ? {
94
+ rss: latest.rss,
95
+ heap: latest.heap,
96
+ cpu: latest.cpu,
97
+ } : null,
98
+ peak: {
99
+ memory: this._peakMemory,
100
+ },
101
+ average: {
102
+ memory: this._sampleCount > 0 ? Math.round(this._avgMemory / this._sampleCount) : 0,
103
+ cpu: avgCpu,
104
+ },
105
+ leakDetection: {
106
+ consecutiveGrowth: this._consecutiveGrowth,
107
+ threshold: this.leakSamples,
108
+ warning: this._consecutiveGrowth >= Math.floor(this.leakSamples * 0.7),
109
+ },
110
+ samples: this._samples.slice(-30).map(s => ({
111
+ t: s.timestamp,
112
+ rss: s.rss,
113
+ heap: s.heap,
114
+ cpu: s.cpu,
115
+ })),
116
+ };
117
+ }
118
+
119
+ // -- Private --
120
+
121
+ _sample() {
122
+ if (!this._running || !this.pid) return;
123
+
124
+ if (!this._isAlive()) {
125
+ // Process died — runner will handle this via exit event
126
+ return;
127
+ }
128
+
129
+ try {
130
+ // Get memory from process
131
+ const memInfo = this._getMemory();
132
+ const cpuPercent = this._getCpu();
133
+
134
+ const sample = {
135
+ timestamp: Date.now(),
136
+ rss: memInfo.rss,
137
+ heap: memInfo.heap,
138
+ cpu: cpuPercent,
139
+ };
140
+
141
+ this._samples.push(sample);
142
+ if (this._samples.length > 100) this._samples.shift();
143
+
144
+ // Track peaks/averages
145
+ if (memInfo.rss > this._peakMemory) this._peakMemory = memInfo.rss;
146
+ this._avgMemory += memInfo.rss;
147
+ this._sampleCount++;
148
+
149
+ this._cpuSamples.push(cpuPercent);
150
+ if (this._cpuSamples.length > 60) this._cpuSamples.shift();
151
+
152
+ // Check memory threshold
153
+ if (memInfo.rss > this.maxMemoryMB) {
154
+ console.log(chalk.red(` 🚨 Memory limit exceeded: ${memInfo.rss}MB > ${this.maxMemoryMB}MB`));
155
+ if (this.logger) {
156
+ this.logger.error("process.memory_exceeded", `Memory ${memInfo.rss}MB exceeds ${this.maxMemoryMB}MB`, sample);
157
+ }
158
+ if (this._onRestart) this._onRestart("memory_exceeded");
159
+ return;
160
+ }
161
+
162
+ // Leak detection: memory growing for N consecutive samples
163
+ if (this._samples.length >= 2) {
164
+ const prev = this._samples[this._samples.length - 2];
165
+ if (sample.rss > prev.rss) {
166
+ this._consecutiveGrowth++;
167
+ } else {
168
+ this._consecutiveGrowth = 0;
169
+ }
170
+
171
+ if (this._consecutiveGrowth >= this.leakSamples) {
172
+ const growth = sample.rss - this._samples[this._samples.length - this.leakSamples].rss;
173
+ console.log(chalk.yellow(` ⚠️ Memory leak detected: +${growth}MB over ${this.leakSamples} samples`));
174
+ if (this.logger) {
175
+ this.logger.warn("process.memory_leak", `Memory leak: +${growth}MB over ${this.leakSamples} samples`, { growth, rss: sample.rss });
176
+ }
177
+ if (this._onRestart) this._onRestart("memory_leak");
178
+ this._consecutiveGrowth = 0;
179
+ return;
180
+ }
181
+ }
182
+ } catch {}
183
+ }
184
+
185
+ _getMemory() {
186
+ try {
187
+ // On Windows, use tasklist; on Unix, use /proc
188
+ if (process.platform === "win32") {
189
+ const output = execSync(`tasklist /FI "PID eq ${this.pid}" /FO CSV /NH`, { encoding: "utf-8", timeout: 3000 });
190
+ const match = output.match(/"(\d[\d,]+)\s*K"/);
191
+ if (match) {
192
+ const kb = parseInt(match[1].replace(/,/g, ""), 10);
193
+ return { rss: Math.round(kb / 1024), heap: 0 };
194
+ }
195
+ } else {
196
+ const output = execSync(`ps -o rss= -p ${this.pid}`, { encoding: "utf-8", timeout: 3000 });
197
+ const kb = parseInt(output.trim(), 10);
198
+ return { rss: Math.round(kb / 1024), heap: 0 };
199
+ }
200
+ } catch {}
201
+ return { rss: 0, heap: 0 };
202
+ }
203
+
204
+ _getCpu() {
205
+ try {
206
+ if (process.platform === "win32") {
207
+ const output = execSync(
208
+ `wmic path Win32_PerfFormattedData_PerfProc_Process where "IDProcess=${this.pid}" get PercentProcessorTime /VALUE`,
209
+ { encoding: "utf-8", timeout: 3000 }
210
+ );
211
+ const match = output.match(/PercentProcessorTime=(\d+)/);
212
+ return match ? parseInt(match[1], 10) : 0;
213
+ } else {
214
+ const output = execSync(`ps -o %cpu= -p ${this.pid}`, { encoding: "utf-8", timeout: 3000 });
215
+ return Math.round(parseFloat(output.trim()));
216
+ }
217
+ } catch {}
218
+ return 0;
219
+ }
220
+
221
+ _isAlive() {
222
+ try {
223
+ process.kill(this.pid, 0); // signal 0 = check if alive
224
+ return true;
225
+ } catch {
226
+ return false;
227
+ }
228
+ }
229
+ }
230
+
231
+ module.exports = { ProcessMonitor };
@@ -0,0 +1,191 @@
1
+ const http = require("http");
2
+ const chalk = require("chalk");
3
+
4
+ /**
5
+ * Route Prober — discovers and tests ALL server routes periodically.
6
+ *
7
+ * Instead of only checking /health, probes every route discovered
8
+ * in the function map. Tracks response times per endpoint over time.
9
+ *
10
+ * Adapts automatically: when the function map updates (new routes added),
11
+ * the prober picks them up on the next cycle.
12
+ */
13
+
14
+ class RouteProber {
15
+ constructor(options = {}) {
16
+ this.port = options.port || parseInt(process.env.PORT, 10) || 3000;
17
+ this.logger = options.logger;
18
+ this.brain = options.brain;
19
+ this.intervalMs = options.intervalMs || 30000; // probe every 30s
20
+
21
+ // Per-route analytics
22
+ this._routeMetrics = {}; // path → { samples[], avg, min, max, errors, lastStatus }
23
+ this._timer = null;
24
+ this._running = false;
25
+ }
26
+
27
+ start() {
28
+ this._running = true;
29
+ // First probe after a delay to let server boot
30
+ setTimeout(() => {
31
+ this._probe();
32
+ this._timer = setInterval(() => this._probe(), this.intervalMs);
33
+ }, 15000);
34
+ }
35
+
36
+ stop() {
37
+ this._running = false;
38
+ if (this._timer) {
39
+ clearInterval(this._timer);
40
+ this._timer = null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Get analytics for all probed routes.
46
+ */
47
+ getMetrics() {
48
+ const result = {};
49
+ for (const [path, m] of Object.entries(this._routeMetrics)) {
50
+ const samples = m.samples;
51
+ const avg = samples.length > 0 ? Math.round(samples.reduce((a, b) => a + b, 0) / samples.length) : 0;
52
+ result[path] = {
53
+ avgMs: avg,
54
+ minMs: m.min,
55
+ maxMs: m.max,
56
+ samples: samples.length,
57
+ errors: m.errors,
58
+ lastStatus: m.lastStatus,
59
+ lastProbe: m.lastProbe,
60
+ healthy: m.lastStatus >= 200 && m.lastStatus < 400,
61
+ // Response time trend (last 5 vs overall)
62
+ trend: this._calcTrend(samples),
63
+ };
64
+ }
65
+ return result;
66
+ }
67
+
68
+ /**
69
+ * Get a summary suitable for the dashboard.
70
+ */
71
+ getSummary() {
72
+ const metrics = this.getMetrics();
73
+ const routes = Object.keys(metrics);
74
+ const healthy = routes.filter(r => metrics[r].healthy).length;
75
+ const unhealthy = routes.filter(r => !metrics[r].healthy).length;
76
+ const slowest = routes.sort((a, b) => (metrics[b].avgMs || 0) - (metrics[a].avgMs || 0))[0];
77
+
78
+ return {
79
+ totalRoutes: routes.length,
80
+ healthy,
81
+ unhealthy,
82
+ slowest: slowest ? { path: slowest, avgMs: metrics[slowest].avgMs } : null,
83
+ };
84
+ }
85
+
86
+ async _probe() {
87
+ if (!this._running) return;
88
+
89
+ // Get current routes from brain's function map
90
+ let routes = [];
91
+ if (this.brain && this.brain.functionMap) {
92
+ routes = (this.brain.functionMap.routes || [])
93
+ .filter(r => r.method === "GET" || r.method === "*")
94
+ .map(r => r.path);
95
+ }
96
+
97
+ // Always include root and health
98
+ if (!routes.includes("/")) routes.unshift("/");
99
+ if (!routes.includes("/health")) routes.push("/health");
100
+
101
+ // Deduplicate
102
+ routes = [...new Set(routes)];
103
+
104
+ for (const routePath of routes) {
105
+ await this._probeRoute(routePath);
106
+ }
107
+ }
108
+
109
+ _probeRoute(routePath) {
110
+ return new Promise((resolve) => {
111
+ const startTime = Date.now();
112
+
113
+ const req = http.get({
114
+ hostname: "127.0.0.1",
115
+ port: this.port,
116
+ path: routePath,
117
+ timeout: 5000,
118
+ }, (res) => {
119
+ const responseTime = Date.now() - startTime;
120
+ let body = "";
121
+ res.on("data", (d) => { body += d; });
122
+ res.on("end", () => {
123
+ this._record(routePath, responseTime, res.statusCode);
124
+ resolve();
125
+ });
126
+ });
127
+
128
+ req.on("timeout", () => {
129
+ req.destroy();
130
+ const responseTime = Date.now() - startTime;
131
+ this._record(routePath, responseTime, 0);
132
+ resolve();
133
+ });
134
+
135
+ req.on("error", () => {
136
+ const responseTime = Date.now() - startTime;
137
+ this._record(routePath, responseTime, 0);
138
+ resolve();
139
+ });
140
+ });
141
+ }
142
+
143
+ _record(routePath, responseTime, statusCode) {
144
+ if (!this._routeMetrics[routePath]) {
145
+ this._routeMetrics[routePath] = {
146
+ samples: [],
147
+ min: Infinity,
148
+ max: 0,
149
+ errors: 0,
150
+ lastStatus: 0,
151
+ lastProbe: 0,
152
+ };
153
+ }
154
+
155
+ const m = this._routeMetrics[routePath];
156
+ m.samples.push(responseTime);
157
+ if (m.samples.length > 60) m.samples.shift(); // keep last 60 samples
158
+ m.min = Math.min(m.min, responseTime);
159
+ m.max = Math.max(m.max, responseTime);
160
+ m.lastStatus = statusCode;
161
+ m.lastProbe = Date.now();
162
+
163
+ if (statusCode === 0 || statusCode >= 500) {
164
+ m.errors++;
165
+ }
166
+
167
+ // Log slow routes
168
+ if (responseTime > 2000) {
169
+ console.log(chalk.yellow(` ⚡ Slow route: ${routePath} took ${responseTime}ms`));
170
+ if (this.logger) {
171
+ this.logger.warn("perf.slow_route", `${routePath}: ${responseTime}ms`, { path: routePath, ms: responseTime, status: statusCode });
172
+ }
173
+ }
174
+ }
175
+
176
+ _calcTrend(samples) {
177
+ if (samples.length < 6) return "stable";
178
+ const recent = samples.slice(-5);
179
+ const older = samples.slice(-10, -5);
180
+ if (older.length === 0) return "stable";
181
+
182
+ const recentAvg = recent.reduce((a, b) => a + b, 0) / recent.length;
183
+ const olderAvg = older.reduce((a, b) => a + b, 0) / older.length;
184
+
185
+ if (recentAvg > olderAvg * 1.5) return "degrading";
186
+ if (recentAvg < olderAvg * 0.7) return "improving";
187
+ return "stable";
188
+ }
189
+ }
190
+
191
+ module.exports = { RouteProber };
@@ -0,0 +1,227 @@
1
+ const chalk = require("chalk");
2
+ const { aiCall } = require("../core/ai-client");
3
+ const { getModel } = require("../core/models");
4
+
5
+ /**
6
+ * Notification System — alerts for issues the AI cannot fix.
7
+ *
8
+ * Some errors require human intervention:
9
+ * - API keys expired/rotated/revoked
10
+ * - Billing/quota exceeded
11
+ * - External service down (database, third-party API)
12
+ * - Permission denied (file system, network)
13
+ * - Certificate errors
14
+ * - Environment misconfiguration
15
+ *
16
+ * The notifier:
17
+ * 1. Classifies errors as AI-fixable vs human-required
18
+ * 2. Summarizes human-required issues into short, clear sentences
19
+ * 3. Sanitizes through the redactor (no secrets in notifications)
20
+ * 4. Delivers via console, dashboard events, and optional webhook
21
+ */
22
+
23
+ // Patterns that indicate human-required intervention
24
+ const HUMAN_REQUIRED_PATTERNS = [
25
+ // API/Auth failures
26
+ { pattern: /401\s*unauthorized/i, category: "auth", hint: "API key or credentials may be invalid or expired" },
27
+ { pattern: /403\s*forbidden/i, category: "auth", hint: "Access denied — check permissions or API key scope" },
28
+ { pattern: /invalid.*(api|auth|token|key|credential)/i, category: "auth", hint: "Authentication credential is invalid" },
29
+ { pattern: /(api|auth|token|key|credential).*(expired|revoked|rotated|invalid)/i, category: "auth", hint: "Credential has expired or been revoked" },
30
+ { pattern: /authentication\s+failed/i, category: "auth", hint: "Authentication failed — check credentials" },
31
+
32
+ // Billing/Quota
33
+ { pattern: /429\s*(too many|rate limit)/i, category: "billing", hint: "Rate limit hit — may need to upgrade plan or wait" },
34
+ { pattern: /(quota|limit|credits?)\s*(exceeded|exhausted|depleted)/i, category: "billing", hint: "Usage quota or credits exhausted" },
35
+ { pattern: /billing.*(?:issue|error|failed|inactive)/i, category: "billing", hint: "Billing issue on the account" },
36
+ { pattern: /insufficient.*(funds|credits|quota)/i, category: "billing", hint: "Insufficient credits or funds" },
37
+
38
+ // External service failures
39
+ { pattern: /ECONNREFUSED/i, category: "service", hint: "External service connection refused — is it running?" },
40
+ { pattern: /ENOTFOUND/i, category: "service", hint: "DNS lookup failed — check hostname or network" },
41
+ { pattern: /ETIMEDOUT/i, category: "service", hint: "Connection timed out — service may be down" },
42
+ { pattern: /ECONNRESET/i, category: "service", hint: "Connection reset by remote server" },
43
+ { pattern: /503\s*service\s*unavailable/i, category: "service", hint: "External service is temporarily unavailable" },
44
+ { pattern: /502\s*bad\s*gateway/i, category: "service", hint: "Bad gateway — upstream service may be down" },
45
+
46
+ // Certificates
47
+ { pattern: /CERT_|certificate|SSL|TLS/i, category: "cert", hint: "SSL/TLS certificate issue — may need renewal" },
48
+ { pattern: /self.signed/i, category: "cert", hint: "Self-signed certificate rejected" },
49
+
50
+ // Permissions
51
+ { pattern: /EACCES/i, category: "permission", hint: "Permission denied on file system" },
52
+ { pattern: /EPERM/i, category: "permission", hint: "Operation not permitted — check file/process permissions" },
53
+
54
+ // Environment
55
+ { pattern: /not\s+set|undefined.*env|missing.*env/i, category: "env", hint: "Environment variable not configured" },
56
+ { pattern: /missing.*config/i, category: "env", hint: "Configuration file or value missing" },
57
+
58
+ // Disk
59
+ { pattern: /ENOSPC/i, category: "disk", hint: "Disk space full" },
60
+ { pattern: /ENOMEM/i, category: "disk", hint: "Out of memory" },
61
+ ];
62
+
63
+ const CATEGORY_ICONS = {
64
+ auth: "🔑",
65
+ billing: "💳",
66
+ service: "🌐",
67
+ cert: "📜",
68
+ permission: "🔒",
69
+ env: "⚙️",
70
+ disk: "💾",
71
+ };
72
+
73
+ class Notifier {
74
+ constructor(options = {}) {
75
+ this.logger = options.logger;
76
+ this.redactor = options.redactor;
77
+ this.webhookUrl = options.webhookUrl || process.env.WOLVERINE_WEBHOOK_URL || null;
78
+
79
+ // Dedup: don't spam the same notification repeatedly
80
+ this._sentNotifications = new Map(); // key → timestamp
81
+ this._dedupeWindowMs = 300000; // 5 minutes
82
+ }
83
+
84
+ /**
85
+ * Classify an error: can AI fix it, or does it need human intervention?
86
+ * Returns { humanRequired: boolean, category?, hint?, matches? }
87
+ */
88
+ classify(errorMessage, stackTrace) {
89
+ const combined = `${errorMessage}\n${stackTrace || ""}`;
90
+ const matches = [];
91
+
92
+ for (const { pattern, category, hint } of HUMAN_REQUIRED_PATTERNS) {
93
+ if (pattern.test(combined)) {
94
+ matches.push({ category, hint });
95
+ }
96
+ }
97
+
98
+ if (matches.length === 0) {
99
+ return { humanRequired: false };
100
+ }
101
+
102
+ // Use the most specific match (first one)
103
+ return {
104
+ humanRequired: true,
105
+ category: matches[0].category,
106
+ hint: matches[0].hint,
107
+ matches,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Process an error — classify it, and if human-required, generate and send a notification.
113
+ * Returns the notification object if sent, null if AI-fixable.
114
+ */
115
+ async notify(errorMessage, stackTrace) {
116
+ const classification = this.classify(errorMessage, stackTrace);
117
+ if (!classification.humanRequired) return null;
118
+
119
+ // Dedup check
120
+ const dedupeKey = `${classification.category}:${classification.hint}`;
121
+ const lastSent = this._sentNotifications.get(dedupeKey);
122
+ if (lastSent && Date.now() - lastSent < this._dedupeWindowMs) {
123
+ return null; // already notified recently
124
+ }
125
+
126
+ // Sanitize through redactor
127
+ const safeError = this.redactor ? this.redactor.redact(errorMessage) : errorMessage;
128
+ const safeStack = this.redactor ? this.redactor.redact(stackTrace || "") : (stackTrace || "");
129
+
130
+ // Generate short summary using AI
131
+ let summary;
132
+ try {
133
+ summary = await this._summarize(safeError, safeStack, classification);
134
+ } catch {
135
+ // If AI call fails (maybe THAT's the auth error), use the pattern hint
136
+ summary = classification.hint;
137
+ }
138
+
139
+ const notification = {
140
+ category: classification.category,
141
+ icon: CATEGORY_ICONS[classification.category] || "⚠️",
142
+ summary,
143
+ hint: classification.hint,
144
+ timestamp: Date.now(),
145
+ iso: new Date().toISOString(),
146
+ };
147
+
148
+ // Mark as sent for dedup
149
+ this._sentNotifications.set(dedupeKey, Date.now());
150
+
151
+ // Deliver through all channels
152
+ this._deliverConsole(notification);
153
+ this._deliverLogger(notification);
154
+ this._deliverWebhook(notification);
155
+
156
+ return notification;
157
+ }
158
+
159
+ /**
160
+ * Use CHAT_MODEL to summarize the issue in 1-2 short sentences.
161
+ */
162
+ async _summarize(safeError, safeStack, classification) {
163
+ let model;
164
+ try {
165
+ model = getModel("chat");
166
+ } catch {
167
+ return classification.hint;
168
+ }
169
+
170
+ const result = await aiCall({
171
+ model,
172
+ systemPrompt: "You summarize server errors for developers. Write 1-2 short sentences. Be direct and actionable. Do not include any secrets, passwords, or API key values — only refer to them by name (e.g. 'the OPENAI_API_KEY').",
173
+ userPrompt: `Summarize this error for a developer notification:\n\nCategory: ${classification.category}\nError: ${safeError}\n\nStack (first 300 chars): ${safeStack.slice(0, 300)}`,
174
+ maxTokens: 100,
175
+ category: "security",
176
+ });
177
+
178
+ // Double-sanitize the AI response (in case the AI echoes something)
179
+ const summary = this.redactor ? this.redactor.redact(result.content) : result.content;
180
+ return summary || classification.hint;
181
+ }
182
+
183
+ _deliverConsole(notification) {
184
+ console.log(chalk.red.bold(`\n ${notification.icon} HUMAN ACTION REQUIRED`));
185
+ console.log(chalk.red(` Category: ${notification.category}`));
186
+ console.log(chalk.yellow(` ${notification.summary}`));
187
+ console.log(chalk.gray(` Hint: ${notification.hint}\n`));
188
+ }
189
+
190
+ _deliverLogger(notification) {
191
+ if (!this.logger) return;
192
+ this.logger.critical("notify.human_required", notification.summary, {
193
+ category: notification.category,
194
+ hint: notification.hint,
195
+ });
196
+ }
197
+
198
+ async _deliverWebhook(notification) {
199
+ if (!this.webhookUrl) return;
200
+
201
+ try {
202
+ const http = this.webhookUrl.startsWith("https") ? require("https") : require("http");
203
+ const url = new URL(this.webhookUrl);
204
+ const payload = JSON.stringify({
205
+ text: `${notification.icon} **Wolverine Alert** [${notification.category}]: ${notification.summary}`,
206
+ ...notification,
207
+ });
208
+
209
+ const req = http.request({
210
+ hostname: url.hostname,
211
+ port: url.port,
212
+ path: url.pathname + url.search,
213
+ method: "POST",
214
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) },
215
+ timeout: 5000,
216
+ });
217
+
218
+ req.on("error", () => {}); // swallow webhook errors
219
+ req.write(payload);
220
+ req.end();
221
+ } catch {
222
+ // Webhook delivery is best-effort
223
+ }
224
+ }
225
+ }
226
+
227
+ module.exports = { Notifier, HUMAN_REQUIRED_PATTERNS, CATEGORY_ICONS };