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
|
@@ -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 ? "
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
//
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
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}
|
|
375
|
+
`*Model:* ${info.name} ${providerTag}\n` +
|
|
239
376
|
`*Effort:* ${EFFORT_LABELS[session.effort]}\n` +
|
|
240
|
-
`*Voice:* ${session.voiceReply ? "on" : "off"}
|
|
241
|
-
|
|
242
|
-
|
|
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}
|
|
248
|
-
`Week: ${usage.week.queries}
|
|
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(
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
const info =
|
|
317
|
-
await ctx.reply(
|
|
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(`
|
|
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
|
-
|
|
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(`
|
|
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
|
|
625
|
-
|
|
626
|
-
|
|
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:
|
|
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("
|
|
817
|
+
await ctx.reply(t("bot.lang.autoEnabled", session.language));
|
|
639
818
|
}
|
|
640
|
-
else if (arg === "de" || arg === "
|
|
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(
|
|
823
|
+
await ctx.reply(t("bot.lang.setFixed", arg, { name: LOCALE_NAMES[arg] }));
|
|
645
824
|
}
|
|
646
825
|
else {
|
|
647
|
-
await ctx.reply("
|
|
826
|
+
await ctx.reply(t("bot.lang.usage", session.language), { parse_mode: "Markdown" });
|
|
648
827
|
}
|
|
649
828
|
});
|
|
650
|
-
|
|
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: "
|
|
658
|
-
await ctx.editMessageText("
|
|
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
|
|
662
|
-
session.language =
|
|
843
|
+
const newLang = choice;
|
|
844
|
+
session.language = newLang;
|
|
663
845
|
const { setExplicitLanguage } = await import("../services/language-detect.js");
|
|
664
|
-
setExplicitLanguage(userId,
|
|
665
|
-
const
|
|
666
|
-
|
|
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:
|
|
850
|
+
reply_markup: buildLangKeyboard(newLang),
|
|
671
851
|
});
|
|
672
|
-
await ctx.answerCallbackQuery(
|
|
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("
|
|
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
|
-
|
|
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
|
}
|