alvin-bot 4.26.0 → 5.1.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.
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Predictive Maintenance via Trends (Self-Preservation Phase 2, 3J).
3
+ *
4
+ * Mechanism:
5
+ * 1. Once every 24 h: snapshot lightweight health metrics and append
6
+ * one JSON line to ~/.alvin-bot/state/trends.jsonl
7
+ * 2. After the file has ≥ 7 days of data: also run a daily AI
8
+ * "anomaly detection" pass over the last 30 days of snapshots
9
+ * 3. If the AI flags a concerning trend → DM operator via 1D
10
+ *
11
+ * Why daily and not continuous: trends are about slow degradation
12
+ * (memory growth, error-rate increase, crash-frequency drift). A
13
+ * 24-hour sampling cadence is right for these timescales and keeps
14
+ * the storage + AI-call cost near zero.
15
+ *
16
+ * Storage format — JSONL, one line per day:
17
+ *
18
+ * {"ts": "2026-05-13T04:00:00Z", "uptime_s": 86400, "rss_mb": 105,
19
+ * "crashes_24h": 0, "diag_24h": 0, "errors_24h": 3,
20
+ * "provider": "claude-sdk", "version": "5.0.0"}
21
+ *
22
+ * The JSONL design is deliberate: easy to inspect with tail/head/awk,
23
+ * easy to truncate to last N days, no parsing pitfalls. The AI gets
24
+ * the whole tail as plain text — works with small-context models.
25
+ *
26
+ * Provider-agnostic — same engine.query() pipeline as 3I.
27
+ *
28
+ * Opt-out:
29
+ * ALVIN_DISABLE_TRENDS=true → skip 3J entirely
30
+ * ALVIN_DISABLE_SELF_PRESERVATION=true → skip all Phase-1/2
31
+ *
32
+ * Tunable for testing:
33
+ * ALVIN_TRENDS_INTERVAL_HOURS=24 → snapshot cadence
34
+ * ALVIN_TRENDS_AI_AFTER_DAYS=7 → days of data before AI analysis kicks in
35
+ */
36
+ import { appendFileSync, existsSync, readFileSync, mkdirSync } from "fs";
37
+ import { join, dirname } from "path";
38
+ import { homedir } from "os";
39
+ import { BOT_VERSION } from "../version.js";
40
+ import { emitCritical } from "./critical-notify.js";
41
+ const TRENDS_PATH = join(homedir(), ".alvin-bot", "state", "trends.jsonl");
42
+ const DEFAULT_INTERVAL_HOURS = 24;
43
+ const DEFAULT_AI_THRESHOLD_DAYS = 7;
44
+ const MAX_RETAIN_DAYS = 90;
45
+ let trendsTimer = null;
46
+ function isDisabled() {
47
+ return (process.env.ALVIN_DISABLE_TRENDS === "true" ||
48
+ process.env.ALVIN_DISABLE_SELF_PRESERVATION === "true");
49
+ }
50
+ function countLogLinesLast24h(filename, pattern) {
51
+ const path = join(homedir(), ".alvin-bot", "logs", filename);
52
+ if (!existsSync(path))
53
+ return 0;
54
+ try {
55
+ const content = readFileSync(path, "utf-8");
56
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
57
+ let count = 0;
58
+ for (const line of content.split("\n")) {
59
+ const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)/);
60
+ if (!tsMatch)
61
+ continue;
62
+ const lineTs = new Date(tsMatch[1]).getTime();
63
+ if (Number.isFinite(lineTs) && lineTs >= cutoff) {
64
+ if (!pattern || pattern.test(line))
65
+ count++;
66
+ }
67
+ }
68
+ return count;
69
+ }
70
+ catch {
71
+ return 0;
72
+ }
73
+ }
74
+ function readWatchdogCrashes24h() {
75
+ try {
76
+ const path = join(homedir(), ".alvin-bot", "state", "watchdog.json");
77
+ if (!existsSync(path))
78
+ return 0;
79
+ const data = JSON.parse(readFileSync(path, "utf-8"));
80
+ return data.dailyCrashCount ?? 0;
81
+ }
82
+ catch {
83
+ return 0;
84
+ }
85
+ }
86
+ function countDiagnosticBundlesLast24h() {
87
+ try {
88
+ const dir = join(homedir(), ".alvin-bot", "diagnostics");
89
+ if (!existsSync(dir))
90
+ return 0;
91
+ const { readdirSync, statSync } = require("fs");
92
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
93
+ return readdirSync(dir)
94
+ .filter((f) => f.endsWith(".md") && !f.endsWith(".analysis.md"))
95
+ .filter((f) => {
96
+ try {
97
+ return statSync(join(dir, f)).mtimeMs >= cutoff;
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }).length;
103
+ }
104
+ catch {
105
+ return 0;
106
+ }
107
+ }
108
+ function takeSnapshot(activeProvider) {
109
+ const mem = process.memoryUsage();
110
+ return {
111
+ ts: new Date().toISOString(),
112
+ uptime_s: Math.round(process.uptime()),
113
+ rss_mb: Math.round(mem.rss / 1024 / 1024),
114
+ heap_mb: Math.round(mem.heapUsed / 1024 / 1024),
115
+ crashes_24h: readWatchdogCrashes24h(),
116
+ diag_24h: countDiagnosticBundlesLast24h(),
117
+ errors_24h: countLogLinesLast24h("alvin-bot.err.log"),
118
+ provider: activeProvider,
119
+ version: BOT_VERSION,
120
+ };
121
+ }
122
+ function appendSnapshot(snap) {
123
+ try {
124
+ mkdirSync(dirname(TRENDS_PATH), { recursive: true });
125
+ appendFileSync(TRENDS_PATH, JSON.stringify(snap) + "\n");
126
+ }
127
+ catch {
128
+ // Disk full / permissions — non-fatal
129
+ }
130
+ }
131
+ function readSnapshots(lastN = 30) {
132
+ if (!existsSync(TRENDS_PATH))
133
+ return [];
134
+ try {
135
+ const content = readFileSync(TRENDS_PATH, "utf-8");
136
+ const lines = content.split("\n").filter((l) => l.trim());
137
+ const recent = lines.slice(-lastN);
138
+ return recent
139
+ .map((l) => {
140
+ try {
141
+ return JSON.parse(l);
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ })
147
+ .filter((s) => s !== null);
148
+ }
149
+ catch {
150
+ return [];
151
+ }
152
+ }
153
+ const TREND_PROMPT_TEMPLATE = `You are an SRE monitoring an Alvin Bot instance over the last days.
154
+ Below is one JSON line per day with the bot's daily health metrics.
155
+
156
+ Detect any CONCERNING trend that suggests slow degradation — like:
157
+ - Memory (rss_mb / heap_mb) growing day over day
158
+ - Error rate (errors_24h) climbing
159
+ - Crashes (crashes_24h) above 0 for multiple days
160
+ - Diagnostic bundles (diag_24h) > 0 repeatedly
161
+
162
+ If there is NO concerning trend, respond with EXACTLY this one line:
163
+ ANOMALY: NONE
164
+
165
+ If there IS a concerning trend, respond in this 3-line format — nothing else:
166
+
167
+ ANOMALY: <one short sentence — what trend you noticed>
168
+ SEVERITY: <warn | critical>
169
+ SUGGESTION: <one shell command OR observation for the operator>
170
+
171
+ --- LAST {N} DAYS OF SNAPSHOTS ---
172
+ {SNAPSHOTS}
173
+ --- END ---`;
174
+ function parseTrendResponse(text) {
175
+ if (/^ANOMALY:\s*NONE/im.test(text)) {
176
+ return {
177
+ anomalyDetected: false,
178
+ description: "no concerning trend detected",
179
+ severity: "none",
180
+ suggestion: "",
181
+ raw: text,
182
+ };
183
+ }
184
+ const get = (key) => {
185
+ const m = text.match(new RegExp(`^${key}:\\s*(.+?)$`, "m"));
186
+ return m ? m[1].trim() : "";
187
+ };
188
+ const sevRaw = get("SEVERITY").toLowerCase();
189
+ const sev = sevRaw === "critical" ? "critical" : "warn";
190
+ return {
191
+ anomalyDetected: true,
192
+ description: get("ANOMALY") || "(no description)",
193
+ severity: sev,
194
+ suggestion: get("SUGGESTION") || "(no suggestion)",
195
+ raw: text,
196
+ };
197
+ }
198
+ export async function analyzeTrends(registry) {
199
+ if (isDisabled())
200
+ return null;
201
+ if (!registry)
202
+ return null;
203
+ const snaps = readSnapshots(30);
204
+ const threshold = parseInt(process.env.ALVIN_TRENDS_AI_AFTER_DAYS || "", 10) || DEFAULT_AI_THRESHOLD_DAYS;
205
+ if (snaps.length < threshold)
206
+ return null;
207
+ let provider;
208
+ try {
209
+ provider = registry.getActive();
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ if (!provider)
215
+ return null;
216
+ const snapsBlock = snaps.map((s) => JSON.stringify(s)).join("\n");
217
+ const prompt = TREND_PROMPT_TEMPLATE.replace("{N}", String(snaps.length)).replace("{SNAPSHOTS}", snapsBlock);
218
+ const abortController = new AbortController();
219
+ const timer = setTimeout(() => abortController.abort(), 60_000);
220
+ let fullText = "";
221
+ try {
222
+ for await (const chunk of provider.query({
223
+ prompt,
224
+ systemPrompt: "You are a precise SRE assistant. Reply ONLY in the requested format.",
225
+ abortSignal: abortController.signal,
226
+ })) {
227
+ if (chunk.type === "text") {
228
+ if (chunk.delta)
229
+ fullText += chunk.delta;
230
+ else if (chunk.text)
231
+ fullText = chunk.text;
232
+ }
233
+ else if (chunk.type === "error") {
234
+ clearTimeout(timer);
235
+ return null;
236
+ }
237
+ else if (chunk.type === "done") {
238
+ if (chunk.text)
239
+ fullText = chunk.text;
240
+ break;
241
+ }
242
+ }
243
+ }
244
+ catch {
245
+ clearTimeout(timer);
246
+ return null;
247
+ }
248
+ clearTimeout(timer);
249
+ if (!fullText.trim())
250
+ return null;
251
+ return parseTrendResponse(fullText);
252
+ }
253
+ async function dailyTask(registry) {
254
+ try {
255
+ // Snapshot first — always, regardless of AI being available
256
+ const activeProviderKey = (() => {
257
+ try {
258
+ return registry?.getActiveKey() || "none";
259
+ }
260
+ catch {
261
+ return "none";
262
+ }
263
+ })();
264
+ const snap = takeSnapshot(activeProviderKey);
265
+ appendSnapshot(snap);
266
+ console.log(`📊 Trends snapshot taken: rss=${snap.rss_mb}MB errors=${snap.errors_24h} crashes=${snap.crashes_24h}`);
267
+ // Then attempt AI analysis if we have enough history
268
+ const result = await analyzeTrends(registry);
269
+ if (!result)
270
+ return;
271
+ if (!result.anomalyDetected) {
272
+ console.log(`📊 Trends AI: no anomaly detected`);
273
+ return;
274
+ }
275
+ console.log(`📊 Trends AI: ANOMALY (${result.severity}) — ${result.description}`);
276
+ emitCritical({
277
+ category: "custom",
278
+ severity: result.severity === "critical" ? "critical" : "warn",
279
+ title: `Trend anomaly detected: ${result.description}`,
280
+ detail: `30-day trend analysis flagged a concerning pattern.\n\n` +
281
+ `Suggestion: ${result.suggestion}\n\n` +
282
+ `Trend data: ${TRENDS_PATH}`,
283
+ suggestedAction: result.suggestion,
284
+ });
285
+ }
286
+ catch (err) {
287
+ console.warn(`📊 Trends daily task threw: ${err instanceof Error ? err.message : String(err)}`);
288
+ }
289
+ }
290
+ export function startTrendsCollector(registry) {
291
+ if (isDisabled())
292
+ return;
293
+ const intervalH = parseInt(process.env.ALVIN_TRENDS_INTERVAL_HOURS || "", 10) || DEFAULT_INTERVAL_HOURS;
294
+ const intervalMs = intervalH * 60 * 60 * 1000;
295
+ // Initial: take a first snapshot after 60 s to avoid measuring the
296
+ // startup transient. Subsequent snapshots every intervalMs.
297
+ setTimeout(() => {
298
+ void dailyTask(registry);
299
+ trendsTimer = setInterval(() => void dailyTask(registry), intervalMs);
300
+ if (trendsTimer.unref)
301
+ trendsTimer.unref();
302
+ }, 60_000);
303
+ }
304
+ export function stopTrendsCollector() {
305
+ if (trendsTimer) {
306
+ clearInterval(trendsTimer);
307
+ trendsTimer = null;
308
+ }
309
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "4.26.0",
3
+ "version": "5.1.0",
4
4
  "description": "Alvin Bot — Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -16,6 +16,8 @@
16
16
  "test:watch": "vitest",
17
17
  "test:ui": "vitest --ui",
18
18
  "privacy-check": "bash scripts/privacy-check.sh",
19
+ "audit": "npm audit --audit-level=high",
20
+ "audit:full": "npm audit",
19
21
  "prepublishOnly": "bash scripts/privacy-check.sh",
20
22
  "electron:compile": "tsc -p electron/tsconfig.json",
21
23
  "electron:dev": "npm run electron:compile && electron .",