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.
- package/CHANGELOG.md +115 -0
- package/README.md +98 -14
- package/bin/cli.js +95 -9
- package/dist/handlers/commands.js +26 -0
- package/dist/index.js +20 -0
- package/dist/services/permissions-wizard.js +291 -0
- package/dist/services/self-diagnosis.js +272 -0
- package/dist/services/sudo.js +66 -6
- package/dist/services/trends.js +309 -0
- package/package.json +3 -1
|
@@ -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": "
|
|
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 .",
|