alvin-bot 4.25.1 → 5.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/CHANGELOG.md CHANGED
@@ -2,6 +2,173 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [5.0.0] — 2026-05-13
6
+
7
+ ### Self-Preservation Phase 2 — the bot now reasons about its own failures
8
+
9
+ The bot now uses **its own AI provider** (whichever one you have configured — claude-sdk, codex-cli, groq, gemini, openai, ollama/gemma4, openrouter, nvidia-nim) to analyze why it failed and where it's heading. Two new features, both event-driven, both opt-out.
10
+
11
+ The major-version bump reflects a conceptual shift: AI is now part of the bot's **operational loop** about itself, not just the user-facing chat. The feature surface is backwards-compatible — existing setups keep running unchanged; everything new is additive and opt-out via env vars.
12
+
13
+ #### AI-driven Self-Diagnosis on bundles (feature 3I)
14
+
15
+ When the watchdog brake fires and 2F writes a forensic bundle, 3I picks up the analysis at **the next successful bot start**:
16
+
17
+ 1. Bot starts → Pre-Flight runs → 3I scans `~/.alvin-bot/diagnostics/` for unanalyzed bundles
18
+ 2. For each bundle without a `.analysis.md` sidecar, send it (clipped to ~12 KB, head+tail) to the active AI provider via `provider.query()`
19
+ 3. AI returns a structured 5-line response — `HYPOTHESIS / ROOT_CAUSE_CATEGORY / REMEDIATION / CONFIDENCE / EXPLANATION`
20
+ 4. Result is written as `.analysis.md` sidecar AND delivered to the operator via 1D Telegram DM
21
+
22
+ The 5-line plain-text format was chosen over JSON because **JSON parsing reliability is uneven across providers**, especially with smaller models. The format is hard to mess up — and we parse it with a tolerant regex.
23
+
24
+ Live verified on Apple Silicon with `claude-sdk`: bundle from the actual brake earlier that day was analyzed correctly — AI identified "skills-reload triggered repeated graceful restarts that tripped the brake", suggested the documented recovery command (`rm crash-loop.alert && alvin-bot launchd install`), all within ~9 s.
25
+
26
+ **Safety policy v1**: the AI's suggested remediation is shown to the operator but **NEVER auto-applied**. This is intentional — we want a track record of accurate suggestions before granting the bot any self-modifying power.
27
+
28
+ #### Predictive Maintenance via Trends (feature 3J)
29
+
30
+ A second daily timer writes a one-line JSON snapshot of bot health to `~/.alvin-bot/state/trends.jsonl`:
31
+
32
+ ```jsonl
33
+ {"ts":"2026-05-13T...","uptime_s":86400,"rss_mb":105,"heap_mb":33,"crashes_24h":0,"diag_24h":0,"errors_24h":3,"provider":"claude-sdk","version":"5.0.0"}
34
+ ```
35
+
36
+ After 7 days of data accumulate, every daily snapshot also triggers an AI **anomaly-detection pass** over the last 30 days. Output is a strict 3-line format — `ANOMALY: ... / SEVERITY: warn|critical / SUGGESTION: ...`, or just `ANOMALY: NONE` when nothing's concerning.
37
+
38
+ Live verified with synthetic 30-day memory-leak data (RSS climbing 100 → 220 MB linearly): `claude-sdk` correctly identified the leak, classified as `critical`, suggested heap-snapshot capture via `kill -USR2`. Confirmed end-to-end with file flag + Telegram DM delivered via 1D.
39
+
40
+ #### Provider-agnostic by design
41
+
42
+ Both 3I and 3J use the existing `provider.query()` abstraction — the same code path the bot uses for normal chat. Switching provider via `alvin-bot provider switch <key>` (added in 4.24.0) automatically retargets 3I + 3J as well. No provider-specific code in either feature.
43
+
44
+ **Tested provider**: `claude-sdk` (the "B1" test path). The `offline-gemma4` test path (B4) — stress-test of prompt design against a small-context local model — is deferred to a follow-up session; the deferral and its acceptance criteria are documented in the (gitignored) project `BACKLOG.md`.
45
+
46
+ #### Performance budget held
47
+
48
+ All new code runs detached, on long timers, or at startup-only. Steady-state cost: zero. The startup analyzer (3I) only runs if unanalyzed bundles exist — typically 0 on a healthy run. The trends collector (3J) runs once every 24 h with a 60 s warmup after startup.
49
+
50
+ Measured on Apple Silicon (vs. 4.26.0 baseline):
51
+ - RSS idle: **+0 MB** (modules loaded lazily via dynamic `import()`)
52
+ - Cold-start ready: **unchanged** (both modules load post-startup, fire-and-forget)
53
+ - 3I per-bundle latency on claude-sdk: ~9 s
54
+ - 3J per-analysis latency on claude-sdk: ~10 s
55
+
56
+ #### New env vars
57
+
58
+ ```
59
+ ALVIN_DISABLE_SELF_DIAGNOSIS=true # disable 3I
60
+ ALVIN_DISABLE_TRENDS=true # disable 3J
61
+ ALVIN_TRENDS_INTERVAL_HOURS=24 # default
62
+ ALVIN_TRENDS_AI_AFTER_DAYS=7 # min history before AI kicks in
63
+ ```
64
+
65
+ (All Phase-1 env vars from 4.26.0 continue to work — `ALVIN_DISABLE_SELF_PRESERVATION=true` still kills everything.)
66
+
67
+ #### Why a major version bump
68
+
69
+ Semantically, the bot is now **closing a loop on itself**: it observes its own forensics, asks an AI to interpret them, and reports back. That's a conceptual line worth marking. Nothing breaks — existing users update with `npm install -g alvin-bot@latest` and the new behaviour just appears in their next failure analysis.
70
+
71
+ #### Files added
72
+
73
+ - `src/services/self-diagnosis.ts` — 3I startup analyzer + analyzeBundle()
74
+ - `src/services/trends.ts` — 3J snapshot collector + analyzeTrends()
75
+ - `src/index.ts` — two fire-and-forget dynamic imports after Pre-Flight
76
+
77
+ ## [4.26.0] — 2026-05-13
78
+
79
+ ### Self-Preservation Phase 1 — four new resilience features, zero hot-path cost
80
+
81
+ Bot now **survives more failure modes** and **alerts you when it can't survive them**. All four features run event-driven or on low-frequency timers — no hot-path overhead, measured RSS +4 MB / cold-start +81 ms vs baseline on a real Apple Silicon Mac (within the +5 MB / +2000 ms tolerance budget).
82
+
83
+ #### Pre-Flight Sanity Check at startup (feature 1A)
84
+
85
+ In parallel at boot, the bot now checks: (1) Telegram `getMe`, (2) AI provider `isAvailable()` — provider-agnostic via the existing Provider interface, works equally for `claude-sdk` / `codex-cli` / `groq` / `gemini` / `offline-gemma4` / etc., (3) SQLite `PRAGMA quick_check` on the embeddings DB, (4) Disk space ≥ 1 GB. Fire-and-forget — startup is **not** delayed; results land ~1 s after `Alvin Bot started` with severity-tagged output:
86
+
87
+ ```
88
+ 🩺 ✅ Pre-Flight: all checks ok — 986ms total
89
+ ✓ telegram bot=@AlvinMBAM4_bot (405ms)
90
+ ✓ ai-provider claude-sdk reachable (922ms)
91
+ ✓ sqlite embeddings DB integrity ok (43ms)
92
+ ✓ disk 53.28 GB free (37ms)
93
+ ```
94
+
95
+ Per-check timeouts (3 s / 5 s / 10 s / 2 s) bound the cost. Critical findings will feed Phase 2's auto-diagnostic (already wired). Opt-out: `ALVIN_DISABLE_PREFLIGHT=true`.
96
+
97
+ #### Critical-Event Cross-Channel Notify (feature 1D)
98
+
99
+ When the bot hits a state it can't recover from on its own — watchdog crash-loop brake engaged, repeated Telegram 409s, all providers dead, disk critically low — it now alerts the operator through a **fallback chain that doesn't depend on the bot's own platform being healthy**:
100
+
101
+ 1. **`~/.alvin-bot/CRITICAL.log`** — durable audit trail, always written first. Plain text, dated, machine-readable.
102
+ 2. **macOS native notification** via `osascript` — visible immediately on the user's desktop.
103
+ 3. **Telegram DM to admin** via `curl` — synchronous in exit-imminent contexts so the alert lands before `process.exit()` kills any pending I/O.
104
+
105
+ The synchronous-vs-detached distinction matters: detached child processes get killed by macOS+launchd before they finish their fork-and-exec when the parent exits within a few ms. The watchdog brake explicitly uses `blockTelegram: true` to spawnSync the curl POST and confirm the HTTP response code. Plain-text body (not Markdown) so shell-command `suggestedAction`s with `"`, `&&`, etc. don't trigger Telegram's `Bad Request: can't parse entities` error. Opt-out: `ALVIN_DISABLE_CRITICAL_NOTIFY=true`.
106
+
107
+ #### Zombie Dead-Man-Switch (feature 2E)
108
+
109
+ Bot writes a unix-timestamp heartbeat to `~/.alvin-bot/heartbeat.txt` every 60 s. A **separate, tiny launchd LaunchAgent** (`com.alvinbot.deadman`) wakes every 5 min and checks the heartbeat — if older than 10 min, the watcher fires `launchctl kickstart -k gui/$UID/com.alvinbot.app` to force-restart.
110
+
111
+ Catches the failure mode the in-process watchdog **cannot** see: process is alive but frozen (event-loop deadlock, blocked I/O, native-binding hang). The in-process watchdog can't detect its own death — that's a contradiction in terms — so the external observer is the only architecturally sound solution.
112
+
113
+ Threshold overridable for testing: `ALVIN_DEADMAN_THRESHOLD_SEC=60` (default 600). End-to-end verified on a real Mac: `kill -STOP` froze the bot at PID X, watcher detected stale heartbeat 700 s old, kickstart fired, fresh PID Y came up within 8 s. CPU cost of the watcher: 0.017 %.
114
+
115
+ #### Auto-Diagnostic Logs-Collector (feature 2F)
116
+
117
+ On any critical failure, the bot now writes a structured forensic Markdown bundle to `~/.alvin-bot/diagnostics/<timestamp>-<category>.md` containing:
118
+
119
+ 1. Event detail + suggested action
120
+ 2. Process state (PID, RSS, heap, uptime, node version, platform, argv)
121
+ 3. Non-secret environment vars (PATH, PRIMARY_PROVIDER, FALLBACK_PROVIDERS, WEB_*, …)
122
+ 4. Last 200 lines of `alvin-bot.err.log`
123
+ 5. Last 200 lines of `alvin-bot.out.log`
124
+ 6. Watchdog state (`~/.alvin-bot/state/watchdog.json`)
125
+ 7. System tool inventory (`node`, `npm`, `brew`, `pm2`, `codex`, `claude`, `yt-dlp`, `ffmpeg`, `wacli`, `agent-browser`)
126
+ 8. Disk space (`df -h ~/.alvin-bot`)
127
+ 9. PM2 status (if PM2 installed — the same kind of state that bit us in 4.25.1)
128
+
129
+ Bundles are ~18 KB each, capped at 50 retained files (oldest pruned automatically). The Telegram DM from feature 1D now includes the bundle path so the operator can immediately `cat` or scp it.
130
+
131
+ This is also the data input the 5.0.0 AI-Self-Diagnosis (feature 3I) will feed to a sub-agent for automated analysis. As a 4.26.0 deliverable it stands on its own as "human-readable forensic dump".
132
+
133
+ Opt-out: `ALVIN_DISABLE_AUTO_DIAGNOSTIC=true`.
134
+
135
+ ### Bundle wacli (WhatsApp CLI) with conditional opt-in
136
+
137
+ `wacli` (https://wacli.sh, brew tap `steipete/tap`, v0.8.1, ~25 MB Go binary) is now part of `BOOTSTRAP_TOOLS` — but with a **hybrid install condition** that avoids forcing it onto users who don't use WhatsApp:
138
+
139
+ - **If `wacli` is already installed** → bootstrap runs `brew upgrade wacli` (treated like any other bundled tool).
140
+ - **If `WHATSAPP_ENABLED=true` is set in `.env`** → bootstrap installs via `brew install steipete/tap/wacli`.
141
+ - **Otherwise** → silent skip with dimmer `·` icon: `· wacli (WhatsApp CLI) skipped (not opted in)`.
142
+
143
+ License: see https://wacli.sh — alvin-bot does not bundle wacli, only invokes the user's brew, the user remains the licensee. macOS only (no Linux build upstream; bootstrap skips on Linux automatically).
144
+
145
+ ### Opt-out env vars summary
146
+
147
+ For users who want minimal footprint:
148
+
149
+ ```
150
+ ALVIN_DISABLE_SELF_PRESERVATION=true # skip ALL Phase-1 features
151
+ ALVIN_DISABLE_PREFLIGHT=true # skip Pre-Flight only
152
+ ALVIN_DISABLE_CRITICAL_NOTIFY=true # skip cross-channel notify
153
+ ALVIN_DISABLE_DEAD_MAN=true # skip heartbeat writer
154
+ ALVIN_DISABLE_AUTO_DIAGNOSTIC=true # skip diagnostic bundles
155
+ ALVIN_DEADMAN_THRESHOLD_SEC=600 # tune dead-man threshold (default 10 min)
156
+ ```
157
+
158
+ ### Performance budget verified on real hardware
159
+
160
+ End-to-end measurements on Apple Silicon Mac (.75 test box):
161
+
162
+ | Metric | Baseline 4.25.1 | 4.26.0 | Δ | Tolerance |
163
+ |---|---|---|---|---|
164
+ | Cold-start ready (median, throttled) | 5023 ms | 5104 ms | +81 ms | +2000 ms |
165
+ | Cold-start ready (unthrottled, 1st run) | 2189 ms | 2170 ms | -19 ms | +2000 ms |
166
+ | RSS idle steady-state | ~102 MB | 106.4 MB | +4.4 MB | +5 MB |
167
+ | CPU idle | 0.0 % | 0.0 % | 0 | +0.1 % |
168
+ | Log dir growth | stable | stable | n/a | <1 KB/s |
169
+
170
+ All five metrics within tolerance.
171
+
5
172
  ## [4.25.1] — 2026-05-13
6
173
 
7
174
  ### Fixed: `alvin-bot launchd install` now persists the PM2 cleanup
package/bin/cli.js CHANGED
@@ -272,6 +272,24 @@ const BOOTSTRAP_TOOLS = [
272
272
  install: { macos: "brew install ffmpeg", linux: "sudo apt-get install -y ffmpeg" },
273
273
  upgrade: { macos: "brew upgrade ffmpeg", linux: "sudo apt-get install --only-upgrade -y ffmpeg" },
274
274
  },
275
+ {
276
+ // wacli — WhatsApp CLI from steipete/tap. Hybrid bootstrap: only
277
+ // install/upgrade if the user has already installed it (we
278
+ // respect their existing setup) or has explicitly opted in via
279
+ // WHATSAPP_ENABLED=true in .env. This avoids pulling a ~25 MB
280
+ // Go binary onto every public user's machine, including those
281
+ // who never touch WhatsApp.
282
+ cmd: "wacli",
283
+ name: "wacli (WhatsApp CLI)",
284
+ license: "see https://wacli.sh — installed via your own brew, you remain the licensee",
285
+ install: { macos: "brew install steipete/tap/wacli", linux: null },
286
+ upgrade: { macos: "brew upgrade wacli", linux: null },
287
+ // Hybrid: only bootstrap if the user has explicitly signalled
288
+ // interest. installCondition is checked BEFORE any install/upgrade
289
+ // attempt; returns false → tool silently skipped.
290
+ installCondition: (env) =>
291
+ hasCommand("wacli") || env.WHATSAPP_ENABLED === "true",
292
+ },
275
293
  ];
