alvin-bot 4.7.0 → 4.8.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,49 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [4.8.0] — 2026-04-11
6
+
7
+ ### ✨ Offline mode — Gemma 4 E4B via Ollama in the setup wizard
8
+
9
+ Fresh installs on a machine without any AI-provider key can now pick **Offline mode** as the first option in the setup wizard. It runs **Google Gemma 4 E4B** locally via Ollama — no API key, zero running cost, works 100% offline once downloaded.
10
+
11
+ New in `bin/cli.js`:
12
+
13
+ - `PROVIDERS[0]` is now `offline-gemma4`, labeled prominently with the `~10 GB one-time download` so users can't miss the size.
14
+ - `setupOfflineGemma4()` helper walks the user through:
15
+ 1. **Warning** about download size (15–70 min depending on connection) and on-disk footprint (~10 GB in `~/.ollama/models`)
16
+ 2. **Confirmation prompt** — if the user declines, the wizard loops back to the normal provider picker (no dead ends)
17
+ 3. **Ollama install** via the official `curl -fsSL https://ollama.com/install.sh | sh` if the `ollama` binary is missing
18
+ 4. **Daemon check** — ensures Ollama is listening, spawns it in the background if not
19
+ 5. **Cache check** — if `gemma4:e4b` is already pulled, skips the download
20
+ 6. **Model pull** with a second confirmation before the 10 GB actually starts, streaming progress output so the user sees every layer land
21
+ - `.env` gets `PRIMARY_PROVIDER=ollama`. The registry's Ollama preset in `src/providers/types.ts` already defaults to `gemma4:e4b`, so no extra environment variable is needed.
22
+
23
+ macOS + Linux only. Windows users get pointed at https://ollama.com/download.
24
+
25
+ ### ✨ `/version` command + version display in `/status`
26
+
27
+ - New `/version` command in both **Telegram** and **TUI**. Shows `Alvin Bot vX.Y.Z · Node vN · platform/arch`. Registered in `setMyCommands` so Telegram shows it in the autocomplete menu.
28
+ - `/status` header on Telegram now reads `🤖 Alvin Bot vX.Y.Z` instead of just `Alvin Bot Status`.
29
+ - TUI `/status` header also carries the version.
30
+ - **Bug fix**: `/api/status` used to hard-code `version: "3.0.0"` (a leftover from v3). It now reads `BOT_VERSION` dynamically, so the TUI and Web UI see the actual running version.
31
+
32
+ Implementation: new `src/version.ts` module reads `package.json` once at module load, exports `BOT_VERSION` as a const. Path resolution uses `import.meta.url` so the cwd can't break it.
33
+
34
+ ### 🐛 `alvin-bot launchd install` preserves other pm2 projects
35
+
36
+ The initial 4.7.0 release called `pm2 kill` during `launchd install` to stop the pm2 daemon. That's wrong for users who have **other** pm2-managed projects (e.g. `polyseus`) alongside `alvin-bot` — their other work would go down with the switch.
37
+
38
+ New behavior in `bin/cli.js`:
39
+
40
+ - Parse `pm2 jlist` JSON to detect (a) whether `alvin-bot` is pm2-managed and (b) whether any other pm2 projects exist.
41
+ - Only run `pm2 delete alvin-bot` — never `pm2 kill`. The daemon keeps running for the other projects.
42
+ - Post-install hint is smarter:
43
+ - **pm2 now empty** → *"pm2 now has zero managed processes. Remove it with: `npm uninstall -g pm2`"*
44
+ - **pm2 still has other projects** → *"pm2 still has other projects running — leaving it installed."*
45
+
46
+ Caught immediately after 4.7.0 shipped when Ali pointed out his Mac mini has `polyseus` in pm2 alongside `alvin-bot` and didn't want it touched.
47
+
5
48
  ## [4.7.0] — 2026-04-11
6
49
 
7
50
  ### ✨ Sub-Agents Stufe 2 — live-stream, bounded queue, 24h stats
