alvin-bot 4.5.0 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +25 -2
  3. package/alvin-bot-4.5.1.tgz +0 -0
  4. package/bin/cli.js +246 -0
  5. package/dist/handlers/commands.js +461 -63
  6. package/dist/handlers/message.js +209 -14
  7. package/dist/i18n.js +470 -13
  8. package/dist/index.js +44 -5
  9. package/dist/providers/claude-sdk-provider.js +106 -14
  10. package/dist/providers/ollama-provider.js +32 -0
  11. package/dist/providers/openai-compatible.js +10 -1
  12. package/dist/providers/registry.js +112 -17
  13. package/dist/providers/types.js +25 -3
  14. package/dist/services/compaction.js +2 -0
  15. package/dist/services/cron.js +53 -42
  16. package/dist/services/heartbeat.js +41 -7
  17. package/dist/services/language-detect.js +12 -2
  18. package/dist/services/ollama-manager.js +339 -0
  19. package/dist/services/personality.js +20 -14
  20. package/dist/services/session.js +21 -3
  21. package/dist/services/subagent-delivery.js +111 -0
  22. package/dist/services/subagents.js +341 -27
  23. package/dist/services/telegram.js +28 -1
  24. package/dist/services/updater.js +158 -0
  25. package/dist/services/usage-tracker.js +11 -4
  26. package/dist/services/users.js +2 -1
  27. package/dist/tui/index.js +36 -30
  28. package/docs/HANDBOOK.md +819 -0
  29. package/package.json +7 -2
  30. package/test/claude-sdk-provider.test.ts +69 -0
  31. package/test/i18n.test.ts +108 -0
  32. package/test/registry.test.ts +201 -0
  33. package/test/subagent-delivery.test.ts +169 -0
  34. package/test/subagents-commands.test.ts +64 -0
  35. package/test/subagents-config.test.ts +108 -0
  36. package/test/subagents-depth.test.ts +58 -0
  37. package/test/subagents-inheritance.test.ts +67 -0
  38. package/test/subagents-name-resolver.test.ts +122 -0
  39. package/test/subagents-priority-reject.test.ts +60 -0
  40. package/test/subagents-shutdown.test.ts +126 -0
  41. package/test/subagents-toolset.test.ts +51 -0
  42. package/vitest.config.ts +17 -0
package/dist/i18n.js CHANGED
@@ -2,14 +2,19 @@
2
2
  * Alvin Bot — Internationalization (i18n)
3
3
  *
4
4
  * Simple key-based translation system.
5
- * Default: English. Supported: en, de.
5
+ * Default: English. Supported: en, de, es, fr.
6
6
  *
