arisa 4.3.5 → 5.1.2

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 (49) hide show
  1. package/AGENTS.md +21 -19
  2. package/README.md +30 -9
  3. package/package.json +6 -2
  4. package/pnpm-workspace.yaml +1 -0
  5. package/src/core/agent/agent-manager.js +288 -29
  6. package/src/core/agent/auth-flow.js +12 -8
  7. package/src/core/agent/model-selection.js +54 -14
  8. package/src/core/agent/model-speed.js +59 -0
  9. package/src/core/config/config-defaults.js +56 -4
  10. package/src/core/config/config-store.js +5 -1
  11. package/src/core/conversation/conversation-history-store.js +142 -0
  12. package/src/core/tasks/task-store.js +16 -0
  13. package/src/core/tools/daemon-health.js +11 -2
  14. package/src/core/tools/daemon-processes.js +92 -2
  15. package/src/core/tools/daemon-runtime.js +4 -2
  16. package/src/core/tools/ipc-client.js +15 -3
  17. package/src/core/tools/tool-registry.js +42 -2
  18. package/src/core/tools/tool-usage-store.js +59 -0
  19. package/src/index.js +61 -6
  20. package/src/runtime/arisa-capabilities.js +45 -1
  21. package/src/runtime/bootstrap.js +3 -2
  22. package/src/runtime/create-app.js +49 -11
  23. package/src/runtime/doctor.js +365 -0
  24. package/src/runtime/log-viewer.js +165 -0
  25. package/src/runtime/paths.js +8 -1
  26. package/src/runtime/report-format.js +51 -0
  27. package/src/runtime/service-manager.js +106 -8
  28. package/src/runtime/tool-process-supervisor.js +107 -10
  29. package/src/runtime/tool-usage-report.js +10 -0
  30. package/src/runtime/update-manager.js +206 -0
  31. package/src/transport/telegram/bot.js +633 -91
  32. package/src/transport/telegram/model-picker.js +28 -2
  33. package/test/agent-tool-policy.test.js +26 -1
  34. package/test/auth-flow.test.js +28 -2
  35. package/test/capabilities-security.test.js +37 -0
  36. package/test/context-and-task-bounds.test.js +280 -0
  37. package/test/daemon-runtime.test.js +130 -2
  38. package/test/dependency-warnings.test.js +17 -0
  39. package/test/doctor.test.js +111 -0
  40. package/test/log-viewer.test.js +90 -0
  41. package/test/model-selection.test.js +125 -2
  42. package/test/paths.test.js +16 -0
  43. package/test/pi-compaction.test.js +43 -0
  44. package/test/service-manager.test.js +237 -0
  45. package/test/task-store.test.js +31 -0
  46. package/test/telegram-text-artifact.test.js +36 -1
  47. package/test/tool-registry-run.test.js +21 -0
  48. package/test/tool-usage.test.js +37 -0
  49. package/test/update-manager.test.js +87 -0
@@ -3,16 +3,61 @@ import path from "node:path";
3
3
  import { authorizeChat } from "./auth.js";
4
4
  import { captureIncomingArtifact, formatLocationText } from "./media.js";
5
5
  import { buildDeviceCodeTelegramMessage } from "./device-code-message.js";
6
- import { buildEffortPicker, buildModelPicker, parseEffortPickerAction, parseModelPickerAction } from "./model-picker.js";
6
+ import { buildEffortPicker, buildModelPicker, buildSpeedPicker, parseEffortPickerAction, parseModelPickerAction, parseSpeedPickerAction, reverseModelOrder } from "./model-picker.js";
7
7
  import { renderTelegramHtml } from "./text-format.js";
8
8
  import { buildPiAuthRecoveryBlockedMessage, buildPiAuthTelegramMessage, getErrorMessage, getPiAuthIssue, getPiAuthStatus } from "../../core/agent/auth-flow.js";
9
9
  import { createPiOAuthLogin } from "../../core/agent/pi-auth-login.js";
10
- import { resolveChatModel, resolveChatThinkingLevel, selectChatModel, selectChatThinkingLevel } from "../../core/agent/model-selection.js";
10
+ import { getAgentConfig, resolveChatModel, resolveChatSpeed, resolveChatThinkingLevel, selectChatModel, selectChatSpeed, selectChatThinkingLevel } from "../../core/agent/model-selection.js";
11
11
  import { clampModelThinkingLevel, createPiRuntime, listModelThinkingLevels, listProviderModels, modelSupportsThinking } from "../../core/agent/pi-runtime.js";
12
+ import { clampModelSpeed, MODEL_SPEEDS, modelSupportsSpeed } from "../../core/agent/model-speed.js";
12
13
  import { normalizeArtifactForReasoning, shouldNormalizeArtifactToText } from "../../core/artifacts/normalize-for-reasoning.js";
14
+ import { formatPortableSessionHistory } from "../../core/agent/agent-manager.js";
15
+ import { ConversationHistoryStore } from "../../core/conversation/conversation-history-store.js";
16
+ import { formatDoctorReport } from "../../runtime/doctor.js";
17
+ import { formatToolUsageReport } from "../../runtime/tool-usage-report.js";
13
18
 
14
19
  const slowPromptNoticeMs = 300_000;
15
20
 
