@grinev/opencode-telegram-bot 0.13.1 → 0.14.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.
@@ -39,6 +39,7 @@ class SummaryAggregator {
39
39
  onThinkingCallback = null;
40
40
  onTokensCallback = null;
41
41
  onCostCallback = null;
42
+ onSubagentCallback = null;
42
43
  onSessionCompactedCallback = null;
43
44
  onSessionErrorCallback = null;
44
45
  onSessionRetryCallback = null;
@@ -54,6 +55,13 @@ class SummaryAggregator {
54
55
  typingTimer = null;
55
56
  typingIndicatorEnabled = true;
56
57
  partHashes = new Map();
58
+ trackedSessionParents = new Map();
59
+ subagentStates = new Map();
60
+ subagentOrder = [];
61
+ subagentCardIdBySessionId = new Map();
62
+ pendingSubagentCardIdsByParent = new Map();
63
+ pendingChildSessionIdsByParent = new Map();
64
+ fallbackSubagentCardIdsByParent = new Map();
57
65
  setBotAndChatId(bot, chatId) {
58
66
  this.bot = bot;
59
67
  this.chatId = chatId;
@@ -85,6 +93,9 @@ class SummaryAggregator {
85
93
  setOnCost(callback) {
86
94
  this.onCostCallback = callback;
87
95
  }
96
+ setOnSubagent(callback) {
97
+ this.onSubagentCallback = callback;
98
+ }
88
99
  setOnSessionCompacted(callback) {
89
100
  this.onSessionCompactedCallback = callback;
90
101
  }
@@ -150,6 +161,10 @@ class SummaryAggregator {
150
161
  logger.debug(`[Aggregator] Session event: ${event.type}`, JSON.stringify(event.properties, null, 2));
151
162
  }
152
163
  switch (event.type) {
164
+ case "session.created":
165
+ case "session.updated":
166
+ this.handleSessionCreatedOrUpdated(event);
167
+ break;
153
168
  case "message.updated":
154
169
  this.handleMessageUpdated(event);
155
170
  break;
@@ -195,6 +210,7 @@ class SummaryAggregator {
195
210
  if (this.currentSessionId !== sessionId) {
196
211
  this.clear();
197
212
  this.currentSessionId = sessionId;
213
+ this.trackedSessionParents.set(sessionId, null);
198
214
  }
199
215
  }
200
216
  clear() {
@@ -206,6 +222,13 @@ class SummaryAggregator {
206
222
  this.knownTextPartIds.clear();
207
223
  this.processedToolStates.clear();
208
224
  this.thinkingFiredForMessages.clear();
225
+ this.trackedSessionParents.clear();
226
+ this.subagentStates.clear();
227
+ this.subagentOrder = [];
228
+ this.subagentCardIdBySessionId.clear();
229
+ this.pendingSubagentCardIdsByParent.clear();
230
+ this.pendingChildSessionIdsByParent.clear();
231
+ this.fallbackSubagentCardIdsByParent.clear();
209
232
  this.messageCount = 0;
210
233
  this.lastUpdated = 0;
211
234
  if (this.onClearedCallback) {
@@ -217,8 +240,362 @@ class SummaryAggregator {
217
240
  }
218
241
  }
219
242
  }
243
+ isTrackedChildSession(sessionId) {
244
+ return this.trackedSessionParents.has(sessionId) && sessionId !== this.currentSessionId;
245
+ }
246
+ getQueue(map, parentSessionId) {
247
+ const existing = map.get(parentSessionId);
248
+ if (existing) {
249
+ return existing;
250
+ }
251
+ const queue = [];
252
+ map.set(parentSessionId, queue);
253
+ return queue;
254
+ }
255
+ dequeue(map, parentSessionId) {
256
+ const queue = map.get(parentSessionId);
257
+ if (!queue || queue.length === 0) {
258
+ return undefined;
259
+ }
260
+ const value = queue.shift();
261
+ if (queue.length === 0) {
262
+ map.delete(parentSessionId);
263
+ }
264
+ return value;
265
+ }
266
+ removeFromQueue(map, parentSessionId, value) {
267
+ const queue = map.get(parentSessionId);
268
+ if (!queue) {
269
+ return;
270
+ }
271
+ const index = queue.indexOf(value);
272
+ if (index >= 0) {
273
+ queue.splice(index, 1);
274
+ }
275
+ if (queue.length === 0) {
276
+ map.delete(parentSessionId);
277
+ }
278
+ }
279
+ emitSubagentState() {
280
+ if (!this.currentSessionId || !this.onSubagentCallback || this.subagentOrder.length === 0) {
281
+ return;
282
+ }
283
+ const subagents = this.subagentOrder
284
+ .map((cardId) => this.subagentStates.get(cardId))
285
+ .filter((state) => Boolean(state))
286
+ .map((state) => ({
287
+ cardId: state.cardId,
288
+ sessionId: state.sessionId,
289
+ parentSessionId: state.parentSessionId,
290
+ agent: state.agent,
291
+ description: state.description,
292
+ prompt: state.prompt,
293
+ command: state.command,
294
+ status: state.status,
295
+ providerID: state.providerID,
296
+ modelID: state.modelID,
297
+ tokens: { ...state.tokens },
298
+ cost: state.cost,
299
+ currentTool: state.currentTool,
300
+ currentToolInput: state.currentToolInput ? { ...state.currentToolInput } : undefined,
301
+ currentToolTitle: state.currentToolTitle,
302
+ terminalMessage: state.terminalMessage,
303
+ updatedAt: state.updatedAt,
304
+ }));
305
+ this.onSubagentCallback(this.currentSessionId, subagents);
306
+ }
307
+ createSubagentState(parentSessionId, sessionId, cardId = `subagent-${parentSessionId}-${Date.now()}-${this.subagentOrder.length}`) {
308
+ const state = {
309
+ cardId,
310
+ sessionId,
311
+ parentSessionId,
312
+ agent: "",
313
+ description: "",
314
+ prompt: "",
315
+ status: "pending",
316
+ tokens: {
317
+ input: 0,
318
+ output: 0,
319
+ reasoning: 0,
320
+ cacheRead: 0,
321
+ cacheWrite: 0,
322
+ },
323
+ cost: 0,
324
+ terminalMessage: undefined,
325
+ updatedAt: Date.now(),
326
+ hasSubtaskMetadata: false,
327
+ hasTaskToolMetadata: false,
328
+ hasSessionTitleMetadata: false,
329
+ createdAt: Date.now(),
330
+ };
331
+ this.subagentStates.set(cardId, state);
332
+ this.subagentOrder.push(cardId);
333
+ if (sessionId) {
334
+ this.subagentCardIdBySessionId.set(sessionId, cardId);
335
+ }
336
+ return state;
337
+ }
338
+ enrichSubagentFromSubtask(state, details) {
339
+ state.agent = details.agent || state.agent;
340
+ state.description = details.description || details.prompt || state.description;
341
+ state.prompt = details.prompt;
342
+ state.command = details.command;
343
+ state.hasSubtaskMetadata = true;
344
+ state.updatedAt = Date.now();
345
+ }
346
+ enrichSubagentFromTaskTool(state, details) {
347
+ const nextDescription = details.description?.trim() || details.prompt?.trim();
348
+ if (details.agent?.trim()) {
349
+ state.agent = details.agent.trim();
350
+ }
351
+ if (nextDescription) {
352
+ state.description = nextDescription;
353
+ }
354
+ if (details.prompt?.trim()) {
355
+ state.prompt = details.prompt.trim();
356
+ }
357
+ if (details.command?.trim()) {
358
+ state.command = details.command.trim();
359
+ }
360
+ state.hasTaskToolMetadata = true;
361
+ state.updatedAt = Date.now();
362
+ }
363
+ enrichSubagentFromSessionTitle(state, title) {
364
+ const trimmedTitle = title?.trim();
365
+ if (!trimmedTitle) {
366
+ return;
367
+ }
368
+ const match = trimmedTitle.match(/^(.*?)(?:\s+\(@([^\s)]+)\s+subagent\))?$/i);
369
+ const rawDescription = match?.[1]?.trim() || trimmedTitle;
370
+ const rawAgent = match?.[2]?.trim();
371
+ if (rawDescription) {
372
+ state.description = rawDescription;
373
+ }
374
+ if (rawAgent) {
375
+ state.agent = rawAgent.replace(/^@/, "");
376
+ }
377
+ state.hasSessionTitleMetadata = true;
378
+ state.updatedAt = Date.now();
379
+ }
380
+ attachSessionToSubagent(cardId, sessionId) {
381
+ const state = this.subagentStates.get(cardId);
382
+ if (!state) {
383
+ return;
384
+ }
385
+ state.sessionId = sessionId;
386
+ state.updatedAt = Date.now();
387
+ this.subagentCardIdBySessionId.set(sessionId, cardId);
388
+ this.removeFromQueue(this.pendingSubagentCardIdsByParent, state.parentSessionId, cardId);
389
+ }
390
+ findPendingSubagentWithoutSession() {
391
+ for (const cardId of this.subagentOrder) {
392
+ const state = this.subagentStates.get(cardId);
393
+ if (state && !state.sessionId) {
394
+ return state;
395
+ }
396
+ }
397
+ return null;
398
+ }
399
+ attachUnknownSessionToPendingSubagent(sessionId) {
400
+ const pendingState = this.findPendingSubagentWithoutSession();
401
+ if (!pendingState) {
402
+ return false;
403
+ }
404
+ this.trackedSessionParents.set(sessionId, pendingState.parentSessionId);
405
+ this.attachSessionToSubagent(pendingState.cardId, sessionId);
406
+ this.removeFromQueue(this.pendingChildSessionIdsByParent, pendingState.parentSessionId, sessionId);
407
+ this.emitSubagentState();
408
+ return true;
409
+ }
410
+ findNextSubagentForTaskTool(parentSessionId) {
411
+ for (const cardId of this.subagentOrder) {
412
+ const state = this.subagentStates.get(cardId);
413
+ if (state && state.parentSessionId === parentSessionId && !state.hasTaskToolMetadata) {
414
+ return state;
415
+ }
416
+ }
417
+ return null;
418
+ }
419
+ updateSubagentFromTaskTool(parentSessionId, input) {
420
+ const subagent = this.findNextSubagentForTaskTool(parentSessionId);
421
+ if (!subagent || !input) {
422
+ return;
423
+ }
424
+ const description = typeof input.description === "string" ? input.description : undefined;
425
+ const prompt = typeof input.prompt === "string" ? input.prompt : undefined;
426
+ const agent = typeof input.subagent_type === "string" ? input.subagent_type : undefined;
427
+ const command = typeof input.command === "string" ? input.command : undefined;
428
+ if (!description && !prompt && !agent && !command) {
429
+ return;
430
+ }
431
+ this.enrichSubagentFromTaskTool(subagent, { agent, description, prompt, command });
432
+ this.emitSubagentState();
433
+ }
434
+ getOrCreateSubagentForSession(sessionId) {
435
+ const existingCardId = this.subagentCardIdBySessionId.get(sessionId);
436
+ if (existingCardId) {
437
+ return this.subagentStates.get(existingCardId);
438
+ }
439
+ const parentSessionId = this.trackedSessionParents.get(sessionId) ?? this.currentSessionId ?? sessionId;
440
+ this.removeFromQueue(this.pendingChildSessionIdsByParent, parentSessionId, sessionId);
441
+ const state = this.createSubagentState(parentSessionId, sessionId);
442
+ this.getQueue(this.fallbackSubagentCardIdsByParent, parentSessionId).push(state.cardId);
443
+ return state;
444
+ }
445
+ registerSubtaskPart(parentSessionId, partId, agent, description, prompt, command) {
446
+ const fallbackCardId = this.dequeue(this.fallbackSubagentCardIdsByParent, parentSessionId);
447
+ if (fallbackCardId) {
448
+ const fallbackState = this.subagentStates.get(fallbackCardId);
449
+ if (fallbackState) {
450
+ this.enrichSubagentFromSubtask(fallbackState, { agent, description, prompt, command });
451
+ this.emitSubagentState();
452
+ return;
453
+ }
454
+ }
455
+ const state = this.createSubagentState(parentSessionId, null, `subtask-${parentSessionId}-${partId}`);
456
+ this.enrichSubagentFromSubtask(state, { agent, description, prompt, command });
457
+ const pendingChildSessionId = this.dequeue(this.pendingChildSessionIdsByParent, parentSessionId);
458
+ if (pendingChildSessionId) {
459
+ this.attachSessionToSubagent(state.cardId, pendingChildSessionId);
460
+ }
461
+ else {
462
+ this.getQueue(this.pendingSubagentCardIdsByParent, parentSessionId).push(state.cardId);
463
+ }
464
+ this.emitSubagentState();
465
+ }
466
+ trackChildSession(sessionId, parentSessionId) {
467
+ this.trackedSessionParents.set(sessionId, parentSessionId);
468
+ const pendingCardId = this.dequeue(this.pendingSubagentCardIdsByParent, parentSessionId);
469
+ if (pendingCardId) {
470
+ this.attachSessionToSubagent(pendingCardId, sessionId);
471
+ this.emitSubagentState();
472
+ return;
473
+ }
474
+ this.getQueue(this.pendingChildSessionIdsByParent, parentSessionId).push(sessionId);
475
+ }
476
+ handleSessionCreatedOrUpdated(event) {
477
+ if (!this.currentSessionId) {
478
+ return;
479
+ }
480
+ const { info } = event.properties;
481
+ if (!info.parentID) {
482
+ return;
483
+ }
484
+ if (!this.trackedSessionParents.has(info.parentID)) {
485
+ return;
486
+ }
487
+ if (info.id === this.currentSessionId) {
488
+ return;
489
+ }
490
+ if (!this.trackedSessionParents.has(info.id)) {
491
+ this.trackChildSession(info.id, info.parentID);
492
+ }
493
+ const subagent = this.getOrCreateSubagentForSession(info.id);
494
+ this.enrichSubagentFromSessionTitle(subagent, info.title);
495
+ this.emitSubagentState();
496
+ }
497
+ updateSubagentFromAssistantMessage(info) {
498
+ const subagent = this.getOrCreateSubagentForSession(info.sessionID);
499
+ if (info.agent) {
500
+ subagent.agent = info.agent;
501
+ }
502
+ if (info.providerID) {
503
+ subagent.providerID = info.providerID;
504
+ }
505
+ if (info.modelID) {
506
+ subagent.modelID = info.modelID;
507
+ }
508
+ if (info.tokens) {
509
+ subagent.tokens = {
510
+ input: info.tokens.input,
511
+ output: info.tokens.output,
512
+ reasoning: info.tokens.reasoning,
513
+ cacheRead: info.tokens.cache?.read || 0,
514
+ cacheWrite: info.tokens.cache?.write || 0,
515
+ };
516
+ }
517
+ if (typeof info.cost === "number") {
518
+ subagent.cost = info.cost;
519
+ }
520
+ subagent.updatedAt = Date.now();
521
+ this.emitSubagentState();
522
+ }
523
+ updateSubagentToolState(sessionId, state, tool, input, title) {
524
+ const subagent = this.getOrCreateSubagentForSession(sessionId);
525
+ const status = "status" in state ? state.status : undefined;
526
+ if (status === "running") {
527
+ subagent.status = "running";
528
+ subagent.terminalMessage = undefined;
529
+ }
530
+ if (status === "pending" && subagent.status === "pending") {
531
+ subagent.status = "pending";
532
+ subagent.terminalMessage = undefined;
533
+ }
534
+ subagent.currentTool = tool;
535
+ subagent.currentToolInput = input ? { ...input } : undefined;
536
+ subagent.currentToolTitle = title;
537
+ subagent.updatedAt = Date.now();
538
+ this.emitSubagentState();
539
+ }
540
+ updateSubagentStepStart(sessionId, snapshot) {
541
+ const subagent = this.getOrCreateSubagentForSession(sessionId);
542
+ subagent.status = "running";
543
+ subagent.terminalMessage = undefined;
544
+ subagent.currentTool = undefined;
545
+ subagent.currentToolInput = undefined;
546
+ subagent.currentToolTitle = snapshot?.trim() || subagent.currentToolTitle;
547
+ subagent.updatedAt = Date.now();
548
+ this.emitSubagentState();
549
+ }
550
+ updateSubagentStepFinish(sessionId, tokens, cost, snapshot) {
551
+ const subagent = this.getOrCreateSubagentForSession(sessionId);
552
+ subagent.status = "running";
553
+ subagent.terminalMessage = undefined;
554
+ subagent.tokens = {
555
+ input: tokens.input,
556
+ output: tokens.output,
557
+ reasoning: tokens.reasoning,
558
+ cacheRead: tokens.cache.read,
559
+ cacheWrite: tokens.cache.write,
560
+ };
561
+ subagent.cost += cost;
562
+ if (snapshot?.trim()) {
563
+ subagent.currentToolTitle = snapshot.trim();
564
+ }
565
+ subagent.updatedAt = Date.now();
566
+ this.emitSubagentState();
567
+ }
568
+ setSubagentTerminalStatus(sessionId, status, terminalMessage) {
569
+ const cardId = this.subagentCardIdBySessionId.get(sessionId);
570
+ if (!cardId) {
571
+ return;
572
+ }
573
+ const subagent = this.subagentStates.get(cardId);
574
+ if (!subagent) {
575
+ return;
576
+ }
577
+ subagent.status = status;
578
+ subagent.currentTool = undefined;
579
+ subagent.currentToolInput = undefined;
580
+ subagent.currentToolTitle = undefined;
581
+ subagent.terminalMessage = terminalMessage?.trim() || undefined;
582
+ subagent.updatedAt = Date.now();
583
+ this.emitSubagentState();
584
+ }
220
585
  handleMessageUpdated(event) {
221
586
  const { info } = event.properties;
587
+ if (info.sessionID !== this.currentSessionId &&
588
+ !this.trackedSessionParents.has(info.sessionID) &&
589
+ info.role === "assistant") {
590
+ this.attachUnknownSessionToPendingSubagent(info.sessionID);
591
+ }
592
+ if (this.isTrackedChildSession(info.sessionID)) {
593
+ if (info.role === "assistant") {
594
+ const assistantInfo = info;
595
+ this.updateSubagentFromAssistantMessage(assistantInfo);
596
+ }
597
+ return;
598
+ }
222
599
  if (info.sessionID !== this.currentSessionId) {
223
600
  return;
224
601
  }
@@ -242,23 +619,24 @@ class SummaryAggregator {
242
619
  if (!isCompleted && textState.optimisticUpdateCount === 1) {
243
620
  this.emitPartialText(info.sessionID, messageID, messageText);
244
621
  }
622
+ // Extract and report tokens for EVERY message.updated with token data
623
+ // (both intermediate and completed). This keeps keyboard context in sync.
624
+ const assistantInfo = info;
625
+ if (this.onTokensCallback && assistantInfo.tokens) {
626
+ const tokens = {
627
+ input: assistantInfo.tokens.input,
628
+ output: assistantInfo.tokens.output,
629
+ reasoning: assistantInfo.tokens.reasoning,
630
+ cacheRead: assistantInfo.tokens.cache?.read || 0,
631
+ cacheWrite: assistantInfo.tokens.cache?.write || 0,
632
+ };
633
+ logger.debug(`[Aggregator] Tokens: input=${tokens.input}, output=${tokens.output}, reasoning=${tokens.reasoning}, cacheRead=${tokens.cacheRead}, cacheWrite=${tokens.cacheWrite}, completed=${isCompleted}`);
634
+ // Call synchronously so keyboardManager is updated before onComplete sends the reply
635
+ this.onTokensCallback(tokens, isCompleted);
636
+ }
245
637
  if (isCompleted) {
246
638
  const finalText = messageText;
247
639
  logger.debug(`[Aggregator] Message part completed: messageId=${messageID}, textLength=${finalText.length}, totalParts=${textState.orderedPartIds.length}, session=${this.currentSessionId}`);
248
- // Extract and report tokens BEFORE onComplete so keyboard context is updated
249
- const assistantInfo = info;
250
- if (this.onTokensCallback && assistantInfo.tokens) {
251
- const tokens = {
252
- input: assistantInfo.tokens.input,
253
- output: assistantInfo.tokens.output,
254
- reasoning: assistantInfo.tokens.reasoning,
255
- cacheRead: assistantInfo.tokens.cache?.read || 0,
256
- cacheWrite: assistantInfo.tokens.cache?.write || 0,
257
- };
258
- logger.debug(`[Aggregator] Tokens: input=${tokens.input}, output=${tokens.output}, reasoning=${tokens.reasoning}`);
259
- // Call synchronously so keyboardManager is updated before onComplete sends the reply
260
- this.onTokensCallback(tokens);
261
- }
262
640
  // Extract and report cost
263
641
  if (this.onCostCallback && assistantInfo.cost !== undefined) {
264
642
  logger.debug(`[Aggregator] Cost: $${assistantInfo.cost.toFixed(2)}`);
@@ -282,7 +660,35 @@ class SummaryAggregator {
282
660
  }
283
661
  handleMessagePartUpdated(event) {
284
662
  const { part } = event.properties;
285
- if (part.sessionID !== this.currentSessionId) {
663
+ if (part.sessionID !== this.currentSessionId &&
664
+ !this.trackedSessionParents.has(part.sessionID) &&
665
+ part.type !== "subtask") {
666
+ this.attachUnknownSessionToPendingSubagent(part.sessionID);
667
+ }
668
+ const isCurrentRootSession = part.sessionID === this.currentSessionId;
669
+ const isTrackedChildSession = this.isTrackedChildSession(part.sessionID);
670
+ if (!isCurrentRootSession && !isTrackedChildSession) {
671
+ return;
672
+ }
673
+ if (part.type === "subtask") {
674
+ this.registerSubtaskPart(part.sessionID, part.id, part.agent, part.description, part.prompt, part.command);
675
+ this.lastUpdated = Date.now();
676
+ return;
677
+ }
678
+ if (isTrackedChildSession) {
679
+ if (part.type === "tool") {
680
+ const state = part.state;
681
+ const input = "input" in state ? state.input : undefined;
682
+ const title = "title" in state ? state.title : undefined;
683
+ this.updateSubagentToolState(part.sessionID, state, part.tool, input, title);
684
+ }
685
+ if (part.type === "step-start") {
686
+ this.updateSubagentStepStart(part.sessionID, part.snapshot);
687
+ }
688
+ if (part.type === "step-finish") {
689
+ this.updateSubagentStepFinish(part.sessionID, part.tokens, part.cost, part.snapshot);
690
+ }
691
+ this.lastUpdated = Date.now();
286
692
  return;
287
693
  }
288
694
  const messageID = part.messageID;
@@ -337,6 +743,9 @@ class SummaryAggregator {
337
743
  const state = part.state;
338
744
  const input = "input" in state ? state.input : undefined;
339
745
  const title = "title" in state ? state.title : undefined;
746
+ if (part.tool === "task") {
747
+ this.updateSubagentFromTaskTool(part.sessionID, input);
748
+ }
340
749
  logger.debug(`[Aggregator] Tool event: callID=${part.callID}, tool=${part.tool}, status=${"status" in state ? state.status : "unknown"}`);
341
750
  if (part.tool === "question") {
342
751
  logger.debug(`[Aggregator] Question tool part update:`, JSON.stringify(part, null, 2));
@@ -618,6 +1027,11 @@ class SummaryAggregator {
618
1027
  }
619
1028
  handleSessionIdle(event) {
620
1029
  const { sessionID } = event.properties;
1030
+ if (this.isTrackedChildSession(sessionID)) {
1031
+ logger.info(`[Aggregator] Subagent session became idle: ${sessionID}`);
1032
+ this.setSubagentTerminalStatus(sessionID, "completed");
1033
+ return;
1034
+ }
621
1035
  if (sessionID !== this.currentSessionId) {
622
1036
  return;
623
1037
  }
@@ -644,10 +1058,15 @@ class SummaryAggregator {
644
1058
  }
645
1059
  handleSessionError(event) {
646
1060
  const { sessionID, error } = event.properties;
1061
+ const message = error?.data?.message || error?.message || error?.name || "Unknown session error";
1062
+ if (sessionID && this.isTrackedChildSession(sessionID)) {
1063
+ logger.warn(`[Aggregator] Subagent session error: ${sessionID}: ${message}`);
1064
+ this.setSubagentTerminalStatus(sessionID, "error", message);
1065
+ return;
1066
+ }
647
1067
  if (sessionID !== this.currentSessionId) {
648
1068
  return;
649
1069
  }
650
- const message = error?.data?.message || error?.message || error?.name || "Unknown session error";
651
1070
  logger.warn(`[Aggregator] Session error: ${sessionID}: ${message}`);
652
1071
  this.stopTypingIndicator();
653
1072
  if (this.onSessionErrorCallback) {
@@ -5,18 +5,45 @@ import { logger } from "../utils/logger.js";
5
5
  import { t } from "../i18n/index.js";
6
6
  import { getCurrentProject } from "../settings/manager.js";
7
7
  const TELEGRAM_MESSAGE_LIMIT = 4096;
8
- function splitText(text, maxLength) {
8
+ const MARKDOWN_V2_RESERVED_CHARS = /([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g;
9
+ function endsWithOddTrailingBackslashes(text, start, end) {
10
+ let backslashCount = 0;
11
+ for (let index = end - 1; index >= start; index--) {
12
+ if (text[index] !== "\\") {
13
+ break;
14
+ }
15
+ backslashCount += 1;
16
+ }
17
+ return backslashCount % 2 === 1;
18
+ }
19
+ function resolveSplitEndIndex(text, currentIndex, maxLength, options) {
20
+ const hardLimit = Math.min(text.length, currentIndex + maxLength);
21
+ if (hardLimit >= text.length) {
22
+ return text.length;
23
+ }
24
+ let endIndex = hardLimit;
25
+ const breakPoint = text.lastIndexOf("\n", endIndex);
26
+ if (breakPoint > currentIndex) {
27
+ endIndex = breakPoint + 1;
28
+ }
29
+ if (!options?.avoidTrailingMarkdownEscape) {
30
+ return endIndex;
31
+ }
32
+ while (endIndex > currentIndex && endsWithOddTrailingBackslashes(text, currentIndex, endIndex)) {
33
+ endIndex -= 1;
34
+ }
35
+ return endIndex > currentIndex ? endIndex : hardLimit;
36
+ }
37
+ function splitText(text, maxLength, options) {
9
38
  const parts = [];
10
39
  let currentIndex = 0;
11
40
  while (currentIndex < text.length) {
12
- let endIndex = currentIndex + maxLength;
13
- if (endIndex >= text.length) {
14
- parts.push(text.slice(currentIndex));
15
- break;
16
- }
17
- const breakPoint = text.lastIndexOf("\n", endIndex);
18
- if (breakPoint > currentIndex) {
19
- endIndex = breakPoint + 1;
41
+ const endIndex = resolveSplitEndIndex(text, currentIndex, maxLength, options);
42
+ if (endIndex <= currentIndex) {
43
+ const fallbackEnd = Math.min(text.length, currentIndex + 1);
44
+ parts.push(text.slice(currentIndex, fallbackEnd));
45
+ currentIndex = fallbackEnd;
46
+ continue;
20
47
  }
21
48
  parts.push(text.slice(currentIndex, endIndex));
22
49
  currentIndex = endIndex;
@@ -134,16 +161,48 @@ export function getAssistantParseMode() {
134
161
  }
135
162
  return undefined;
136
163
  }
164
+ export function escapePlainTextForTelegramMarkdownV2(text) {
165
+ return text.replace(MARKDOWN_V2_RESERVED_CHARS, "\\$1");
166
+ }
137
167
  function formatMarkdownForTelegram(text) {
138
168
  try {
139
169
  const preprocessed = preprocessMarkdownForTelegram(text);
140
- return convert(preprocessed, "keep");
170
+ return escapeMarkdownV2PipesOutsideCode(convert(preprocessed, "keep"));
141
171
  }
142
172
  catch (error) {
143
173
  logger.warn("[Formatter] Failed to convert markdown summary, falling back to raw text", error);
144
174
  return text;
145
175
  }
146
176
  }
177
+ function escapeMarkdownV2PipesOutsideCode(text) {
178
+ let result = "";
179
+ let index = 0;
180
+ let inInlineCode = false;
181
+ let inCodeFence = false;
182
+ while (index < text.length) {
183
+ if (text.startsWith("```", index)) {
184
+ result += "```";
185
+ index += 3;
186
+ inCodeFence = !inCodeFence;
187
+ continue;
188
+ }
189
+ const char = text[index];
190
+ if (!inCodeFence && char === "`") {
191
+ inInlineCode = !inInlineCode;
192
+ result += char;
193
+ index += 1;
194
+ continue;
195
+ }
196
+ if (!inCodeFence && !inInlineCode && char === "|" && text[index - 1] !== "\\") {
197
+ result += "\\|";
198
+ index += 1;
199
+ continue;
200
+ }
201
+ result += char;
202
+ index += 1;
203
+ }
204
+ return result;
205
+ }
147
206
  export function formatSummaryWithMode(text, mode, maxLength = TELEGRAM_MESSAGE_LIMIT) {
148
207
  if (!text || text.trim().length === 0) {
149
208
  return [];
@@ -159,7 +218,9 @@ export function formatSummaryWithMode(text, mode, maxLength = TELEGRAM_MESSAGE_L
159
218
  }
160
219
  if (mode === "markdown") {
161
220
  const converted = formatMarkdownForTelegram(trimmed);
162
- const convertedParts = splitText(converted, normalizedMaxLength);
221
+ const convertedParts = splitText(converted, normalizedMaxLength, {
222
+ avoidTrailingMarkdownEscape: true,
223
+ });
163
224
  for (const convertedPart of convertedParts) {
164
225
  const normalizedPart = convertedPart.trim();
165
226
  if (normalizedPart) {
@@ -310,7 +371,7 @@ export function formatToolInfo(toolInfo) {
310
371
  const todos = toolInfo.metadata.todos;
311
372
  const toolIcon = getToolIcon(tool);
312
373
  const todosList = formatTodos(todos);
313
- return `${toolIcon} ${tool} (${todos.length})\n${todosList}`;
374
+ return `${toolIcon} ${tool} (${todos.length})\n\n${todosList}`;
314
375
  }
315
376
  let details = title || getToolDetails(tool, input);
316
377
  const toolIcon = getToolIcon(tool);
@@ -360,6 +421,17 @@ export function formatToolInfo(toolInfo) {
360
421
  }
361
422
  return `${toolIcon} ${description}${tool}${detailsStr}${lineInfo}`;
362
423
  }
424
+ export function formatCompactToolInfo(toolInfo, maxLength = 64, fallback = "-") {
425
+ const formatted = formatToolInfo(toolInfo);
426
+ const normalized = formatted?.replace(/\s*\n+\s*/g, " ").trim() ?? "";
427
+ if (!normalized) {
428
+ return fallback;
429
+ }
430
+ if (normalized.length <= maxLength) {
431
+ return normalized;
432
+ }
433
+ return `${normalized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
434
+ }
363
435
  function countLines(text) {
364
436
  return text.split("\n").length;
365
437
  }