alvin-bot 4.6.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 +191 -0
- package/bin/cli.js +314 -27
- package/dist/handlers/commands.js +54 -4
- package/dist/i18n.js +8 -8
- package/dist/index.js +1 -0
- package/dist/services/subagent-delivery.js +155 -0
- package/dist/services/subagent-stats.js +123 -0
- package/dist/services/subagents.js +225 -72
- package/dist/tui/index.js +8 -1
- package/dist/version.js +24 -0
- package/dist/web/server.js +2 -1
- package/docs/HANDBOOK.md +39 -2
- package/package.json +1 -1
- package/test/subagent-delivery.test.ts +104 -0
- package/test/subagent-stats.test.ts +119 -0
- package/test/subagents-config.test.ts +7 -1
- package/test/subagents-priority-reject.test.ts +29 -1
- package/test/subagents-queue.test.ts +127 -0
- package/alvin-bot-4.5.1.tgz +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,197 @@
|
|
|
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
|
+
|
|
48
|
+
## [4.7.0] — 2026-04-11
|
|
49
|
+
|
|
50
|
+
### ✨ Sub-Agents Stufe 2 — live-stream, bounded queue, 24h stats
|
|
51
|
+
|
|
52
|
+
Stufe 2 of the sub-agents refinement spec lands alongside the same-day 4.6.0 release. Everything here builds on the Stufe 1 foundation and is fully unit-tested (85 passing tests).
|
|
53
|
+
|
|
54
|
+
#### A4 Live-Stream for user-spawns
|
|
55
|
+
|
|
56
|
+
`/subagents visibility live` enables a new delivery mode where user-spawned sub-agents stream their text incrementally into a single Telegram message, then post a completion banner as a separate message.
|
|
57
|
+
|
|
58
|
+
Implementation in `src/services/subagent-delivery.ts`:
|
|
59
|
+
|
|
60
|
+
- `LiveStream` class with `start()` / `update()` / `finalize()`
|
|
61
|
+
- `start()` posts an initial `⏳ <name> thinking…` placeholder and records its `message_id`
|
|
62
|
+
- `update()` is called on every text chunk from the agent's generator; it coalesces rapid updates via a throttle window of **800 ms** so we never exceed Telegram's edit rate limit. Multiple `update()` calls within the window collapse into a single edit with the latest accumulated text.
|
|
63
|
+
- `finalize()` flushes any pending text, replaces the `thinking…` header with the final body, then sends a new banner message so the user gets a completion notification (edits don't trigger push notifications).
|
|
64
|
+
- The live-stream message uses **plain text** (no `parse_mode`) so half-formed markdown during streaming can never cause an edit to be rejected. The final banner does use markdown.
|
|
65
|
+
|
|
66
|
+
Wiring in `runSubAgent`:
|
|
67
|
+
|
|
68
|
+
- Detects `effectiveVisibility === "live"` AND `source === "user"` AND `parentChatId`. Cron and implicit spawns are never live-streamed — cron because there's no interactive watcher, implicit because the parent Claude stream already shows everything inline.
|
|
69
|
+
- Creates the `LiveStream` via `createLiveStream()` before the for-await loop.
|
|
70
|
+
- Calls `liveStream.update(chunk.text)` on every text chunk.
|
|
71
|
+
- Calls `liveStream.finalize(info, result)` after the loop and marks `entry.delivered = true` so `spawnSubAgent.finally()` skips the regular `deliverSubAgentResult` path. If finalize fails, the `delivered` flag stays false and the normal banner delivery fires as a fallback.
|
|
72
|
+
- Falls back to `"banner"` mode transparently if the bot API doesn't support `editMessageText` (e.g. during tests or if `attachBotApi` was never called).
|
|
73
|
+
|
|
74
|
+
Tests added in `test/subagent-delivery.test.ts`:
|
|
75
|
+
|
|
76
|
+
- `start` posts an initial placeholder and stores the message_id
|
|
77
|
+
- `update` coalesces rapid calls into a single throttled edit within the 800 ms window
|
|
78
|
+
- `finalize` posts a banner as a new message
|
|
79
|
+
- `createLiveStream` returns `null` when `editMessageText` is missing
|
|
80
|
+
|
|
81
|
+
#### D3 Bounded priority queue
|
|
82
|
+
|
|
83
|
+
Previously, hitting `maxParallel` returned a hard reject. Now spawn requests that don't fit run into a **bounded priority queue**:
|
|
84
|
+
|
|
85
|
+
- Default cap: **20** slots (configurable via `/subagents queue <n>`, clamped to 0–200)
|
|
86
|
+
- Setting cap to 0 disables the queue entirely and restores the old reject-on-full behavior
|
|
87
|
+
- Priority order on drain: **user > cron > implicit**
|
|
88
|
+
- FIFO within each priority class
|
|
89
|
+
- Drains automatically when a running agent finishes — the `runSubAgent.finally()` now calls `drainQueue()` after cleanup
|
|
90
|
+
|
|
91
|
+
New fields:
|
|
92
|
+
|
|
93
|
+
- `SubAgentsConfig.queueCap: number` — persisted in `~/.alvin-bot/sub-agents.json`
|
|
94
|
+
- `SubAgentInfo.status: "queued"` — new valid state
|
|
95
|
+
- `SubAgentInfo.queuePosition?: number` — 1-based position in the queue, shown in `/subagents list` as `#N`
|
|
96
|
+
|
|
97
|
+
Functions in `subagents.ts`:
|
|
98
|
+
|
|
99
|
+
- `getQueueCap()` / `setQueueCap(n)` — public config accessors
|
|
100
|
+
- `drainQueue()` — called from `runSubAgent.finally()`, pops in priority order and transitions entries from `queued` to `running`
|
|
101
|
+
- `popHighestPriorityQueued()` — internal FIFO-per-priority scan
|
|
102
|
+
- `reindexQueue()` — keeps `SubAgentInfo.queuePosition` in sync after pop/cancel
|
|
103
|
+
- `cancelSubAgent()` now handles queued entries by removing them from the queue without starting `runSubAgent` at all
|
|
104
|
+
- `cancelAllSubAgents()` clears the pending queue before cancelling running agents, so shutdown doesn't spawn anything new
|
|
105
|
+
- `spawnSubAgent()` is split: queue decision first (run immediately vs queue vs reject), then `startRun()` helper starts the background loop
|
|
106
|
+
|
|
107
|
+
Reject messages stay priority-aware (D4) but now mention queue saturation:
|
|
108
|
+
|
|
109
|
+
- `user` spawn + pool full + cron/implicit in pool + queue full → *"Alle Slots belegt (N/M), davon X cron/implicit im Hintergrund. Queue voll (Q/C). /subagents list für Details …"*
|
|
110
|
+
- `user` spawn + pool full + user in pool + queue full → *"Alle Slots belegt (N/M) mit eigenen user-Spawns. Queue voll (Q/C). /subagents cancel <name> oder warten."*
|
|
111
|
+
- Non-user spawns + pool + queue full → *"Sub-agent limit reached (N running, Q/C queued). Wait for a running agent to finish or cancel one."*
|
|
112
|
+
|
|
113
|
+
Tests added in `test/subagents-queue.test.ts`:
|
|
114
|
+
|
|
115
|
+
- Default cap is 20
|
|
116
|
+
- Clamping (negative → 0, above 200 → 200, fractional floors)
|
|
117
|
+
- Round-trip through disk
|
|
118
|
+
- Third spawn at full pool lands as `status: "queued"` with `queuePosition: 1`
|
|
119
|
+
- Queue drains automatically when a running agent finishes
|
|
120
|
+
- Priority order: user spawns drain before cron at the same moment
|
|
121
|
+
- `cancelSubAgent` removes a queued entry
|
|
122
|
+
|
|
123
|
+
The existing priority-reject tests now explicitly set `queueCap = 0` to test the old reject path, and a new "queue enabled" test fills both pool and queue before asserting the reject message.
|
|
124
|
+
|
|
125
|
+
#### H3 24-hour run stats
|
|
126
|
+
|
|
127
|
+
New module `src/services/subagent-stats.ts` — a simple append-only JSON ring buffer persisted to `~/.alvin-bot/subagent-stats.json`. Each completed sub-agent run appends one entry:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
{
|
|
131
|
+
completedAt: number;
|
|
132
|
+
name: string;
|
|
133
|
+
source: "user" | "cron" | "implicit";
|
|
134
|
+
status: "completed" | "timeout" | "error" | "cancelled";
|
|
135
|
+
durationMs: number;
|
|
136
|
+
inputTokens: number;
|
|
137
|
+
outputTokens: number;
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
On every load or append, entries older than 24 hours are pruned. A hard cap of 5000 entries protects against unbounded growth on high-frequency bots.
|
|
142
|
+
|
|
143
|
+
Accessors:
|
|
144
|
+
|
|
145
|
+
- `recordSubAgentRun(info, result)` — called from `runSubAgent.finally()` as a non-blocking side effect. Errors are logged but don't affect delivery.
|
|
146
|
+
- `getSubAgentStats()` — returns a `StatsSummary` with totals, per-source breakdown, and per-status counts.
|
|
147
|
+
|
|
148
|
+
New Telegram command **`/subagents stats`** renders the summary:
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
📊 Sub-Agent Stats — last 24h
|
|
152
|
+
|
|
153
|
+
Total: 44 runs · 165k in / 89k out · 12m
|
|
154
|
+
|
|
155
|
+
By source:
|
|
156
|
+
👤 user: 12 runs · 45k in / 22k out
|
|
157
|
+
⏰ cron: 8 runs · 31k in / 15k out
|
|
158
|
+
🔗 implicit: 24 runs · 89k in / 52k out
|
|
159
|
+
|
|
160
|
+
By status:
|
|
161
|
+
✅ completed: 42
|
|
162
|
+
⚠️ cancelled: 1
|
|
163
|
+
⏱️ timeout: 0
|
|
164
|
+
❌ error: 1
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The JSON backing file is a deliberate short-term choice. When the SQLite migration lands (already scoped in a separate memory entry as `project_alvinbot_sqlite_migration.md`), we swap the backend without touching `getSubAgentStats()` or `recordSubAgentRun()` — both are designed as a narrow interface.
|
|
168
|
+
|
|
169
|
+
Tests added in `test/subagent-stats.test.ts`:
|
|
170
|
+
|
|
171
|
+
- Fresh install returns zeros
|
|
172
|
+
- Recording 3 runs updates totals + per-source breakdown
|
|
173
|
+
- Persistence + reload round-trip
|
|
174
|
+
- Entries older than 24h are pruned on load
|
|
175
|
+
- `byStatus` tracks cancelled/error/timeout separately
|
|
176
|
+
|
|
177
|
+
### 🖥 CLI: `alvin-bot start` / `stop` now auto-detect LaunchAgent
|
|
178
|
+
|
|
179
|
+
The `start` and `stop` commands previously always went through pm2. That created a conflict after `alvin-bot launchd install`: the LaunchAgent ran the bot, but `alvin-bot start` would happily spawn a second instance via pm2, and `alvin-bot stop` would try to stop a pm2 process that didn't exist.
|
|
180
|
+
|
|
181
|
+
Now both commands check for `~/Library/LaunchAgents/com.alvinbot.app.plist` on macOS and switch transparently:
|
|
182
|
+
|
|
183
|
+
- **`alvin-bot start`** with a LaunchAgent present → `launchctl kickstart -k gui/$UID/com.alvinbot.app` (or `launchctl load -w` if not loaded yet). No pm2 involvement.
|
|
184
|
+
- **`alvin-bot stop`** with a LaunchAgent present → `launchctl unload -w` (doesn't remove the plist, just stops the daemon).
|
|
185
|
+
- **`alvin-bot start`** on macOS without a LaunchAgent → pm2 path + a helpful tip: *"💡 Tip: on macOS with Claude Code, switch to launchd for automatic Keychain access: alvin-bot launchd install"*.
|
|
186
|
+
|
|
187
|
+
Linux and Windows users are unaffected — they always get the pm2 path.
|
|
188
|
+
|
|
189
|
+
### 🐛 Other
|
|
190
|
+
|
|
191
|
+
- `/subagents queue` is registered in the usage string for en/de/es/fr.
|
|
192
|
+
- `/subagents stats` is registered in the usage string for en/de/es/fr.
|
|
193
|
+
- `/subagents visibility` usage now lists `live` as a valid mode.
|
|
194
|
+
- Removed the leftover `alvin-bot-4.5.1.tgz` from the repo root.
|
|
195
|
+
|
|
5
196
|
## [4.6.0] — 2026-04-11
|
|
6
197
|
|
|
7
198
|
### ✨ Sub-Agents Stufe 1 — context-aware delivery, name-first addressing, shutdown notifications
|
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=${
|
|
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
|
|
|
@@ -1396,40 +1630,93 @@ switch (cmd) {
|
|
|
1396
1630
|
const fg = process.argv.includes("--foreground") || process.argv.includes("-f");
|
|
1397
1631
|
if (fg) {
|
|
1398
1632
|
import("../dist/index.js");
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1633
|
+
break;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// On macOS, if a LaunchAgent plist already exists, we're in "launchd
|
|
1637
|
+
// mode" — don't start pm2 in parallel. Reload the LaunchAgent instead
|
|
1638
|
+
// so a plain `alvin-bot start` still works as "bring the bot up".
|
|
1639
|
+
if (process.platform === "darwin") {
|
|
1640
|
+
const { plistPath, label } = launchdPaths();
|
|
1641
|
+
if (existsSync(plistPath)) {
|
|
1642
|
+
console.log(`🚀 Detected existing LaunchAgent (${label})`);
|
|
1643
|
+
console.log(` Reloading via 'launchctl kickstart -k'...`);
|
|
1406
1644
|
try {
|
|
1407
|
-
execSync(
|
|
1645
|
+
execSync(`launchctl kickstart -k gui/$(id -u)/${label}`, {
|
|
1646
|
+
stdio: "inherit",
|
|
1647
|
+
shell: "/bin/zsh",
|
|
1648
|
+
});
|
|
1408
1649
|
} catch {
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1650
|
+
// Maybe unloaded — load it fresh
|
|
1651
|
+
try {
|
|
1652
|
+
execSync(`launchctl load -w "${plistPath}"`, { stdio: "inherit" });
|
|
1653
|
+
} catch (err) {
|
|
1654
|
+
console.log(`❌ launchctl load failed: ${err.message}`);
|
|
1655
|
+
process.exit(1);
|
|
1656
|
+
}
|
|
1413
1657
|
}
|
|
1658
|
+
console.log("\n✅ Bot is running via launchd.");
|
|
1659
|
+
console.log(" Status: alvin-bot launchd status");
|
|
1660
|
+
console.log(" Stop: alvin-bot stop");
|
|
1661
|
+
console.log(" Logs: ~/.alvin-bot/logs/alvin-bot.out.log");
|
|
1662
|
+
process.exit(0);
|
|
1414
1663
|
}
|
|
1415
|
-
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// Fall-through: pm2 path (Linux, Windows, or macOS without LaunchAgent)
|
|
1667
|
+
try {
|
|
1668
|
+
execSync("pm2 --version", { stdio: "pipe" });
|
|
1669
|
+
} catch {
|
|
1670
|
+
console.log("Installing PM2 for background operation...");
|
|
1416
1671
|
try {
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
}
|
|
1424
|
-
console.log("\n✅ Bot is running in the background.");
|
|
1425
|
-
console.log(" Logs: pm2 logs alvin-bot");
|
|
1426
|
-
console.log(" Stop: alvin-bot stop");
|
|
1427
|
-
console.log(" Restart: alvin-bot start\n");
|
|
1428
|
-
process.exit(0);
|
|
1672
|
+
execSync("npm install -g pm2", { stdio: "inherit", timeout: 60000 });
|
|
1673
|
+
} catch {
|
|
1674
|
+
console.log("Could not install PM2. Starting in foreground instead.");
|
|
1675
|
+
console.log("Tip: Install PM2 manually (npm install -g pm2) to run in background.\n");
|
|
1676
|
+
await import("../dist/index.js");
|
|
1677
|
+
break;
|
|
1678
|
+
}
|
|
1429
1679
|
}
|
|
1430
|
-
|
|
1680
|
+
const cliPath = resolve(join(import.meta.dirname, "cli.js"));
|
|
1681
|
+
try {
|
|
1682
|
+
execSync("pm2 delete alvin-bot", { stdio: "pipe" });
|
|
1683
|
+
} catch { /* not running — fine */ }
|
|
1684
|
+
execSync(`pm2 start "${cliPath}" --name alvin-bot -- start --foreground`, {
|
|
1685
|
+
stdio: "inherit",
|
|
1686
|
+
timeout: 15000,
|
|
1687
|
+
});
|
|
1688
|
+
console.log("\n✅ Bot is running in the background via PM2.");
|
|
1689
|
+
console.log(" Logs: pm2 logs alvin-bot");
|
|
1690
|
+
console.log(" Stop: alvin-bot stop");
|
|
1691
|
+
console.log(" Restart: alvin-bot start");
|
|
1692
|
+
if (process.platform === "darwin") {
|
|
1693
|
+
console.log("");
|
|
1694
|
+
console.log(" 💡 Tip: on macOS with Claude Code, switch to launchd for");
|
|
1695
|
+
console.log(" automatic Keychain access: alvin-bot launchd install");
|
|
1696
|
+
}
|
|
1697
|
+
console.log("");
|
|
1698
|
+
process.exit(0);
|
|
1431
1699
|
}
|
|
1432
1700
|
case "stop": {
|
|
1701
|
+
// On macOS with a LaunchAgent, stopping means unloading the LaunchAgent,
|
|
1702
|
+
// not asking pm2 to stop a process it never managed.
|
|
1703
|
+
if (process.platform === "darwin") {
|
|
1704
|
+
const { plistPath, label } = launchdPaths();
|
|
1705
|
+
if (existsSync(plistPath)) {
|
|
1706
|
+
console.log(`⏹ Stopping LaunchAgent (${label})...`);
|
|
1707
|
+
try {
|
|
1708
|
+
execSync(`launchctl unload -w "${plistPath}"`, { stdio: "inherit" });
|
|
1709
|
+
console.log("✅ LaunchAgent stopped.");
|
|
1710
|
+
console.log(" (The plist is still installed. To remove it: alvin-bot launchd uninstall)");
|
|
1711
|
+
} catch (err) {
|
|
1712
|
+
console.log(`❌ launchctl unload failed: ${err.message}`);
|
|
1713
|
+
process.exit(1);
|
|
1714
|
+
}
|
|
1715
|
+
process.exit(0);
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
// Fall-through: pm2 path
|
|
1433
1720
|
try {
|
|
1434
1721
|
execSync("pm2 stop alvin-bot", { stdio: "inherit", timeout: 10000 });
|
|
1435
1722
|
} catch {
|