21
+ export const telegramCommands = Object.freeze([
22
+ { command: "new", description: "New chat context" },
23
+ { command: "restart", description: "Restart Arisa" },
24
+ { command: "doctor", description: "Check runtime health" },
25
+ { command: "update", description: "Check for updates" },
26
+ { command: "tools", description: "Tool usage counts" },
27
+ { command: "model", description: "Choose chat model" },
28
+ { command: "effort", description: "Choose reasoning effort" },
29
+ { command: "speed", description: "Choose model speed" },
30
+ { command: "auth", description: "Authentication status" }
31
+ ]);
32
+
33
+ export function createTelegramRestartHandler({ authorize, requestRestart, logger }) {
34
+ if (typeof authorize !== "function" || typeof requestRestart !== "function") {
35
+ throw new Error("Telegram restart requires authorization and restart handoff functions");
36
+ }
37
+
38
+ let restartRequested = false;
39
+ return async (ctx) => {
40
+ const auth = await authorize(ctx);
41
+ if (!auth.ok) return;
42
+
43
+ if (restartRequested) {
44
+ await ctx.reply("An Arisa restart is already in progress.");
45
+ return;
46
+ }
47
+
48
+ restartRequested = true;
49
+ try {
50
+ await ctx.reply("Arisa is restarting. I'll be back shortly.");
51
+ const handoff = await requestRestart();
52
+ logger?.log("telegram", `restart handed off to process ${handoff.pid}`);
53
+ } catch (error) {
54
+ restartRequested = false;
55
+ logger?.error("telegram", `restart handoff failed: ${getErrorMessage(error)}`);
56
+ await ctx.reply(`Arisa could not be restarted: ${getErrorMessage(error)}`);
57
+ }
58
+ };
59
+ }
60
+
16
61
  function quotedMessageSummary(message) {
17
62
  if (!message) return [];
18
63
 
@@ -53,6 +98,67 @@ function getIncomingMessageText(message) {
53
98
  return message?.text || message?.caption || formatLocationText(message) || "";
54
99
  }
55
100
 
101
+ function telegramDisplayName(entity = {}) {
102
+ if (entity.username) return `@${entity.username}`;
103
+ return [entity.first_name, entity.last_name].filter(Boolean).join(" ") || entity.title || "unknown";
104
+ }
105
+
106
+ function forwardedMessageSummary(message) {
107
+ const origin = message?.forward_origin;
108
+ if (!origin) return [];
109
+
110
+ const parts = ["forwarded: true", `forwardedOriginType: ${origin.type}`];
111
+ if (origin.type === "user") parts.push(`forwardedFrom: ${telegramDisplayName(origin.sender_user)}`);
112
+ if (origin.type === "hidden_user") parts.push(`forwardedFrom: ${origin.sender_user_name}`);
113
+ if (origin.type === "chat" || origin.type === "channel") {
114
+ parts.push(`forwardedFrom: ${telegramDisplayName(origin.chat)}`);
115
+ }
116
+ if (origin.type === "channel" && origin.message_id) parts.push(`forwardedMessageId: ${origin.message_id}`);
117
+ if (origin.author_signature) parts.push(`forwardedAuthorSignature: ${origin.author_signature}`);
118
+ if (origin.date) parts.push(`forwardedAt: ${new Date(origin.date * 1000).toISOString()}`);
119
+ return parts;
120
+ }
121
+
122
+ function reactionLabel(reaction = {}) {
123
+ if (reaction.type === "emoji") return reaction.emoji || "emoji";
124
+ if (reaction.type === "custom_emoji") return `custom:${reaction.custom_emoji_id || "unknown"}`;
125
+ if (reaction.type === "paid") return "paid";
126
+ return reaction.type || "unknown";
127
+ }
128
+
129
+ function reactionDifference(left = [], right = []) {
130
+ const remaining = right.map(reactionLabel);
131
+ return left.map(reactionLabel).filter((label) => {
132
+ const index = remaining.indexOf(label);
133
+ if (index < 0) return true;
134
+ remaining.splice(index, 1);
135
+ return false;
136
+ });
137
+ }
138
+
139
+ export function buildReactionPrompt({ reaction, reactedMessageText = "" }) {
140
+ const oldReactions = reaction.old_reaction || [];
141
+ const newReactions = reaction.new_reaction || [];
142
+ const added = reactionDifference(newReactions, oldReactions);
143
+ const removed = reactionDifference(oldReactions, newReactions);
144
+ const actor = reaction.user || reaction.actor_chat || {};
145
+ const actorId = reaction.user?.id || reaction.actor_chat?.id || "unknown";
146
+
147
+ return [
148
+ "Incoming Telegram reaction.",
149
+ `chatId: ${reaction.chat.id}`,
150
+ `userId: ${actorId}`,
151
+ `username: ${reaction.user?.username || "(no username)"}`,
152
+ `reactedMessageId: ${reaction.message_id}`,
153
+ reactedMessageText ? `reactedMessageText: ${reactedMessageText}` : null,
154
+ added.length ? `addedReactions: ${added.join(" ")}` : null,
155
+ removed.length ? `removedReactions: ${removed.join(" ")}` : null,
156
+ `currentReactions: ${newReactions.map(reactionLabel).join(" ") || "none"}`,
157
+ `actor: ${telegramDisplayName(actor)}`,
158
+ "Treat this as lightweight feedback on the referenced message. Respond only if the reaction clearly requests action; otherwise stay silent."
159
+ ].filter(Boolean).join("\n");
160
+ }
161
+
56
162
  function baseMimeType(mimeType = "") {
57
163
  return mimeType.split(";")[0].trim().toLowerCase();
58
164
  }
@@ -80,6 +186,7 @@ export function buildPrompt({ ctx, artifact, transcript, toolResult }) {
80
186
 
81
187
  const messageText = getIncomingMessageText(ctx.message);
82
188
  if (messageText) parts.push(`text: ${messageText}`);
189
+ parts.push(...forwardedMessageSummary(ctx.message));
83
190
  parts.push(...quotedMessageSummary(ctx.message?.reply_to_message));
84
191
  if (shouldIncludeArtifactReference({ artifact, messageText })) {
85
192
  if (artifact?.path) parts.push(`artifactPath: ${artifact.path}`);
@@ -219,12 +326,13 @@ function buildStartupMessage(chatMeta = {}) {
219
326
  return "Arisa is back online.";
220
327
  }
221
328
 
222
- async function collectText(session, prompt, { logger, chatId, onSlowPrompt } = {}) {
329
+ export async function collectText(session, prompt, { logger, chatId, onSlowPrompt } = {}) {
223
330
  let text = "";
224
331
  let assistantErrorMessage = "";
225
332
  let shouldSeparateAssistantMessage = false;
226
333
  let slowPromptTimer = null;
227
334
  const unsubscribe = session.subscribe((event) => {
335
+ if (event.arisaPromptScoped === false) return;
228
336
  if (event.type === "message_start" && event.message.role === "assistant") {
229
337
  shouldSeparateAssistantMessage = text.trim().length > 0;
230
338
  }
@@ -235,8 +343,13 @@ async function collectText(session, prompt, { logger, chatId, onSlowPrompt } = {
235
343
  }
236
344
  text += event.assistantMessageEvent.delta;
237
345
  }
238
- if (event.type === "message_end" && event.message?.stopReason === "error") {
239
- assistantErrorMessage = event.message.errorMessage || "assistant message ended with error";
346
+ if (event.type === "message_end" && event.message?.role === "assistant") {
347
+ if (event.message.stopReason === "error") {
348
+ assistantErrorMessage = event.message.errorMessage || "assistant message ended with error";
349
+ } else if (event.message.stopReason !== "aborted") {
350
+ // Auto-compaction and retry can emit a transient error before a successful continuation.
351
+ assistantErrorMessage = "";
352
+ }
240
353
  }
241
354
  const logMessage = sessionEventLogMessage(event);
242
355
  if (logMessage) logger?.log("agent", `chat ${chatId} ${logMessage}`);
@@ -265,6 +378,32 @@ async function collectText(session, prompt, { logger, chatId, onSlowPrompt } = {
265
378
  return text.trim();
266
379
  }
267
380
 
381
+ export function isSilentReply(text) {
382
+ return /^(?:NO_REPLY|No reply needed\.|No action needed\.)(?:\s+(?:NO_REPLY|No reply needed\.|No action needed\.))*$/.test(String(text || "").trim());
383
+ }
384
+
385
+ function buildSessionHandoffPrompt() {
386
+ return [
387
+ "Prepare a concise handoff for the next Arisa session.",
388
+ "Review the entire active session, including any previous compaction summaries and the latest messages.",
389
+ "Keep only durable context: current goals or projects, decisions, user preferences, unresolved tasks, and important facts needed to continue.",
390
+ "Use at most 8 short bullets and at most 1600 characters.",
391
+ "Exclude secrets, tokens, passwords, cookies, API keys, private file paths, full transcripts, and stale chatter.",
392
+ "Do not take actions, call tools, send messages, or explain the process.",
393
+ "Return only the handoff."
394
+ ].join("\n");
395
+ }
396
+
397
+ function sanitizeSessionHandoff(text) {
398
+ const sanitized = String(text || "")
399
+ .replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/gi, "[redacted private key]")
400
+ .replace(/\b(?:sk-[A-Za-z0-9_-]{12,}|gh[opsu]_[A-Za-z0-9_-]{12,}|Bearer\s+[A-Za-z0-9._-]+)\b/gi, "[redacted credential]")
401
+ .replace(/(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|client[_ -]?secret|password|cookie|secret)\s*[:=]\s*[^\s,;]+/gi, "[redacted credential]")
402
+ .trim();
403
+ if (sanitized.length <= 4000) return sanitized;
404
+ return `${sanitized.slice(0, 3997).trim()}...`;
405
+ }
406
+
268
407
  async function withTyping(ctx, work) {
269
408
  await ctx.api.sendChatAction(ctx.chat.id, "typing");
270
409
  const timer = setInterval(() => {
@@ -278,14 +417,152 @@ async function withTyping(ctx, work) {
278
417
  }
279
418
  }
280
419
 
281
- export async function createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger }) {
420
+ export function createChatStateStore() {
421
+ const states = new Map();
422
+
423
+ function reset(chatId) {
424
+ const state = {
425
+ processing: false,
426
+ pendingPrompts: [],
427
+ continueAfterClose: false,
428
+ historyRevision: 0,
429
+ beforeNextPrompt: null,
430
+ activeSession: null,
431
+ activeSteers: [],
432
+ assistantMessages: new Map()
433
+ };
434
+ states.set(String(chatId), state);
435
+ return state;
436
+ }
437
+
438
+ return {
439
+ get(chatId) {
440
+ const key = String(chatId);
441
+ return states.get(key) || reset(key);
442
+ },
443
+ reset,
444
+ anyProcessing() {
445
+ return [...states.values()].some((state) => state.processing);
446
+ }
447
+ };
448
+ }
449
+
450
+ export function queueChatPrompt(chatState, prompt, { replace = false } = {}) {
451
+ if (replace) chatState.pendingPrompts = [];
452
+ chatState.pendingPrompts.push(prompt);
453
+ }
454
+
455
+ function takeQueuedPrompt(chatState) {
456
+ return chatState.pendingPrompts.shift() || "";
457
+ }
458
+
459
+ export function resolveTelegramBusyMessageMode(config, chatId) {
460
+ const chatMode = config.telegram?.chatMeta?.[String(chatId)]?.busyMessageMode;
461
+ const mode = chatMode || config.telegram?.busyMessageMode;
462
+ return mode === "steer" ? "steer" : "queue";
463
+ }
464
+
465
+ export async function routeBusyPrompt({ chatState, prompt, mode = "queue", replaceQueued = false }) {
466
+ const session = chatState.activeSession;
467
+ if (
468
+ mode === "steer"
469
+ && !replaceQueued
470
+ && !chatState.continueAfterClose
471
+ && !chatState.beforeNextPrompt
472
+ && session?.isStreaming
473
+ && typeof session.steer === "function"
474
+ ) {
475
+ try {
476
+ await session.steer(prompt);
477
+ chatState.activeSteers.push(prompt);
478
+ return { disposition: "steered" };
479
+ } catch (error) {
480
+ queueChatPrompt(chatState, prompt);
481
+ return { disposition: "queued", steerError: error };
482
+ }
483
+ }
484
+
485
+ queueChatPrompt(chatState, prompt, { replace: replaceQueued });
486
+ return { disposition: "queued" };
487
+ }
488
+
489
+ export async function drainChatPromptQueue({
490
+ chatState,
491
+ initialPrompt,
492
+ initialCtx = null,
493
+ processPrompt,
494
+ onPromptFailure,
495
+ onPromptInterrupted,
496
+ beforeInitialPrompt
497
+ }) {
498
+ let currentPrompt = initialPrompt;
499
+ let currentCtx = initialCtx;
500
+
501
+ try {
502
+ await beforeInitialPrompt?.();
503
+ while (currentPrompt) {
504
+ while (chatState.beforeNextPrompt) {
505
+ const gate = chatState.beforeNextPrompt;
506
+ await gate;
507
+ if (chatState.beforeNextPrompt === gate) chatState.beforeNextPrompt = null;
508
+ }
509
+ if (chatState.continueAfterClose && chatState.pendingPrompts.length) {
510
+ currentPrompt = takeQueuedPrompt(chatState);
511
+ chatState.continueAfterClose = false;
512
+ currentCtx = null;
513
+ }
514
+ try {
515
+ await processPrompt({ prompt: currentPrompt, ctx: currentCtx });
516
+ } catch (error) {
517
+ if (chatState.continueAfterClose && chatState.pendingPrompts.length) {
518
+ await onPromptInterrupted?.(error);
519
+ } else {
520
+ await onPromptFailure?.(error);
521
+ throw error;
522
+ }
523
+ } finally {
524
+ currentCtx = null;
525
+ }
526
+
527
+ currentPrompt = takeQueuedPrompt(chatState);
528
+ chatState.continueAfterClose = false;
529
+ }
530
+ } finally {
531
+ chatState.processing = false;
532
+ chatState.activeSession = null;
533
+ chatState.activeSteers = [];
534
+ }
535
+ }
536
+
537
+ export async function closeModelPicker(ctx, { messageText, callbackText }) {
538
+ await ctx.api.editMessageText(
539
+ ctx.chat.id,
540
+ ctx.callbackQuery.message.message_id,
541
+ messageText
542
+ );
543
+ await ctx.answerCallbackQuery({ text: callbackText });
544
+ }
545
+
546
+ export async function createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, doctor, checkUpdates, requestRestart, logger }) {
282
547
  const bot = new Bot(config.telegram.token);
283
- const perChatState = new Map();
548
+ const perChatState = createChatStateStore();
549
+ const conversationHistory = new ConversationHistoryStore();
284
550
  const notifiedPromptErrors = new WeakSet();
285
551
  const authRenewals = new Map();
286
552
  let piAuthIssue = null;
287
553
  let taskTimer = null;
288
554
 
555
+ const handleRestartCommand = createTelegramRestartHandler({
556
+ authorize: (ctx) => authorizeChat({
557
+ config,
558
+ chatId: ctx.chat.id,
559
+ saveConfig,
560
+ chatMeta: getIncomingChatMeta(ctx)
561
+ }),
562
+ requestRestart,
563
+ logger
564
+ });
565
+
289
566
  function chatKey(chatId) {
290
567
  return String(chatId);
291
568
  }
@@ -309,7 +586,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
309
586
  if (!issue) return false;
310
587
 
311
588
  try {
312
- await bot.api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, issue }));
589
+ await bot.api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, chatId, issue }));
313
590
  markPromptErrorNotified(error);
314
591
  return true;
315
592
  } catch (notifyError) {
@@ -328,16 +605,16 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
328
605
  async function finishAuthRenewal(chatId, renewal) {
329
606
  try {
330
607
  await renewal.promise;
331
- await agentManager.validatePiAgent();
608
+ await agentManager.validateAgent();
332
609
  agentManager.clearSessionCache(chatId);
333
610
  piAuthIssue = null;
334
611
  logger?.log("telegram", `Pi auth renewal completed for chat ${chatId}`);
335
- await bot.api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, verified: true }));
612
+ await bot.api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, chatId, verified: true }));
336
613
  } catch (error) {
337
614
  const issue = rememberPiAuthIssue(error) || { kind: "validation-failed", message: getErrorMessage(error) };
338
615
  piAuthIssue = issue;
339
616
  logger?.error("telegram", `Pi auth renewal failed for chat ${chatId}: ${getErrorMessage(error)}`);
340
- await bot.api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, issue })).catch((notifyError) => {
617
+ await bot.api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, chatId, issue })).catch((notifyError) => {
341
618
  logger?.error("telegram", `auth renewal failure notice failed for chat ${chatId}: ${getErrorMessage(notifyError)}`);
342
619
  });