7
- * Detection order:
8
- * 1. --lang <en|de> CLI flag
9
- * 2. ALVIN_LANG env var
10
- * 3. LANG env var (e.g. de_DE.UTF-8 de)
11
- * 4. Default: en
7
+ * Two usage patterns:
8
+ * 1. Global locale (for CLI/TUI where there's one user): call initI18n()
9
+ * at startup, then t(key) reads the global currentLocale.
10
+ * 2. Per-call locale (for Telegram bot where every user has their own
11
+ * language preference): t(key, userLocale) overrides the global.
12
+ *
13
+ * Simple {var} interpolation is supported: t("bot.error.timeout", "en", { min: 5 })
12
14
  */
15
+ // Partial<Record<Locale,string>> so existing TUI/CLI keys can stay en+de only
16
+ // without TypeScript forcing us to translate every single startup string to
17
+ // es/fr. t() falls back to "en" when a locale is missing for a key.
13
18
  const strings = {
14
19
  // ── TUI ───────────────────────────────────────────────
15
20
  "tui.title": { en: "🤖 Alvin Bot TUI", de: "🤖 Alvin Bot TUI" },
@@ -161,6 +166,419 @@ const strings = {
161
166
  "update.done": { en: "Update complete!\n Restart: pm2 restart alvin-bot", de: "Update abgeschlossen!\n Neustarten: pm2 restart alvin-bot" },
162
167
  "update.npm": { en: "📦 Updating via npm...", de: "📦 Update via npm..." },
163
168
  "update.failed": { en: "Update failed:", de: "Update fehlgeschlagen:" },
169
+ // ══════════════════════════════════════════════════════
170
+ // ── Telegram bot commands (user-facing at runtime) ────
171
+ // All four locales (en/de/es/fr) are mandatory here because every
172
+ // Telegram user can pick any of them via /language.
173
+ // ══════════════════════════════════════════════════════
174
+ // /restart
175
+ "bot.restart.triggered": {
176
+ en: "♻️ Restart triggered — the bot will be back in a few seconds.",
177
+ de: "♻️ Restart wird ausgelöst — der Bot ist in wenigen Sekunden wieder da.",
178
+ es: "♻️ Reinicio activado — el bot volverá en unos segundos.",
179
+ fr: "♻️ Redémarrage déclenché — le bot sera de retour dans quelques secondes.",
180
+ },
181
+ // /update
182
+ "bot.update.checking": {
183
+ en: "🔄 Checking for updates…",
184
+ de: "🔄 Suche nach Updates…",
185
+ es: "🔄 Buscando actualizaciones…",
186
+ fr: "🔄 Recherche de mises à jour…",
187
+ },
188
+ "bot.update.restarting": {
189
+ en: "♻️ Restarting bot…",
190
+ de: "♻️ Bot wird neu gestartet…",
191
+ es: "♻️ Reiniciando el bot…",
192
+ fr: "♻️ Redémarrage du bot…",
193
+ },
194
+ "bot.update.failed": {
195
+ en: "❌ Update failed:",
196
+ de: "❌ Update fehlgeschlagen:",
197
+ es: "❌ Actualización fallida:",
198
+ fr: "❌ Échec de la mise à jour :",
199
+ },
200
+ "bot.update.error": {
201
+ en: "❌ Update error:",
202
+ de: "❌ Update-Fehler:",
203
+ es: "❌ Error de actualización:",
204
+ fr: "❌ Erreur de mise à jour :",
205
+ },
206
+ // /autoupdate
207
+ "bot.autoupdate.enabled": {
208
+ en: "✅ Auto-update *enabled* — checking every 6 h for new commits and installing them automatically.",
209
+ de: "✅ Auto-Update *aktiviert* — alle 6 h wird auf neue Commits geprüft und automatisch installiert.",
210
+ es: "✅ Auto-actualización *activada* — cada 6 h busca y aplica nuevos commits automáticamente.",
211
+ fr: "✅ Mise à jour automatique *activée* — vérification toutes les 6 h des nouveaux commits et installation automatique.",
212
+ },
213
+ "bot.autoupdate.disabled": {
214
+ en: "⏸️ Auto-update *disabled*.",
215
+ de: "⏸️ Auto-Update *deaktiviert*.",
216
+ es: "⏸️ Auto-actualización *desactivada*.",
217
+ fr: "⏸️ Mise à jour automatique *désactivée*.",
218
+ },
219
+ "bot.autoupdate.statusLabel": {
220
+ en: "Auto-update:",
221
+ de: "Auto-Update:",
222
+ es: "Auto-actualización:",
223
+ fr: "Mise à jour automatique :",
224
+ },
225
+ "bot.autoupdate.commandsLabel": {
226
+ en: "Commands:",
227
+ de: "Befehle:",
228
+ es: "Comandos:",
229
+ fr: "Commandes :",
230
+ },
231
+ // /status — session block
232
+ "bot.status.sessionHeader": {
233
+ en: "📊 *Session*",
234
+ de: "📊 *Session*",
235
+ es: "📊 *Sesión*",
236
+ fr: "📊 *Session*",
237
+ },
238
+ "bot.status.sessionNew": {
239
+ en: "🌱 New — send a message to start",
240
+ de: "🌱 Neu — sende eine Nachricht um zu starten",
241
+ es: "🌱 Nueva — envía un mensaje para empezar",
242
+ fr: "🌱 Nouvelle — envoie un message pour démarrer",
243
+ },
244
+ "bot.status.active": {
245
+ en: "🟢 Active",
246
+ de: "🟢 Aktiv",
247
+ es: "🟢 Activa",
248
+ fr: "🟢 Active",
249
+ },
250
+ "bot.status.idle": {
251
+ en: "💤 Idle",
252
+ de: "💤 Idle",
253
+ es: "💤 Inactiva",
254
+ fr: "💤 Inactive",
255
+ },
256
+ "bot.status.message": {
257
+ en: "message",
258
+ de: "Nachricht",
259
+ es: "mensaje",
260
+ fr: "message",
261
+ },
262
+ "bot.status.messages": {
263
+ en: "messages",
264
+ de: "Nachrichten",
265
+ es: "mensajes",
266
+ fr: "messages",
267
+ },
268
+ "bot.status.toolCall": {
269
+ en: "tool call",
270
+ de: "Tool-Call",
271
+ es: "llamada a herramienta",
272
+ fr: "appel d'outil",
273
+ },
274
+ "bot.status.toolCalls": {
275
+ en: "tool calls",
276
+ de: "Tool-Calls",
277
+ es: "llamadas a herramientas",
278
+ fr: "appels d'outils",
279
+ },
280
+ "bot.status.duration": {
281
+ en: "Duration",
282
+ de: "Dauer",
283
+ es: "Duración",
284
+ fr: "Durée",
285
+ },
286
+ "bot.status.lastTurn": {
287
+ en: "Last turn",
288
+ de: "Letzter Turn",
289
+ es: "Último turno",
290
+ fr: "Dernier tour",
291
+ },
292
+ "bot.status.lessThanMin": {
293
+ en: "< 1 min",
294
+ de: "< 1 Min",
295
+ es: "< 1 min",
296
+ fr: "< 1 min",
297
+ },
298
+ "bot.status.homeLabel": {
299
+ en: "Home",
300
+ de: "Home",
301
+ es: "Inicio",
302
+ fr: "Dossier personnel",
303
+ },
304
+ "bot.status.providerHealth": {
305
+ en: "💓 *Provider Health*",
306
+ de: "💓 *Provider-Status*",
307
+ es: "💓 *Estado de los proveedores*",
308
+ fr: "💓 *État des fournisseurs*",
309
+ },
310
+ "bot.status.failedOver": {
311
+ en: "⚠️ *FAILED OVER*",
312
+ de: "⚠️ *FAILOVER AKTIV*",
313
+ es: "⚠️ *FAILOVER ACTIVO*",
314
+ fr: "⚠️ *BASCULE ACTIVE*",
315
+ },
316
+ "bot.status.ollamaOnDemand": {
317
+ en: "(on-demand, not running)",
318
+ de: "(on-demand, läuft nicht)",
319
+ es: "(bajo demanda, no activo)",
320
+ fr: "(à la demande, non lancé)",
321
+ },
322
+ "bot.status.ollamaBotManaged": {
323
+ en: "(bot-managed, running)",
324
+ de: "(bot-verwaltet, läuft)",
325
+ es: "(gestionado por el bot, activo)",
326
+ fr: "(géré par le bot, en cours)",
327
+ },
328
+ "bot.status.ollamaExternal": {
329
+ en: "(external, running)",
330
+ de: "(extern, läuft)",
331
+ es: "(externo, activo)",
332
+ fr: "(externe, en cours)",
333
+ },
334
+ // /cancel
335
+ "bot.cancel.cancelling": {
336
+ en: "Cancelling request…",
337
+ de: "Anfrage wird abgebrochen…",
338
+ es: "Cancelando la solicitud…",
339
+ fr: "Annulation de la requête…",
340
+ },
341
+ "bot.cancel.noRunning": {
342
+ en: "No running request.",
343
+ de: "Keine laufende Anfrage.",
344
+ es: "No hay ninguna solicitud en curso.",
345
+ fr: "Aucune requête en cours.",
346
+ },
347
+ // /model
348
+ "bot.model.chooseHeader": {
349
+ en: "🤖 *Choose model:*",
350
+ de: "🤖 *Modell wählen:*",
351
+ es: "🤖 *Elige modelo:*",
352
+ fr: "🤖 *Choisis un modèle :*",
353
+ },
354
+ "bot.model.active": {
355
+ en: "Active:",
356
+ de: "Aktiv:",
357
+ es: "Activo:",
358
+ fr: "Actif :",
359
+ },
360
+ "bot.model.switched": {
361
+ en: "✅ Switched model:",
362
+ de: "✅ Modell gewechselt:",
363
+ es: "✅ Modelo cambiado:",
364
+ fr: "✅ Modèle changé :",
365
+ },
366
+ "bot.model.switchFailed": {
367
+ en: "❌ Switch failed:",
368
+ de: "❌ Wechsel fehlgeschlagen:",
369
+ es: "❌ Cambio fallido:",
370
+ fr: "❌ Échec du changement :",
371
+ },
372
+ "bot.model.notFoundHint": {
373
+ en: "Use /model to see all options.",
374
+ de: "Nutze /model um alle Optionen zu sehen.",
375
+ es: "Usa /model para ver todas las opciones.",
376
+ fr: "Utilise /model pour voir toutes les options.",
377
+ },
378
+ "bot.model.bootFailed": {
379
+ en: "failed to start {key} daemon (is it installed?)",
380
+ de: "Start des {key}-Daemons fehlgeschlagen (ist er installiert?)",
381
+ es: "no se pudo iniciar el daemon de {key} (¿está instalado?)",
382
+ fr: "échec du démarrage du daemon {key} (est-il installé ?)",
383
+ },
384
+ // /lang
385
+ "bot.lang.header": {
386
+ en: "🌐 *Language:*",
387
+ de: "🌐 *Sprache:*",
388
+ es: "🌐 *Idioma:*",
389
+ fr: "🌐 *Langue :*",
390
+ },
391
+ "bot.lang.autoEnabled": {
392
+ en: "🔄 Auto-detection enabled. I'll adapt to the language you write in.",
393
+ de: "🔄 Auto-Erkennung aktiv. Ich passe mich deiner Sprache an.",
394
+ es: "🔄 Detección automática activada. Me adaptaré al idioma en que escribas.",
395
+ fr: "🔄 Détection automatique activée. Je m'adapterai à la langue que tu écris.",
396
+ },
397
+ "bot.lang.setFixed": {
398
+ en: "✅ Language: {name} (fixed)",
399
+ de: "✅ Sprache: {name} (fest)",
400
+ es: "✅ Idioma: {name} (fijo)",
401
+ fr: "✅ Langue : {name} (fixe)",
402
+ },
403
+ "bot.lang.usage": {
404
+ en: "Use: `/lang de`, `/lang en`, `/lang es`, `/lang fr`, or `/lang auto`",
405
+ de: "Nutze: `/lang de`, `/lang en`, `/lang es`, `/lang fr`, oder `/lang auto`",
406
+ es: "Usa: `/lang de`, `/lang en`, `/lang es`, `/lang fr`, o `/lang auto`",
407
+ fr: "Utilise : `/lang de`, `/lang en`, `/lang es`, `/lang fr`, ou `/lang auto`",
408
+ },
409
+ "bot.lang.autoDetect": {
410
+ en: "🔄 Auto-detect",
411
+ de: "🔄 Auto-Erkennung",
412
+ es: "🔄 Auto-detectar",
413
+ fr: "🔄 Détection auto",
414
+ },
415
+ // Errors (in the message handler)
416
+ // Adaptive timeout: two distinct messages for "stuck" (no progress) vs
417
+ // "absolute max" (total runtime cap) so the user understands WHICH limit
418
+ // was hit. Prior single "bot.error.timeout" is kept as a backward-compat
419
+ // alias pointing at the stuck variant.
420
+ "bot.error.timeoutStuck": {
421
+ en: "⏱️ No response for {min} minutes — Claude seems stuck. Request aborted.",
422
+ de: "⏱️ Keine Antwort seit {min} Minuten — Claude scheint hängen geblieben zu sein. Abgebrochen.",
423
+ es: "⏱️ Sin respuesta durante {min} minutos — Claude parece atascado. Solicitud cancelada.",
424
+ fr: "⏱️ Aucune réponse depuis {min} minutes — Claude semble bloqué. Requête annulée.",
425
+ },
426
+ "bot.error.timeoutMax": {
427
+ en: "⏱️ Maximum runtime ({min} minutes) reached. Request aborted.",
428
+ de: "⏱️ Maximale Laufzeit ({min} Minuten) erreicht. Abgebrochen.",
429
+ es: "⏱️ Tiempo máximo de ejecución ({min} minutos) alcanzado. Solicitud cancelada.",
430
+ fr: "⏱️ Durée maximale ({min} minutes) atteinte. Requête annulée.",
431
+ },
432
+ "bot.error.timeout": {
433
+ // Backward-compat alias — points at the stuck variant so older callers
434
+ // still work. New code should prefer timeoutStuck / timeoutMax.
435
+ en: "⏱️ No response for {min} minutes — Claude seems stuck. Request aborted.",
436
+ de: "⏱️ Keine Antwort seit {min} Minuten — Claude scheint hängen geblieben zu sein. Abgebrochen.",
437
+ es: "⏱️ Sin respuesta durante {min} minutos — Claude parece atascado. Solicitud cancelada.",
438
+ fr: "⏱️ Aucune réponse depuis {min} minutes — Claude semble bloqué. Requête annulée.",
439
+ },
440
+ "bot.error.requestCancelled": {
441
+ en: "Request cancelled.",
442
+ de: "Anfrage abgebrochen.",
443
+ es: "Solicitud cancelada.",
444
+ fr: "Requête annulée.",
445
+ },
446
+ "bot.error.prefix": {
447
+ en: "Error:",
448
+ de: "Fehler:",
449
+ es: "Error:",
450
+ fr: "Erreur :",
451
+ },
452
+ // This is a composed error used in registry.ts mid-stream failure.
453
+ // {name} = provider display name, {detail} = the upstream error.
454
+ "bot.error.midStream": {
455
+ en: "{name} was interrupted mid-stream: {detail}. Please send the request again.",
456
+ de: "{name} wurde mid-stream unterbrochen: {detail}. Bitte Anfrage erneut senden.",
457
+ es: "{name} se interrumpió a mitad de flujo: {detail}. Por favor envía la solicitud de nuevo.",
458
+ fr: "{name} a été interrompu en cours de flux : {detail}. Veuillez renvoyer la requête.",
459
+ },
460
+ // /sub-agents command
461
+ "bot.subagents.header": {
462
+ en: "🤖 *Sub-Agents*",
463
+ de: "🤖 *Sub-Agents*",
464
+ es: "🤖 *Sub-Agentes*",
465
+ fr: "🤖 *Sous-agents*",
466
+ },
467
+ "bot.subagents.maxLabel": {
468
+ en: "Max parallel:",
469
+ de: "Max parallel:",
470
+ es: "Máx. paralelos:",
471
+ fr: "Max parallèles :",
472
+ },
473
+ "bot.subagents.autoSuffix": {
474
+ en: "(auto = {n})",
475
+ de: "(auto = {n})",
476
+ es: "(auto = {n})",
477
+ fr: "(auto = {n})",
478
+ },
479
+ "bot.subagents.noneRunning": {
480
+ en: "No agents running or recently completed.",
481
+ de: "Keine Agents aktiv oder kürzlich beendet.",
482
+ es: "Ningún agente en ejecución o recién finalizado.",
483
+ fr: "Aucun agent en cours ou récemment terminé.",
484
+ },
485
+ "bot.subagents.activeHeader": {
486
+ en: "Active / Recent:",
487
+ de: "Aktiv / Kürzlich:",
488
+ es: "Activos / Recientes:",
489
+ fr: "Actifs / Récents :",
490
+ },
491
+ "bot.subagents.maxSet": {
492
+ en: "✅ Max parallel set to {n} (effective: {eff})",
493
+ de: "✅ Max parallel auf {n} gesetzt (effektiv: {eff})",
494
+ es: "✅ Máx. paralelos establecido en {n} (efectivo: {eff})",
495
+ fr: "✅ Max parallèles défini à {n} (effectif : {eff})",
496
+ },
497
+ "bot.subagents.cancelled": {
498
+ en: "🛑 Cancelled agent {id}",
499
+ de: "🛑 Agent {id} abgebrochen",
500
+ es: "🛑 Agente {id} cancelado",
501
+ fr: "🛑 Agent {id} annulé",
502
+ },
503
+ "bot.subagents.notFound": {
504
+ en: "❌ Agent {id} not found or not running",
505
+ de: "❌ Agent {id} nicht gefunden oder nicht aktiv",
506
+ es: "❌ Agente {id} no encontrado o inactivo",
507
+ fr: "❌ Agent {id} introuvable ou inactif",
508
+ },
509
+ "bot.subagents.resultHeader": {
510
+ en: "🤖 Agent: {name} ({status})",
511
+ de: "🤖 Agent: {name} ({status})",
512
+ es: "🤖 Agente: {name} ({status})",
513
+ fr: "🤖 Agent : {name} ({status})",
514
+ },
515
+ "bot.subagents.resultDuration": {
516
+ en: "Duration: {sec}s · Tokens: {in}/{out}",
517
+ de: "Dauer: {sec}s · Tokens: {in}/{out}",
518
+ es: "Duración: {sec}s · Tokens: {in}/{out}",
519
+ fr: "Durée : {sec}s · Tokens : {in}/{out}",
520
+ },
521
+ "bot.subagents.usage": {
522
+ en: "Commands:\n/subagents — show status\n/subagents max <n> — set parallel limit (0=auto)\n/subagents visibility <auto|banner|silent> — delivery mode\n/subagents list — list all\n/subagents cancel <name|id> — cancel one\n/subagents result <name|id> — show result",
523
+ de: "Befehle:\n/subagents — Status anzeigen\n/subagents max <n> — Parallel-Limit setzen (0=auto)\n/subagents visibility <auto|banner|silent> — Delivery-Modus\n/subagents list — alle anzeigen\n/subagents cancel <name|id> — abbrechen\n/subagents result <name|id> — Ergebnis anzeigen",
524
+ es: "Comandos:\n/subagents — ver estado\n/subagents max <n> — establecer límite (0=auto)\n/subagents visibility <auto|banner|silent> — modo de entrega\n/subagents list — listar todos\n/subagents cancel <nombre|id> — cancelar uno\n/subagents result <nombre|id> — ver resultado",
525
+ fr: "Commandes :\n/subagents — état\n/subagents max <n> — limite parallèle (0=auto)\n/subagents visibility <auto|banner|silent> — mode de livraison\n/subagents list — lister tous\n/subagents cancel <nom|id> — annuler un\n/subagents result <nom|id> — voir résultat",
526
+ },
527
+ "bot.subagents.visibilityLabel": {
528
+ en: "Visibility:",
529
+ de: "Sichtbarkeit:",
530
+ es: "Visibilidad:",
531
+ fr: "Visibilité :",
532
+ },
533
+ "bot.subagents.visibilitySet": {
534
+ en: "✅ Visibility set to *{mode}*",
535
+ de: "✅ Sichtbarkeit auf *{mode}* gesetzt",
536
+ es: "✅ Visibilidad establecida a *{mode}*",
537
+ fr: "✅ Visibilité réglée sur *{mode}*",
538
+ },
539
+ "bot.subagents.visibilityInvalid": {
540
+ en: "❌ Invalid mode _{mode}_. Use: auto | banner | silent",
541
+ de: "❌ Ungültiger Modus _{mode}_. Nutze: auto | banner | silent",
542
+ es: "❌ Modo inválido _{mode}_. Usa: auto | banner | silent",
543
+ fr: "❌ Mode invalide _{mode}_. Utilise : auto | banner | silent",
544
+ },
545
+ // Relative time formatting (formatRelativeTime helper)
546
+ "bot.time.justNow": {
547
+ en: "just now",
548
+ de: "gerade eben",
549
+ es: "justo ahora",
550
+ fr: "à l'instant",
551
+ },
552
+ "bot.time.secondsAgo": {
553
+ en: "{n}s ago",
554
+ de: "vor {n} s",
555
+ es: "hace {n} s",
556
+ fr: "il y a {n} s",
557
+ },
558
+ "bot.time.minutesAgo": {
559
+ en: "{n}min ago",
560
+ de: "vor {n} min",
561
+ es: "hace {n} min",
562
+ fr: "il y a {n} min",
563
+ },
564
+ "bot.time.hoursAgo": {
565
+ en: "{n}h ago",
566
+ de: "vor {n} h",
567
+ es: "hace {n} h",
568
+ fr: "il y a {n} h",
569
+ },
570
+ "bot.time.dayAgo": {
571
+ en: "{n} day ago",
572
+ de: "vor {n} Tag",
573
+ es: "hace {n} día",
574
+ fr: "il y a {n} jour",
575
+ },
576
+ "bot.time.daysAgo": {
577
+ en: "{n} days ago",
578
+ de: "vor {n} Tagen",
579
+ es: "hace {n} días",
580
+ fr: "il y a {n} jours",
581
+ },
164
582
  // ── Default SOUL.md ───────────────────────────────────
165
583
  "soul.default": {
166
584
  en: `# SOUL.md — Your Bot's Personality
@@ -263,21 +681,25 @@ Ich bin nicht statisch. Im Laufe unserer Interaktion lerne ich deine Präferenze
263
681
  };
264
682
  // ── Runtime ─────────────────────────────────────────────
265
683
  let currentLocale = "en";
684
+ const SUPPORTED_LOCALES = ["en", "de", "es", "fr"];
685
+ function isLocale(v) {
686
+ return !!v && SUPPORTED_LOCALES.includes(v);
687
+ }
266
688
  /**
267
689
  * Detect locale from CLI flags and environment.
268
- * Only explicit opt-in switches to German:
269
- * --lang de | ALVIN_LANG=de
690
+ * Explicit opt-in only:
691
+ * --lang <en|de|es|fr> | ALVIN_LANG=<en|de|es|fr>
270
692
  * System LANG is NOT used (too many false positives on multilingual systems).
271
693
  */
272
694
  export function detectLocale() {
273
695
  const langIdx = process.argv.indexOf("--lang");
274
696
  if (langIdx !== -1) {
275
697
  const val = process.argv[langIdx + 1]?.toLowerCase();
276
- if (val === "de" || val === "en")
698
+ if (isLocale(val))
277
699
  return val;
278
700
  }
279
701
  const envLang = process.env.ALVIN_LANG?.toLowerCase();
280
- if (envLang === "de" || envLang === "en")
702
+ if (isLocale(envLang))
281
703
  return envLang;
282
704
  return "en";
283
705
  }
@@ -293,7 +715,42 @@ export function getLocale() {
293
715
  export function setLocale(locale) {
294
716
  currentLocale = locale;
295
717
  }
296
- /** Translate a key. Returns the key itself if not found. */
297
- export function t(key) {
298
- return strings[key]?.[currentLocale] || strings[key]?.["en"] || key;
718
+ /**
719
+ * Simple {var} interpolation. Missing vars leave the placeholder intact
720
+ * so bugs are visible rather than swallowed silently.
721
+ */
722
+ function interpolate(template, vars) {
723
+ return template.replace(/\{(\w+)\}/g, (_, key) => {
724
+ const v = vars[key];
725
+ return v !== undefined ? String(v) : `{${key}}`;
726
+ });
299
727
  }
728
+ /**
729
+ * Translate a key.
730
+ *
731
+ * - If `locale` is passed, it overrides the global currentLocale (this is
732
+ * what the Telegram bot handlers use to pick each user's own language).
733
+ * - Falls back to English if the key is missing in the requested locale.
734
+ * - Falls back to the key itself if missing everywhere (makes missing
735
+ * translations visible in the UI rather than silent empty strings).
736
+ * - Optional {var} interpolation via the third parameter.
737
+ */
738
+ export function t(key, locale, vars) {
739
+ const loc = locale || currentLocale;
740
+ const raw = strings[key]?.[loc] || strings[key]?.["en"] || key;
741
+ return vars ? interpolate(raw, vars) : raw;
742
+ }
743
+ /** Human-readable language names in their own language. */
744
+ export const LOCALE_NAMES = {
745
+ en: "English",
746
+ de: "Deutsch",
747
+ es: "Español",
748
+ fr: "Français",
749
+ };
750
+ /** Flag emoji for a locale. Used in the /language keyboard. */
751
+ export const LOCALE_FLAGS = {
752
+ en: "🇬🇧",
753
+ de: "🇩🇪",
754
+ es: "🇪🇸",
755
+ fr: "🇫🇷",
756
+ };
package/dist/index.js CHANGED
@@ -28,15 +28,22 @@ if (config.allowedUsers.length === 0 && hasTelegram) {
28
28
  console.warn("⚠️ ALLOWED_USERS not set — nobody can message the Telegram bot yet.");
29
29
  console.warn(" Send /start to @userinfobot on Telegram to find your ID.");
30
30
  }
31
- // Check if the chosen provider has a corresponding API key
31
+ // Check if the chosen provider has a corresponding API key.
32
+ // Keys here MUST match the registry keys from src/providers/registry.ts
33
+ // (createRegistry). Providers that authenticate differently (claude-sdk
34
+ // via OAuth, codex-cli/ollama via local binary) are deliberately absent.
35
+ // Custom providers from docs/custom-models.json handle their own apiKeyEnv.
32
36
  const providerKeyMap = {
37
+ google: "GOOGLE_API_KEY",
38
+ // Legacy custom-model aliases kept so older configs don't break their
39
+ // pre-flight warning — if users have these as primary they're coming
40
+ // from docs/custom-models.json.
33
41
  groq: "GROQ_API_KEY",
42
+ openai: "OPENAI_API_KEY",
43
+ openrouter: "OPENROUTER_API_KEY",
34
44
  "nvidia-llama-3.3-70b": "NVIDIA_API_KEY",
35
45
  "nvidia-kimi-k2.5": "NVIDIA_API_KEY",
36
- "gemini-2.5-flash": "GOOGLE_API_KEY",
37
- openai: "OPENAI_API_KEY",
38
46
  "gpt-4o": "OPENAI_API_KEY",
39
- openrouter: "OPENROUTER_API_KEY",
40
47
  };
41
48
  const requiredKey = providerKeyMap[config.primaryProvider];
42
49
  if (requiredKey) {
@@ -69,6 +76,7 @@ import { loadSkills } from "./services/skills.js";
69
76
  import { loadHooks } from "./services/hooks.js";
70
77
  import { registerShutdownHandler } from "./services/restart.js";
71
78
  import { cancelAllSubAgents } from "./services/subagents.js";
79
+ import { getRegistry } from "./engine.js";
72
80
  import { scanAssets } from "./services/asset-index.js";
73
81
  // Scan asset directory and generate INDEX.json + INDEX.md
74
82
  const assetScanResult = scanAssets();
@@ -118,6 +126,15 @@ if (hasMCPConfig()) {
118
126
  let bot = null;
119
127
  if (hasTelegram) {
120
128
  bot = new Bot(config.botToken);
129
+ // Wire the sub-agent delivery router so async agent finals can reach
130
+ // Telegram (cron-spawned agents, user-spawned async finals, shutdown
131
+ // cancellation notifications). Lazy-import avoids a top-level cycle.
132
+ const { attachBotApi } = await import("./services/subagent-delivery.js");
133
+ const botRef = bot;
134
+ attachBotApi({
135
+ sendMessage: (chatId, text, opts) => botRef.api.sendMessage(chatId, text, opts),
136
+ sendDocument: (chatId, doc, opts) => botRef.api.sendDocument(chatId, doc, opts),
137
+ });
121
138
  // Auth middleware — alle Messages durchlaufen das
122
139
  bot.use(authMiddleware);
123
140
  // Commands registrieren
@@ -213,7 +230,10 @@ const shutdown = async () => {
213
230
  return;
214
231
  isShuttingDown = true;
215
232
  console.log("Graceful shutdown initiated...");
216
- cancelAllSubAgents();
233
+ // E2: shutdown-notification — await the async cancellation so running
234
+ // agents can post a cancellation message to Telegram before the bot
235
+ // stops. Capped at 5s internally so a hang can't block shutdown.
236
+ await cancelAllSubAgents(true);
217
237
  stopScheduler();
218
238
  stopSessionCleanup();
219
239
  if (queueInterval)
@@ -224,6 +244,25 @@ const shutdown = async () => {
224
244
  bot.stop();
225
245
  await unloadPlugins().catch(() => { });
226
246
  await disconnectMCP().catch(() => { });
247
+ // Tear down any bot-managed local runners (Ollama, LM Studio, …) so VRAM
248
+ // is freed and no daemon outlives the bot as a zombie. Iterates generically
249
+ // over every registered provider that exposes a lifecycle.
250
+ try {
251
+ const registry = getRegistry();
252
+ const providers = await registry.listAll();
253
+ for (const p of providers) {
254
+ const provider = registry.get(p.key);
255
+ if (provider?.lifecycle?.isBotManaged()) {
256
+ console.log(`Tearing down bot-managed ${p.key}...`);
257
+ await provider.lifecycle.ensureStopped().catch((err) => {
258
+ console.warn(`${p.key} shutdown teardown failed:`, err);
259
+ });
260
+ }
261
+ }
262
+ }
263
+ catch (err) {
264
+ console.warn("lifecycle teardown failed:", err);
265
+ }
227
266
  console.log("Goodbye! 👋");
228
267
  process.exit(0);
229
268
  };