alvin-bot 4.5.1 → 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 (41) hide show
  1. package/CHANGELOG.md +130 -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/docs/HANDBOOK.md +819 -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 +169 -0
  33. package/test/subagents-commands.test.ts +64 -0
  34. package/test/subagents-config.test.ts +108 -0
  35. package/test/subagents-depth.test.ts +58 -0
  36. package/test/subagents-inheritance.test.ts +67 -0
  37. package/test/subagents-name-resolver.test.ts +122 -0
  38. package/test/subagents-priority-reject.test.ts +60 -0
  39. package/test/subagents-shutdown.test.ts +126 -0
  40. package/test/subagents-toolset.test.ts +51 -0
  41. package/vitest.config.ts +17 -0
@@ -20,6 +20,15 @@ import { storePassword, revokePassword, getSudoStatus, verifyPassword } from "..
20
20
  import { config } from "../config.js";
21
21
  import { getWebPort } from "../web/server.js";
22
22
  import { getUsageSummary, getAllRateLimits, formatTokens } from "../services/usage-tracker.js";
23
+ import { runUpdate, getAutoUpdate, setAutoUpdate, startAutoUpdateLoop } from "../services/updater.js";
24
+ import { getHealthStatus, isFailedOver } from "../services/heartbeat.js";
25
+ import { t, LOCALE_NAMES, LOCALE_FLAGS } from "../i18n.js";
26
+ // Kick off auto-update loop on module load if the persistent flag is set.
27
+ // Doing this as a module side-effect avoids touching the bot entry point.
28
+ if (getAutoUpdate()) {
29
+ // 30s delay so the bot is fully started before the first check
30
+ setTimeout(() => startAutoUpdateLoop(), 30_000);
31
+ }
23
32
  /** Bot start time for uptime tracking */
24
33
  const botStartTime = Date.now();
25
34
  /** Format bytes to human-readable */
@@ -32,6 +41,44 @@ function formatBytes(bytes) {
32
41
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
33
42
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
34
43
  }
44
+ /** Render a working directory path with a meaningful label.
45
+ * Home → šŸ  ~ (Home), anywhere under home → šŸ“ ~/rel/path, absolute → šŸ“ /path */
46
+ function formatWorkingDir(workingDir, locale) {
47
+ const home = os.homedir();
48
+ if (workingDir === home) {
49
+ return `šŸ  \`~\` _(${t("bot.status.homeLabel", locale)})_`;
50
+ }
51
+ if (workingDir.startsWith(home + "/")) {
52
+ return `šŸ“ \`~${workingDir.slice(home.length)}\``;
53
+ }
54
+ return `šŸ“ \`${workingDir}\``;
55
+ }
56
+ /** Format a raw token count for the context progress line.
57
+ * Keeps precision tight — "450k/1M" not "450.2k/1.0M". */
58
+ function formatContextTokens(n) {
59
+ if (n < 1_000)
60
+ return String(n);
61
+ if (n < 1_000_000)
62
+ return `${Math.round(n / 1_000)}k`;
63
+ const m = n / 1_000_000;
64
+ return m >= 10 ? `${Math.round(m)}M` : `${m.toFixed(1)}M`;
65
+ }
66
+ /** Human relative-time rendered in the user's locale. */
67
+ function formatRelativeTime(ms, locale) {
68
+ const s = Math.floor(ms / 1000);
69
+ if (s < 10)
70
+ return t("bot.time.justNow", locale);
71
+ if (s < 60)
72
+ return t("bot.time.secondsAgo", locale, { n: s });
73
+ const m = Math.floor(s / 60);
74
+ if (m < 60)
75
+ return t("bot.time.minutesAgo", locale, { n: m });
76
+ const h = Math.floor(m / 60);
77
+ if (h < 24)
78
+ return t("bot.time.hoursAgo", locale, { n: h });
79
+ const d = Math.floor(h / 24);
80
+ return t(d === 1 ? "bot.time.dayAgo" : "bot.time.daysAgo", locale, { n: d });
81
+ }
35
82
  const EFFORT_LABELS = {
36
83
  low: "Low — Quick, concise answers",
37
84
  medium: "Medium — Moderate reasoning depth",
@@ -80,6 +127,10 @@ export function registerCommands(bot) {
80
127
  `/status — Current status\n` +
81
128
  `/new — Start new session\n` +
82
129
  `/cancel — Cancel running request\n\n` +
130
+ `šŸ”§ *Ops*\n` +
131
+ `/restart — Restart the bot\n` +
132
+ `/update — Pull latest + rebuild + restart\n` +
133
+ `/autoupdate on|off — Auto-update loop (6h)\n\n` +
83
134
  `_Tip: Send me documents, photos, or voice messages!_\n` +
84
135
  `_In groups: @mention me or reply to my messages._`, { parse_mode: "Markdown" });
85
136
  });
