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.
- package/CHANGELOG.md +150 -0
- package/README.md +25 -2
- package/alvin-bot-4.5.1.tgz +0 -0
- package/bin/cli.js +246 -0
- package/dist/handlers/commands.js +461 -63
- package/dist/handlers/message.js +209 -14
- package/dist/i18n.js +470 -13
- package/dist/index.js +44 -5
- package/dist/providers/claude-sdk-provider.js +106 -14
- package/dist/providers/ollama-provider.js +32 -0
- package/dist/providers/openai-compatible.js +10 -1
- package/dist/providers/registry.js +112 -17
- package/dist/providers/types.js +25 -3
- package/dist/services/compaction.js +2 -0
- package/dist/services/cron.js +53 -42
- package/dist/services/heartbeat.js +41 -7
- package/dist/services/language-detect.js +12 -2
- package/dist/services/ollama-manager.js +339 -0
- package/dist/services/personality.js +20 -14
- package/dist/services/session.js +21 -3
- package/dist/services/subagent-delivery.js +111 -0
- package/dist/services/subagents.js +341 -27
- package/dist/services/telegram.js +28 -1
- package/dist/services/updater.js +158 -0
- package/dist/services/usage-tracker.js +11 -4
- package/dist/services/users.js +2 -1
- package/dist/tui/index.js +36 -30
- package/docs/HANDBOOK.md +819 -0
- package/package.json +7 -2
- package/test/claude-sdk-provider.test.ts +69 -0
- package/test/i18n.test.ts +108 -0
- package/test/registry.test.ts +201 -0
- package/test/subagent-delivery.test.ts +169 -0
- package/test/subagents-commands.test.ts +64 -0
- package/test/subagents-config.test.ts +108 -0
- package/test/subagents-depth.test.ts +58 -0
- package/test/subagents-inheritance.test.ts +67 -0
- package/test/subagents-name-resolver.test.ts +122 -0
- package/test/subagents-priority-reject.test.ts +60 -0
- package/test/subagents-shutdown.test.ts +126 -0
- package/test/subagents-toolset.test.ts +51 -0
- 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
|
-
|
|
96
|
-
|
|
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:
|
|
102
|
-
avgDailyCost:
|
|
108
|
+
avgDailyTokens: avgTokensPerDay,
|
|
109
|
+
avgDailyCost: avgCostPerDay,
|
|
103
110
|
};
|
|
104
111
|
}
|
|
105
112
|
/** Store rate limit info from provider response headers. */
|
package/dist/services/users.js
CHANGED
|
@@ -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
|
|
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
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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;
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
512
|
-
|
|
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({
|
|
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 —
|
|
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();
|