276
294
 
277
295
  // Memoized: `brew update` is slow (5-30s) but needs to run at least once
@@ -309,6 +327,22 @@ function detectPlatformPm() {
309
327
  function bootstrapOneTool(tool, platform) {
310
328
  const cmdAvailable = hasCommand(tool.cmd);
311
329
 
330
+ // installCondition: optional gate that respects user intent. A tool with
331
+ // installCondition returning false is treated as "user hasn't opted in,
332
+ // silently skip". This is how wacli avoids forcing a 25 MB WhatsApp CLI
333
+ // onto every public user — only installs if they have it already or
334
+ // explicitly set WHATSAPP_ENABLED=true in .env.
335
+ if (typeof tool.installCondition === "function") {
336
+ try {
337
+ if (!tool.installCondition(process.env)) {
338
+ return { ok: true, skipped: true, message: `${tool.name} skipped (not opted in)` };
339
+ }
340
+ } catch {
341
+ // condition function threw — be defensive, skip
342
+ return { ok: true, skipped: true, message: `${tool.name} skipped (condition error)` };
343
+ }
344
+ }
345
+
312
346
  // Linux-only prerequisite check (e.g. pipx for yt-dlp).
313
347
  if (platform === "linux" && tool.linuxSkipIf && !hasCommand(tool.linuxSkipIf)) {
314
348
  return { ok: false, message: `${tool.name} skipped — needs '${tool.linuxSkipIf}' on Linux` };
@@ -376,12 +410,12 @@ async function ensureBootstrapTools(opts = {}) {
376
410
  const platform = detectPlatformPm();
377
411
  if (!platform) return;
378
412
 
379
- console.log("\n🎬 Setting up media tools (yt-dlp + ffmpeg)...");
413
+ console.log("\n🎬 Setting up bundled tools (yt-dlp, ffmpeg, wacli on opt-in)...");
380
414
 
381
415
  // macOS needs brew on PATH — same trick as ensureBrewOnPath() uses.
382
416
  if (platform === "macos" && !hasCommand("brew")) {
383
417
  if (!ensureBrewOnPath()) {
384
- console.log(" ⚠️ Skipping media-tool bootstrap — Homebrew not installed.");
418
+ console.log(" ⚠️ Skipping tool bootstrap — Homebrew not installed.");
385
419
  console.log(" To enable: install brew from https://brew.sh and re-run setup.");
386
420
  return;
387
421
  }
@@ -389,7 +423,9 @@ async function ensureBootstrapTools(opts = {}) {
389
423
 
390
424
  for (const tool of BOOTSTRAP_TOOLS) {
391
425
  const result = bootstrapOneTool(tool, platform);
392
- console.log(` ${result.ok ? "✓" : "⚠"} ${result.message}`);
426
+ // skipped (opt-in not signaled) use dimmer icon, less attention-grabbing
427
+ const icon = result.skipped ? "·" : result.ok ? "✓" : "⚠";
428
+ console.log(` ${icon} ${result.message}`);
393
429
  }
394
430
  console.log("");
395
431
  }
@@ -2688,7 +2724,80 @@ function launchdPaths() {
2688
2724
  const entryPoint = resolve(join(import.meta.dirname, "..", "dist", "index.js"));
2689
2725
  const cwd = resolve(join(import.meta.dirname, ".."));
2690
2726
  const nodePath = process.execPath;
2691
- return { home, label, plistPath, logDir, entryPoint, cwd, nodePath };
2727
+ // Dead-man-switch watcher (Self-Preservation Phase 1, feature 2E).
2728
+ // Separate, tiny LaunchAgent that fires every 5 min and force-restarts
2729
+ // the main bot if its heartbeat-file is stale. The two agents are
2730
+ // intentionally independent: if the main bot is wedged, the dead-man
2731
+ // agent is still scheduling and reading the file.
2732
+ const deadmanLabel = "com.alvinbot.deadman";
2733
+ const deadmanPlistPath = join(home, "Library", "LaunchAgents", `${deadmanLabel}.plist`);
2734
+ return { home, label, plistPath, logDir, entryPoint, cwd, nodePath, deadmanLabel, deadmanPlistPath };
2735
+ }
2736
+
2737
+ /**
2738
+ * Generate the dead-man watcher LaunchAgent plist. It runs a tiny shell
2739
+ * script every 5 minutes (StartInterval) that compares the bot's
2740
+ * heartbeat-file timestamp against now. If the heartbeat is more than
2741
+ * 10 minutes stale, it `launchctl kickstart -k`s the main bot.
2742
+ *
2743
+ * The threshold is overridable via ALVIN_DEADMAN_THRESHOLD_SEC for
2744
+ * testing; default is 600 s = 10 minutes.
2745
+ *
2746
+ * Why inline shell instead of a bundled script:
2747
+ * - Zero extra files to ship via npm
2748
+ * - Trivial to audit: 12 lines of POSIX sh
2749
+ * - No PATH dependency (uses absolute /bin paths)
2750
+ */
2751
+ function renderDeadmanPlist({ deadmanLabel, mainLabel, home, logDir }) {
2752
+ // Inline shell — kept POSIX-clean, uses only built-ins + launchctl.
2753
+ // The redirect to logDir/deadman.log gives us a record of any
2754
+ // kickstart actions without the watcher writing more than ~50
2755
+ // bytes per event.
2756
+ const script = `
2757
+ HEARTBEAT="${home}/.alvin-bot/heartbeat.txt"
2758
+ LOG="${logDir}/deadman.log"
2759
+ THRESHOLD="\${ALVIN_DEADMAN_THRESHOLD_SEC:-600}"
2760
+ if [ ! -f "$HEARTBEAT" ]; then exit 0; fi
2761
+ LAST=$(cat "$HEARTBEAT" 2>/dev/null | tr -d ' \\n')
2762
+ NOW=$(date +%s)
2763
+ case "$LAST" in
2764
+ ''|*[!0-9]*) exit 0 ;;
2765
+ esac
2766
+ DIFF=$((NOW - LAST))
2767
+ if [ "$DIFF" -gt "$THRESHOLD" ]; then
2768
+ echo "$(date -u +%FT%TZ) deadman: heartbeat $DIFF s old (> $THRESHOLD s), kickstarting ${mainLabel}" >> "$LOG"
2769
+ /bin/launchctl kickstart -k "gui/$(id -u)/${mainLabel}" 2>>"$LOG" || true
2770
+ fi
2771
+ `.trim();
2772
+
2773
+ return `<?xml version="1.0" encoding="UTF-8"?>
2774
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2775
+ <plist version="1.0">
2776
+ <dict>
2777
+ <key>Label</key>
2778
+ <string>${deadmanLabel}</string>
2779
+
2780
+ <key>ProgramArguments</key>
2781
+ <array>
2782
+ <string>/bin/sh</string>
2783
+ <string>-c</string>
2784
+ <string>${script.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</string>
2785
+ </array>
2786
+
2787
+ <key>StartInterval</key>
2788
+ <integer>300</integer>
2789
+
2790
+ <key>RunAtLoad</key>
2791
+ <false/>
2792
+
2793
+ <key>StandardErrorPath</key>
2794
+ <string>${logDir}/deadman.err.log</string>
2795
+
2796
+ <key>LimitLoadToSessionType</key>
2797
+ <string>Aqua</string>
2798
+ </dict>
2799
+ </plist>
2800
+ `;
2692
2801
  }
2693
2802
 
2694
2803
  async function launchdInstall() {
@@ -2830,6 +2939,38 @@ async function launchdInstall() {
2830
2939
  console.log(` protected files. (Granted path: ${fda.realNodePath})`);
2831
2940
  }
2832
2941
 
2942
+ // ── Dead-Man-Switch (Self-Preservation Phase 1, feature 2E) ──────────
2943
+ // Install a second tiny LaunchAgent that wakes every 5 min and force-
2944
+ // restarts the main bot if its heartbeat-file is stale. Catches "process
2945
+ // alive but frozen" — event-loop deadlocks, blocked I/O, etc. — that
2946
+ // the in-process watchdog can't see.
2947
+ // Opt-out: ALVIN_DISABLE_DEAD_MAN=true or ALVIN_DISABLE_SELF_PRESERVATION=true.
2948
+ if (
2949
+ process.env.ALVIN_DISABLE_DEAD_MAN !== "true" &&
2950
+ process.env.ALVIN_DISABLE_SELF_PRESERVATION !== "true"
2951
+ ) {
2952
+ const { deadmanLabel, deadmanPlistPath } = launchdPaths();
2953
+ const deadmanPlist = renderDeadmanPlist({
2954
+ deadmanLabel,
2955
+ mainLabel: label,
2956
+ home,
2957
+ logDir,
2958
+ });
2959
+ writeFileSync(deadmanPlistPath, deadmanPlist, { mode: 0o644 });
2960
+ console.log("");
2961
+ console.log(`📝 Wrote ${deadmanPlistPath}`);
2962
+ try {
2963
+ execSync(`launchctl bootout gui/$(id -u)/${deadmanLabel} 2>/dev/null || true`, { stdio: "pipe" });
2964
+ } catch {}
2965
+ try {
2966
+ execSync(`launchctl bootstrap gui/$(id -u) "${deadmanPlistPath}"`, { stdio: "pipe" });
2967
+ console.log("🛡️ Dead-man watcher active — checks every 5 min, force-restarts main bot if heartbeat > 10 min stale.");
2968
+ } catch (err) {
2969
+ console.log(`⚠️ Dead-man watcher load failed (non-fatal): ${err.message?.split("\n")[0] || err}`);
2970
+ console.log(" The main bot still works; only zombie-detection is disabled.");
2971
+ }
2972
+ }
2973
+
2833
2974
  process.exit(0);
2834
2975
  }
2835
2976
 
@@ -2859,6 +3000,20 @@ async function launchdUninstall() {
2859
3000
  console.log(`⚠️ Could not remove plist: ${err.message}`);
2860
3001
  }
2861
3002
 
3003
+ // Dead-Man watcher (feature 2E) — also remove its companion plist.
3004
+ const { deadmanLabel, deadmanPlistPath } = launchdPaths();
3005
+ if (existsSync(deadmanPlistPath)) {
3006
+ try {
3007
+ execSync(`launchctl bootout gui/$(id -u)/${deadmanLabel} 2>/dev/null || true`, { stdio: "pipe" });
3008
+ } catch {}
3009
+ try {
3010
+ execSync(`rm -f "${deadmanPlistPath}"`);
3011
+ console.log(`🗑 Removed ${deadmanPlistPath} (dead-man watcher)`);
3012
+ } catch (err) {
3013
+ console.log(`⚠️ Could not remove dead-man plist: ${err.message}`);
3014
+ }
3015
+ }
3016
+
2862
3017
  console.log("");
2863
3018
  console.log("✅ alvin-bot is no longer a launchd user agent.");
2864
3019
  process.exit(0);
package/dist/index.js CHANGED
@@ -204,6 +204,37 @@ if (hasProvider) {
204
204
  else {
205
205
  console.warn("⚠️ Engine not initialized — no AI provider configured.");
206
206
  }
207
+ // Pre-Flight Sanity Check (Self-Preservation Phase 1, feature 1A) —
208
+ // runs in parallel, fire-and-forget. Does NOT block startup.
209
+ // Catches misconfigurations + degraded state at boot time.
210
+ import("./services/preflight.js")
211
+ .then(({ runPreFlight, formatPreFlightReport }) => runPreFlight(config.botToken, registry).then((report) => {
212
+ console.log(formatPreFlightReport(report));
213
+ }))
214
+ .catch((err) => {
215
+ // Pre-Flight itself must never crash the bot.
216
+ console.warn("⚠️ Pre-Flight check threw:", err?.message || err);
217
+ });
218
+ // AI Self-Diagnosis startup analyzer (Self-Preservation Phase 2, 3I).
219
+ // Scans ~/.alvin-bot/diagnostics/ for forensic bundles without a
220
+ // .analysis.md sidecar and runs AI analysis on each. Findings land on
221
+ // the operator's phone via 1D Telegram channel within ~30 s of the
222
+ // bot recovering from a brake. Fire-and-forget, never blocks startup.
223
+ // Provider-agnostic: uses the active Provider's query() async generator.
224
+ import("./services/self-diagnosis.js")
225
+ .then(({ runStartupAnalyzer }) => runStartupAnalyzer(registry))
226
+ .catch((err) => {
227
+ console.warn("⚠️ Self-diagnosis analyzer threw:", err?.message || err);
228
+ });
229
+ // Predictive-Maintenance Trends collector (Self-Preservation Phase 2, 3J).
230
+ // Snapshots health metrics every 24 h (first one after 60 s warmup).
231
+ // After 7 days of data, also runs AI anomaly detection daily.
232
+ // If a concerning trend is flagged → DM operator via 1D channel.
233
+ import("./services/trends.js")
234
+ .then(({ startTrendsCollector }) => startTrendsCollector(registry))
235
+ .catch((err) => {
236
+ console.warn("⚠️ Trends collector threw:", err?.message || err);
237
+ });
207
238
  // Load plugins
208
239
  const pluginResult = await loadPlugins();
209
240
  if (pluginResult.loaded.length > 0) {
@@ -527,6 +558,14 @@ setNotifyCallback(async (target, text) => {
527
558
  enqueue(target.platform, String(target.chatId), text);
528
559
  });
529
560
  startScheduler();
561
+ // Heartbeat-file writer (Self-Preservation Phase 1, feature 2E).
562
+ // Writes ~/.alvin-bot/heartbeat.txt every 60 s so an external
563
+ // dead-man-watch launchd agent can detect "process alive but frozen"
564
+ // and force-restart the bot. Catches event-loop deadlocks that the
565
+ // in-process watchdog cannot see.
566
+ import("./services/heartbeat-file.js").then(({ startHeartbeatWriter }) => {
567
+ startHeartbeatWriter();
568
+ });
530
569
  // Start the async-agent watcher (Fix #17 Stage 2). Polls outputFiles
531
570
  // of background sub-agents Claude launched with run_in_background and
532
571
  // delivers their completed reports as separate Telegram messages.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Auto-Diagnostic Logs-Collector (Self-Preservation Phase 1, feature 2F).
3
+ *
4
+ * On critical failure, write a structured Markdown "forensic bundle" to
5
+ * ~/.alvin-bot/diagnostics/<timestamp>-<category>.md containing:
6
+ *
7
+ * - Bot version + boot info
8
+ * - Last 200 lines of out.log + err.log
9
+ * - Current process state (PID, RSS, uptime, node version, platform)
10
+ * - Non-secret environment vars (PATH, PRIMARY_PROVIDER, …)
11
+ * - Watchdog state (~/.alvin-bot/state/watchdog.json)
12
+ * - System tool inventory (which node/codex/claude/pm2/yt-dlp/…)
13
+ * - Disk space snapshot
14
+ * - The triggering event itself + suggestion
15
+ *
16
+ * The bundle is the input that the 5.0.0 AI-Diagnostic feature (3I) will
17
+ * later feed to a sub-agent for automated analysis. As of 4.26.0 it's a
18
+ * "human-readable forensic dump" — useful on its own, no AI required.
19
+ *
20
+ * Auto-prune: max 50 retained bundles, oldest deleted on next write.
21
+ *
22
+ * Performance: <100KB per bundle, ~50-200ms wall-clock per write,
23
+ * synchronous (we're typically called right before process.exit so
24
+ * blocking is the right semantic). Files are atomic — full bundle or
25
+ * nothing.
26
+ *
27
+ * Opt-out:
28
+ * ALVIN_DISABLE_AUTO_DIAGNOSTIC=true → skip bundle writes
29
+ * ALVIN_DISABLE_SELF_PRESERVATION=true → skip ALL Phase-1
30
+ */
31
+ import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync, } from "fs";
32
+ import { join } from "path";
33
+ import { homedir } from "os";
34
+ import { execSync } from "child_process";
35
+ import { BOT_VERSION } from "../version.js";
36
+ const MAX_BUNDLES = 50;
37
+ function isDisabled() {
38
+ return (process.env.ALVIN_DISABLE_AUTO_DIAGNOSTIC === "true" ||
39
+ process.env.ALVIN_DISABLE_SELF_PRESERVATION === "true");
40
+ }
41
+ function safeReadTail(filename, n) {
42
+ try {
43
+ const path = join(homedir(), ".alvin-bot", "logs", filename);
44
+ if (!existsSync(path))
45
+ return "(log file not present)";
46
+ const content = readFileSync(path, "utf-8");
47
+ const lines = content.split("\n");
48
+ return lines.slice(Math.max(0, lines.length - n)).join("\n");
49
+ }
50
+ catch (err) {
51
+ return `(read failed: ${err instanceof Error ? err.message : String(err)})`;
52
+ }
53
+ }
54
+ function safeShell(cmd, timeoutMs = 5000) {
55
+ try {
56
+ return execSync(cmd, { encoding: "utf-8", timeout: timeoutMs, stdio: ["ignore", "pipe", "pipe"] }).trim();
57
+ }
58
+ catch (err) {
59
+ const e = err;
60
+ const out = e.stdout?.toString().trim() ?? "";
61
+ const stderr = e.stderr?.toString().trim() ?? "";
62
+ if (out)
63
+ return out + (stderr ? `\n[stderr]: ${stderr}` : "");
64
+ return `(command failed: ${e.message || "unknown"})`;
65
+ }
66
+ }
67
+ function safeReadFile(path) {
68
+ try {
69
+ return readFileSync(path, "utf-8").trim();
70
+ }
71
+ catch (err) {
72
+ return `(could not read ${path}: ${err instanceof Error ? err.message : String(err)})`;
73
+ }
74
+ }
75
+ /**
76
+ * Prune diagnostic bundles older than MAX_BUNDLES (50). Oldest deleted
77
+ * first by mtime. Best-effort: silent on errors.
78
+ */
79
+ export function pruneDiagnostics(maxKeep = MAX_BUNDLES) {
80
+ try {
81
+ const dir = join(homedir(), ".alvin-bot", "diagnostics");
82
+ if (!existsSync(dir))
83
+ return;
84
+ const files = readdirSync(dir)
85
+ .filter((f) => f.endsWith(".md"))
86
+ .map((f) => {
87
+ try {
88
+ return { name: f, mtime: statSync(join(dir, f)).mtimeMs };
89
+ }
90
+ catch {
91
+ return { name: f, mtime: 0 };
92
+ }
93
+ })
94
+ .sort((a, b) => b.mtime - a.mtime);
95
+ for (const f of files.slice(maxKeep)) {
96
+ try {
97
+ unlinkSync(join(dir, f.name));
98
+ }
99
+ catch {
100
+ /* best-effort */
101
+ }
102
+ }
103
+ }
104
+ catch {
105
+ /* never fail the caller */
106
+ }
107
+ }
108
+ /**
109
+ * Write a diagnostic bundle for the given event. Returns the absolute
110
+ * path to the written file, or null if disabled / failed.
111
+ *
112
+ * Safe to call from any context — never throws. Side-effects:
113
+ * - Creates ~/.alvin-bot/diagnostics/ if absent
114
+ * - Writes a single ~50-100KB markdown file
115
+ * - Prunes to MAX_BUNDLES retained
116
+ */
117
+ export function writeDiagnosticBundle(event) {
118
+ if (isDisabled())
119
+ return null;
120
+ try {
121
+ const dir = join(homedir(), ".alvin-bot", "diagnostics");
122
+ mkdirSync(dir, { recursive: true });
123
+ const ts = (event.ts || new Date()).toISOString().replace(/[:.]/g, "-");
124
+ const filename = `${ts}-${event.category}.md`;
125
+ const filepath = join(dir, filename);
126
+ const mem = process.memoryUsage();
127
+ const rssMB = Math.round(mem.rss / 1024 / 1024);
128
+ const heapMB = Math.round(mem.heapUsed / 1024 / 1024);
129
+ const sections = [
130
+ `# Alvin Bot — Diagnostic Bundle`,
131
+ ``,
132
+ `**Generated:** ${new Date().toISOString()}`,
133
+ `**Bot version:** ${BOT_VERSION}`,
134
+ `**Trigger category:** ${event.category}`,
135
+ `**Severity:** ${event.severity}`,
136
+ `**Title:** ${event.title}`,
137
+ ``,
138
+ `## 1. Event Detail`,
139
+ ``,
140
+ "```",
141
+ event.detail,
142
+ "```",
143
+ ``,
144
+ ...(event.suggestedAction
145
+ ? [`### Suggested action`, ``, "```", event.suggestedAction, "```", ``]
146
+ : []),
147
+ `## 2. Process State`,
148
+ ``,
149
+ `- PID: ${process.pid}`,
150
+ `- RSS memory: ${rssMB} MB`,
151
+ `- Heap used: ${heapMB} MB`,
152
+ `- Uptime: ${Math.round(process.uptime())} s`,
153
+ `- Node.js: ${process.version}`,
154
+ `- Platform: ${process.platform} (${process.arch})`,
155
+ `- argv: ${process.argv.join(" ")}`,
156
+ ``,
157
+ `## 3. Environment (non-secret only)`,
158
+ ``,
159
+ ...[
160
+ "NODE_ENV",
161
+ "HOME",
162
+ "PATH",
163
+ "PRIMARY_PROVIDER",
164
+ "FALLBACK_PROVIDERS",
165
+ "AUTH_MODE",
166
+ "SESSION_MODE",
167
+ "WEB_HOST",
168
+ "WEB_PORT",
169
+ "WORKING_DIR",
170
+ "MAX_BUDGET_USD",
171
+ "ALVIN_DATA_DIR",
172
+ "ALVIN_DEADMAN_THRESHOLD_SEC",
173
+ "ALVIN_DISABLE_SELF_PRESERVATION",
174
+ ].map((key) => `- ${key}: ${process.env[key] ?? "(unset)"}`),
175
+ ``,
176
+ `## 4. Recent stderr (last 200 lines)`,
177
+ ``,
178
+ "```",
179
+ safeReadTail("alvin-bot.err.log", 200),
180
+ "```",
181
+ ``,
182
+ `## 5. Recent stdout (last 200 lines)`,
183
+ ``,
184
+ "```",
185
+ safeReadTail("alvin-bot.out.log", 200),
186
+ "```",
187
+ ``,
188
+ `## 6. Watchdog state`,
189
+ ``,
190
+ "```json",
191
+ safeReadFile(join(homedir(), ".alvin-bot", "state", "watchdog.json")),
192
+ "```",
193
+ ``,
194
+ `## 7. System tool inventory`,
195
+ ``,
196
+ "```",
197
+ safeShell("for t in node npm brew pm2 codex claude yt-dlp ffmpeg wacli agent-browser; do printf '%-15s %s\\n' \"$t\" \"$(command -v $t 2>/dev/null || echo NOT_FOUND)\"; done"),
198
+ "```",
199
+ ``,
200
+ `## 8. Disk space (.alvin-bot data dir)`,
201
+ ``,
202
+ "```",
203
+ safeShell(`df -h "${join(homedir(), ".alvin-bot")}" 2>&1 | head -2`),
204
+ "```",
205
+ ``,
206
+ `## 9. PM2 status (if installed)`,
207
+ ``,
208
+ "```",
209
+ safeShell("command -v pm2 >/dev/null && pm2 jlist 2>/dev/null | head -50 || echo 'pm2 not installed'", 3000),
210
+ "```",
211
+ ``,
212
+ `---`,
213
+ ``,
214
+ `*This bundle was generated automatically by the Alvin Bot auto-diagnostic system.*`,
215
+ `*Set \`ALVIN_DISABLE_AUTO_DIAGNOSTIC=true\` in ~/.alvin-bot/.env to opt out.*`,
216
+ ``,
217
+ ];
218
+ writeFileSync(filepath, sections.join("\n"), { mode: 0o600 });
219
+ pruneDiagnostics();
220
+ return filepath;
221
+ }
222
+ catch (err) {
223
+ // Diagnostic writer must not be a new failure mode. Log to stderr
224
+ // (which the critical-notify file flag will reference) and bail.
225
+ console.error(`[auto-diagnostic] failed to write bundle: ${err instanceof Error ? err.message : String(err)}`);
226
+ return null;
227
+ }
228
+ }