package/bin/cli.js CHANGED
@@ -54,6 +54,17 @@ const LOGO = `
54
54
  // ── Provider Definitions ────────────────────────────────────────────────────
55
55
 
56
56
  const PROVIDERS = [
57
+ {
58
+ key: "offline-gemma4",
59
+ name: "🔒 Offline — Gemma 4 E4B (no API key, ~10 GB one-time download)",
60
+ desc: () => "Works without internet. Runs Google Gemma 4 E4B locally via Ollama. Big first-time download, zero running cost, works forever offline.",
61
+ free: true,
62
+ envKey: null,
63
+ signup: null,
64
+ model: "gemma4:e4b",
65
+ needsCLI: false,
66
+ offline: true,
67
+ },
57
68
  {
58
69
  key: "groq",
59
70
  name: "Groq (Llama 3.3 70B)",
@@ -117,6 +128,165 @@ const PROVIDERS = [
117
128
  },
118
129
  ];
119
130
 
131
+ // ── Offline mode: Ollama + Gemma 4 E4B ─────────────────────────────────────
132
+
133
+ /**
134
+ * Check whether the `ollama` binary is present on PATH.
135
+ */
136
+ function hasOllama() {
137
+ try {
138
+ execSync("ollama --version", { stdio: "pipe" });
139
+ return true;
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Install Ollama via the official installer. Prints progress to stdout.
147
+ * Returns true on success, false on failure.
148
+ */
149
+ function installOllama() {
150
+ console.log("\n📥 Installing Ollama (official installer)...");
151
+ try {
152
+ if (process.platform === "darwin" || process.platform === "linux") {
153
+ execSync("curl -fsSL https://ollama.com/install.sh | sh", {
154
+ stdio: "inherit",
155
+ timeout: 300_000, // 5 minutes
156
+ });
157
+ return hasOllama();
158
+ } else {
159
+ console.log(" ❌ Offline mode only supported on macOS and Linux.");
160
+ console.log(" Windows users: download from https://ollama.com/download");
161
+ return false;
162
+ }
163
+ } catch (err) {
164
+ console.log(`\n ❌ Ollama install failed: ${err.message || err}`);
165
+ console.log(" Try manually: curl -fsSL https://ollama.com/install.sh | sh");
166
+ return false;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Ensure the Ollama daemon is running. Spawns it in the background if not.
172
+ */
173
+ function ensureOllamaServe() {
174
+ try {
175
+ // 'ollama list' needs the daemon running
176
+ execSync("ollama list", { stdio: "pipe", timeout: 5000 });
177
+ return true;
178
+ } catch {
179
+ // Daemon not running — spawn it
180
+ try {
181
+ execSync("nohup ollama serve > /tmp/ollama-setup.log 2>&1 &", {
182
+ stdio: "pipe",
183
+ shell: "/bin/sh",
184
+ });
185
+ // Give it a moment
186
+ execSync("sleep 2", { stdio: "pipe" });
187
+ execSync("ollama list", { stdio: "pipe", timeout: 5000 });
188
+ return true;
189
+ } catch {
190
+ return false;
191
+ }
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Check whether gemma4:e4b is already pulled into Ollama's model cache.
197
+ */
198
+ function hasGemma4E4b() {
199
+ try {
200
+ const out = execSync("ollama list", { encoding: "utf-8", timeout: 5000 });
201
+ return /gemma4[:\s].*e4b/i.test(out);
202
+ } catch {
203
+ return false;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Pull gemma4:e4b from the Ollama registry. Streams progress to stdout.
209
+ * Returns true on success, false on failure.
210
+ */
211
+ function pullGemma4E4b() {
212
+ console.log("\n📥 Downloading gemma4:e4b (~10 GB — this can take 10-30 min)...\n");
213
+ try {
214
+ execSync("ollama pull gemma4:e4b", {
215
+ stdio: "inherit",
216
+ timeout: 45 * 60_000, // 45 minutes
217
+ });
218
+ return hasGemma4E4b();
219
+ } catch (err) {
220
+ console.log(`\n ❌ Pull failed: ${err.message || err}`);
221
+ return false;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Full offline-mode setup flow: warn about download size, confirm, install
227
+ * Ollama if missing, pull the model, verify. Returns true on success,
228
+ * false if the user bailed or something broke (caller falls back to
229
+ * interactive provider selection).
230
+ */
231
+ async function setupOfflineGemma4() {
232
+ console.log("\n ⚠️ Offline mode uses Google Gemma 4 E4B via Ollama.");
233
+ console.log(" • One-time download: ~10 GB");
234
+ console.log(" • On a 100 Mbps connection: ~15 minutes");
235
+ console.log(" • On a 20 Mbps connection: ~70 minutes");
236
+ console.log(" • Disk usage: ~10 GB in ~/.ollama/models");
237
+ console.log(" • Runs on CPU + GPU via Metal (macOS) / CUDA (Linux)");
238
+ console.log(" • Works 100% offline once downloaded\n");
239
+
240
+ const yesChars = getLocale() === "de" ? ["j", "ja", "y", "yes"] : ["y", "yes"];
241
+ const proceed = (await ask(" Continue with offline mode? (y/N): ")).trim().toLowerCase();
242
+ if (!yesChars.includes(proceed)) {
243
+ console.log("\n ℹ️ Offline mode declined — returning to provider selection.\n");
244
+ return false;
245
+ }
246
+
247
+ // Step 1: Ollama binary
248
+ if (!hasOllama()) {
249
+ console.log("\n ℹ️ Ollama not installed.");
250
+ const installProceed = (await ask(" Install Ollama now? (y/N): ")).trim().toLowerCase();
251
+ if (!yesChars.includes(installProceed)) {
252
+ console.log("\n ℹ️ Offline mode cancelled — Ollama is required.\n");
253
+ return false;
254
+ }
255
+ if (!installOllama()) return false;
256
+ console.log(" ✅ Ollama installed");
257
+ } else {
258
+ console.log("\n ✅ Ollama already installed");
259
+ }
260
+
261
+ // Step 2: Ensure daemon is running
262
+ if (!ensureOllamaServe()) {
263
+ console.log("\n ⚠️ Could not start Ollama daemon. Try manually:");
264
+ console.log(" ollama serve");
265
+ console.log(" (in a separate terminal, then re-run alvin-bot setup)\n");
266
+ return false;
267
+ }
268
+ console.log(" ✅ Ollama daemon responding");
269
+
270
+ // Step 3: Model already present?
271
+ if (hasGemma4E4b()) {
272
+ console.log(" ✅ gemma4:e4b already downloaded — skipping pull");
273
+ return true;
274
+ }
275
+
276
+ // Step 4: Pull the model (big download)
277
+ console.log("\n 📦 gemma4:e4b not in cache yet.");
278
+ const pullProceed = (await ask(" Start 10 GB download now? (y/N): ")).trim().toLowerCase();
279
+ if (!yesChars.includes(pullProceed)) {
280
+ console.log("\n ℹ️ Pull cancelled. You can run this later:");
281
+ console.log(" ollama pull gemma4:e4b\n");
282
+ return false;
283
+ }
284
+
285
+ if (!pullGemma4E4b()) return false;
286
+ console.log("\n ✅ gemma4:e4b downloaded and ready\n");
287
+ return true;
288
+ }
289
+
120
290
  // ── Provider Validation ────────────────────────────────────────────────────
121
291
 
122
292
  /**
@@ -594,6 +764,32 @@ async function setup() {
594
764
 
595
765
  console.log(`\n✅ ${t("setup.providerSelected")} ${provider.name}`);
596
766
 
767
+ // ── Offline mode: Gemma 4 E4B via Ollama ────────────────────────
768
+ // Handled specially because it needs a 10 GB model download, not an
769
+ // API key. If the user bails out anywhere in the flow, we loop back
770
+ // to the normal provider picker so setup isn't a dead-end.
771
+ if (provider.offline) {
772
+ const ok = await setupOfflineGemma4();
773
+ if (!ok) {
774
+ // User declined or something failed — pick a different provider
775
+ console.log(`\n Choose a different provider:\n`);
776
+ for (let i = 0; i < PROVIDERS.length; i++) {
777
+ if (PROVIDERS[i].offline) continue;
778
+ const p = PROVIDERS[i];
779
+ const badge = p.free ? "🆓" : "💰";
780
+ const premium = p.needsCLI ? " ⭐" : "";
781
+ console.log(` ${i + 1}. ${badge} ${p.name}${premium}`);
782
+ }
783
+ console.log("");
784
+ const fallbackChoice = parseInt((await ask(t("setup.yourChoice"))).trim()) || 2;
785
+ provider = PROVIDERS[Math.max(1, Math.min(fallbackChoice - 1, PROVIDERS.length - 1))];
786
+ console.log(`\n✅ ${t("setup.providerSelected")} ${provider.name}`);
787
+ }
788
+ // Note: if setupOfflineGemma4 succeeded, we skip further API-key
789
+ // validation below — offline mode doesn't need a key. The .env
790
+ // write step reads provider.offline and sets PRIMARY_PROVIDER=ollama.
791
+ }
792
+
597
793
  // ── Validate Provider ────────────────────────────────────────────
598
794
 
599
795
  // Claude SDK: show requirements upfront
@@ -803,13 +999,17 @@ async function setup() {
803
999
  // ── Write .env
804
1000
  console.log(`\n${t("setup.writingConfig")}`);
805
1001
 
1002
+ // Offline mode translates to PRIMARY_PROVIDER=ollama — the registry's
1003
+ // ollama preset already points at gemma4:e4b, so no extra env needed.
1004
+ const primaryKey = provider.offline ? "ollama" : provider.key;
1005
+
806
1006
  const envLines = [
807
1007
  "# === Telegram ===",
808
1008
  `BOT_TOKEN=${botToken || ""}`,
809
1009
  `ALLOWED_USERS=${userId || ""}`,
810
1010
  "",
811
1011
  "# === AI Provider ===",
812
- `PRIMARY_PROVIDER=${provider.key}`,
1012
+ `PRIMARY_PROVIDER=${primaryKey}`,
813
1013
  ];
814
1014
 
815
1015
  if (provider.envKey && providerApiKey) {
@@ -1261,6 +1461,32 @@ async function launchdInstall() {
1261
1461
  execSync(`launchctl unload -w "${plistPath}"`, { stdio: "pipe" });
1262
1462
  } catch { /* not loaded yet — fine */ }
1263
1463
 
1464
+ // If pm2 is managing an alvin-bot process, tear that one process down.
1465
+ // We deliberately do NOT `pm2 kill` the whole daemon — the user may
1466
+ // have other pm2-managed projects (polyseus, etc.) and we must not
1467
+ // nuke those. Only the alvin-bot entry is removed.
1468
+ let pm2HadAlvinBot = false;
1469
+ let pm2StillHasOtherProcesses = false;
1470
+ try {
1471
+ execSync("pm2 --version", { stdio: "pipe" });
1472
+ // Check whether alvin-bot is currently pm2-managed
1473
+ try {
1474
+ const lsOut = execSync("pm2 jlist", { stdio: ["pipe", "pipe", "pipe"], encoding: "utf-8" });
1475
+ const procs = JSON.parse(lsOut);
1476
+ if (Array.isArray(procs)) {
1477
+ pm2HadAlvinBot = procs.some((p) => p && p.name === "alvin-bot");
1478
+ pm2StillHasOtherProcesses = procs.some((p) => p && p.name !== "alvin-bot");
1479
+ }
1480
+ } catch { /* pm2 jlist can fail on empty list or missing daemon — ignore */ }
1481
+
1482
+ if (pm2HadAlvinBot) {
1483
+ try {
1484
+ execSync("pm2 delete alvin-bot", { stdio: "pipe" });
1485
+ console.log("🧹 Removed alvin-bot from pm2 (other pm2 projects left intact).");
1486
+ } catch { /* already gone */ }
1487
+ }
1488
+ } catch { /* pm2 not installed — nothing to clean up */ }
1489
+
1264
1490
  // Stop any nohup'd bot that might still be running
1265
1491
  try {
1266
1492
  execSync(`pkill -TERM -f 'node.*dist/index.js' || true`, { stdio: "pipe" });
@@ -1289,6 +1515,14 @@ async function launchdInstall() {
1289
1515
  console.log(" the macOS Keychain is automatically unlocked — Claude Code");
1290
1516
  console.log(" OAuth tokens (Max subscription) just work, no SSH keychain");
1291
1517
  console.log(" dance needed anymore.");
1518
+ if (pm2HadAlvinBot && !pm2StillHasOtherProcesses) {
1519
+ console.log("");
1520
+ console.log("💡 pm2 now has zero managed processes. You can remove it entirely:");
1521
+ console.log(" npm uninstall -g pm2");
1522
+ } else if (pm2HadAlvinBot && pm2StillHasOtherProcesses) {
1523
+ console.log("");
1524
+ console.log("💡 pm2 still has other projects running — leaving it installed.");
1525
+ }
1292
1526
  process.exit(0);
1293
1527
  }
1294
1528
 
@@ -18,6 +18,7 @@ import { screenshotUrl, extractText, generatePdf, hasPlaywright } from "../servi
18
18
  import { listJobs, createJob, deleteJob, toggleJob, runJobNow, formatNextRun, humanReadableSchedule } from "../services/cron.js";
19
19
  import { storePassword, revokePassword, getSudoStatus, verifyPassword } from "../services/sudo.js";
20
20
  import { config } from "../config.js";
21
+ import { BOT_VERSION } from "../version.js";
21
22
  import { getWebPort } from "../web/server.js";
22
23
  import { getUsageSummary, getAllRateLimits, formatTokens } from "../services/usage-tracker.js";
23
24
  import { runUpdate, getAutoUpdate, setAutoUpdate, startAutoUpdateLoop } from "../services/updater.js";
@@ -141,6 +142,7 @@ export function registerCommands(bot) {
141
142
  { command: "effort", description: "Set reasoning depth" },
142
143
  { command: "voice", description: "Voice replies on/off" },
143
144
  { command: "status", description: "Current status" },
145
+ { command: "version", description: "Show Alvin Bot version" },
144
146
  { command: "new", description: "Start new session" },
145
147
  { command: "dir", description: "Change working directory" },
146
148
  { command: "web", description: "Quick web search" },
@@ -219,6 +221,10 @@ export function registerCommands(bot) {
219
221
  await ctx.reply(`Directory not found: ${resolved}`);
220
222
  }
221
223
  });
224
+ bot.command("version", async (ctx) => {
225
+ await ctx.reply(`🤖 *Alvin Bot* \`v${BOT_VERSION}\`\n` +
226
+ `Node ${process.version} · ${process.platform}/${process.arch}`, { parse_mode: "Markdown" });
227
+ });
222
228
  bot.command("status", async (ctx) => {
223
229
  const userId = ctx.from.id;
224
230
  const session = getSession(userId);
@@ -371,7 +377,7 @@ export function registerCommands(bot) {
371
377
  const failoverBadge = failedOver ? ` ${t("bot.status.failedOver", lang)}` : "";
372
378
  healthLines = `\n${t("bot.status.providerHealth", lang)}${failoverBadge}\n${rows.join("\n")}\n`;
373
379
  }
374
- await ctx.reply(`🤖 *Alvin Bot Status*\n\n` +
380
+ await ctx.reply(`🤖 *Alvin Bot* \`v${BOT_VERSION}\`\n\n` +
375
381
  `*Model:* ${info.name} ${providerTag}\n` +
376
382
  `*Effort:* ${EFFORT_LABELS[session.effort]}\n` +
377
383
  `*Voice:* ${session.voiceReply ? "on" : "off"}\n` +
package/dist/tui/index.js CHANGED
@@ -20,6 +20,7 @@ import { createInterface, cursorTo, clearLine as rlClearLine } from "readline";
20
20
  import WebSocket from "ws";
21
21
  import http from "http";
22
22
  import { initI18n, t } from "../i18n.js";
23
+ import { BOT_VERSION } from "../version.js";
23
24
  // Init i18n before anything else
24
25
  initI18n();
25
26
  // ── ANSI Colors & Styles ────────────────────────────────
@@ -410,11 +411,17 @@ async function handleCommand(cmd) {
410
411
  }
411
412
  break;
412
413
  }
414
+ case "version":
415
+ case "v": {
416
+ console.log(`\n${C.bold}${C.brightCyan}🤖 Alvin Bot${C.reset} ${C.dim}v${BOT_VERSION}${C.reset}`);
417
+ console.log(`${C.dim}Node ${process.version} · ${process.platform}/${process.arch}${C.reset}\n`);
418
+ break;
419
+ }
413
420
  case "status":
414
421
  case "s": {
415
422
  try {
416
423
  const data = await apiGet("/api/status");
417
- console.log(`\n${C.bold}${C.brightCyan}${t("status.title")}${C.reset}`);
424
+ console.log(`\n${C.bold}${C.brightCyan}🤖 Alvin Bot${C.reset} ${C.dim}v${BOT_VERSION}${C.reset}`);
418
425
  console.log(`${C.gray}${"─".repeat(40)}${C.reset}`);
419
426
  if (data.model) {
420
427
  console.log(` ${C.cyan}${t("status.model")}${C.reset} ${data.model.model || data.model.name || "?"}`);
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Central source of truth for the running Alvin Bot version.
3
+ * Read from package.json once at module load — subsequent imports
4
+ * return the cached string without touching disk.
5
+ */
6
+ import { readFileSync } from "fs";
7
+ import { dirname, resolve } from "path";
8
+ import { fileURLToPath } from "url";
9
+ function readVersion() {
10
+ try {
11
+ // dist/version.js is two levels deep; package.json sits at the root
12
+ const here = dirname(fileURLToPath(import.meta.url));
13
+ const pkgPath = resolve(here, "..", "package.json");
14
+ const raw = readFileSync(pkgPath, "utf-8");
15
+ const parsed = JSON.parse(raw);
16
+ if (typeof parsed.version === "string")
17
+ return parsed.version;
18
+ }
19
+ catch {
20
+ /* fall through to unknown */
21
+ }
22
+ return "unknown";
23
+ }
24
+ export const BOT_VERSION = readVersion();
@@ -29,6 +29,7 @@ import { handleOpenAICompat } from "./openai-compat.js";
29
29
  import { addCanvasClient } from "./canvas.js";
30
30
  import { BOT_ROOT, ENV_FILE, PUBLIC_DIR, MEMORY_DIR, MEMORY_FILE, SOUL_FILE, DATA_DIR, MCP_CONFIG, SKILLS_DIR } from "../paths.js";
31
31
  import { broadcast } from "../services/broadcast.js";
32
+ import { BOT_VERSION } from "../version.js";
32
33
  const WEB_PORT = parseInt(process.env.WEB_PORT || "3100");
33
34
  const WEB_PASSWORD = process.env.WEB_PASSWORD || "";
34
35
  /** The actual port the Web UI is running on (may differ from WEB_PORT if busy). */
@@ -259,7 +260,7 @@ async function handleAPI(req, res, urlPath, body) {
259
260
  }
260
261
  const { config: appConfig } = await import("../config.js");
261
262
  res.end(JSON.stringify({
262
- bot: { version: "3.0.0", uptime: process.uptime() },
263
+ bot: { version: BOT_VERSION, uptime: process.uptime() },
263
264
  model: modelInfo,
264
265
  memory: { ...memory, vectors: index.entries, indexSize: index.sizeBytes },
265
266
  plugins: plugins.length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "4.7.0",
3
+ "version": "4.8.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",