@markusylisiurunen/tau 0.2.63 → 0.2.65

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 (62) hide show
  1. package/dist/core/async/cli.js +68 -104
  2. package/dist/core/async/cli.js.map +1 -1
  3. package/dist/core/async/http_protocol.js +119 -8
  4. package/dist/core/async/http_protocol.js.map +1 -1
  5. package/dist/core/async/http_server.js +120 -240
  6. package/dist/core/async/http_server.js.map +1 -1
  7. package/dist/core/async/index.js +1 -1
  8. package/dist/core/async/index.js.map +1 -1
  9. package/dist/core/async/server_config.js +161 -356
  10. package/dist/core/async/server_config.js.map +1 -1
  11. package/dist/core/async/session_manager.js +5 -17
  12. package/dist/core/async/session_manager.js.map +1 -1
  13. package/dist/core/async/telegram.js +268 -171
  14. package/dist/core/async/telegram.js.map +1 -1
  15. package/dist/core/auth/providers/openai_codex.js +4 -9
  16. package/dist/core/auth/providers/openai_codex.js.map +1 -1
  17. package/dist/core/config/content_loader.js +57 -205
  18. package/dist/core/config/content_loader.js.map +1 -1
  19. package/dist/core/config/markdown_frontmatter.js +34 -0
  20. package/dist/core/config/markdown_frontmatter.js.map +1 -0
  21. package/dist/core/config/schema.js +266 -332
  22. package/dist/core/config/schema.js.map +1 -1
  23. package/dist/core/config/skill_parser.js +8 -32
  24. package/dist/core/config/skill_parser.js.map +1 -1
  25. package/dist/core/config/skills_loader.js +32 -18
  26. package/dist/core/config/skills_loader.js.map +1 -1
  27. package/dist/core/events/index.js +1 -0
  28. package/dist/core/events/index.js.map +1 -1
  29. package/dist/core/events/parser.js +115 -0
  30. package/dist/core/events/parser.js.map +1 -0
  31. package/dist/core/index.js +1 -1
  32. package/dist/core/index.js.map +1 -1
  33. package/dist/core/modes/rpc_protocol.js +249 -189
  34. package/dist/core/modes/rpc_protocol.js.map +1 -1
  35. package/dist/core/session/session_engine.js +1 -3
  36. package/dist/core/session/session_engine.js.map +1 -1
  37. package/dist/core/subagents/control_plane.js +13 -2
  38. package/dist/core/subagents/control_plane.js.map +1 -1
  39. package/dist/core/subagents/subagent_engine.js +3 -3
  40. package/dist/core/subagents/subagent_engine.js.map +1 -1
  41. package/dist/core/tools/emit_output.js +3 -2
  42. package/dist/core/tools/emit_output.js.map +1 -1
  43. package/dist/core/tools/registry.js +6 -0
  44. package/dist/core/tools/registry.js.map +1 -1
  45. package/dist/core/tools/send_input_to_agent.js +12 -16
  46. package/dist/core/tools/send_input_to_agent.js.map +1 -1
  47. package/dist/core/tools/spawn_agent.js +25 -30
  48. package/dist/core/tools/spawn_agent.js.map +1 -1
  49. package/dist/core/tools/terminate_agent.js +10 -14
  50. package/dist/core/tools/terminate_agent.js.map +1 -1
  51. package/dist/core/tools/wait_for_agent.js +10 -14
  52. package/dist/core/tools/wait_for_agent.js.map +1 -1
  53. package/dist/core/utils/mistral_transcription.js +13 -21
  54. package/dist/core/utils/mistral_transcription.js.map +1 -1
  55. package/dist/core/utils/parallel_api.js +15 -19
  56. package/dist/core/utils/parallel_api.js.map +1 -1
  57. package/dist/core/utils/zod.js +7 -0
  58. package/dist/core/utils/zod.js.map +1 -1
  59. package/dist/core/version.js +1 -1
  60. package/dist/tui/chat_controller/session_maintenance_service.js +98 -171
  61. package/dist/tui/chat_controller/session_maintenance_service.js.map +1 -1
  62. package/package.json +1 -1
@@ -1,8 +1,9 @@
1
1
  import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { basename, extname, join } from "node:path";
4
+ import { z } from "zod";
4
5
  import { transcribeMistralAudio } from "../utils/mistral_transcription.js";
5
- import { isRecord } from "../utils/type_guards.js";
6
+ import { formatZodError } from "../utils/zod.js";
6
7
  import { AsyncSessionManagerError, createScopedAsyncSessionManager, } from "./session_manager.js";
7
8
  const DEFAULT_POLL_INTERVAL_MS = 1000;
8
9
  const DEFAULT_REQUEST_TIMEOUT_SECONDS = 30;
