alvin-bot 4.5.0 → 4.6.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +25 -2
  3. package/alvin-bot-4.5.1.tgz +0 -0
  4. package/bin/cli.js +246 -0
  5. package/dist/handlers/commands.js +461 -63
  6. package/dist/handlers/message.js +209 -14
  7. package/dist/i18n.js +470 -13
  8. package/dist/index.js +44 -5
  9. package/dist/providers/claude-sdk-provider.js +106 -14
  10. package/dist/providers/ollama-provider.js +32 -0
  11. package/dist/providers/openai-compatible.js +10 -1
  12. package/dist/providers/registry.js +112 -17
  13. package/dist/providers/types.js +25 -3
  14. package/dist/services/compaction.js +2 -0
  15. package/dist/services/cron.js +53 -42
  16. package/dist/services/heartbeat.js +41 -7
  17. package/dist/services/language-detect.js +12 -2
  18. package/dist/services/ollama-manager.js +339 -0
  19. package/dist/services/personality.js +20 -14
  20. package/dist/services/session.js +21 -3
  21. package/dist/services/subagent-delivery.js +111 -0
  22. package/dist/services/subagents.js +341 -27
  23. package/dist/services/telegram.js +28 -1
  24. package/dist/services/updater.js +158 -0
  25. package/dist/services/usage-tracker.js +11 -4
  26. package/dist/services/users.js +2 -1
  27. package/dist/tui/index.js +36 -30
  28. package/docs/HANDBOOK.md +819 -0
  29. package/package.json +7 -2
  30. package/test/claude-sdk-provider.test.ts +69 -0
  31. package/test/i18n.test.ts +108 -0
  32. package/test/registry.test.ts +201 -0
  33. package/test/subagent-delivery.test.ts +169 -0
  34. package/test/subagents-commands.test.ts +64 -0
  35. package/test/subagents-config.test.ts +108 -0
  36. package/test/subagents-depth.test.ts +58 -0
  37. package/test/subagents-inheritance.test.ts +67 -0
  38. package/test/subagents-name-resolver.test.ts +122 -0
  39. package/test/subagents-priority-reject.test.ts +60 -0
  40. package/test/subagents-shutdown.test.ts +126 -0
  41. package/test/subagents-toolset.test.ts +51 -0
  42. package/vitest.config.ts +17 -0
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Updater Service — git-based self-update for alvin-bot.
3
+ *
4
+ * Provides:
5
+ * - runUpdate(): manual update (git pull + install + build)
6
+ * - getAutoUpdate() / setAutoUpdate(): persistent on/off toggle
7
+ * - startAutoUpdateLoop(): periodic check every 6h if enabled
8
+ *
9
+ * After a successful update that produces new artifacts, the bot calls
10
+ * process.exit(0) and PM2 auto-restarts it with fresh code. This is the
11
+ * only safe self-restart path — we never re-exec the Node process directly.
12
+ *
13
+ * The auto-update flag is persisted to ~/.alvin-bot/auto-update.flag
14
+ * (a plain text file containing "on" or "off"), so it survives restarts.
15
+ */
16
+ import { exec } from "child_process";
17
+ import { promisify } from "util";
18
+ import { resolve, dirname } from "path";
19
+ import { fileURLToPath } from "url";
20
+ import fs from "fs";
21
+ import os from "os";
22
+ const execAsync = promisify(exec);
23
+ const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
24
+ const DATA_DIR = process.env.ALVIN_DATA_DIR || resolve(os.homedir(), ".alvin-bot");
25
+ const FLAG_FILE = resolve(DATA_DIR, "auto-update.flag");
26
+ const AUTO_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
27
+ let autoTimer = null;
28
+ async function isGitRepo() {
29
+ try {
30
+ const { stdout } = await execAsync("git rev-parse --is-inside-work-tree", {
31
+ cwd: PROJECT_ROOT,
32
+ timeout: 5_000,
33
+ });
34
+ return stdout.trim() === "true";
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ /** Pull latest changes, install deps, rebuild. Returns a structured result
41
+ * instead of throwing so the /update command can report cleanly to Telegram. */
42
+ export async function runUpdate() {
43
+ try {
44
+ const isRepo = await isGitRepo();
45
+ if (!isRepo) {
46
+ return {
47
+ ok: false,
48
+ message: "Not in a git repo — update only supported for source installs.",
49
+ requiresRestart: false,
50
+ };
51
+ }
52
+ // Fetch latest without merging
53
+ await execAsync("git fetch --quiet", {
54
+ cwd: PROJECT_ROOT,
55
+ timeout: 30_000,
56
+ });
57
+ // Count commits we're behind the upstream
58
+ let behindCount = 0;
59
+ try {
60
+ const { stdout } = await execAsync("git rev-list --count HEAD..@{upstream}", {
61
+ cwd: PROJECT_ROOT,
62
+ timeout: 10_000,
63
+ });
64
+ behindCount = parseInt(stdout.trim() || "0", 10);
65
+ }
66
+ catch {
67
+ // No upstream configured — treat as up-to-date
68
+ behindCount = 0;
69
+ }
70
+ if (behindCount === 0) {
71
+ return {
72
+ ok: true,
73
+ message: "Already up to date — no new commits.",
74
+ requiresRestart: false,
75
+ };
76
+ }
77
+ // Fast-forward pull (refuses to merge if history diverged)
78
+ await execAsync("git pull --ff-only", {
79
+ cwd: PROJECT_ROOT,
80
+ timeout: 60_000,
81
+ });
82
+ // Install (frozen lockfile to match upstream exactly)
83
+ await execAsync("pnpm install --frozen-lockfile", {
84
+ cwd: PROJECT_ROOT,
85
+ timeout: 180_000,
86
+ });
87
+ // Build
88
+ await execAsync("pnpm run build", {
89
+ cwd: PROJECT_ROOT,
90
+ timeout: 180_000,
91
+ });
92
+ return {
93
+ ok: true,
94
+ message: `Installed ${behindCount} commit(s), build successful.`,
95
+ requiresRestart: true,
96
+ };
97
+ }
98
+ catch (err) {
99
+ const raw = err instanceof Error ? err.message : String(err);
100
+ // Keep error messages short — Telegram has a 4096 char limit and the
101
+ // user doesn't need a 50-line stack trace.
102
+ const message = raw.length > 300 ? raw.slice(0, 300) + "…" : raw;
103
+ return { ok: false, message, requiresRestart: false };
104
+ }
105
+ }
106
+ export function getAutoUpdate() {
107
+ try {
108
+ if (!fs.existsSync(FLAG_FILE))
109
+ return false;
110
+ return fs.readFileSync(FLAG_FILE, "utf-8").trim() === "on";
111
+ }
112
+ catch {
113
+ return false;
114
+ }
115
+ }
116
+ export function setAutoUpdate(enabled) {
117
+ try {
118
+ fs.mkdirSync(dirname(FLAG_FILE), { recursive: true });
119
+ fs.writeFileSync(FLAG_FILE, enabled ? "on" : "off", "utf-8");
120
+ if (enabled) {
121
+ startAutoUpdateLoop();
122
+ }
123
+ else {
124
+ stopAutoUpdateLoop();
125
+ }
126
+ }
127
+ catch (err) {
128
+ console.error("[auto-update] setAutoUpdate failed:", err);
129
+ }
130
+ }
131
+ export function startAutoUpdateLoop() {
132
+ if (autoTimer)
133
+ return;
134
+ if (!getAutoUpdate())
135
+ return;
136
+ autoTimer = setInterval(async () => {
137
+ const result = await runUpdate();
138
+ if (result.ok && result.requiresRestart) {
139
+ console.log(`[auto-update] ${result.message} — exiting for PM2 restart`);
140
+ // Small delay so any in-flight log write completes
141
+ setTimeout(() => process.exit(0), 1_000);
142
+ }
143
+ else if (result.ok) {
144
+ // up-to-date, no-op
145
+ }
146
+ else {
147
+ console.log(`[auto-update] check failed: ${result.message}`);
148
+ }
149
+ }, AUTO_CHECK_INTERVAL_MS);
150
+ console.log(`[auto-update] loop started (interval: 6h)`);
151
+ }
152
+ export function stopAutoUpdateLoop() {
153
+ if (autoTimer) {
154
+ clearInterval(autoTimer);
155
+ autoTimer = null;
156
+ console.log(`[auto-update] loop stopped`);
157
+ }
158
+ }
@@ -92,14 +92,21 @@ export function getUsageSummary() {
92
92
  }
93
93
  }
94
94
  const totalDays = Object.keys(data.daily).length;
95
- const allTokens = Object.values(data.daily).reduce((s, d) => s + d.inputTokens + d.outputTokens, 0);
96
- const allCost = Object.values(data.daily).reduce((s, d) => s + d.costUsd, 0);
95
+ // Average is computed over the same 7-day window as `week` so it
96
+ // stays internally consistent: a user reading "Week: 250M" directly
97
+ // above "Avg: 50M/day" would otherwise rightly assume 50×7=350 and
98
+ // conclude the bot was lying. Previously the avg was total-ever/days-ever
99
+ // which diverged from week/7 as soon as usage was uneven or the bot
100
+ // was only a few days old.
101
+ const weekTokens = weekStats.inputTokens + weekStats.outputTokens;
102
+ const avgTokensPerDay = Math.round(weekTokens / 7);
103
+ const avgCostPerDay = weekStats.costUsd / 7;
97
104
  return {
98
105
  today: todayStats,
99
106
  week: weekStats,
100
107
  daysTracked: totalDays,
101
- avgDailyTokens: totalDays > 0 ? Math.round(allTokens / totalDays) : 0,
102
- avgDailyCost: totalDays > 0 ? allCost / totalDays : 0,
108
+ avgDailyTokens: avgTokensPerDay,
109
+ avgDailyCost: avgCostPerDay,
103
110
  };
104
111
  }
