alvin-bot 4.5.1 → 4.7.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 (43) hide show
  1. package/CHANGELOG.md +278 -0
  2. package/README.md +25 -2
  3. package/bin/cli.js +325 -26
  4. package/dist/handlers/commands.js +505 -63
  5. package/dist/handlers/message.js +209 -14
  6. package/dist/i18n.js +470 -13
  7. package/dist/index.js +45 -5
  8. package/dist/providers/claude-sdk-provider.js +106 -14
  9. package/dist/providers/ollama-provider.js +32 -0
  10. package/dist/providers/openai-compatible.js +10 -1
  11. package/dist/providers/registry.js +112 -17
  12. package/dist/providers/types.js +25 -3
  13. package/dist/services/compaction.js +2 -0
  14. package/dist/services/cron.js +53 -42
  15. package/dist/services/heartbeat.js +41 -7
  16. package/dist/services/language-detect.js +12 -2
  17. package/dist/services/ollama-manager.js +339 -0
  18. package/dist/services/personality.js +20 -14
  19. package/dist/services/session.js +21 -3
  20. package/dist/services/subagent-delivery.js +266 -0
  21. package/dist/services/subagent-stats.js +123 -0
  22. package/dist/services/subagents.js +509 -42
  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/docs/HANDBOOK.md +856 -0
  28. package/package.json +7 -2
  29. package/test/claude-sdk-provider.test.ts +69 -0
  30. package/test/i18n.test.ts +108 -0
  31. package/test/registry.test.ts +201 -0
  32. package/test/subagent-delivery.test.ts +273 -0
  33. package/test/subagent-stats.test.ts +119 -0
  34. package/test/subagents-commands.test.ts +64 -0
  35. package/test/subagents-config.test.ts +114 -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 +88 -0
  40. package/test/subagents-queue.test.ts +127 -0
  41. package/test/subagents-shutdown.test.ts +126 -0
  42. package/test/subagents-toolset.test.ts +51 -0
  43. package/vitest.config.ts +17 -0
@@ -27,15 +27,20 @@ export function getSession(key) {
27
27
  totalCost: 0,
28
28
  costByProvider: {},
29
29
  queriesByProvider: {},
30
- effort: "high",
30
+ effort: "medium",
31
31
  voiceReply: false,
32
32
  messageCount: 0,
33
33
  toolUseCount: 0,
34
34
  totalInputTokens: 0,
35
35
  totalOutputTokens: 0,
36
+ lastTurnInputTokens: 0,
37
+ compactionCount: 0,
38
+ checkpointHintsInjected: 0,
39
+ sdkSubTaskCount: 0,
36
40
  history: [],
37
41
  language: "en",
38
42
  messageQueue: [],
43
+ lastSdkHistoryIndex: -1,
39
44
  };
40
45
  sessions.set(k, session);
41
46
  }
@@ -56,7 +61,12 @@ export function resetSession(key) {
56
61
  session.toolUseCount = 0;
57
62
  session.totalInputTokens = 0;
58
63
  session.totalOutputTokens = 0;
64
+ session.lastTurnInputTokens = 0;
65
+ session.compactionCount = 0;
66
+ session.checkpointHintsInjected = 0;
67
+ session.sdkSubTaskCount = 0;
59
68
  session.history = [];
69
+ session.lastSdkHistoryIndex = -1;
60
70
  session.startedAt = Date.now();
61
71
  // Reset budget warning flags so the user gets fresh warnings in the new session.
62
72
  session._budgetWarned80 = false;
@@ -138,13 +148,21 @@ export function stopSessionCleanup() {
138
148
  cleanupTimer = null;
139
149
  }
140
150
  }
141
- /** Add a message to conversation history (for non-SDK providers). */
151
+ /** Add a message to conversation history. Unified across all provider types
152
+ * — SDK providers resume from their filesystem session but we still track the
153
+ * transcript here so failovers (and the B2 bridge-message) have context. */
142
154
  export function addToHistory(key, message) {
143
155
  const session = getSession(key);
144
156
  session.history.push(message);
145
- // Trim oldest messages if history gets too long
157
+ // Trim oldest messages if history gets too long. Adjust lastSdkHistoryIndex
158
+ // by the number of dropped entries so it keeps pointing at the correct
159
+ // (now shifted) assistant turn — or collapses to -1 if it falls off the front.
146
160
  if (session.history.length > MAX_HISTORY) {
161
+ const dropped = session.history.length - MAX_HISTORY;
147
162
  session.history = session.history.slice(-MAX_HISTORY);
163
+ if (session.lastSdkHistoryIndex >= 0) {
164
+ session.lastSdkHistoryIndex = Math.max(-1, session.lastSdkHistoryIndex - dropped);
165
+ }
148
166
  }
149
167
  }