@@ -23,6 +24,7 @@ const MAX_TELEGRAM_ATTACHMENTS_PER_TURN = 10;
23
24
  const MAX_TELEGRAM_ATTACHMENT_FILE_BYTES = 20 * 1024 * 1024;
24
25
  const MAX_TELEGRAM_ATTACHMENT_TOTAL_BYTES = 50 * 1024 * 1024;
25
26
  const TELEGRAM_ATTACHMENT_TEMP_DIR_PREFIX = "tau-telegram-attachments-";
27
+ const NO_ACTIVE_SESSION_MESSAGE = "no active session. use /new or /sessions";
26
28
  const SUPPORTED_TEXT_ATTACHMENT_EXTENSIONS = new Set([
27
29
  ".txt",
28
30
  ".md",
@@ -91,17 +93,10 @@ async function sweepStaleTelegramAttachmentTempDirs() {
91
93
  return;
92
94
  }
93
95
  }
94
- const TELEGRAM_COMMANDS = [
95
- { command: "help", description: "show supported commands" },
96
- { command: "new", description: "start a new session" },
97
- { command: "projects", description: "list configured projects" },
98
- { command: "use", description: "switch active session" },
99
- { command: "sessions", description: "list sessions" },
100
- { command: "status", description: "show active session status" },
101
- { command: "interrupt", description: "interrupt active run" },
102
- { command: "close", description: "close session(s)" },
103
- { command: "verbose", description: "stream progress updates" },
104
- { command: "quiet", description: "only send final assistant message" },
96
+ const QUICK_ACTION_ROWS = [
97
+ ["new", "sessions", "status"],
98
+ ["interrupt", "close"],
99
+ ["quiet", "verbose"],
105
100
  ];
106
101
  function splitCommandText(text) {
107
102
  return text
@@ -251,9 +246,76 @@ function describeSession(session, details = {}) {
251
246
  function formatSessionHeadline(sessionId, label) {
252
247
  return `(${sessionId}) ${label}`;
253
248
  }
249
+ const telegramObject = (shape) => z.object(shape).passthrough();
250
+ const telegramPartialObject = (shape) => telegramObject(shape).partial();
251
+ function parseOrThrow(schema, raw, message) {
252
+ const parsed = schema.safeParse(raw);
253
+ if (!parsed.success) {
254
+ throw new Error(`${message}: ${formatZodError(parsed.error)}`);
255
+ }
256
+ return parsed.data;
257
+ }
258
+ const TelegramEnvelopeSchema = z.discriminatedUnion("ok", [
259
+ z.object({
260
+ ok: z.literal(true),
261
+ description: z.string().optional(),
262
+ result: z.unknown(),
263
+ }),
264
+ z.object({
265
+ ok: z.literal(false),
266
+ description: z.string().optional(),
267
+ result: z.unknown().optional(),
268
+ }),
269
+ ]);
270
+ const TelegramChatSchema = telegramObject({ id: z.number(), type: z.string() });
271
+ const TelegramUserSchema = telegramObject({ id: z.number() });
272
+ const TELEGRAM_FILE_SHAPE = {
273
+ file_id: z.string(),
274
+ file_name: z.string(),
275
+ mime_type: z.string(),
276
+ file_size: z.number(),
277
+ };
278
+ const TelegramFileSchema = telegramPartialObject(TELEGRAM_FILE_SHAPE);
279
+ const TelegramPhotoVariantSchema = telegramPartialObject({
280
+ file_id: z.string(),
281
+ file_size: z.number(),
282
+ width: z.number(),
283
+ height: z.number(),
284
+ });
285
+ const TelegramVoiceSchema = TelegramFileSchema.pick({ file_id: true, mime_type: true });
286
+ const TelegramAudioSchema = TelegramFileSchema.pick({
287
+ file_id: true,
288
+ mime_type: true,
289
+ file_name: true,
290
+ });
291
+ const TelegramMessageSchema = telegramPartialObject({
292
+ message_id: z.number(),
293
+ chat: TelegramChatSchema,
294
+ from: TelegramUserSchema,
295
+ text: z.string(),
296
+ caption: z.string(),
297
+ photo: z.array(TelegramPhotoVariantSchema),
298
+ document: TelegramFileSchema,
299
+ voice: TelegramVoiceSchema,
300
+ audio: TelegramAudioSchema,
301
+ });
302
+ const TelegramCallbackQuerySchema = telegramPartialObject({
303
+ id: z.string(),
304
+ from: TelegramUserSchema,
305
+ data: z.string(),
306
+ message: telegramPartialObject({ chat: TelegramChatSchema }),
307
+ });
308
+ const TelegramUpdateSchema = telegramPartialObject({
309
+ update_id: z.number(),
310
+ message: TelegramMessageSchema,
311
+ callback_query: TelegramCallbackQuerySchema,
312
+ });
313
+ const TelegramGetUpdatesResultSchema = z.array(TelegramUpdateSchema);
314
+ const TelegramGetFileResultSchema = z.object({ file_path: z.string() });
315
+ const TelegramAckResultSchema = z.literal(true);
254
316
  function createTelegramApi(botToken) {
255
317
  const apiUrl = `https://api.telegram.org/bot${botToken}`;
256
- async function callTelegramMethod(method, payload) {
318
+ async function callTelegramMethod(method, payload, resultSchema) {
257
319
  const response = await fetch(`${apiUrl}/${method}`, {
258
320
  method: "POST",
259
321
  headers: {
@@ -265,43 +327,40 @@ function createTelegramApi(botToken) {
265
327
  const detail = (await response.text()).trim();
266
328
  throw new Error(detail || `telegram ${method} failed: HTTP ${response.status}`);
267
329
  }
268
- let data;
330
+ let raw;
269
331
  try {
270
- data = await response.json();
332
+ raw = await response.json();
271
333
  }
272
334
  catch {
273
335
  throw new Error(`telegram ${method} returned invalid JSON`);
274
336
  }
275
- if (!isRecord(data)) {
276
- throw new Error(`telegram ${method} returned an invalid response payload`);
277
- }
278
- if (data.ok !== true) {
279
- const detail = typeof data.description === "string" ? data.description.trim() : "";
337
+ const envelope = parseOrThrow(TelegramEnvelopeSchema, raw, `telegram ${method} returned an invalid response payload`);
338
+ if (!envelope.ok) {
339
+ const detail = envelope.description?.trim() ?? "";
280
340
  throw new Error(detail || `telegram ${method} request failed`);
281
341
  }
282
- return data.result;
342
+ return parseOrThrow(resultSchema, envelope.result, `telegram ${method} returned an invalid result`);
283
343
  }
284
344
  return {
285
345
  async getUpdates(args) {
286
- const updates = await callTelegramMethod("getUpdates", {
346
+ return callTelegramMethod("getUpdates", {
287
347
  offset: args.offset,
288
348
  timeout: args.timeoutSeconds,
289
349
  allowed_updates: args.allowedUpdates,
290
- });
291
- return updates;
350
+ }, TelegramGetUpdatesResultSchema);
292
351
  },
293
352
  async sendMessage(chatId, text, options) {
294
353
  await callTelegramMethod("sendMessage", {
295
354
  chat_id: chatId,
296
355
  text,
297
356
  ...(options?.replyMarkup ? { reply_markup: options.replyMarkup } : {}),
298
- });
357
+ }, z.unknown());
299
358
  },
300
359
  async downloadFile(fileId) {
301
- const file = await callTelegramMethod("getFile", {
360
+ const parsed = await callTelegramMethod("getFile", {
302
361
  file_id: fileId,
303
- });
304
- const filePath = file.file_path?.trim();
362
+ }, TelegramGetFileResultSchema);
363
+ const filePath = parsed.file_path?.trim();
305
364
  if (!filePath) {
306
365
  throw new Error("telegram file path is missing");
307
366
  }
@@ -316,20 +375,20 @@ function createTelegramApi(botToken) {
316
375
  async setCommands(commands) {
317
376
  await callTelegramMethod("setMyCommands", {
318
377
  commands,
319
- });
378
+ }, TelegramAckResultSchema);
320
379
  },
321
380
  async setMessageReaction(chatId, messageId) {
322
381
  await callTelegramMethod("setMessageReaction", {
323
382
  chat_id: chatId,
324
383
  message_id: messageId,
325
384
  reaction: [{ type: "emoji", emoji: MESSAGE_QUEUED_REACTION_EMOJI }],
326
- });
385
+ }, TelegramAckResultSchema);
327
386
  },
328
387
  async answerCallbackQuery(callbackQueryId, text) {
329
388
  await callTelegramMethod("answerCallbackQuery", {
330
389
  callback_query_id: callbackQueryId,
331
390
  ...(text ? { text } : {}),
332
- });
391
+ }, TelegramAckResultSchema);
333
392
  },
334
393
  };
335
394
  }
@@ -349,6 +408,9 @@ class AsyncTelegramAdapterImpl {
349
408
  api;
350
409
  fetchImpl;
351
410
  onLog;
411
+ commandDefinitions;
412
+ commandHandlers;
413
+ callbackActionHandlers;
352
414
  abortController = new AbortController();
353
415
  activeSessionsByChat = new Map();
354
416
  sessionsByChat = new Map();
@@ -390,6 +452,15 @@ class AsyncTelegramAdapterImpl {
390
452
  this.api = options.api ?? createTelegramApi(options.botToken);
391
453
  this.fetchImpl = options.fetchImpl;
392
454
  this.onLog = options.onLog;
455
+ this.commandDefinitions = this.createCommandDefinitions();
456
+ this.commandHandlers = new Map();
457
+ this.callbackActionHandlers = new Map();
458
+ for (const definition of this.commandDefinitions) {
459
+ this.commandHandlers.set(definition.command, definition.handler);
460
+ if (definition.callbackAction) {
461
+ this.callbackActionHandlers.set(definition.callbackAction, definition.handler);
462
+ }
463
+ }
393
464
  this.unsubscribeSessionEvents = this.sessionManager.onEvent((event) => {
394
465
  this.onSessionEvent(event);
395
466
  });
@@ -422,13 +493,88 @@ class AsyncTelegramAdapterImpl {
422
493
  log(level, message, data) {
423
494
  this.onLog?.({ level, message, ...(data === undefined ? {} : { data }) });
424
495
  }
496
+ createCommandDefinitions() {
497
+ return [
498
+ {
499
+ command: "/help",
500
+ description: "show supported commands",
501
+ usage: "/help",
502
+ handler: async (chatId) => this.handleHelp(chatId),
503
+ },
504
+ {
505
+ command: "/new",
506
+ description: "start a new session",
507
+ usage: "/new [projectId]",
508
+ callbackAction: "new",
509
+ handler: async (chatId, args) => this.handleNew(chatId, args),
510
+ },
511
+ {
512
+ command: "/projects",
513
+ description: "list configured projects",
514
+ usage: "/projects",
515
+ handler: async (chatId) => this.handleProjects(chatId),
516
+ },
517
+ {
518
+ command: "/use",
519
+ description: "switch active session",
520
+ usage: "/use <sessionId|prefix|index>",
521
+ handler: async (chatId, args) => this.handleUse(chatId, args),
522
+ },
523
+ {
524
+ command: "/sessions",
525
+ description: "list sessions",
526
+ usage: "/sessions",
527
+ callbackAction: "sessions",
528
+ handler: async (chatId) => this.handleSessions(chatId),
529
+ },
530
+ {
531
+ command: "/status",
532
+ description: "show active session status",
533
+ usage: "/status",
534
+ callbackAction: "status",
535
+ handler: async (chatId) => this.handleStatus(chatId),
536
+ },
537
+ {
538
+ command: "/interrupt",
539
+ description: "interrupt active run",
540
+ usage: "/interrupt",
541
+ callbackAction: "interrupt",
542
+ handler: async (chatId) => this.handleInterrupt(chatId),
543
+ },
544
+ {
545
+ command: "/close",
546
+ description: "close session(s)",
547
+ usage: "/close [<sessionId>|all]",
548
+ callbackAction: "close",
549
+ handler: async (chatId, args) => this.handleClose(chatId, args),
550
+ },
551
+ {
552
+ command: "/verbose",
553
+ description: "stream progress updates",
554
+ usage: "/verbose",
555
+ callbackAction: "verbose",
556
+ handler: async (chatId) => this.handleVerbosityCommand(chatId, "verbose"),
557
+ },
558
+ {
559
+ command: "/quiet",
560
+ description: "only send final assistant message",
561
+ usage: "/quiet",
562
+ callbackAction: "quiet",
563
+ handler: async (chatId) => this.handleVerbosityCommand(chatId, "quiet"),
564
+ },
565
+ ];
566
+ }
425
567
  async syncCommands() {
426
568
  if (!this.api.setCommands) {
427
569
  return;
428
570
  }
571
+ const commands = this.commandDefinitions.map((definition) => ({
572
+ command: definition.command.slice(1),
573
+ description: definition.description,
574
+ }));
429
575
  try {
430
- await this.api.setCommands(TELEGRAM_COMMANDS);
431
- this.log("info", "telegram commands synced", { count: TELEGRAM_COMMANDS.length });
576
+ await this.api.setCommands(commands);
577
+ this.log("info", "telegram commands synced", { count: commands.length });
432
578
  }
433
579
  catch (error) {
434
580
  this.log("warn", "failed to sync telegram commands", {
@@ -471,7 +617,7 @@ class AsyncTelegramAdapterImpl {
471
617
  if (updates === undefined) {
472
618
  return [];
473
619
  }
474
- return updates.filter((entry) => isRecord(entry));
620
+ return updates;
475
621
  }
476
622
  async raceWithAbort(promise) {
477
623
  if (this.abortController.signal.aborted) {
@@ -624,9 +770,8 @@ class AsyncTelegramAdapterImpl {
624
770
  if (parsedAttachments.length === 0) {
625
771
  return;
626
772
  }
627
- const session = this.getActiveOrSingleSession(chatId);
773
+ const session = await this.requireActiveOrSingleSession(chatId);
628
774
  if (!session) {
629
- await this.reply(chatId, "no active session. use /new or /sessions");
630
775
  return;
631
776
  }
632
777
  const pending = this.pendingAttachmentsBySession.get(session.id) ?? [];
@@ -636,17 +781,21 @@ class AsyncTelegramAdapterImpl {
636
781
  for (const attachment of parsedAttachments) {
637
782
  const attachmentLabel = describeAttachment(attachment.fileName, attachment.mimeType);
638
783
  if (pending.length >= MAX_TELEGRAM_ATTACHMENTS_PER_TURN) {
639
- await this.reply(chatId, `skipped attachment ${attachmentLabel}: exceeds attachment limit (${MAX_TELEGRAM_ATTACHMENTS_PER_TURN} files per turn)`);
784
+ await this.replySkippedAttachment(chatId, attachmentLabel, `exceeds attachment limit (${MAX_TELEGRAM_ATTACHMENTS_PER_TURN} files per turn)`);
640
785
  continue;
641
786
  }
642
- if (typeof attachment.sizeBytes === "number" &&
643
- attachment.sizeBytes > MAX_TELEGRAM_ATTACHMENT_FILE_BYTES) {
644
- await this.reply(chatId, `skipped attachment ${attachmentLabel}: exceeds per-file limit (${describeAttachmentLimitBytes(MAX_TELEGRAM_ATTACHMENT_FILE_BYTES)})`);
787
+ const declaredFileLimitReason = typeof attachment.sizeBytes === "number"
788
+ ? this.getAttachmentPerFileLimitReason(attachment.sizeBytes)
789
+ : undefined;
790
+ if (declaredFileLimitReason) {
791
+ await this.replySkippedAttachment(chatId, attachmentLabel, declaredFileLimitReason);
645
792
  continue;
646
793
  }
647
- if (typeof attachment.sizeBytes === "number" &&
648
- totalSizeBytes + attachment.sizeBytes > MAX_TELEGRAM_ATTACHMENT_TOTAL_BYTES) {
649
- await this.reply(chatId, `skipped attachment ${attachmentLabel}: exceeds per-turn total limit (${describeAttachmentLimitBytes(MAX_TELEGRAM_ATTACHMENT_TOTAL_BYTES)})`);
794
+ const declaredTotalLimitReason = typeof attachment.sizeBytes === "number"
795
+ ? this.getAttachmentTotalLimitReason(totalSizeBytes, attachment.sizeBytes)
796
+ : undefined;
797
+ if (declaredTotalLimitReason) {
798
+ await this.replySkippedAttachment(chatId, attachmentLabel, declaredTotalLimitReason);
650
799
  continue;
651
800
  }
652
801
  let bytes;
@@ -658,12 +807,14 @@ class AsyncTelegramAdapterImpl {
658
807
  continue;
659
808
  }
660
809
  const sizeBytes = bytes.byteLength;
661
- if (sizeBytes > MAX_TELEGRAM_ATTACHMENT_FILE_BYTES) {
662
- await this.reply(chatId, `skipped attachment ${attachmentLabel}: exceeds per-file limit (${describeAttachmentLimitBytes(MAX_TELEGRAM_ATTACHMENT_FILE_BYTES)})`);
810
+ const fileLimitReason = this.getAttachmentPerFileLimitReason(sizeBytes);
811
+ if (fileLimitReason) {
812
+ await this.replySkippedAttachment(chatId, attachmentLabel, fileLimitReason);
663
813
  continue;
664
814
  }
665
- if (totalSizeBytes + sizeBytes > MAX_TELEGRAM_ATTACHMENT_TOTAL_BYTES) {
666
- await this.reply(chatId, `skipped attachment ${attachmentLabel}: exceeds per-turn total limit (${describeAttachmentLimitBytes(MAX_TELEGRAM_ATTACHMENT_TOTAL_BYTES)})`);
815
+ const totalLimitReason = this.getAttachmentTotalLimitReason(totalSizeBytes, sizeBytes);
816
+ if (totalLimitReason) {
817
+ await this.replySkippedAttachment(chatId, attachmentLabel, totalLimitReason);
667
818
  continue;
668
819
  }
669
820
  const tempDirPath = await this.getOrCreatePendingAttachmentTempDir(session.id);
@@ -727,12 +878,14 @@ class AsyncTelegramAdapterImpl {
727
878
  await this.reply(chatId, `skipped attachment ${attachmentLabel}: local temp file is missing`);
728
879
  continue;
729
880
  }
730
- if (materialized.sizeBytes > MAX_TELEGRAM_ATTACHMENT_FILE_BYTES) {
731
- await this.reply(chatId, `skipped attachment ${attachmentLabel}: exceeds per-file limit (${describeAttachmentLimitBytes(MAX_TELEGRAM_ATTACHMENT_FILE_BYTES)})`);
881
+ const fileLimitReason = this.getAttachmentPerFileLimitReason(materialized.sizeBytes);
882
+ if (fileLimitReason) {
883
+ await this.replySkippedAttachment(chatId, attachmentLabel, fileLimitReason);
732
884
  continue;
733
885
  }
734
- if (totalSizeBytes + materialized.sizeBytes > MAX_TELEGRAM_ATTACHMENT_TOTAL_BYTES) {
735
- await this.reply(chatId, `skipped attachment ${attachmentLabel}: exceeds per-turn total limit (${describeAttachmentLimitBytes(MAX_TELEGRAM_ATTACHMENT_TOTAL_BYTES)})`);
886
+ const totalLimitReason = this.getAttachmentTotalLimitReason(totalSizeBytes, materialized.sizeBytes);
887
+ if (totalLimitReason) {
888
+ await this.replySkippedAttachment(chatId, attachmentLabel, totalLimitReason);
736
889
  continue;
737
890
  }
738
891
  totalSizeBytes += materialized.sizeBytes;
@@ -826,47 +979,12 @@ class AsyncTelegramAdapterImpl {
826
979
  const parts = splitCommandText(text);
827
980
  const command = stripCommandMention(parts[0] ?? "");
828
981
  const args = parts.slice(1);
829
- if (command === "/help") {
830
- await this.handleHelp(chatId);
831
- return;
832
- }
833
- if (command === "/new") {
834
- await this.handleNew(chatId, args);
835
- return;
836
- }
837
- if (command === "/projects") {
838
- await this.handleProjects(chatId);
839
- return;
840
- }
841
- if (command === "/use") {
842
- await this.handleUse(chatId, args);
843
- return;
844
- }
845
- if (command === "/sessions") {
846
- await this.handleSessions(chatId);
847
- return;
848
- }
849
- if (command === "/status") {
850
- await this.handleStatus(chatId);
851
- return;
852
- }
853
- if (command === "/interrupt") {
854
- await this.handleInterrupt(chatId);
855
- return;
856
- }
857
- if (command === "/close") {
858
- await this.handleClose(chatId, args);
859
- return;
860
- }
861
- if (command === "/verbose") {
862
- await this.handleVerbosityCommand(chatId, "verbose");
982
+ const handler = this.commandHandlers.get(command);
983
+ if (!handler) {
984
+ await this.reply(chatId, "unsupported command. use /help");
863
985
  return;
864
986
  }
865
- if (command === "/quiet") {
866
- await this.handleVerbosityCommand(chatId, "quiet");
867
- return;
868
- }
869
- await this.reply(chatId, "unsupported command. use /help");
987
+ await handler(chatId, args);
870
988
  }
871
989
  async handleCallback(chatId, callbackData) {
872
990
  if (callbackData.startsWith(CALLBACK_USE_PREFIX)) {
@@ -880,50 +998,18 @@ class AsyncTelegramAdapterImpl {
880
998
  if (!callbackData.startsWith(CALLBACK_ACTION_PREFIX)) {
881
999
  return false;
882
1000
  }
883
- const action = callbackData.slice(CALLBACK_ACTION_PREFIX.length);
884
- if (action === "new") {
885
- await this.handleNew(chatId, []);
886
- return true;
887
- }
888
- if (action === "sessions") {
889
- await this.handleSessions(chatId);
890
- return true;
891
- }
892
- if (action === "status") {
893
- await this.handleStatus(chatId);
894
- return true;
895
- }
896
- if (action === "interrupt") {
897
- await this.handleInterrupt(chatId);
898
- return true;
899
- }
900
- if (action === "close") {
901
- await this.handleClose(chatId, []);
902
- return true;
903
- }
904
- if (action === "quiet") {
905
- await this.handleVerbosityCommand(chatId, "quiet");
906
- return true;
907
- }
908
- if (action === "verbose") {
909
- await this.handleVerbosityCommand(chatId, "verbose");
910
- return true;
1001
+ const action = callbackData.slice(CALLBACK_ACTION_PREFIX.length).trim();
1002
+ const handler = this.callbackActionHandlers.get(action);
1003
+ if (!handler) {
1004
+ return false;
911
1005
  }
912
- return false;
1006
+ await handler(chatId, []);
1007
+ return true;
913
1008
  }
914
1009
  async handleHelp(chatId) {
915
1010
  const lines = [
916
1011
  "commands:",
917
- "/help",
918
- "/new [projectId]",
919
- "/projects",
920
- "/sessions",
921
- "/use <sessionId|prefix|index>",
922
- "/status",
923
- "/interrupt",
924
- "/close [<sessionId>|all]",
925
- "/verbose",
926
- "/quiet",
1012
+ ...this.commandDefinitions.map((definition) => definition.usage),
927
1013
  "",
928
1014
  "tip: use /sessions and tap a session button to switch quickly",
929
1015
  ];
@@ -1113,27 +1199,15 @@ class AsyncTelegramAdapterImpl {
1113
1199
  }
1114
1200
  buildQuickActionsKeyboard() {
1115
1201
  return {
1116
- inline_keyboard: [
1117
- [
1118
- { text: "/new", callback_data: `${CALLBACK_ACTION_PREFIX}new` },
1119
- { text: "/sessions", callback_data: `${CALLBACK_ACTION_PREFIX}sessions` },
1120
- { text: "/status", callback_data: `${CALLBACK_ACTION_PREFIX}status` },
1121
- ],
1122
- [
1123
- { text: "/interrupt", callback_data: `${CALLBACK_ACTION_PREFIX}interrupt` },
1124
- { text: "/close", callback_data: `${CALLBACK_ACTION_PREFIX}close` },
1125
- ],
1126
- [
1127
- { text: "/quiet", callback_data: `${CALLBACK_ACTION_PREFIX}quiet` },
1128
- { text: "/verbose", callback_data: `${CALLBACK_ACTION_PREFIX}verbose` },
1129
- ],
1130
- ],
1202
+ inline_keyboard: QUICK_ACTION_ROWS.map((row) => row.map((action) => ({
1203
+ text: `/${action}`,
1204
+ callback_data: `${CALLBACK_ACTION_PREFIX}${action}`,
1205
+ }))),
1131
1206
  };
1132
1207
  }
1133
1208
  async handleStatus(chatId) {
1134
- const session = this.getActiveSession(chatId);
1209
+ const session = await this.requireActiveSession(chatId);
1135
1210
  if (!session) {
1136
- await this.reply(chatId, "no active session. use /new or /sessions");
1137
1211
  return;
1138
1212
  }
1139
1213
  await this.reply(chatId, describeSession(session, {
@@ -1145,9 +1219,8 @@ class AsyncTelegramAdapterImpl {
1145
1219
  });
1146
1220
  }
1147
1221
  async handleInterrupt(chatId) {
1148
- const session = this.getActiveSession(chatId);
1222
+ const session = await this.requireActiveSession(chatId);
1149
1223
  if (!session) {
1150
- await this.reply(chatId, "no active session. use /new or /sessions");
1151
1224
  return;
1152
1225
  }
1153
1226
  try {
@@ -1190,7 +1263,7 @@ class AsyncTelegramAdapterImpl {
1190
1263
  }
1191
1264
  const sessionId = target ?? this.getActiveSession(chatId)?.id;
1192
1265
  if (!sessionId) {
1193
- await this.reply(chatId, "no active session. use /new or /sessions");
1266
+ await this.reply(chatId, NO_ACTIVE_SESSION_MESSAGE);
1194
1267
  return;
1195
1268
  }
1196
1269
  try {
@@ -1204,38 +1277,28 @@ class AsyncTelegramAdapterImpl {
1204
1277
  }
1205
1278
  }
1206
1279
  async handleVerbosityCommand(chatId, verbosity) {
1207
- const session = this.getActiveSession(chatId);
1280
+ const session = await this.requireActiveSession(chatId);
1208
1281
  if (!session) {
1209
- await this.reply(chatId, "no active session. use /new or /sessions");
1210
1282
  return;
1211
1283
  }
1212
1284
  this.sessionVerbosityBySession.set(session.id, verbosity);
1213
1285
  await this.reply(chatId, formatSessionHeadline(session.id, `verbosity set to ${verbosity}`));
1214
1286
  }
1215
1287
  async handleMessage(chatId, text, sourceMessageId) {
1216
- const session = this.getActiveOrSingleSession(chatId);
1288
+ const session = await this.requireActiveOrSingleSession(chatId);
1217
1289
  if (!session) {
1218
- await this.reply(chatId, "no active session. use /new or /sessions");
1219
1290
  return;
1220
1291
  }
1221
1292
  try {
1222
- const textWithAttachments = await this.buildMessageTextWithAttachments(session.id, text, chatId);
1223
- const sessionManager = this.getSessionManagerForChat(chatId);
1224
- await sessionManager.sendMessage(session.id, textWithAttachments, this.systemMessage ? { additionalSystemMessage: this.systemMessage } : undefined);
1225
- this.resetPendingAttachmentQueue(session.id);
1226
- await this.reactToQueuedMessage(chatId, sourceMessageId);
1227
- if (this.isVerboseSession(session.id)) {
1228
- await this.reply(chatId, this.formatMessageQueued(session.id));
1229
- }
1293
+ await this.submitSessionMessage(chatId, session.id, text, sourceMessageId);
1230
1294
  }
1231
1295
  catch (error) {
1232
1296
  await this.reply(chatId, this.formatManagerError(error));
1233
1297
  }
1234
1298
  }
1235
1299
  async handleAudioMessage(chatId, message, sourceMessageId) {
1236
- const session = this.getActiveOrSingleSession(chatId);
1300
+ const session = await this.requireActiveOrSingleSession(chatId);
1237
1301
  if (!session) {
1238
- await this.reply(chatId, "no active session. use /new or /sessions");
1239
1302
  return;
1240
1303
  }
1241
1304
  if (!this.mistralApiKey) {
@@ -1262,19 +1325,53 @@ class AsyncTelegramAdapterImpl {
1262
1325
  return;
1263
1326
  }
1264
1327
  try {
1265
- const textWithAttachments = await this.buildMessageTextWithAttachments(session.id, transcript, chatId);
1266
- const sessionManager = this.getSessionManagerForChat(chatId);
1267
- await sessionManager.sendMessage(session.id, textWithAttachments, this.systemMessage ? { additionalSystemMessage: this.systemMessage } : undefined);
1268
- this.resetPendingAttachmentQueue(session.id);
1269
- await this.reactToQueuedMessage(chatId, sourceMessageId);
1270
- if (this.isVerboseSession(session.id)) {
1271
- await this.reply(chatId, this.formatMessageQueued(session.id));
1272
- }
1328
+ await this.submitSessionMessage(chatId, session.id, transcript, sourceMessageId);
1273
1329
  }
1274
1330
  catch (error) {
1275
1331
  await this.reply(chatId, this.formatManagerError(error));
1276
1332
  }
1277
1333
  }
1334
+ async requireActiveSession(chatId) {
1335
+ const session = this.getActiveSession(chatId);
1336
+ if (!session) {
1337
+ await this.reply(chatId, NO_ACTIVE_SESSION_MESSAGE);
1338
+ return undefined;
1339
+ }
1340
+ return session;
1341
+ }
1342
+ async requireActiveOrSingleSession(chatId) {
1343
+ const session = this.getActiveOrSingleSession(chatId);
1344
+ if (!session) {
1345
+ await this.reply(chatId, NO_ACTIVE_SESSION_MESSAGE);
1346
+ return undefined;
1347
+ }
1348
+ return session;
1349
+ }
1350
+ async submitSessionMessage(chatId, sessionId, text, sourceMessageId) {
1351
+ const textWithAttachments = await this.buildMessageTextWithAttachments(sessionId, text, chatId);
1352
+ const sessionManager = this.getSessionManagerForChat(chatId);
1353
+ await sessionManager.sendMessage(sessionId, textWithAttachments, this.systemMessage ? { additionalSystemMessage: this.systemMessage } : undefined);
1354
+ this.resetPendingAttachmentQueue(sessionId);
1355
+ await this.reactToQueuedMessage(chatId, sourceMessageId);
1356
+ if (this.isVerboseSession(sessionId)) {
1357
+ await this.reply(chatId, this.formatMessageQueued(sessionId));
1358
+ }
1359
+ }
1360
+ getAttachmentPerFileLimitReason(sizeBytes) {
1361
+ if (sizeBytes <= MAX_TELEGRAM_ATTACHMENT_FILE_BYTES) {
1362
+ return undefined;
1363
+ }
1364
+ return `exceeds per-file limit (${describeAttachmentLimitBytes(MAX_TELEGRAM_ATTACHMENT_FILE_BYTES)})`;
1365
+ }
1366
+ getAttachmentTotalLimitReason(totalSizeBytes, nextSizeBytes) {
1367
+ if (totalSizeBytes + nextSizeBytes <= MAX_TELEGRAM_ATTACHMENT_TOTAL_BYTES) {
1368
+ return undefined;
1369
+ }
1370
+ return `exceeds per-turn total limit (${describeAttachmentLimitBytes(MAX_TELEGRAM_ATTACHMENT_TOTAL_BYTES)})`;
1371
+ }
1372
+ async replySkippedAttachment(chatId, attachmentLabel, reason) {
1373
+ await this.reply(chatId, `skipped attachment ${attachmentLabel}: ${reason}`);
1374
+ }
1278
1375
  getActiveSession(chatId) {
1279
1376
  const sessionId = this.activeSessionsByChat.get(chatId);
1280
1377
  if (!sessionId) {