105
112
  /** Store rate limit info from provider response headers. */
@@ -12,6 +12,7 @@
12
12
  import fs from "fs";
13
13
  import { resolve } from "path";
14
14
  import { config } from "../config.js";
15
+ import { LOCALE_NAMES } from "../i18n.js";
15
16
  import { killSession } from "./session.js";
16
17
  import { USERS_DIR, MEMORY_DIR } from "../paths.js";
17
18
  // Ensure users dir exists
@@ -190,7 +191,7 @@ export function buildUserContext(userId) {
190
191
  return "";
191
192
  const parts = [];
192
193
  parts.push(`User: ${profile.name}${profile.username ? ` (@${profile.username})` : ""}`);
193
- parts.push(`Language: ${profile.language === "de" ? "Deutsch" : "English"}`);
194
+ parts.push(`Language: ${LOCALE_NAMES[profile.language] || profile.language}`);
194
195
  parts.push(`Messages: ${profile.totalMessages}`);
195
196
  if (profile.notes) {
196
197
  parts.push(`\nNotes about this user:\n${profile.notes}`);
package/dist/tui/index.js CHANGED
@@ -105,27 +105,25 @@ function drawHeader() {
105
105
  console.log(`${C.gray}${"─".repeat(w)}${C.reset}`);
106
106
  }
107
107
  /**
108
- * Redraw the header in place. Only safe to call when NOT streaming —
109
- * the previous implementation used aggressive cursor save/restore escape
110
- * sequences that collided with readline's internal cursor state and
111
- * produced garbled output. Now this is a no-op during streaming and
112
- * a clean redraw otherwise.
108
+ * Redraw the header. The old "in-place" implementation used cursor save/
109
+ * restore escape sequences and jumped to \x1b[H — but once the terminal
110
+ * has scrolled past the original header, \x1b[H resolves to the current
111
+ * viewport top (not the document top), which means the header gets
112
+ * re-rendered inline in the middle of the content. That's what produced
113
+ * the "header appears in the middle of the bot response" bug in 4.5.0.
114
+ *
115
+ * The only safe way to redraw the header in a scrolling terminal is to
116
+ * clear the whole screen and redraw from scratch. Do that only in
117
+ * explicit reset contexts (/clear, SIGWINCH resize, initial connect).
118
+ * For mid-session cost/status updates, use inline info messages instead.
113
119
  */
114
- function redrawHeader() {
120
+ function redrawHeader(opts = {}) {
115
121
  if (isStreaming)
116
- return; // Don't touch the cursor mid-stream
117
- clearCurrentLine();
118
- process.stdout.write("\x1b[s"); // Save cursor
119
- process.stdout.write("\x1b[H"); // Move to top-left
120
- for (let i = 0; i < HEADER_LINES; i++) {
121
- cursorTo(process.stdout, 0);
122
- rlClearLine(process.stdout, 0);
123
- if (i < HEADER_LINES - 1)
124
- process.stdout.write("\x1b[1B");
122
+ return;
123
+ if (opts.clearScreen) {
124
+ console.clear();
125
125
  }
126
- process.stdout.write("\x1b[H");
127
126
  drawHeader();
128
- process.stdout.write("\x1b[u"); // Restore cursor
129
127
  if (rl && !isStreaming)
130
128
  rl.prompt(true);
131
129
  }
@@ -222,7 +220,8 @@ function connectWebSocket() {
222
220
  ws = new WebSocket(wsUrl);
223
221
  ws.on("open", () => {
224
222
  connected = true;
225
- redrawHeader();
223
+ // No header redraw here — the header was already drawn at startTUI().
224
+ // Calling redrawHeader() in a scrolled terminal re-renders it inline.
226
225
  printInfo(t("tui.connectedTo"));
227
226
  showPrompt();
228
227
  });
@@ -236,7 +235,7 @@ function connectWebSocket() {
236
235
  ws.on("close", () => {
237
236
  connected = false;
238
237
  isStreaming = false;
239
- redrawHeader();
238
+ // No header redraw — it would appear inline mid-chat.
240
239
  printError(t("tui.connectionLost"));
241
240
  setTimeout(connectWebSocket, 3000);
242
241
  });
@@ -279,7 +278,10 @@ function handleMessage(msg) {
279
278
  isStreaming = false;
280
279
  currentResponse = "";
281
280
  currentToolName = "";
282
- redrawHeader(); // Update cost in header (only if not streaming see redrawHeader)
281
+ // NOTE: do NOT call redrawHeader() here. On a scrolled terminal it
282
+ // renders the header inline at the viewport top, which looks like
283
+ // the header appeared in the middle of the conversation. The total
284
+ // cost is already shown inline at the end of each response.
283
285
  showPrompt();
284
286
  break;
285
287
  case "error":
@@ -395,7 +397,8 @@ async function handleCommand(cmd) {
395
397
  if (res.ok) {
396
398
  currentModel = res.active || parts[1];
397
399
  printSuccess(`${t("tui.switchedTo")}: ${currentModel}`);
398
- redrawHeader();
400
+ // Header stays as-is (would appear inline otherwise)
401
+ // next /clear redraws it with the new model.
399
402
  }
400
403
  else {
401
404
  printError(res.error || t("tui.switchError"));
@@ -508,10 +511,15 @@ async function handleCommand(cmd) {
508
511
  }
509
512
  case "clear":
510
513
  case "c":
511
- console.clear();
512
- drawHeader();
514
+ // /clear is the ONLY command that safely redraws the header, because
515
+ // it wipes the entire screen first.
516
+ redrawHeader({ clearScreen: true });
513
517
  if (ws?.readyState === WebSocket.OPEN) {
514
- ws.send(JSON.stringify({ type: "reset" }));
518
+ ws.send(JSON.stringify({
519
+ type: "reset",
520
+ target: activeTarget,
521
+ sessionKey: activeTarget === "tui" ? tuiSessionKey : undefined,
522
+ }));
515
523
  }
516
524
  break;
517
525
  case "target":
@@ -520,12 +528,10 @@ async function handleCommand(cmd) {
520
528
  if (val === "tui") {
521
529
  activeTarget = "tui";
522
530
  printSuccess("Target: TUI (your own isolated session)");
523
- redrawHeader();
524
531
  }
525
532
  else if (val === "telegram" || val === "tel") {
526
533
  activeTarget = "telegram";
527
534
  printSuccess("Target: Telegram (your messages now go into the Telegram session — the bot replies in Telegram AND here)");
528
- redrawHeader();
529
535
  }
530
536
  else {
531
537
  printInfo(`Current target: ${activeTarget}. Use /target tui or /target telegram.`);
@@ -639,12 +645,12 @@ export async function startTUI() {
639
645
  // mode on top of that causes every keystroke to be echoed TWICE (once by
640
646
  // the terminal, once by readline's line editor) — producing the classic
641
647
  // "hheelllloo" double-echo bug. Let readline manage the tty mode itself.
642
- // Handle terminal resize — redraw header to fit the new width.
648
+ // Handle terminal resize — we can't safely redraw the header in place
649
+ // on a scrolled buffer. Just re-render the prompt so readline picks up
650
+ // the new width for its line editor.
643
651
  process.stdout.on("resize", () => {
644
- if (!isStreaming) {
645
- redrawHeader();
652
+ if (!isStreaming)
646
653
  showPrompt();
647
- }
648
654
  });
649
655
  await fetchInitialModel();
650
656
  connectWebSocket();