150
168
  /** Get all active sessions (for web UI session browser). */
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Sub-Agent Delivery Router (I3) — context-aware rendering of sub-agent
3
+ * results into Telegram. Source decides the delivery path:
4
+ * - implicit → no-op (main stream already shows the Task-tool result)
5
+ * - user → banner+final as a new message in parentChatId
6
+ * - cron → banner+final in chatId from the CronJob target
7
+ *
8
+ * The caller is responsible for passing a correct `parentChatId` on the
9
+ * SubAgentInfo. Lookup of the bot API is lazy so we can unit-test the
10
+ * module with a fake bot via __setBotApiForTest.
11
+ */
12
+ import { getVisibility } from "./subagents.js";
13
+ const MAX_TG_CHUNK = 3800; // below Telegram's 4096 limit with headroom
14
+ const FILE_UPLOAD_THRESHOLD = 20_000; // switch to .md file upload above this
15
+ let injectedApi = null;
16
+ let runtimeApi = null;
17
+ /** Test-only hook for injecting a fake bot API. Production code must NEVER call this. */
18
+ export function __setBotApiForTest(api) {
19
+ injectedApi = api;
20
+ }
21
+ /** Wire the grammy bot API once at startup (called from src/index.ts). */
22
+ export function attachBotApi(api) {
23
+ runtimeApi = api;
24
+ }
25
+ function getBotApi() {
26
+ return injectedApi ?? runtimeApi;
27
+ }
28
+ function formatTokens(n) {
29
+ if (n < 1000)
30
+ return `${n}`;
31
+ return `${(n / 1000).toFixed(1)}k`;
32
+ }
33
+ function formatDuration(ms) {
34
+ const s = Math.floor(ms / 1000);
35
+ if (s < 60)
36
+ return `${s}s`;
37
+ const m = Math.floor(s / 60);
38
+ const rem = s - m * 60;
39
+ return `${m}m ${rem}s`;
40
+ }
41
+ function statusIcon(status) {
42
+ switch (status) {
43
+ case "completed": return "✅";
44
+ case "timeout": return "⏱️";
45
+ case "cancelled": return "⚠️";
46
+ case "error": return "❌";
47
+ }
48
+ }
49
+ function buildBanner(info, result) {
50
+ const icon = statusIcon(result.status);
51
+ const dur = formatDuration(result.duration);
52
+ const ti = formatTokens(result.tokensUsed.input);
53
+ const to = formatTokens(result.tokensUsed.output);
54
+ return `${icon} *${info.name}* ${result.status} · ${dur} · ${ti} in / ${to} out`;
55
+ }
56
+ // ── A4 Live-Stream ──────────────────────────────────────────
57
+ /**
58
+ * Per-spawn live-stream state. Edits a single Telegram message as the
59
+ * sub-agent produces text, throttled to ~800ms between edits. Posts a
60
+ * separate banner message at finalize so the user gets a completion
61
+ * notification (edits don't trigger Telegram notifications).
62
+ *
63
+ * The live message uses plain text (no parse_mode) so half-formed
64
+ * markdown during streaming can never crash the edit. The final banner
65
+ * does use markdown.
66
+ */
67
+ const LIVE_EDIT_THROTTLE_MS = 800;
68
+ const LIVE_INITIAL_TEXT = (name) => `⏳ ${name} thinking…`;
69
+ export class LiveStream {
70
+ api;
71
+ chatId;
72
+ agentName;
73
+ messageId = null;
74
+ lastEditAt = 0;
75
+ pendingText = null;
76
+ pendingTimer = null;
77
+ started = false;
78
+ failed = false;
79
+ constructor(api, chatId, agentName) {
80
+ this.api = api;
81
+ this.chatId = chatId;
82
+ this.agentName = agentName;
83
+ }
84
+ /** Post the initial placeholder message. Called before the first chunk. */
85
+ async start() {
86
+ if (!this.api.editMessageText) {
87
+ this.failed = true;
88
+ console.warn(`[subagent-live] bot api has no editMessageText — falling back`);
89
+ return;
90
+ }
91
+ try {
92
+ const initial = LIVE_INITIAL_TEXT(this.agentName);
93
+ const msg = await this.api.sendMessage(this.chatId, initial);
94
+ const msgId = msg.message_id;
95
+ if (typeof msgId === "number") {
96
+ this.messageId = msgId;
97
+ this.lastEditAt = Date.now();
98
+ this.started = true;
99
+ }
100
+ else {
101
+ console.warn(`[subagent-live] sendMessage returned no message_id`);
102
+ this.failed = true;
103
+ }
104
+ }
105
+ catch (err) {
106
+ console.error(`[subagent-live] start failed:`, err);
107
+ this.failed = true;
108
+ }
109
+ }
110
+ /**
111
+ * Record a new accumulated text state. Will schedule a throttled edit
112
+ * ~800ms after the previous edit. Later updates that arrive before
113
+ * the throttled flush coalesce — only the latest text is used.
114
+ */
115
+ update(text) {
116
+ if (!this.started || this.failed || this.messageId === null)
117
+ return;
118
+ this.pendingText = text;
119
+ if (this.pendingTimer)
120
+ return;
121
+ const elapsed = Date.now() - this.lastEditAt;
122
+ const delay = Math.max(0, LIVE_EDIT_THROTTLE_MS - elapsed);
123
+ this.pendingTimer = setTimeout(() => {
124
+ this.flush().catch((err) => {
125
+ console.warn(`[subagent-live] scheduled flush failed:`, err);
126
+ });
127
+ }, delay);
128
+ }
129
+ async flush() {
130
+ this.pendingTimer = null;
131
+ if (!this.pendingText || this.messageId === null || this.failed)
132
+ return;
133
+ if (!this.api.editMessageText) {
134
+ this.failed = true;
135
+ return;
136
+ }
137
+ // Cap edit length — Telegram rejects >4096 chars
138
+ const body = this.pendingText.slice(0, MAX_TG_CHUNK);
139
+ const display = `⏳ ${this.agentName}\n\n${body}`;
140
+ try {
141
+ await this.api.editMessageText(this.chatId, this.messageId, display);
142
+ this.lastEditAt = Date.now();
143
+ }
144
+ catch (err) {
145
+ // "message is not modified" is harmless (same content as before)
146
+ const msg = err instanceof Error ? err.message : String(err);
147
+ if (!/not modified/i.test(msg)) {
148
+ console.warn(`[subagent-live] edit failed:`, msg);
149
+ }
150
+ }
151
+ this.pendingText = null;
152
+ }
153
+ /**
154
+ * Flush any pending edit, then post the final banner as a new message
155
+ * so the user gets a notification. The live-stream message stays in
156
+ * place as the body; the banner is a separate message above/below it.
157
+ */
158
+ async finalize(info, result) {
159
+ if (this.pendingTimer) {
160
+ clearTimeout(this.pendingTimer);
161
+ this.pendingTimer = null;
162
+ }
163
+ if (this.pendingText) {
164
+ await this.flush();
165
+ }
166
+ this.started = false;
167
+ if (this.failed)
168
+ return;
169
+ // One last edit to remove the "thinking…" header (replace with final text)
170
+ if (this.messageId !== null && this.api.editMessageText) {
171
+ const finalBody = (result.output?.trim() || "(empty output)").slice(0, MAX_TG_CHUNK);
172
+ const finalDisplay = `${info.name}\n\n${finalBody}`;
173
+ try {
174
+ await this.api.editMessageText(this.chatId, this.messageId, finalDisplay);
175
+ }
176
+ catch {
177
+ // If the final edit fails, the "thinking…" header stays —
178
+ // the banner below will still communicate completion.
179
+ }
180
+ }
181
+ // Post the banner as a new message (notification-triggering)
182
+ const banner = buildBanner(info, result);
183
+ try {
184
+ await this.api.sendMessage(this.chatId, banner, { parse_mode: "Markdown" });
185
+ }
186
+ catch (err) {
187
+ console.error(`[subagent-live] finalize banner failed:`, err);
188
+ this.failed = true;
189
+ throw err;
190
+ }
191
+ }
192
+ }
193
+ /**
194
+ * Factory for LiveStream — returns null if the bot api isn't attached
195
+ * yet, or if the api doesn't support editMessageText. Callers check
196
+ * the return value and fall back to normal delivery if null.
197
+ */
198
+ export function createLiveStream(chatId, agentName) {
199
+ const api = getBotApi();
200
+ if (!api || !api.editMessageText) {
201
+ console.warn(`[subagent-live] no compatible bot api — live mode unavailable`);
202
+ return null;
203
+ }
204
+ return new LiveStream(api, chatId, agentName);
205
+ }
206
+ // ── Main delivery entry point ───────────────────────────────
207
+ /**
208
+ * Main delivery entry point. Resolves the effective visibility (override →
209
+ * config default), then dispatches to the source-specific renderer.
210
+ *
211
+ * Errors are logged but never thrown — delivery must not break the sub-agent
212
+ * lifecycle. A failed Telegram send falls through silently.
213
+ */
214
+ export async function deliverSubAgentResult(info, result, opts = {}) {
215
+ // Implicit spawns: the Task-tool bridge in the main stream has already
216
+ // surfaced the output; extra delivery would be duplication.
217
+ if (info.source === "implicit")
218
+ return;
219
+ const effective = opts.visibility ?? getVisibility();
220
+ if (effective === "silent")
221
+ return;
222
+ // "live" mode is handled inline by runSubAgent via LiveStream. If we
223
+ // get here with "live" visibility it means the live-stream path wasn't
224
+ // applicable (wrong source, missing editMessageText, etc.) — fall
225
+ // through to the normal banner+final behavior below.
226
+ const api = getBotApi();
227
+ if (!api) {
228
+ console.warn(`[subagent-delivery] no bot api available for ${info.name}`);
229
+ return;
230
+ }
231
+ if (!info.parentChatId) {
232
+ console.warn(`[subagent-delivery] missing parentChatId for ${info.name} (source=${info.source})`);
233
+ return;
234
+ }
235
+ const banner = buildBanner(info, result);
236
+ const body = result.output?.trim() || `(empty output)`;
237
+ try {
238
+ // Case 1: very long output → file upload with a short banner
239
+ if (body.length > FILE_UPLOAD_THRESHOLD) {
240
+ await api.sendMessage(info.parentChatId, banner, { parse_mode: "Markdown" });
241
+ try {
242
+ const { InputFile } = await import("grammy");
243
+ const buf = Buffer.from(body, "utf-8");
244
+ await api.sendDocument(info.parentChatId, new InputFile(buf, `${info.name}.md`));
245
+ }
246
+ catch (err) {
247
+ console.error(`[subagent-delivery] file upload failed:`, err);
248
+ await api.sendMessage(info.parentChatId, body.slice(0, MAX_TG_CHUNK));
249
+ }
250
+ return;
251
+ }
252
+ // Case 2: fits in a single message → banner + body joined
253
+ if (body.length + banner.length + 2 <= MAX_TG_CHUNK) {
254
+ await api.sendMessage(info.parentChatId, `${banner}\n\n${body}`, { parse_mode: "Markdown" });
255
+ return;
256
+ }
257
+ // Case 3: medium output → banner as its own message, body chunked
258
+ await api.sendMessage(info.parentChatId, banner, { parse_mode: "Markdown" });
259
+ for (let i = 0; i < body.length; i += MAX_TG_CHUNK) {
260
+ await api.sendMessage(info.parentChatId, body.slice(i, i + MAX_TG_CHUNK));
261
+ }
262
+ }
263
+ catch (err) {
264
+ console.error(`[subagent-delivery] send failed for ${info.name}:`, err);
265
+ }
266
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Sub-Agent Stats (H3) — rolling 24h aggregation of per-agent run data.
3
+ *
4
+ * Append-only JSON ring buffer persisted to ~/.alvin-bot/subagent-stats.json.
5
+ * On load, entries older than 24h are pruned. On each append, entries older
6
+ * than 24h are pruned.
7
+ *
8
+ * Used by /subagents stats to show run totals per source (user, cron, implicit)
9
+ * over the last 24 hours. No SQLite dependency — when a real SQLite migration
10
+ * lands we can swap the backend without touching the consumer API.
11
+ */
12
+ import os from "os";
13
+ import fs from "fs";
14
+ import { resolve, dirname } from "path";
15
+ const DATA_DIR = process.env.ALVIN_DATA_DIR || resolve(os.homedir(), ".alvin-bot");
16
+ const STATS_FILE = resolve(DATA_DIR, "subagent-stats.json");
17
+ const WINDOW_MS = 24 * 60 * 60 * 1000; // 24 hours
18
+ const MAX_ENTRIES = 5000; // hard cap to prevent unbounded growth on high-frequency bots
19
+ let cache = null;
20
+ function load() {
21
+ if (cache)
22
+ return cache;
23
+ try {
24
+ const raw = fs.readFileSync(STATS_FILE, "utf-8");
25
+ const parsed = JSON.parse(raw);
26
+ if (!Array.isArray(parsed)) {
27
+ cache = [];
28
+ return cache;
29
+ }
30
+ // Prune stale entries (> 24h old) on load
31
+ const cutoff = Date.now() - WINDOW_MS;
32
+ cache = parsed.filter((e) => typeof e === "object" &&
33
+ e !== null &&
34
+ typeof e.completedAt === "number" &&
35
+ e.completedAt >= cutoff);
36
+ return cache;
37
+ }
38
+ catch {
39
+ cache = [];
40
+ return cache;
41
+ }
42
+ }
43
+ function save(entries) {
44
+ try {
45
+ fs.mkdirSync(dirname(STATS_FILE), { recursive: true });
46
+ fs.writeFileSync(STATS_FILE, JSON.stringify(entries, null, 0), "utf-8");
47
+ }
48
+ catch (err) {
49
+ console.error("[subagent-stats] failed to write:", err);
50
+ }
51
+ }
52
+ /**
53
+ * Record a completed sub-agent run. Called from runSubAgent.finally() via
54
+ * a side-effect hook. Automatically prunes entries older than 24h and
55
+ * keeps the file bounded at MAX_ENTRIES.
56
+ */
57
+ export function recordSubAgentRun(info, result) {
58
+ const entries = load();
59
+ const cutoff = Date.now() - WINDOW_MS;
60
+ // Prune in-place
61
+ const pruned = entries.filter((e) => e.completedAt >= cutoff);
62
+ const newEntry = {
63
+ completedAt: Date.now(),
64
+ name: info.name,
65
+ source: (info.source ?? "implicit"),
66
+ status: result.status,
67
+ durationMs: result.duration,
68
+ inputTokens: result.tokensUsed.input,
69
+ outputTokens: result.tokensUsed.output,
70
+ };
71
+ pruned.push(newEntry);
72
+ // Enforce hard cap — oldest entries drop first
73
+ const final = pruned.length > MAX_ENTRIES ? pruned.slice(-MAX_ENTRIES) : pruned;
74
+ cache = final;
75
+ save(final);
76
+ }
77
+ /**
78
+ * Compute a summary of the last 24h of sub-agent runs. Safe to call
79
+ * concurrently with recordSubAgentRun — both read from the same cache.
80
+ */
81
+ export function getSubAgentStats() {
82
+ const entries = load();
83
+ const cutoff = Date.now() - WINDOW_MS;
84
+ const recent = entries.filter((e) => e.completedAt >= cutoff);
85
+ const empty = () => ({
86
+ runs: 0,
87
+ inputTokens: 0,
88
+ outputTokens: 0,
89
+ totalDurationMs: 0,
90
+ });
91
+ const bySource = {
92
+ user: empty(),
93
+ cron: empty(),
94
+ implicit: empty(),
95
+ };
96
+ const byStatus = {
97
+ completed: 0,
98
+ timeout: 0,
99
+ error: 0,
100
+ cancelled: 0,
101
+ };
102
+ const total = empty();
103
+ for (const e of recent) {
104
+ const bucket = bySource[e.source] ?? bySource.implicit;
105
+ bucket.runs += 1;
106
+ bucket.inputTokens += e.inputTokens;
107
+ bucket.outputTokens += e.outputTokens;
108
+ bucket.totalDurationMs += e.durationMs;
109
+ total.runs += 1;
110
+ total.inputTokens += e.inputTokens;
111
+ total.outputTokens += e.outputTokens;
112
+ total.totalDurationMs += e.durationMs;
113
+ byStatus[e.status] = (byStatus[e.status] ?? 0) + 1;
114
+ }
115
+ return { windowHours: 24, total, bySource, byStatus };
116
+ }
117
+ /**
118
+ * Reset the in-memory cache — for test isolation. Does NOT delete the
119
+ * file; use ALVIN_DATA_DIR in tests to point at a fresh temp dir.
120
+ */
121
+ export function __resetStatsCacheForTest() {
122
+ cache = null;
123
+ }