343
620
  } finally {
@@ -407,26 +684,25 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
407
684
  }
408
685
 
409
686
  function getChatState(chatId) {
410
- if (!perChatState.has(chatId)) {
411
- perChatState.set(chatId, { processing: false, nextPrompt: "" });
412
- }
413
687
  return perChatState.get(chatId);
414
688
  }
415
689
 
416
- function getProviderModels() {
690
+ async function getProviderModels(chatId) {
417
691
  const runtime = createPiRuntime({
418
692
  provider: config.pi.provider,
419
693
  apiKey: config.pi.apiKey
420
694
  });
421
- return listProviderModels(config.pi.provider, runtime);
695
+ return reverseModelOrder(listProviderModels(config.pi.provider, runtime));
422
696
  }
423
697
 
424
698
  async function showModelPicker(ctx, page = 0) {
699
+ const agentConfig = getAgentConfig(config);
425
700
  const picker = buildModelPicker({
426
- provider: config.pi.provider,
427
- models: getProviderModels(),
701
+ provider: agentConfig.provider,
702
+ models: await getProviderModels(ctx.chat.id),
428
703
  selectedModelId: resolveChatModel(config, ctx.chat.id),
429
704
  selectedThinkingLevel: resolveChatThinkingLevel(config, ctx.chat.id),
705
+ selectedSpeed: resolveChatSpeed(config, ctx.chat.id),
430
706
  page,
431
707
  pageSize: config.telegram.modelPickerPageSize
432
708
  });
@@ -439,10 +715,11 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
439
715
  }
440
716
 
441
717
  async function showEffortPicker(ctx, { model, modelIndex, selectedThinkingLevel } = {}) {
442
- const models = getProviderModels();
718
+ const agentConfig = getAgentConfig(config);
719
+ const models = await getProviderModels(ctx.chat.id);
443
720
  const resolvedModel = model || models.find((item) => item.id === resolveChatModel(config, ctx.chat.id));
444
721
  if (!resolvedModel) {
445
- throw new Error(`Model not found for provider ${config.pi.provider}`);
722
+ throw new Error(`Model not found for provider ${agentConfig.provider}`);
446
723
  }
447
724
  if (!modelSupportsThinking(resolvedModel)) {
448
725
  const text = `${resolvedModel.provider}/${resolvedModel.id} does not support effort levels.`;
@@ -468,20 +745,46 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
468
745
  return ctx.reply(picker.text, extra);
469
746
  }
470
747
 
748
+ async function showSpeedPicker(ctx) {
749
+ const agentConfig = getAgentConfig(config);
750
+ const models = await getProviderModels(ctx.chat.id);
751
+ const model = models.find((item) => item.id === resolveChatModel(config, ctx.chat.id));
752
+ if (!model) throw new Error(`Model not found for provider ${agentConfig.provider}`);
753
+ if (!modelSupportsSpeed(model)) {
754
+ const text = `${model.provider}/${model.id} does not support speed 1.5x.`;
755
+ if (ctx.callbackQuery?.message?.message_id) {
756
+ return ctx.api.editMessageText(ctx.chat.id, ctx.callbackQuery.message.message_id, text);
757
+ }
758
+ return ctx.reply(text);
759
+ }
760
+ const picker = buildSpeedPicker({
761
+ provider: model.provider,
762
+ modelId: model.id,
763
+ speeds: MODEL_SPEEDS,
764
+ selectedSpeed: resolveChatSpeed(config, ctx.chat.id)
765
+ });
766
+ const extra = { reply_markup: picker.replyMarkup };
767
+ const messageId = ctx.callbackQuery?.message?.message_id;
768
+ if (messageId) return ctx.api.editMessageText(ctx.chat.id, messageId, picker.text, extra);
769
+ return ctx.reply(picker.text, extra);
770
+ }
771
+
471
772
  async function persistChatModel(chatId, model, thinkingLevel) {
773
+ const agentConfig = getAgentConfig(config);
472
774
  const key = chatKey(chatId);
473
- const hadSelections = Boolean(config.pi.chatModels);
474
- const previousSelection = config.pi.chatModels?.[key];
775
+ const hadSelections = Boolean(agentConfig.chatModels);
776
+ const previousSelection = agentConfig.chatModels?.[key];
475
777
  const level = clampModelThinkingLevel(model, thinkingLevel ?? resolveChatThinkingLevel(config, chatId));
476
- selectChatModel(config, chatId, model, { thinkingLevel: level });
778
+ const speed = clampModelSpeed(model, resolveChatSpeed(config, chatId));
779
+ selectChatModel(config, chatId, model, { thinkingLevel: level, speed });
477
780
  try {
478
781
  await saveConfig(config);
479
782
  } catch (error) {
480
783
  if (previousSelection) {
481
- config.pi.chatModels[key] = previousSelection;
784
+ agentConfig.chatModels[key] = previousSelection;
482
785
  } else {
483
- delete config.pi.chatModels[key];
484
- if (!hadSelections) delete config.pi.chatModels;
786
+ delete agentConfig.chatModels[key];
787
+ if (!hadSelections) delete agentConfig.chatModels;
485
788
  }
486
789
  throw error;
487
790
  }
@@ -490,20 +793,44 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
490
793
  }
491
794
 
492
795
  async function persistChatEffort(chatId, model, thinkingLevel) {
796
+ const agentConfig = getAgentConfig(config);
493
797
  const key = chatKey(chatId);
494
- const hadSelections = Boolean(config.pi.chatModels);
495
- const previousSelection = config.pi.chatModels?.[key];
798
+ const hadSelections = Boolean(agentConfig.chatModels);
799
+ const previousSelection = agentConfig.chatModels?.[key];
496
800
  const level = clampModelThinkingLevel(model, thinkingLevel);
497
801
  selectChatThinkingLevel(config, chatId, level);
498
802
  try {
499
803
  await saveConfig(config);
500
804
  } catch (error) {
501
805
  if (previousSelection) {
502
- config.pi.chatModels[key] = previousSelection;
806
+ agentConfig.chatModels[key] = previousSelection;
807
+ } else {
808
+ delete agentConfig.chatModels[key];
809
+ if (!hadSelections) delete agentConfig.chatModels;
810
+ }
811
+ throw error;
812
+ }
813
+ return level;
814
+ }
815
+
816
+ async function persistChatSpeed(chatId, model, speed) {
817
+ const agentConfig = getAgentConfig(config);
818
+ const key = chatKey(chatId);
819
+ const hadSelections = Boolean(agentConfig.chatModels);
820
+ const previousSelection = agentConfig.chatModels?.[key];
821
+ const level = clampModelSpeed(model, speed);
822
+ await agentManager.setModelSpeed(chatId, level);
823
+ selectChatSpeed(config, chatId, level);
824
+ try {
825
+ await saveConfig(config);
826
+ } catch (error) {
827
+ if (previousSelection) {
828
+ agentConfig.chatModels[key] = previousSelection;
503
829
  } else {
504
- delete config.pi.chatModels[key];
505
- if (!hadSelections) delete config.pi.chatModels;
830
+ delete agentConfig.chatModels[key];
831
+ if (!hadSelections) delete agentConfig.chatModels;
506
832
  }
833
+ agentManager.clearSessionCache(chatId);
507
834
  throw error;
508
835
  }
509
836
  return level;
@@ -526,6 +853,11 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
526
853
  async function sendTextReply({ sendText, sendDocument, chatId, text }) {
527
854
  const maxInlineReplyLength = 3500;
528
855
 
856
+ if (isSilentReply(text)) {
857
+ logger?.log("telegram", `suppressing silent reply for chat ${chatId}`);
858
+ return;
859
+ }
860
+
529
861
  if (text.length > maxInlineReplyLength) {
530
862
  logger?.log("telegram", `sending long reply as markdown attachment for chat ${chatId}`);
531
863
  const chatArtifactStore = artifactStore.forChat(chatId);
@@ -544,7 +876,12 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
544
876
  }
545
877
 
546
878
  logger?.log("telegram", `sending text reply for chat ${chatId}`);
547
- await sendText(renderTelegramHtml(text), { parse_mode: "HTML" });
879
+ const sent = await sendText(renderTelegramHtml(text), { parse_mode: "HTML" });
880
+ if (sent?.message_id) {
881
+ const messages = getChatState(chatId).assistantMessages;
882
+ messages.set(sent.message_id, text);
883
+ while (messages.size > 50) messages.delete(messages.keys().next().value);
884
+ }
548
885
  }
549
886
 
550
887
  function createTelegramSessionBridge(chatId) {
@@ -561,10 +898,35 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
561
898
  };
562
899
  }
563
900
 
901
+ agentManager.setArtifactDeliveryHandler?.(async ({ chatId, artifact, caption, method }) => {
902
+ const resolvedMethod = method
903
+ || artifact.metadata?.delivery?.method
904
+ || (artifact.kind === "audio" || artifact.mimeType?.startsWith("audio/") ? "audio"
905
+ : artifact.kind === "image" || artifact.mimeType?.startsWith("image/") ? "photo"
906
+ : artifact.kind === "video" || artifact.mimeType?.startsWith("video/") ? "video"
907
+ : "document");
908
+ const safeCaption = caption && !/(^|\s)(\/[^\s]|[A-Za-z]:[\\/])/.test(caption) ? caption : undefined;
909
+ await createTelegramSessionBridge(chatId).sendMedia(artifact.path, {
910
+ method: resolvedMethod,
911
+ caption: safeCaption,
912
+ filename: path.basename(artifact.path)
913
+ });
914
+ return { ok: true, artifactId: artifact.id, method: resolvedMethod };
915
+ });
916
+
564
917
  async function processPromptForChat({ chatId, prompt, ctx = null }) {
565
918
  const work = async () => {
566
919
  const { session } = await agentManager.getSessionContext(chatId, createTelegramSessionBridge(chatId));
920
+ const historyRevision = getChatState(chatId).historyRevision;
921
+ await conversationHistory.ensureSeed(chatId, {
922
+ runtime: "pi",
923
+ history: formatPortableSessionHistory(session.messages)
924
+ });
567
925
  let text = "";
926
+ let steeredPrompts = [];
927
+ const chatState = getChatState(chatId);
928
+ chatState.activeSession = session;
929
+ chatState.activeSteers = [];
568
930
  try {
569
931
  text = await collectText(session, prompt, {
570
932
  logger,
@@ -577,6 +939,20 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
577
939
  } catch (error) {
578
940
  agentManager.resetSession(chatId);
579
941
  throw error;
942
+ } finally {
943
+ steeredPrompts = [...chatState.activeSteers];
944
+ if (chatState.activeSession === session) chatState.activeSession = null;
945
+ chatState.activeSteers = [];
946
+ }
947
+ if (getChatState(chatId).historyRevision === historyRevision) {
948
+ const historyPrompt = steeredPrompts.length
949
+ ? [prompt, ...steeredPrompts.map((message) => `[Steering message]\n${message}`)].join("\n\n")
950
+ : prompt;
951
+ await conversationHistory.appendTurn(chatId, {
952
+ runtime: "pi",
953
+ prompt: historyPrompt,
954
+ response: text
955
+ });
580
956
  }
581
957
  if (text) {
582
958
  await sendTextReply({
@@ -592,46 +968,53 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
592
968
  return work();
593
969
  }
594
970
 
595
- async function enqueuePrompt({ chatId, prompt, label, ctx = null }) {
971
+ async function enqueuePrompt({ chatId, prompt, label, ctx = null, replaceQueued = false, busyMessageMode = "queue" }) {
596
972
  const chatState = getChatState(chatId);
597
973
 
598
974
  if (chatState.processing) {
599
- logger?.log("telegram", `chat ${chatId} busy, queueing ${label}`);
600
- chatState.nextPrompt = chatState.nextPrompt
601
- ? `${chatState.nextPrompt}\n\n${prompt}`
602
- : prompt;
975
+ const routed = await routeBusyPrompt({
976
+ chatState,
977
+ prompt,
978
+ mode: busyMessageMode,
979
+ replaceQueued
980
+ });
981
+ if (routed.disposition === "steered") {
982
+ logger?.log("telegram", `chat ${chatId} busy, steering ${label}`);
983
+ } else {
984
+ logger?.log("telegram", `chat ${chatId} busy, queueing ${label}`);
985
+ if (routed.steerError) {
986
+ logger?.log("telegram", `steer failed for chat ${chatId}, queued instead: ${getErrorMessage(routed.steerError)}`);
987
+ }
988
+ }
989
+ if (replaceQueued) chatState.continueAfterClose = true;
603
990
  return;
604
991
  }
605
992
 
606
993
  chatState.processing = true;
607
994
  logger?.log("telegram", `processing ${label} in chat ${chatId}`);
608
- let currentPrompt = prompt;
609
- let currentCtx = ctx;
610
-
611
- try {
612
- while (currentPrompt) {
613
- try {
614
- logger?.log("telegram", `prompt dispatch for chat ${chatId}`);
615
- await processPromptForChat({ chatId, prompt: currentPrompt, ctx: currentCtx });
616
- } catch (error) {
617
- const message = getErrorMessage(error);
618
- logger?.error("telegram", `${label} failed for chat ${chatId}: ${message}`);
619
- await notifyPiAuthIssueIfNeeded(chatId, error);
620
- throw error;
621
- } finally {
622
- currentCtx = null;
623
- }
995
+ return processChatPromptQueue({ chatId, prompt, label, ctx });
996
+ }
624
997
 
625
- if (chatState.nextPrompt) {
626
- currentPrompt = chatState.nextPrompt;
627
- chatState.nextPrompt = "";
628
- } else {
629
- currentPrompt = "";
630
- }
998
+ function processChatPromptQueue({ chatId, prompt, label, ctx = null, beforeInitialPrompt }) {
999
+ const chatState = getChatState(chatId);
1000
+ return drainChatPromptQueue({
1001
+ chatState,
1002
+ initialPrompt: prompt,
1003
+ initialCtx: ctx,
1004
+ beforeInitialPrompt,
1005
+ processPrompt: ({ prompt: currentPrompt, ctx: currentCtx }) => {
1006
+ logger?.log("telegram", `prompt dispatch for chat ${chatId}`);
1007
+ return processPromptForChat({ chatId, prompt: currentPrompt, ctx: currentCtx });
1008
+ },
1009
+ onPromptInterrupted: (error) => {
1010
+ logger?.log("telegram", `${label} interrupted by queued /new for chat ${chatId}: ${getErrorMessage(error)}`);
1011
+ },
1012
+ onPromptFailure: async (error) => {
1013
+ const message = getErrorMessage(error);
1014
+ logger?.error("telegram", `${label} failed for chat ${chatId}: ${message}`);
1015
+ await notifyPiAuthIssueIfNeeded(chatId, error);
631
1016
  }
632
- } finally {
633
- chatState.processing = false;
634
- }
1017
+ });
635
1018
  }
636
1019
 
637
1020
  async function enqueueOrProcess(ctx) {
@@ -639,10 +1022,14 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
639
1022
 
640
1023
  if (chatState.processing) {
641
1024
  const incomingPrompt = await buildIncomingPrompt(ctx);
1025
+ const busyMessageMode = typeof ctx.message?.text === "string"
1026
+ ? resolveTelegramBusyMessageMode(config, ctx.chat.id)
1027
+ : "queue";
642
1028
  return enqueuePrompt({
643
1029
  chatId: ctx.chat.id,
644
1030
  prompt: incomingPrompt,
645
- label: `message ${ctx.msg.message_id}`
1031
+ label: `message ${ctx.msg.message_id}`,
1032
+ busyMessageMode
646
1033
  });
647
1034
  }
648
1035
 
@@ -747,14 +1134,61 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
747
1134
  }
748
1135
  }
749
1136
 
1137
+ async function summarizeSessionBeforeReset(chatId) {
1138
+ try {
1139
+ const context = await agentManager.getSessionContext(chatId, createTelegramSessionBridge(chatId));
1140
+ const parentSession = context.session.sessionFile || "";
1141
+ if (!context.session.messages.length) return { handoff: "", parentSession: "" };
1142
+
1143
+ const summary = await collectText(context.session, buildSessionHandoffPrompt(), { logger, chatId });
1144
+ return { handoff: sanitizeSessionHandoff(summary), parentSession };
1145
+ } catch (error) {
1146
+ logger?.log("agent", `session handoff summary failed for chat ${chatId}: ${getErrorMessage(error)}`);
1147
+ return { handoff: "", parentSession: "" };
1148
+ }
1149
+ }
1150
+
750
1151
  async function handleNewCommand(ctx) {
751
- agentManager.resetSession(ctx.chat.id);
752
- perChatState.set(ctx.chat.id, { processing: false, nextPrompt: "" });
753
- await enqueuePrompt({
1152
+ const chatState = getChatState(ctx.chat.id);
1153
+ const wasProcessing = chatState.processing;
1154
+ chatState.historyRevision += 1;
1155
+ const commandRevision = chatState.historyRevision;
1156
+ const prompt = buildNewSessionPrompt(ctx);
1157
+
1158
+ if (wasProcessing) {
1159
+ logger?.log("telegram", `chat ${ctx.chat.id} busy, queueing new-session command`);
1160
+ queueChatPrompt(chatState, prompt, { replace: true });
1161
+ chatState.continueAfterClose = true;
1162
+ const reset = (async () => {
1163
+ await conversationHistory.reset(ctx.chat.id, { runtime: "pi" });
1164
+ agentManager.resetSession(ctx.chat.id);
1165
+ })();
1166
+ chatState.beforeNextPrompt = reset;
1167
+ try {
1168
+ await reset;
1169
+ } finally {
1170
+ if (chatState.beforeNextPrompt === reset) chatState.beforeNextPrompt = null;
1171
+ }
1172
+ return;
1173
+ }
1174
+
1175
+ chatState.processing = true;
1176
+ logger?.log("telegram", `processing new-session command in chat ${ctx.chat.id}`);
1177
+ await processChatPromptQueue({
754
1178
  chatId: ctx.chat.id,
755
- prompt: buildNewSessionPrompt(ctx),
1179
+ prompt,
756
1180
  label: "new-session command",
757
- ctx
1181
+ ctx,
1182
+ beforeInitialPrompt: async () => {
1183
+ const handoff = await withTyping(ctx, () => summarizeSessionBeforeReset(ctx.chat.id));
1184
+ if (chatState.historyRevision !== commandRevision) return;
1185
+ await conversationHistory.reset(ctx.chat.id, {
1186
+ runtime: "pi",
1187
+ history: handoff.handoff
1188
+ });
1189
+ if (chatState.historyRevision !== commandRevision) return;
1190
+ agentManager.resetSession(ctx.chat.id, handoff);
1191
+ }
758
1192
  });
759
1193
  }
760
1194
 
@@ -775,6 +1209,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
775
1209
  if (piAuthIssue) {
776
1210
  await ctx.reply(buildPiAuthRecoveryBlockedMessage({
777
1211
  config,
1212
+ chatId: ctx.chat.id,
778
1213
  issue: piAuthIssue,
779
1214
  renewalActive: authRenewals.has(chatKey(ctx.chat.id))
780
1215
  }));
@@ -783,6 +1218,48 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
783
1218
  await handleNewCommand(ctx);
784
1219
  });
785
1220
 
1221
+ bot.command("restart", handleRestartCommand);
1222
+
1223
+ bot.command("doctor", async (ctx) => {
1224
+ const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
1225
+ if (!auth.ok) return;
1226
+ const pending = await ctx.reply(renderTelegramHtml("```text\nRunning Arisa Doctor…\n```"), { parse_mode: "HTML" });
1227
+ try {
1228
+ await ctx.api.editMessageText(
1229
+ ctx.chat.id,
1230
+ pending.message_id,
1231
+ renderTelegramHtml(formatDoctorReport(await doctor())),
1232
+ { parse_mode: "HTML" }
1233
+ );
1234
+ } catch (error) {
1235
+ logger?.error("doctor", `doctor command failed: ${getErrorMessage(error)}`);
1236
+ await ctx.api.editMessageText(ctx.chat.id, pending.message_id, `Arisa Doctor failed: ${getErrorMessage(error)}`);
1237
+ }
1238
+ });
1239
+
1240
+ bot.command("update", async (ctx) => {
1241
+ const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
1242
+ if (!auth.ok) return;
1243
+ const pending = await ctx.reply(renderTelegramHtml("```text\nChecking Arisa and official tool updates…\n```"), { parse_mode: "HTML" });
1244
+ try {
1245
+ await ctx.api.editMessageText(
1246
+ ctx.chat.id,
1247
+ pending.message_id,
1248
+ renderTelegramHtml(await checkUpdates(ctx.chat.id)),
1249
+ { parse_mode: "HTML" }
1250
+ );
1251
+ } catch (error) {
1252
+ logger?.error("update", `update check failed: ${getErrorMessage(error)}`);
1253
+ await ctx.api.editMessageText(ctx.chat.id, pending.message_id, `Arisa update check failed: ${getErrorMessage(error)}`);
1254
+ }
1255
+ });
1256
+
1257
+ bot.command("tools", async (ctx) => {
1258
+ const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
1259
+ if (!auth.ok) return;
1260
+ await ctx.reply(renderTelegramHtml(formatToolUsageReport(await toolRegistry.usage(ctx.chat.id))), { parse_mode: "HTML" });
1261
+ });
1262
+
786
1263
  bot.command("model", async (ctx) => {
787
1264
  const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
788
1265
  if (!auth.ok) return;
@@ -795,22 +1272,28 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
795
1272
  await showEffortPicker(ctx);
796
1273
  });
797
1274
 
1275
+ bot.command("speed", async (ctx) => {
1276
+ const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
1277
+ if (!auth.ok) return;
1278
+ await showSpeedPicker(ctx);
1279
+ });
1280
+
798
1281
  bot.command("auth", async (ctx) => {
799
1282
  const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
800
1283
  if (!auth.ok) return;
801
1284
 
802
- const status = getPiAuthStatus(config);
1285
+ const status = getPiAuthStatus(config, ctx.chat.id);
803
1286
  if (status.hasApiKey || !status.supportsOAuth) {
804
1287
  await withTyping(ctx, async () => {
805
1288
  try {
806
- await agentManager.validatePiAgent();
1289
+ await agentManager.validateAgent();
807
1290
  agentManager.clearSessionCache(ctx.chat.id);
808
1291
  piAuthIssue = null;
809
- await ctx.reply(buildPiAuthTelegramMessage({ config, verified: true }));
1292
+ await ctx.reply(buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, verified: true }));
810
1293
  } catch (error) {
811
1294
  const issue = rememberPiAuthIssue(error) || { kind: "validation-failed", message: getErrorMessage(error) };
812
1295
  piAuthIssue = issue;
813
- await ctx.reply(buildPiAuthTelegramMessage({ config, issue }));
1296
+ await ctx.reply(buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, issue }));
814
1297
  }
815
1298
  });
816
1299
  return;
@@ -824,14 +1307,15 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
824
1307
  } catch (error) {
825
1308
  const issue = rememberPiAuthIssue(error) || { kind: "validation-failed", message: getErrorMessage(error) };
826
1309
  piAuthIssue = issue;
827
- await ctx.reply(buildPiAuthTelegramMessage({ config, issue }));
1310
+ await ctx.reply(buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, issue }));
828
1311
  }
829
1312
  });
830
1313
 
831
1314
  bot.on("callback_query:data", async (ctx, next) => {
832
1315
  const modelAction = parseModelPickerAction(ctx.callbackQuery.data);
833
1316
  const effortAction = modelAction ? null : parseEffortPickerAction(ctx.callbackQuery.data);
834
- const action = modelAction || effortAction;
1317
+ const speedAction = modelAction || effortAction ? null : parseSpeedPickerAction(ctx.callbackQuery.data);
1318
+ const action = modelAction || effortAction || speedAction;
835
1319
  if (!action) return next();
836
1320
  if (action.type === "noop") {
837
1321
  await ctx.answerCallbackQuery();
@@ -851,7 +1335,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
851
1335
  return;
852
1336
  }
853
1337
 
854
- const models = getProviderModels();
1338
+ const models = await getProviderModels(ctx.chat.id);
855
1339
  const chatBusy = getChatState(ctx.chat.id).processing;
856
1340
 
857
1341
  if (action.type === "select") {
@@ -886,7 +1370,10 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
886
1370
  const currentModelId = resolveChatModel(config, ctx.chat.id);
887
1371
  const currentEffort = resolveChatThinkingLevel(config, ctx.chat.id);
888
1372
  if (model.id === currentModelId && currentEffort === "off") {
889
- await ctx.answerCallbackQuery({ text: `Already using ${model.id}.` });
1373
+ await closeModelPicker(ctx, {
1374
+ messageText: `Already using ${model.provider}/${model.id}.`,
1375
+ callbackText: `Already using ${model.id}.`
1376
+ });
890
1377
  return;
891
1378
  }
892
1379
 
@@ -921,7 +1408,10 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
921
1408
  const currentModelId = resolveChatModel(config, ctx.chat.id);
922
1409
  const currentEffort = resolveChatThinkingLevel(config, ctx.chat.id);
923
1410
  if (model.id === currentModelId && action.level === currentEffort) {
924
- await ctx.answerCallbackQuery({ text: `Already using ${model.id} at ${action.level}.` });
1411
+ await closeModelPicker(ctx, {
1412
+ messageText: `Already using ${model.provider}/${model.id} (effort: ${action.level}).`,
1413
+ callbackText: `Already using ${model.id} at ${action.level}.`
1414
+ });
925
1415
  return;
926
1416
  }
927
1417
 
@@ -981,7 +1471,10 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
981
1471
  }
982
1472
  const currentEffort = resolveChatThinkingLevel(config, ctx.chat.id);
983
1473
  if (action.level === currentEffort) {
984
- await ctx.answerCallbackQuery({ text: `Already using effort ${action.level}.` });
1474
+ await closeModelPicker(ctx, {
1475
+ messageText: `Already using effort ${action.level} for ${model.provider}/${model.id}.`,
1476
+ callbackText: `Already using effort ${action.level}.`
1477
+ });
985
1478
  return;
986
1479
  }
987
1480
  await persistChatEffort(ctx.chat.id, model, action.level);
@@ -991,16 +1484,69 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
991
1484
  `Effort set to ${action.level} for ${model.provider}/${model.id}.`
992
1485
  );
993
1486
  await ctx.answerCallbackQuery({ text: `Effort: ${action.level}.` });
1487
+ return;
1488
+ }
1489
+
1490
+ if (action.type === "speed") {
1491
+ const model = models.find((item) => item.id === resolveChatModel(config, ctx.chat.id));
1492
+ if (!model) {
1493
+ await ctx.answerCallbackQuery({
1494
+ text: "Current model is unavailable. Run /model again.",
1495
+ show_alert: true
1496
+ });
1497
+ return;
1498
+ }
1499
+ if (!modelSupportsSpeed(model)) {
1500
+ await ctx.answerCallbackQuery({
1501
+ text: "This model does not support speed 1.5x.",
1502
+ show_alert: true
1503
+ });
1504
+ return;
1505
+ }
1506
+ const currentSpeed = resolveChatSpeed(config, ctx.chat.id);
1507
+ if (action.speed === currentSpeed) {
1508
+ await closeModelPicker(ctx, {
1509
+ messageText: `Already using speed ${action.speed.toFixed(1)}x for ${model.provider}/${model.id}.`,
1510
+ callbackText: `Already using speed ${action.speed.toFixed(1)}x.`
1511
+ });
1512
+ return;
1513
+ }
1514
+ await persistChatSpeed(ctx.chat.id, model, action.speed);
1515
+ await ctx.api.editMessageText(
1516
+ ctx.chat.id,
1517
+ ctx.callbackQuery.message.message_id,
1518
+ `Speed set to ${action.speed.toFixed(1)}x for ${model.provider}/${model.id}.`
1519
+ );
1520
+ await ctx.answerCallbackQuery({ text: `Speed: ${action.speed.toFixed(1)}x.` });
994
1521
  }
995
1522
  } catch (error) {
996
1523
  logger?.error("telegram", `model selection failed for chat ${ctx.chat.id}: ${getErrorMessage(error)}`);
997
1524
  await ctx.answerCallbackQuery({
998
- text: "Could not change the model or effort.",
1525
+ text: "Could not change the model, effort, or speed.",
999
1526
  show_alert: true
1000
1527
  }).catch(() => {});
1001
1528
  }
1002
1529
  });
1003
1530
 
1531
+ bot.on("message_reaction", async (ctx) => {
1532
+ const reaction = ctx.messageReaction;
1533
+ const chatId = reaction.chat.id;
1534
+ const auth = await authorizeChat({ config, chatId, saveConfig });
1535
+ if (!auth.ok || piAuthIssue) return;
1536
+
1537
+ const reactedMessageText = getChatState(chatId).assistantMessages.get(reaction.message_id) || "";
1538
+ const prompt = buildReactionPrompt({ reaction, reactedMessageText });
1539
+ enqueuePrompt({
1540
+ chatId,
1541
+ prompt,
1542
+ label: `reaction to message ${reaction.message_id}`,
1543
+ busyMessageMode: "queue"
1544
+ }).catch((error) => {
1545
+ getChatState(chatId).processing = false;
1546
+ logger?.error("telegram", `reaction handling failed for chat ${chatId}: ${getErrorMessage(error)}`);
1547
+ });
1548
+ });
1549
+
1004
1550
  bot.on("message", async (ctx) => {
1005
1551
  const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
1006
1552
  if (!auth.ok) return;
@@ -1013,34 +1559,30 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1013
1559
  if (piAuthIssue) {
1014
1560
  await ctx.reply(buildPiAuthRecoveryBlockedMessage({
1015
1561
  config,
1562
+ chatId: ctx.chat.id,
1016
1563
  issue: piAuthIssue,
1017
1564
  renewalActive: authRenewals.has(chatKey(ctx.chat.id))
1018
1565
  }));
1019
1566
  return;
1020
1567
  }
1021
1568
 
1022
- try {
1023
- await enqueueOrProcess(ctx);
1024
- } catch (error) {
1569
+ // grammY long polling awaits each middleware. Keep prompt execution in the background so
1570
+ // the next Telegram update can reach the active session as a steer or queued message.
1571
+ enqueueOrProcess(ctx).catch(async (error) => {
1025
1572
  const chatState = getChatState(ctx.chat.id);
1026
1573
  chatState.processing = false;
1027
1574
  if (wasPromptErrorNotified(error)) return;
1028
1575
  const issue = getPiAuthIssue(error);
1029
1576
  await ctx.reply(issue
1030
- ? buildPiAuthTelegramMessage({ config, issue })
1577
+ ? buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, issue })
1031
1578
  : getErrorMessage(error));
1032
- }
1579
+ });
1033
1580
  });
1034
1581
 
1035
1582
  return {
1036
1583
  async start({ skipAgentStartupPrompts = false } = {}) {
1037
1584
  config.telegram.chatMeta ||= {};
1038
- await bot.api.setMyCommands([
1039
- { command: "new", description: "Start a new chat context" },
1040
- { command: "model", description: "Choose the model for this chat" },
1041
- { command: "effort", description: "Choose reasoning effort for this chat" },
1042
- { command: "auth", description: "Show Pi authentication status" }
1043
- ]);
1585
+ await bot.api.setMyCommands(telegramCommands);
1044
1586
  if (!taskTimer) {
1045
1587
  taskTimer = setInterval(() => {
1046
1588
  dispatchDueTasks().catch((error) => {
@@ -1052,7 +1594,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1052
1594
  await bot.api.deleteWebhook({ drop_pending_updates: true });
1053
1595
  logger?.log("telegram", "bot polling started");
1054
1596
  scheduleStartupMessages({ skipAgentStartupPrompts });
1055
- await bot.start();
1597
+ await bot.start({ allowed_updates: ["message", "callback_query", "message_reaction"] });
1056
1598
  },
1057
1599
 
1058
1600
  async stop() {