@@ -99,9 +150,13 @@ export function registerCommands(bot) {
99
150
  { command: "recall", description: "Semantic memory search" },
100
151
  { command: "remember", description: "Remember something" },
101
152
  { command: "cron", description: "Manage scheduled jobs" },
153
+ { command: "subagents", description: "Manage background sub-agents" },
102
154
  { command: "webui", description: "Open Web UI in browser" },
103
155
  { command: "setup", description: "Configure API keys & platforms" },
104
156
  { command: "cancel", description: "Cancel running request" },
157
+ { command: "restart", description: "Restart the bot (PM2)" },
158
+ { command: "update", description: "Pull latest, build, restart" },
159
+ { command: "autoupdate", description: "Auto-update on|off|status" },
105
160
  ]).catch(err => console.error("Failed to set bot commands:", err));
106
161
  bot.command("start", async (ctx) => {
107
162
  const registry = getRegistry();
@@ -167,6 +222,7 @@ export function registerCommands(bot) {
167
222
  bot.command("status", async (ctx) => {
168
223
  const userId = ctx.from.id;
169
224
  const session = getSession(userId);
225
+ const lang = session.language;
170
226
  const registry = getRegistry();
171
227
  const active = registry.getActive();
172
228
  const info = active.getInfo();
@@ -174,28 +230,75 @@ export function registerCommands(bot) {
174
230
  const uptimeMs = Date.now() - botStartTime;
175
231
  const uptimeH = Math.floor(uptimeMs / 3_600_000);
176
232
  const uptimeM = Math.floor((uptimeMs % 3_600_000) / 60_000);
177
- // Session duration
178
- const sessionMs = Date.now() - session.startedAt;
179
- const sessionM = Math.floor(sessionMs / 60_000);
180
233
  // Provider type detection
181
234
  const isOAuth = active.config.type === "claude-sdk" || active.config.type === "codex-cli";
182
- const providerTag = isOAuth ? "OAuth/Flat-Rate" : "API";
183
- // Token stats
184
- const inTok = formatTokens(session.totalInputTokens);
185
- const outTok = formatTokens(session.totalOutputTokens);
186
- // Cost display
187
- const costStr = isOAuth
188
- ? `$0.00 (Flat-Rate) | ${formatTokens(session.totalInputTokens + session.totalOutputTokens)} tokens`
189
- : `$${session.totalCost.toFixed(4)}`;
190
- // Provider breakdown (session)
191
- let providerLines = "";
192
- const providers = Object.entries(session.queriesByProvider);
193
- if (providers.length > 0) {
194
- providerLines = providers.map(([key, queries]) => {
195
- const cost = session.costByProvider[key] || 0;
196
- const costLabel = isOAuth && key === registry.getActiveKey() ? "flat" : `$${cost.toFixed(4)}`;
197
- return ` ${key}: ${queries}q, ${costLabel}`;
198
- }).join("\n");
235
+ const providerTag = isOAuth ? "_Flat-Rate_" : "_API_";
236
+ // ── Session block — intelligent empty/active/idle rendering ─────────
237
+ // The in-memory session is always fresh after a bot restart, so plain
238
+ // "Session (0 min)" with all zeros looks broken. Render an empty state
239
+ // explicitly, plus active/idle badges based on last activity.
240
+ const now = Date.now();
241
+ const idleMs = now - session.lastActivity;
242
+ const sessionAgeMs = now - session.startedAt;
243
+ const sessionAgeMin = Math.floor(sessionAgeMs / 60_000);
244
+ const IDLE_THRESHOLD_MS = 2 * 60 * 1000;
245
+ const isEmpty = session.messageCount === 0
246
+ && !session.sessionId
247
+ && session.history.length === 0;
248
+ let sessionBlock;
249
+ const sessionHeader = t("bot.status.sessionHeader", lang);
250
+ if (isEmpty) {
251
+ sessionBlock = `${sessionHeader}\n${t("bot.status.sessionNew", lang)}`;
252
+ }
253
+ else {
254
+ const isActiveNow = idleMs < IDLE_THRESHOLD_MS;
255
+ const badge = isActiveNow ? t("bot.status.active", lang) : t("bot.status.idle", lang);
256
+ // Line 1: activity summary
257
+ const msgWord = t(session.messageCount === 1 ? "bot.status.message" : "bot.status.messages", lang);
258
+ const toolWord = t(session.toolUseCount === 1 ? "bot.status.toolCall" : "bot.status.toolCalls", lang);
259
+ const summary = `${badge} — ${session.messageCount} ${msgWord}, ${session.toolUseCount} ${toolWord}`;
260
+ // Line 2: tokens (only if non-zero — zero is noise after restart)
261
+ const totalTok = session.totalInputTokens + session.totalOutputTokens;
262
+ const tokenLine = totalTok > 0
263
+ ? `\nTokens: ${formatTokens(session.totalInputTokens)} in / ${formatTokens(session.totalOutputTokens)} out`
264
+ : "";
265
+ // Line 2.5: context window usage progress (X / Y with percentage).
266
+ // Shown only when we have both a last-turn input token count AND the
267
+ // provider declares its context window. Otherwise skipped to avoid
268
+ // showing meaningless zeros.
269
+ const ctxWindow = active.config.contextWindow;
270
+ let contextLine = "";
271
+ if (session.lastTurnInputTokens > 0 && typeof ctxWindow === "number" && ctxWindow > 0) {
272
+ const used = session.lastTurnInputTokens;
273
+ const pct = Math.round((used / ctxWindow) * 100);
274
+ contextLine = `\nContext: ${formatContextTokens(used)}/${formatContextTokens(ctxWindow)} (${pct}%)`;
275
+ }
276
+ // Line 3: timing (age + last turn)
277
+ const ageStr = sessionAgeMin >= 1
278
+ ? `${sessionAgeMin} min`
279
+ : t("bot.status.lessThanMin", lang);
280
+ const timingLine = `\n${t("bot.status.duration", lang)}: ${ageStr} | ${t("bot.status.lastTurn", lang)}: ${formatRelativeTime(idleMs, lang)}`;
281
+ // Line 4: cost (only for non-OAuth providers AND only when meaningful)
282
+ const costLine = (!isOAuth && session.totalCost > 0)
283
+ ? `\nCost: $${session.totalCost.toFixed(4)}`
284
+ : "";
285
+ // Line 5: telemetry counters — compactions (non-SDK), checkpoint
286
+ // hints (SDK), and SDK-internal sub-tasks (Claude's Task tool).
287
+ // Each only shown when > 0 to keep the status clean.
288
+ const telemetryParts = [];
289
+ if (session.compactionCount > 0) {
290
+ telemetryParts.push(`Compactions: ${session.compactionCount}`);
291
+ }
292
+ if (session.checkpointHintsInjected > 0) {
293
+ telemetryParts.push(`Checkpoint hints: ${session.checkpointHintsInjected}`);
294
+ }
295
+ if (session.sdkSubTaskCount > 0) {
296
+ telemetryParts.push(`SDK sub-tasks: ${session.sdkSubTaskCount}`);
297
+ }
298
+ const telemetryLine = telemetryParts.length > 0
299
+ ? `\n${telemetryParts.join(" | ")}`
300
+ : "";
301
+ sessionBlock = `${sessionHeader}\n${summary}${tokenLine}${contextLine}${timingLine}${costLine}${telemetryLine}`;
199
302
  }
200
303
  // Usage summary (daily/weekly from tracker)
201
304
  const usage = getUsageSummary();
@@ -234,20 +337,52 @@ export function registerCommands(bot) {
234
337
  const memStats = getMemoryStats();
235
338
  const idxStats = getIndexStats();
236
339
  const memLine = `${memStats.dailyLogs} days, ${memStats.todayEntries} entries today, ${formatBytes(memStats.longTermSize)} LTM | šŸ” ${idxStats.entries} vectors`;
340
+ // Provider health + failover state
341
+ const healthRows = getHealthStatus();
342
+ const failedOver = isFailedOver();
343
+ const activeKey = registry.getActiveKey();
344
+ let healthLines = "";
345
+ if (healthRows.length > 0) {
346
+ // Render each row, live-checking lifecycle-managed providers so the
347
+ // status reflects reality (not just heartbeat's always-healthy flag
348
+ // for on-demand runners).
349
+ const rows = await Promise.all(healthRows.map(async (h) => {
350
+ const isActive = h.key === activeKey;
351
+ const arrow = isActive ? "→" : " ";
352
+ const provider = registry.get(h.key);
353
+ // Lifecycle-managed providers (local runners) get on-demand rendering
354
+ if (provider?.lifecycle) {
355
+ const running = await provider.lifecycle.isRunning();
356
+ const botManaged = provider.lifecycle.isBotManaged();
357
+ if (!running) {
358
+ return `${arrow} šŸ’¤ ${h.key} ${t("bot.status.ollamaOnDemand", lang)}`;
359
+ }
360
+ if (botManaged) {
361
+ return `${arrow} šŸ”§ ${h.key} ${t("bot.status.ollamaBotManaged", lang)}`;
362
+ }
363
+ return `${arrow} āœ… ${h.key} ${t("bot.status.ollamaExternal", lang)}`;
364
+ }
365
+ // Default rendering for cloud providers
366
+ const icon = h.healthy ? "āœ…" : "āŒ";
367
+ const latency = h.latencyMs > 0 ? ` ${h.latencyMs}ms` : "";
368
+ const fails = h.failCount > 0 ? ` (${h.failCount} fails)` : "";
369
+ return `${arrow} ${icon} ${h.key}${latency}${fails}`;
370
+ }));
371
+ const failoverBadge = failedOver ? ` ${t("bot.status.failedOver", lang)}` : "";
372
+ healthLines = `\n${t("bot.status.providerHealth", lang)}${failoverBadge}\n${rows.join("\n")}\n`;
373
+ }
237
374
  await ctx.reply(`šŸ¤– *Alvin Bot Status*\n\n` +
238
- `*Model:* ${info.name} — ${providerTag}\n` +
375
+ `*Model:* ${info.name} ${providerTag}\n` +
239
376
  `*Effort:* ${EFFORT_LABELS[session.effort]}\n` +
240
- `*Voice:* ${session.voiceReply ? "on" : "off"} | *Dir:* \`${session.workingDir.replace(os.homedir(), "~")}\`\n\n` +
241
- `šŸ“Š *Session* (${sessionM} min)\n` +
242
- `Messages: ${session.messageCount} | Tools: ${session.toolUseCount}\n` +
243
- `Tokens: ${inTok} in / ${outTok} out\n` +
244
- `Cost: ${costStr}\n` +
245
- (providerLines ? `${providerLines}\n` : "") +
377
+ `*Voice:* ${session.voiceReply ? "on" : "off"}\n` +
378
+ `*Working Dir:* ${formatWorkingDir(session.workingDir, lang)}\n\n` +
379
+ `${sessionBlock}\n` +
246
380
  `\nšŸ“ˆ *Usage*\n` +
247
- `Today: ${usage.today.queries}q, ${todayTok} tokens${todayCostStr}\n` +
248
- `Week: ${usage.week.queries}q, ${weekTok} tokens${weekCostStr}\n` +
249
- (usage.daysTracked > 1 ? `Avg: ${formatTokens(usage.avgDailyTokens)} tok/day\n` : "") +
381
+ `Today: ${usage.today.queries} req, ${todayTok} tokens${todayCostStr}\n` +
382
+ `Week: ${usage.week.queries} req, ${weekTok} tokens${weekCostStr}\n` +
383
+ (usage.daysTracked > 1 ? `Avg: ${formatTokens(usage.avgDailyTokens)} tok/day _(7d rolling)_\n` : "") +
250
384
  rlLines +
385
+ healthLines +
251
386
  `\n🧠 *Memory:* ${memLine}\n` +
252
387
  `ā± *Uptime:* ${uptimeH}h ${uptimeM}m`, { parse_mode: "Markdown" });
253
388
  });
@@ -297,7 +432,39 @@ export function registerCommands(bot) {
297
432
  await ctx.editMessageText(`🧠 *Choose reasoning depth:*\n\nActive: *${EFFORT_LABELS[session.effort]}*`, { parse_mode: "Markdown", reply_markup: keyboard });
298
433
  await ctx.answerCallbackQuery(`Effort: ${EFFORT_LABELS[session.effort]}`);
299
434
  });
435
+ // Helper: switch provider with lifecycle management for local runners.
436
+ // Boots the target's lifecycle daemon (if any) BEFORE the switch, and
437
+ // tears down the previous provider's lifecycle (if any) AFTER the switch.
438
+ // Fully generic — no hardcoded provider keys.
439
+ async function switchProviderWithLifecycle(targetKey, lang) {
440
+ const registry = getRegistry();
441
+ const previousKey = registry.getActiveKey();
442
+ if (previousKey === targetKey)
443
+ return { ok: true };
444
+ const target = registry.get(targetKey);
445
+ if (!target)
446
+ return { ok: false, error: `provider "${targetKey}" not found` };
447
+ const previous = registry.get(previousKey);
448
+ // Boot the target's lifecycle (if any) before the switch
449
+ if (target.lifecycle) {
450
+ const booted = await target.lifecycle.ensureRunning();
451
+ if (!booted) {
452
+ return { ok: false, error: t("bot.model.bootFailed", lang, { key: targetKey }) };
453
+ }
454
+ }
455
+ if (!registry.switchTo(targetKey)) {
456
+ return { ok: false, error: "switch rejected by registry" };
457
+ }
458
+ // Tear down the previous provider's lifecycle (if any) after the switch.
459
+ // ensureStopped() internally checks isBotManaged — no-op for externally
460
+ // managed daemons.
461
+ if (previous?.lifecycle) {
462
+ await previous.lifecycle.ensureStopped();
463
+ }
464
+ return { ok: true };
465
+ }
300
466
  bot.command("model", async (ctx) => {
467
+ const lang = getSession(ctx.from.id).language;
301
468
  const arg = ctx.match?.trim().toLowerCase();
302
469
  const registry = getRegistry();
303
470
  if (!arg) {
@@ -308,23 +475,25 @@ export function registerCommands(bot) {
308
475
  const label = p.active ? `āœ… ${p.name}` : p.name;
309
476
  keyboard.text(label, `model:${p.key}`).row();
310
477
  }
311
- await ctx.reply(`šŸ¤– *Choose model:*\n\nActive: *${registry.getActive().getInfo().name}*`, { parse_mode: "Markdown", reply_markup: keyboard });
478
+ await ctx.reply(`${t("bot.model.chooseHeader", lang)}\n\n${t("bot.model.active", lang)} *${registry.getActive().getInfo().name}*`, { parse_mode: "Markdown", reply_markup: keyboard });
312
479
  return;
313
480
  }
314
- if (registry.switchTo(arg)) {
315
- const provider = registry.get(arg);
316
- const info = provider.getInfo();
317
- await ctx.reply(`āœ… Switched model: ${info.name} (${info.model})`);
481
+ const result = await switchProviderWithLifecycle(arg, lang);
482
+ if (result.ok) {
483
+ const info = registry.get(arg).getInfo();
484
+ await ctx.reply(`${t("bot.model.switched", lang)} ${info.name} (${info.model})`);
318
485
  }
319
486
  else {
320
- await ctx.reply(`Model "${arg}" not found. Use /model to see all options.`);
487
+ await ctx.reply(`${t("bot.model.switchFailed", lang)} ${result.error || `"${arg}"`}\n${t("bot.model.notFoundHint", lang)}`);
321
488
  }
322
489
  });
323
490
  // Inline keyboard callback for model switching
324
491
  bot.callbackQuery(/^model:(.+)$/, async (ctx) => {
325
492
  const key = ctx.match[1];
326
493
  const registry = getRegistry();
327
- if (registry.switchTo(key)) {
494
+ const lang = getSession(ctx.from.id).language;
495
+ const result = await switchProviderWithLifecycle(key, lang);
496
+ if (result.ok) {
328
497
  const provider = registry.get(key);
329
498
  const info = provider.getInfo();
330
499
  // Update the keyboard to show new selection
@@ -338,7 +507,7 @@ export function registerCommands(bot) {
338
507
  await ctx.answerCallbackQuery(`Switched: ${info.name}`);
339
508
  }
340
509
  else {
341
- await ctx.answerCallbackQuery(`Model "${key}" not found`);
510
+ await ctx.answerCallbackQuery(`Switch failed: ${result.error || "unknown"}`);
342
511
  }
343
512
  });
344
513
  // ── Fallback Order ────────────────────────────────────────────────────
@@ -616,60 +785,71 @@ export function registerCommands(bot) {
616
785
  caption: `šŸ“„ Export: ${session.history.length} messages`,
617
786
  });
618
787
  });
788
+ // Helper: build the /language inline keyboard for all 4 locales + auto.
789
+ function buildLangKeyboard(current) {
790
+ const kb = new InlineKeyboard();
791
+ const order = ["en", "de", "es", "fr"];
792
+ // First row: 2 buttons
793
+ kb.text(`${current === "en" ? "āœ… " : ""}${LOCALE_FLAGS.en} ${LOCALE_NAMES.en}`, "lang:en").text(`${current === "de" ? "āœ… " : ""}${LOCALE_FLAGS.de} ${LOCALE_NAMES.de}`, "lang:de").row();
794
+ // Second row: 2 buttons
795
+ kb.text(`${current === "es" ? "āœ… " : ""}${LOCALE_FLAGS.es} ${LOCALE_NAMES.es}`, "lang:es").text(`${current === "fr" ? "āœ… " : ""}${LOCALE_FLAGS.fr} ${LOCALE_NAMES.fr}`, "lang:fr").row();
796
+ // Third row: auto-detect
797
+ void order; // silence unused warning from the `order` declaration (kept for doc clarity)
798
+ kb.text(t("bot.lang.autoDetect", current), "lang:auto");
799
+ return kb;
800
+ }
619
801
  bot.command("lang", async (ctx) => {
620
802
  const userId = ctx.from.id;
621
803
  const session = getSession(userId);
622
804
  const arg = ctx.match?.trim().toLowerCase();
623
805
  if (!arg) {
624
- const keyboard = new InlineKeyboard()
625
- .text(session.language === "de" ? "āœ… Deutsch" : "Deutsch", "lang:de")
626
- .text(session.language === "en" ? "āœ… English" : "English", "lang:en")
627
- .row()
628
- .text("šŸ”„ Auto-detect", "lang:auto");
629
- await ctx.reply(`🌐 *Language:* ${session.language === "de" ? "Deutsch" : "English"}`, {
806
+ const header = t("bot.lang.header", session.language);
807
+ const currentName = `${LOCALE_FLAGS[session.language]} ${LOCALE_NAMES[session.language]}`;
808
+ await ctx.reply(`${header} ${currentName}`, {
630
809
  parse_mode: "Markdown",
631
- reply_markup: keyboard,
810
+ reply_markup: buildLangKeyboard(session.language),
632
811
  });
633
812
  return;
634
813
  }
635
814
  if (arg === "auto") {
636
815
  const { resetToAutoLanguage } = await import("../services/language-detect.js");
637
816
  resetToAutoLanguage(userId);
638
- await ctx.reply("šŸ”„ Auto-detection enabled. I'll adapt to the language you write in.");
817
+ await ctx.reply(t("bot.lang.autoEnabled", session.language));
639
818
  }
640
- else if (arg === "de" || arg === "en") {
819
+ else if (arg === "en" || arg === "de" || arg === "es" || arg === "fr") {
641
820
  session.language = arg;
642
821
  const { setExplicitLanguage } = await import("../services/language-detect.js");
643
822
  setExplicitLanguage(userId, arg);
644
- await ctx.reply(arg === "de" ? "āœ… Language: Deutsch (fixed)" : "āœ… Language: English (fixed)");
823
+ await ctx.reply(t("bot.lang.setFixed", arg, { name: LOCALE_NAMES[arg] }));
645
824
  }
646
825
  else {
647
- await ctx.reply("Use: `/lang de`, `/lang en`, or `/lang auto`", { parse_mode: "Markdown" });
826
+ await ctx.reply(t("bot.lang.usage", session.language), { parse_mode: "Markdown" });
648
827
  }
649
828
  });
650
- bot.callbackQuery(/^lang:(de|en|auto)$/, async (ctx) => {
829
+ // /lang callback — accept all 4 locales plus auto
830
+ bot.callbackQuery(/^lang:(en|de|es|fr|auto)$/, async (ctx) => {
651
831
  const choice = ctx.match[1];
652
832
  const userId = ctx.from.id;
653
833
  const session = getSession(userId);
654
834
  if (choice === "auto") {
655
835
  const { resetToAutoLanguage } = await import("../services/language-detect.js");
656
836
  resetToAutoLanguage(userId);
657
- await ctx.answerCallbackQuery({ text: "šŸ”„ Auto-detect enabled" });
658
- await ctx.editMessageText("🌐 *Language:* Auto-detect šŸ”„", { parse_mode: "Markdown" });
837
+ await ctx.answerCallbackQuery({ text: t("bot.lang.autoEnabled", session.language).slice(0, 60) });
838
+ await ctx.editMessageText(`${t("bot.lang.header", session.language)} ${t("bot.lang.autoDetect", session.language)}`, {
839
+ parse_mode: "Markdown",
840
+ });
659
841
  return;
660
842
  }
661
- const lang = choice;
662
- session.language = lang;
843
+ const newLang = choice;
844
+ session.language = newLang;
663
845
  const { setExplicitLanguage } = await import("../services/language-detect.js");
664
- setExplicitLanguage(userId, lang);
665
- const keyboard = new InlineKeyboard()
666
- .text(lang === "de" ? "āœ… Deutsch" : "Deutsch", "lang:de")
667
- .text(lang === "en" ? "āœ… English" : "English", "lang:en");
668
- await ctx.editMessageText(`🌐 *Language:* ${lang === "de" ? "Deutsch" : "English"}`, {
846
+ setExplicitLanguage(userId, newLang);
847
+ const currentName = `${LOCALE_FLAGS[newLang]} ${LOCALE_NAMES[newLang]}`;
848
+ await ctx.editMessageText(`${t("bot.lang.header", newLang)} ${currentName}`, {
669
849
  parse_mode: "Markdown",
670
- reply_markup: keyboard,
850
+ reply_markup: buildLangKeyboard(newLang),
671
851
  });
672
- await ctx.answerCallbackQuery(lang === "de" ? "Deutsch" : "English");
852
+ await ctx.answerCallbackQuery(LOCALE_NAMES[newLang]);
673
853
  });
674
854
  bot.command("memory", async (ctx) => {
675
855
  const stats = getMemoryStats();
@@ -1478,12 +1658,230 @@ export function registerCommands(bot) {
1478
1658
  bot.command("cancel", async (ctx) => {
1479
1659
  const userId = ctx.from.id;
1480
1660
  const session = getSession(userId);
1661
+ const lang = session.language;
1481
1662
  if (session.isProcessing && session.abortController) {
1482
1663
  session.abortController.abort();
1483
- await ctx.reply("Cancelling request...");
1664
+ await ctx.reply(t("bot.cancel.cancelling", lang));
1665
+ }
1666
+ else {
1667
+ await ctx.reply(t("bot.cancel.noRunning", lang));
1668
+ }
1669
+ });
1670
+ // /restart — trigger a PM2-managed restart by exiting the process.
1671
+ // The PM2 supervisor picks up the exit and respawns with --update-env.
1672
+ bot.command("restart", async (ctx) => {
1673
+ const lang = getSession(ctx.from.id).language;
1674
+ await ctx.reply(t("bot.restart.triggered", lang));
1675
+ // Small delay so the Telegram message is actually delivered before exit
1676
+ setTimeout(() => process.exit(0), 500);
1677
+ });
1678
+ // /update — git pull + install + build, then PM2-restart if anything changed.
1679
+ bot.command("update", async (ctx) => {
1680
+ const lang = getSession(ctx.from.id).language;
1681
+ await ctx.reply(t("bot.update.checking", lang));
1682
+ try {
1683
+ const result = await runUpdate();
1684
+ if (result.ok) {
1685
+ await ctx.reply(`āœ… ${result.message}`);
1686
+ if (result.requiresRestart) {
1687
+ await ctx.reply(t("bot.update.restarting", lang));
1688
+ setTimeout(() => process.exit(0), 500);
1689
+ }
1690
+ }
1691
+ else {
1692
+ await ctx.reply(`${t("bot.update.failed", lang)}\n\`${result.message}\``, { parse_mode: "Markdown" });
1693
+ }
1694
+ }
1695
+ catch (err) {
1696
+ const msg = err instanceof Error ? err.message : String(err);
1697
+ await ctx.reply(`${t("bot.update.error", lang)} ${msg}`);
1698
+ }
1699
+ });
1700
+ // /autoupdate on|off|status — toggle the background auto-update loop.
1701
+ // Persisted to ~/.alvin-bot/auto-update.flag so it survives restarts.
1702
+ bot.command("autoupdate", async (ctx) => {
1703
+ const lang = getSession(ctx.from.id).language;
1704
+ const arg = (ctx.match || "").trim().toLowerCase();
1705
+ if (arg === "on") {
1706
+ setAutoUpdate(true);
1707
+ await ctx.reply(t("bot.autoupdate.enabled", lang), { parse_mode: "Markdown" });
1708
+ }
1709
+ else if (arg === "off") {
1710
+ setAutoUpdate(false);
1711
+ await ctx.reply(t("bot.autoupdate.disabled", lang), { parse_mode: "Markdown" });
1712
+ }
1713
+ else {
1714
+ const status = getAutoUpdate();
1715
+ await ctx.reply(`${t("bot.autoupdate.statusLabel", lang)} *${status ? "ON" : "OFF"}*\n\n${t("bot.autoupdate.commandsLabel", lang)}\n\`/autoupdate on\`\n\`/autoupdate off\``, { parse_mode: "Markdown" });
1716
+ }
1717
+ });
1718
+ // ── /sub-agents — manage background subagents (cron jobs + manual spawns) ──
1719
+ //
1720
+ // /sub-agents → show current config + running agents
1721
+ // /sub-agents max <n> → set max parallel (0 = auto = CPU cores, capped 16)
1722
+ // /sub-agents list → list all agents with IDs
1723
+ // /sub-agents cancel <id> → cancel a specific running agent
1724
+ // /sub-agents result <id> → show the result of a completed agent
1725
+ //
1726
+ // Grammy normalises command names — dashes are not allowed in the command
1727
+ // string, so the actual handler binds to "subagents" (no dash). Users can
1728
+ // type both "/sub-agents" and "/subagents" — Telegram routes both to this.
1729
+ bot.command(["sub_agents", "subagents"], async (ctx) => {
1730
+ const lang = getSession(ctx.from.id).language;
1731
+ const { listSubAgents, cancelSubAgent, getSubAgentResult, getMaxParallelAgents, getConfiguredMaxParallel, setMaxParallelAgents, findSubAgentByName, getVisibility, setVisibility, } = await import("../services/subagents.js");
1732
+ const arg = (ctx.match || "").trim();
1733
+ const tokens = arg.split(/\s+/).filter(Boolean);
1734
+ const sub = tokens[0]?.toLowerCase() || "";
1735
+ // Helper: shorten a UUID to its first 8 chars for display
1736
+ const shortId = (id) => id.slice(0, 8);
1737
+ // Helper: format a SubAgentInfo line with depth indent (F2 visibility)
1738
+ const formatAgent = (a) => {
1739
+ const indent = " ".repeat(Math.max(0, a.depth));
1740
+ const ageSec = Math.floor((Date.now() - a.startedAt) / 1000);
1741
+ const ageLabel = ageSec < 60 ? `${ageSec}s` : ageSec < 3600 ? `${Math.floor(ageSec / 60)}m` : `${Math.floor(ageSec / 3600)}h`;
1742
+ const sourceBadge = a.source === "cron" ? "ā°" : a.source === "implicit" ? "šŸ”—" : "šŸ‘¤";
1743
+ const depthTag = a.depth > 0 ? ` d${a.depth}` : "";
1744
+ return `${indent}${sourceBadge} \`${shortId(a.id)}\` ${a.name} (${a.status}, ${ageLabel}${depthTag})`;
1745
+ };
1746
+ // /sub-agents max <n>
1747
+ if (sub === "max") {
1748
+ const n = parseInt(tokens[1] || "", 10);
1749
+ if (isNaN(n)) {
1750
+ await ctx.reply(t("bot.subagents.usage", lang), { parse_mode: "Markdown" });
1751
+ return;
1752
+ }
1753
+ const effective = setMaxParallelAgents(n);
1754
+ await ctx.reply(t("bot.subagents.maxSet", lang, { n, eff: effective }), { parse_mode: "Markdown" });
1755
+ return;
1756
+ }
1757
+ // /sub-agents visibility <auto|banner|silent>
1758
+ if (sub === "visibility") {
1759
+ const mode = tokens[1];
1760
+ if (!mode) {
1761
+ // Show current value
1762
+ const current = getVisibility();
1763
+ await ctx.reply(`${t("bot.subagents.visibilityLabel", lang)} *${current}*\n\n${t("bot.subagents.usage", lang)}`, { parse_mode: "Markdown" });
1764
+ return;
1765
+ }
1766
+ try {
1767
+ setVisibility(mode);
1768
+ await ctx.reply(t("bot.subagents.visibilitySet", lang, { mode }), { parse_mode: "Markdown" });
1769
+ }
1770
+ catch {
1771
+ await ctx.reply(t("bot.subagents.visibilityInvalid", lang, { mode }), { parse_mode: "Markdown" });
1772
+ }
1773
+ return;
1774
+ }
1775
+ // /sub-agents list — same rendering as the default, but forced
1776
+ if (sub === "list") {
1777
+ const agents = listSubAgents();
1778
+ if (agents.length === 0) {
1779
+ await ctx.reply(t("bot.subagents.noneRunning", lang));
1780
+ return;
1781
+ }
1782
+ const lines = agents.map(formatAgent).join("\n");
1783
+ await ctx.reply(`${t("bot.subagents.activeHeader", lang)}\n${lines}`, { parse_mode: "Markdown" });
1784
+ return;
1785
+ }
1786
+ // /sub-agents cancel <name|id>
1787
+ // Resolution order: exact name → base-name (single match) → UUID prefix.
1788
+ // If the base name is ambiguous, show a disambiguation reply.
1789
+ if (sub === "cancel") {
1790
+ const key = tokens[1] || "";
1791
+ if (!key) {
1792
+ await ctx.reply(t("bot.subagents.usage", lang), { parse_mode: "Markdown" });
1793
+ return;
1794
+ }
1795
+ // 1. Ambiguity check via the resolver
1796
+ const ambig = findSubAgentByName(key, { ambiguousAsList: true });
1797
+ if (ambig && "ambiguous" in ambig) {
1798
+ const list = ambig.candidates.map((c) => `• ${c.name}`).join("\n");
1799
+ await ctx.reply(`Mehrdeutig — welchen meinst du?\n${list}\n\nBenutze den genauen Namen (z.B. \`${ambig.candidates[0].name}\`).`, { parse_mode: "Markdown" });
1800
+ return;
1801
+ }
1802
+ // 2. Name match → cancel via that id
1803
+ let targetId = null;
1804
+ let displayName = key;
1805
+ if (ambig && !("ambiguous" in ambig)) {
1806
+ targetId = ambig.id;
1807
+ displayName = ambig.name;
1808
+ }
1809
+ else {
1810
+ // 3. Fallback: UUID-prefix match (back-compat with old usage)
1811
+ const allAgents = listSubAgents();
1812
+ const byId = allAgents.find((a) => a.id.startsWith(key));
1813
+ if (byId) {
1814
+ targetId = byId.id;
1815
+ displayName = byId.name;
1816
+ }
1817
+ }
1818
+ if (!targetId || !cancelSubAgent(targetId)) {
1819
+ await ctx.reply(t("bot.subagents.notFound", lang, { id: key }));
1820
+ return;
1821
+ }
1822
+ await ctx.reply(t("bot.subagents.cancelled", lang, { id: displayName }));
1823
+ return;
1824
+ }
1825
+ // /sub-agents result <name|id>
1826
+ if (sub === "result") {
1827
+ const key = tokens[1] || "";
1828
+ if (!key) {
1829
+ await ctx.reply(t("bot.subagents.usage", lang), { parse_mode: "Markdown" });
1830
+ return;
1831
+ }
1832
+ const ambig = findSubAgentByName(key, { ambiguousAsList: true });
1833
+ if (ambig && "ambiguous" in ambig) {
1834
+ const list = ambig.candidates.map((c) => `• ${c.name}`).join("\n");
1835
+ await ctx.reply(`Mehrdeutig — welchen meinst du?\n${list}`, { parse_mode: "Markdown" });
1836
+ return;
1837
+ }
1838
+ let target = null;
1839
+ if (ambig && !("ambiguous" in ambig)) {
1840
+ target = { id: ambig.id, name: ambig.name };
1841
+ }
1842
+ else {
1843
+ const allAgents = listSubAgents();
1844
+ const byId = allAgents.find((a) => a.id.startsWith(key));
1845
+ if (byId)
1846
+ target = { id: byId.id, name: byId.name };
1847
+ }
1848
+ if (!target) {
1849
+ await ctx.reply(t("bot.subagents.notFound", lang, { id: key }));
1850
+ return;
1851
+ }
1852
+ const result = getSubAgentResult(target.id);
1853
+ if (!result) {
1854
+ await ctx.reply(t("bot.subagents.notFound", lang, { id: key }));
1855
+ return;
1856
+ }
1857
+ const duration = Math.floor(result.duration / 1000);
1858
+ const header = t("bot.subagents.resultHeader", lang, { name: result.name, status: result.status });
1859
+ const meta = t("bot.subagents.resultDuration", lang, { sec: duration, in: result.tokensUsed.input, out: result.tokensUsed.output });
1860
+ // Cap output at ~3500 chars to stay inside Telegram message limit
1861
+ const body = result.output.length > 3500 ? result.output.slice(0, 3500) + "\n\n…[truncated]" : result.output;
1862
+ await ctx.reply(`${header}\n${meta}\n\n${body || result.error || "(no output)"}`, { parse_mode: "Markdown" }).catch(() =>
1863
+ // retry without markdown in case the body has unescaped characters
1864
+ ctx.reply(`${header}\n${meta}\n\n${body || result.error || "(no output)"}`));
1865
+ return;
1866
+ }
1867
+ // Default: /sub-agents — show state + running list
1868
+ const configured = getConfiguredMaxParallel();
1869
+ const effective = getMaxParallelAgents();
1870
+ const maxLabel = configured === 0
1871
+ ? `${t("bot.subagents.maxLabel", lang)} 0 ${t("bot.subagents.autoSuffix", lang, { n: effective })}`
1872
+ : `${t("bot.subagents.maxLabel", lang)} ${configured}`;
1873
+ const visibilityLabel = `${t("bot.subagents.visibilityLabel", lang)} *${getVisibility()}*`;
1874
+ const agents = listSubAgents();
1875
+ let body = "";
1876
+ if (agents.length === 0) {
1877
+ body = `\n${t("bot.subagents.noneRunning", lang)}`;
1484
1878
  }
1485
1879
  else {
1486
- await ctx.reply("No running request.");
1880
+ body = `\n${t("bot.subagents.activeHeader", lang)}\n${agents.map(formatAgent).join("\n")}`;
1487
1881
  }
1882
+ const header = t("bot.subagents.header", lang);
1883
+ const usage = `\n\n${t("bot.subagents.usage", lang)}`;
1884
+ const full = `${header}\n${maxLabel}\n${visibilityLabel}${body}${usage}`;
1885
+ await ctx.reply(full, { parse_mode: "Markdown" }).catch(() => ctx.reply(full));
1488
1886
  });
1489
1887
  }