@fonz/tgcc 0.6.15 β†’ 0.6.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/streaming.js CHANGED
@@ -10,6 +10,12 @@ export function escapeHtml(text) {
10
10
  .replace(/</g, '&lt;')
11
11
  .replace(/>/g, '&gt;');
12
12
  }
13
+ /** Create visually distinct system message with enhanced styling */
14
+ export function formatSystemMessage(type, content, expandable = false) {
15
+ const emoji = { thinking: 'πŸ’­', tool: '⚑', usage: 'πŸ“Š', error: '⚠️', status: 'ℹ️' }[type];
16
+ const wrapper = expandable ? 'blockquote expandable' : 'blockquote';
17
+ return `<${wrapper}>${emoji} ${content}</${wrapper.split(' ')[0]}>`;
18
+ }
13
19
  /**
14
20
  * Convert markdown text to Telegram-safe HTML using the marked library.
15
21
  * Replaces the old hand-rolled implementation with a proper markdown parser.
@@ -45,33 +51,59 @@ export class StreamAccumulator {
45
51
  lastEditTime = 0;
46
52
  editTimer = null;
47
53
  thinkingIndicatorShown = false;
54
+ thinkingMessageId = null; // preserved separately from tgMessageId
48
55
  messageIds = []; // all message IDs sent during this turn
49
56
  finished = false;
50
57
  sendQueue = Promise.resolve();
51
58
  turnUsage = null;
52
- // Per-tool-use independent indicator messages (persists across resets)
59
+ /** Usage from the most recent message_start event β€” represents a single API call's context (not cumulative). */
60
+ _lastMsgStartCtx = null;
61
+ // Per-tool-use consolidated indicator message (persists across resets)
53
62
  toolMessages = new Map();
54
63
  toolInputBuffers = new Map(); // tool block ID β†’ accumulated input JSON
55
64
  currentToolBlockId = null;
65
+ consolidatedToolMsgId = null; // shared TG message for all tool indicators
56
66
  constructor(options) {
57
67
  this.chatId = options.chatId;
58
68
  this.sender = options.sender;
59
69
  this.editIntervalMs = options.editIntervalMs ?? 1000;
60
- this.splitThreshold = options.splitThreshold ?? 4000;
70
+ this.splitThreshold = options.splitThreshold ?? 3500;
61
71
  this.logger = options.logger;
62
72
  this.onError = options.onError;
63
73
  }
64
74
  get allMessageIds() { return [...this.messageIds]; }
65
75
  /** Set usage stats for the current turn (called from bridge on result event) */
66
76
  setTurnUsage(usage) {
67
- this.turnUsage = usage;
77
+ // Merge in per-API-call ctx tokens if we captured them from message_start events.
78
+ // These are bounded by the context window (unlike result event usage which accumulates across tool loops).
79
+ if (this._lastMsgStartCtx) {
80
+ this.turnUsage = {
81
+ ...usage,
82
+ ctxInputTokens: this._lastMsgStartCtx.input,
83
+ ctxCacheReadTokens: this._lastMsgStartCtx.cacheRead,
84
+ ctxCacheCreationTokens: this._lastMsgStartCtx.cacheCreation,
85
+ };
86
+ }
87
+ else {
88
+ this.turnUsage = usage;
89
+ }
68
90
  }
69
91
  // ── Process stream events ──
70
92
  async handleEvent(event) {
71
93
  switch (event.type) {
72
- case 'message_start':
94
+ case 'message_start': {
73
95
  // Bridge handles reset decision - no automatic reset here
96
+ // Capture per-API-call token counts for accurate context % (not cumulative like result event)
97
+ const msUsage = event.message?.usage;
98
+ if (msUsage) {
99
+ this._lastMsgStartCtx = {
100
+ input: msUsage.input_tokens ?? 0,
101
+ cacheRead: msUsage.cache_read_input_tokens ?? 0,
102
+ cacheCreation: msUsage.cache_creation_input_tokens ?? 0,
103
+ };
104
+ }
74
105
  break;
106
+ }
75
107
  case 'content_block_start':
76
108
  await this.onContentBlockStart(event);
77
109
  break;
@@ -98,23 +130,44 @@ export class StreamAccumulator {
98
130
  if (blockType === 'thinking') {
99
131
  this.currentBlockType = 'thinking';
100
132
  if (!this.thinkingIndicatorShown && !this.buffer) {
101
- await this.sendOrEdit('<blockquote>πŸ’­ Thinking...</blockquote>', true);
133
+ await this.sendOrEdit(formatSystemMessage('thinking', 'Processing...'), true);
102
134
  this.thinkingIndicatorShown = true;
103
135
  }
104
136
  }
105
137
  else if (blockType === 'text') {
106
138
  this.currentBlockType = 'text';
107
- // Text always gets its own message β€” don't touch tool indicator messages
108
- if (!this.tgMessageId) {
109
- // Will create a new message on next sendOrEdit
139
+ // If thinking indicator was shown, update it with actual thinking content NOW
140
+ // (before text starts streaming below) β€” don't wait for finalize()
141
+ if (this.thinkingIndicatorShown && this.tgMessageId) {
142
+ this.thinkingMessageId = this.tgMessageId;
143
+ this.tgMessageId = null; // force new message for response text
144
+ // Flush "Processing..." β†’ actual thinking content immediately
145
+ if (this.thinkingBuffer && this.thinkingMessageId) {
146
+ const thinkingPreview = this.thinkingBuffer.length > 1024
147
+ ? this.thinkingBuffer.slice(0, 1024) + '…'
148
+ : this.thinkingBuffer;
149
+ const html = formatSystemMessage('thinking', markdownToTelegramHtml(thinkingPreview), true);
150
+ this.sendQueue = this.sendQueue.then(async () => {
151
+ try {
152
+ await this.sender.editMessage(this.chatId, this.thinkingMessageId, html, 'HTML');
153
+ }
154
+ catch {
155
+ // Non-critical β€” thinking message update
156
+ }
157
+ }).catch(err => {
158
+ this.logger?.error?.({ err }, 'Failed to flush thinking indicator');
159
+ });
160
+ }
110
161
  }
111
162
  }
112
163
  else if (blockType === 'tool_use') {
113
164
  this.currentBlockType = 'tool_use';
114
165
  const block = event.content_block;
115
166
  this.currentToolBlockId = block.id;
116
- // Send an independent indicator message for this tool_use block
117
- await this.sendToolIndicator(block.id, block.name);
167
+ // Sub-agent tools are handled by SubAgentTracker β€” skip duplicate indicator
168
+ if (!isSubAgentTool(block.name)) {
169
+ await this.sendToolIndicator(block.id, block.name);
170
+ }
118
171
  }
119
172
  else if (blockType === 'image') {
120
173
  this.currentBlockType = 'image';
@@ -214,23 +267,74 @@ export class StreamAccumulator {
214
267
  }
215
268
  this.imageBase64Buffer = '';
216
269
  }
217
- /** Send an independent tool indicator message (not through the accumulator's sendOrEdit). */
270
+ /** Send or update the consolidated tool indicator message. */
218
271
  async sendToolIndicator(blockId, toolName) {
219
272
  const startTime = Date.now();
273
+ if (this.consolidatedToolMsgId) {
274
+ // Reuse existing consolidated message
275
+ this.toolMessages.set(blockId, { msgId: this.consolidatedToolMsgId, toolName, startTime, resolved: false, isError: false, elapsed: null });
276
+ this.updateConsolidatedToolMessage();
277
+ }
278
+ else {
279
+ // Create the first consolidated tool message
280
+ this.sendQueue = this.sendQueue.then(async () => {
281
+ try {
282
+ const html = this.buildConsolidatedToolHtml(blockId, toolName);
283
+ const msgId = await this.sender.sendMessage(this.chatId, html, 'HTML');
284
+ this.consolidatedToolMsgId = msgId;
285
+ this.toolMessages.set(blockId, { msgId, toolName, startTime, resolved: false, isError: false, elapsed: null });
286
+ }
287
+ catch (err) {
288
+ this.logger?.debug?.({ err, toolName }, 'Failed to send tool indicator');
289
+ }
290
+ }).catch(err => {
291
+ this.logger?.error?.({ err }, 'sendToolIndicator queue error');
292
+ });
293
+ return this.sendQueue;
294
+ }
295
+ }
296
+ /** Build HTML for the consolidated tool indicator message. */
297
+ buildConsolidatedToolHtml(pendingBlockId, pendingToolName) {
298
+ const lines = [];
299
+ for (const [blockId, entry] of this.toolMessages) {
300
+ const summary = this.toolIndicatorLastSummary.get(blockId);
301
+ const codePart = summary ? ` Β· <code>${escapeHtml(summary)}</code>` : '';
302
+ if (entry.resolved) {
303
+ const statLine = this.toolResolvedStats.get(blockId);
304
+ const statPart = statLine ? ` Β· ${escapeHtml(statLine)}` : '';
305
+ const icon = entry.isError ? '❌' : 'βœ…';
306
+ lines.push(`${icon} ${escapeHtml(entry.toolName)} (${entry.elapsed})${codePart}${statPart}`);
307
+ }
308
+ else {
309
+ lines.push(`⚑ ${escapeHtml(entry.toolName)}…${codePart}`);
310
+ }
311
+ }
312
+ // Include pending tool that hasn't been added to toolMessages yet
313
+ if (pendingBlockId && pendingToolName && !this.toolMessages.has(pendingBlockId)) {
314
+ lines.push(`⚑ ${escapeHtml(pendingToolName)}…`);
315
+ }
316
+ return `<blockquote expandable>${lines.join('\n')}</blockquote>`;
317
+ }
318
+ /** Resolved tool result stats, keyed by blockId */
319
+ toolResolvedStats = new Map();
320
+ /** Edit the consolidated tool message with current state of all tools. */
321
+ updateConsolidatedToolMessage() {
322
+ if (!this.consolidatedToolMsgId)
323
+ return;
324
+ const msgId = this.consolidatedToolMsgId;
325
+ const html = this.buildConsolidatedToolHtml();
220
326
  this.sendQueue = this.sendQueue.then(async () => {
221
327
  try {
222
- const html = `<blockquote expandable>⚑ ${escapeHtml(toolName)}…</blockquote>`;
223
- const msgId = await this.sender.sendMessage(this.chatId, html, 'HTML');
224
- this.toolMessages.set(blockId, { msgId, toolName, startTime });
328
+ await this.sender.editMessage(this.chatId, msgId, html, 'HTML');
225
329
  }
226
330
  catch (err) {
227
- // Tool indicator is non-critical β€” log and continue
228
- this.logger?.debug?.({ err, toolName }, 'Failed to send tool indicator');
331
+ if (err instanceof Error && err.message.includes('message is not modified'))
332
+ return;
333
+ this.logger?.debug?.({ err }, 'Failed to update consolidated tool message');
229
334
  }
230
335
  }).catch(err => {
231
- this.logger?.error?.({ err }, 'sendToolIndicator queue error');
336
+ this.logger?.error?.({ err }, 'updateConsolidatedToolMessage queue error');
232
337
  });
233
- return this.sendQueue;
234
338
  }
235
339
  /** Update a tool indicator message with input preview once the JSON value is complete. */
236
340
  toolIndicatorLastSummary = new Map(); // blockId β†’ last rendered summary
@@ -248,105 +352,118 @@ export class StreamAccumulator {
248
352
  if (this.toolIndicatorLastSummary.get(blockId) === summary)
249
353
  return;
250
354
  this.toolIndicatorLastSummary.set(blockId, summary);
251
- const codeLine = `\n<code>${escapeHtml(summary)}</code>`;
252
- const html = `<blockquote expandable>⚑ ${escapeHtml(entry.toolName)}…${codeLine}</blockquote>`;
253
- this.sendQueue = this.sendQueue.then(async () => {
254
- try {
255
- await this.sender.editMessage(this.chatId, entry.msgId, html, 'HTML');
256
- }
257
- catch (err) {
258
- // "message is not modified" or other edit failure β€” non-critical
259
- this.logger?.debug?.({ err }, 'Failed to update tool indicator with input');
260
- }
261
- }).catch(err => {
262
- this.logger?.error?.({ err }, 'updateToolIndicatorWithInput queue error');
263
- });
264
- return this.sendQueue;
355
+ // Update the consolidated tool message
356
+ this.updateConsolidatedToolMessage();
265
357
  }
266
- /** Resolve a tool indicator with success/failure status. Edits to a compact summary with input detail. */
358
+ /** MCP media tools β€” on success, delete indicator (the media was sent directly). On failure, keep + react ❌. */
359
+ static MCP_MEDIA_TOOLS = new Set(['mcp__tgcc__send_image', 'mcp__tgcc__send_file', 'mcp__tgcc__send_voice']);
360
+ /** Resolve a tool indicator with success/failure status. Updates the consolidated message. */
267
361
  async resolveToolMessage(blockId, isError, errorMessage, resultContent, toolUseResult) {
268
362
  const entry = this.toolMessages.get(blockId);
269
363
  if (!entry)
270
364
  return;
271
- const { msgId, toolName, startTime } = entry;
365
+ const { toolName, startTime } = entry;
272
366
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
367
+ // MCP media tools: remove from consolidated on success (the media itself is the result)
368
+ if (StreamAccumulator.MCP_MEDIA_TOOLS.has(toolName) && !isError) {
369
+ this.toolInputBuffers.delete(blockId);
370
+ this.toolIndicatorLastSummary.delete(blockId);
371
+ this.toolResolvedStats.delete(blockId);
372
+ this.toolMessages.delete(blockId);
373
+ // If that was the only tool, delete the consolidated message
374
+ if (this.toolMessages.size === 0 && this.consolidatedToolMsgId) {
375
+ const msgId = this.consolidatedToolMsgId;
376
+ this.consolidatedToolMsgId = null;
377
+ this.sendQueue = this.sendQueue.then(async () => {
378
+ try {
379
+ if (this.sender.deleteMessage)
380
+ await this.sender.deleteMessage(this.chatId, msgId);
381
+ }
382
+ catch (err) {
383
+ this.logger?.debug?.({ err, toolName }, 'Failed to delete consolidated tool message');
384
+ }
385
+ }).catch(err => {
386
+ this.logger?.error?.({ err }, 'resolveToolMessage queue error');
387
+ });
388
+ }
389
+ else {
390
+ this.updateConsolidatedToolMessage();
391
+ }
392
+ return;
393
+ }
394
+ // Compute final input summary if we don't have one yet
273
395
  const inputJson = this.toolInputBuffers.get(blockId) ?? '';
274
- const summary = extractToolInputSummary(toolName, inputJson);
396
+ if (!this.toolIndicatorLastSummary.has(blockId)) {
397
+ const summary = extractToolInputSummary(toolName, inputJson);
398
+ if (summary)
399
+ this.toolIndicatorLastSummary.set(blockId, summary);
400
+ }
401
+ // Store result stat for display
275
402
  const resultStat = extractToolResultStat(toolName, resultContent, toolUseResult);
276
- const codeLine = summary ? `\n<code>${escapeHtml(summary)}</code>` : '';
277
- const statLine = resultStat ? `\n${escapeHtml(resultStat)}` : '';
278
- const icon = isError ? '❌' : 'βœ…';
279
- const html = `<blockquote expandable>${icon} ${escapeHtml(toolName)} (${elapsed}s)${codeLine}${statLine}</blockquote>`;
403
+ if (resultStat)
404
+ this.toolResolvedStats.set(blockId, resultStat);
405
+ // Mark as resolved
406
+ entry.resolved = true;
407
+ entry.isError = isError;
408
+ entry.elapsed = elapsed + 's';
280
409
  // Clean up input buffer
281
410
  this.toolInputBuffers.delete(blockId);
282
- this.toolIndicatorLastSummary.delete(blockId);
283
- this.sendQueue = this.sendQueue.then(async () => {
284
- try {
285
- await this.sender.editMessage(this.chatId, msgId, html, 'HTML');
286
- }
287
- catch (err) {
288
- // Edit failure on resolve β€” non-critical
289
- this.logger?.debug?.({ err, toolName }, 'Failed to resolve tool indicator');
290
- }
291
- }).catch(err => {
292
- this.logger?.error?.({ err }, 'resolveToolMessage queue error');
293
- });
294
- return this.sendQueue;
411
+ // Update the consolidated message
412
+ this.updateConsolidatedToolMessage();
295
413
  }
296
- /** Edit a specific tool indicator message by block ID. */
297
- async editToolMessage(blockId, html) {
298
- const entry = this.toolMessages.get(blockId);
299
- if (!entry)
414
+ /** Edit a specific tool indicator message by block ID (updates the consolidated message). */
415
+ async editToolMessage(blockId, _html) {
416
+ if (!this.toolMessages.has(blockId))
300
417
  return;
301
- this.sendQueue = this.sendQueue.then(async () => {
302
- try {
303
- await this.sender.editMessage(this.chatId, entry.msgId, html, 'HTML');
304
- }
305
- catch (err) {
306
- // Edit failure β€” non-critical
307
- this.logger?.debug?.({ err }, 'Failed to edit tool message');
308
- }
309
- }).catch(err => {
310
- this.logger?.error?.({ err }, 'editToolMessage queue error');
311
- });
312
- return this.sendQueue;
418
+ // With consolidation, just rebuild the consolidated message
419
+ this.updateConsolidatedToolMessage();
313
420
  }
314
- /** Delete a specific tool indicator message by block ID. */
421
+ /** Delete a specific tool from the consolidated indicator. */
315
422
  async deleteToolMessage(blockId) {
316
- const entry = this.toolMessages.get(blockId);
317
- if (!entry)
423
+ if (!this.toolMessages.has(blockId))
318
424
  return;
319
425
  this.toolMessages.delete(blockId);
320
- this.sendQueue = this.sendQueue.then(async () => {
321
- try {
322
- if (this.sender.deleteMessage) {
323
- await this.sender.deleteMessage(this.chatId, entry.msgId);
426
+ this.toolInputBuffers.delete(blockId);
427
+ this.toolIndicatorLastSummary.delete(blockId);
428
+ this.toolResolvedStats.delete(blockId);
429
+ if (this.toolMessages.size === 0 && this.consolidatedToolMsgId) {
430
+ // Last tool removed β€” delete the consolidated message
431
+ const msgId = this.consolidatedToolMsgId;
432
+ this.consolidatedToolMsgId = null;
433
+ this.sendQueue = this.sendQueue.then(async () => {
434
+ try {
435
+ if (this.sender.deleteMessage)
436
+ await this.sender.deleteMessage(this.chatId, msgId);
324
437
  }
325
- }
326
- catch (err) {
327
- // Delete failure β€” non-critical
328
- this.logger?.debug?.({ err }, 'Failed to delete tool message');
329
- }
330
- }).catch(err => {
331
- this.logger?.error?.({ err }, 'deleteToolMessage queue error');
332
- });
333
- return this.sendQueue;
438
+ catch (err) {
439
+ this.logger?.debug?.({ err }, 'Failed to delete consolidated tool message');
440
+ }
441
+ }).catch(err => {
442
+ this.logger?.error?.({ err }, 'deleteToolMessage queue error');
443
+ });
444
+ }
445
+ else {
446
+ this.updateConsolidatedToolMessage();
447
+ }
334
448
  }
335
- /** Delete all tool indicator messages. */
449
+ /** Delete the consolidated tool indicator message. */
336
450
  async deleteAllToolMessages() {
337
- const ids = [...this.toolMessages.values()];
338
451
  this.toolMessages.clear();
339
- if (!this.sender.deleteMessage)
452
+ this.toolInputBuffers.clear();
453
+ this.toolIndicatorLastSummary.clear();
454
+ this.toolResolvedStats.clear();
455
+ if (!this.consolidatedToolMsgId || !this.sender.deleteMessage) {
456
+ this.consolidatedToolMsgId = null;
340
457
  return;
458
+ }
459
+ const msgId = this.consolidatedToolMsgId;
460
+ this.consolidatedToolMsgId = null;
341
461
  this.sendQueue = this.sendQueue.then(async () => {
342
- for (const { msgId } of ids) {
343
- try {
344
- await this.sender.deleteMessage(this.chatId, msgId);
345
- }
346
- catch (err) {
347
- // Delete failure β€” non-critical
348
- this.logger?.debug?.({ err }, 'Failed to delete tool message in batch');
349
- }
462
+ try {
463
+ await this.sender.deleteMessage(this.chatId, msgId);
464
+ }
465
+ catch (err) {
466
+ this.logger?.debug?.({ err }, 'Failed to delete consolidated tool message');
350
467
  }
351
468
  }).catch(err => {
352
469
  this.logger?.error?.({ err }, 'deleteAllToolMessages queue error');
@@ -377,16 +494,19 @@ export class StreamAccumulator {
377
494
  */
378
495
  buildFullText(includeSuffix = false) {
379
496
  let text = '';
380
- if (this.thinkingBuffer) {
497
+ // Thinking goes in its own message (thinkingMessageId), not prepended to response
498
+ if (this.thinkingBuffer && !this.thinkingMessageId) {
499
+ // Only prepend if thinking didn't get its own message (edge case: no indicator was shown)
381
500
  const thinkingPreview = this.thinkingBuffer.length > 1024
382
501
  ? this.thinkingBuffer.slice(0, 1024) + '…'
383
502
  : this.thinkingBuffer;
384
- text += `<blockquote expandable>πŸ’­ Thinking\n${markdownToTelegramHtml(thinkingPreview)}</blockquote>\n`;
503
+ text += formatSystemMessage('thinking', markdownToTelegramHtml(thinkingPreview), true) + '\n';
385
504
  }
386
505
  // Convert markdown buffer to HTML-safe text
387
506
  text += makeHtmlSafe(this.buffer);
388
507
  if (includeSuffix && this.turnUsage) {
389
- text += '\n' + formatUsageFooter(this.turnUsage);
508
+ const usageContent = formatUsageFooter(this.turnUsage, this.turnUsage.model).replace(/<\/?i>/g, '');
509
+ text += '\n' + formatSystemMessage('usage', usageContent);
390
510
  }
391
511
  return { text, hasHtmlSuffix: includeSuffix && !!this.turnUsage };
392
512
  }
@@ -443,17 +563,29 @@ export class StreamAccumulator {
443
563
  clearTimeout(this.editTimer);
444
564
  this.editTimer = null;
445
565
  }
566
+ // Update thinking message with final content (if it has its own message)
567
+ if (this.thinkingBuffer && this.thinkingMessageId) {
568
+ const thinkingPreview = this.thinkingBuffer.length > 1024
569
+ ? this.thinkingBuffer.slice(0, 1024) + '…'
570
+ : this.thinkingBuffer;
571
+ try {
572
+ await this.sender.editMessage(this.chatId, this.thinkingMessageId, formatSystemMessage('thinking', markdownToTelegramHtml(thinkingPreview), true), 'HTML');
573
+ }
574
+ catch {
575
+ // Thinking message edit failed β€” not critical
576
+ }
577
+ }
446
578
  if (this.buffer) {
447
- // Final edit with complete text including thinking blockquote and usage footer
579
+ // Final edit with complete text (thinking is in its own message)
448
580
  const { text } = this.buildFullText(true);
449
581
  await this.sendOrEdit(text, true); // buildFullText already does makeHtmlSafe
450
582
  }
451
- else if (this.thinkingBuffer && this.thinkingIndicatorShown) {
452
- // Only thinking happened, no text β€” show thinking as expandable blockquote
583
+ else if (this.thinkingBuffer && !this.thinkingMessageId && this.thinkingIndicatorShown) {
584
+ // Only thinking happened, no text, no separate message β€” show as expandable blockquote
453
585
  const thinkingPreview = this.thinkingBuffer.length > 1024
454
586
  ? this.thinkingBuffer.slice(0, 1024) + '…'
455
587
  : this.thinkingBuffer;
456
- await this.sendOrEdit(`<blockquote expandable>πŸ’­ Thinking\n${markdownToTelegramHtml(thinkingPreview)}</blockquote>`, true);
588
+ await this.sendOrEdit(formatSystemMessage('thinking', markdownToTelegramHtml(thinkingPreview), true), true);
457
589
  }
458
590
  }
459
591
  clearEditTimer() {
@@ -472,18 +604,26 @@ export class StreamAccumulator {
472
604
  this.currentToolBlockId = null;
473
605
  this.lastEditTime = 0;
474
606
  this.thinkingIndicatorShown = false;
607
+ this.thinkingMessageId = null;
475
608
  this.finished = false;
476
609
  this.turnUsage = null;
610
+ this._lastMsgStartCtx = null;
477
611
  this.clearEditTimer();
478
612
  }
479
613
  /** Full reset: also clears tgMessageId (next send creates a new message).
480
614
  * Chains on the existing sendQueue so any pending finalize() edits complete first.
481
- * toolMessages persists β€” they are independent fire-and-forget messages. */
615
+ * Consolidated tool message resets so next turn starts a fresh batch. */
482
616
  reset() {
483
617
  const prevQueue = this.sendQueue;
484
618
  this.softReset();
485
619
  this.tgMessageId = null;
486
620
  this.messageIds = [];
621
+ // Reset tool consolidation for next turn
622
+ this.consolidatedToolMsgId = null;
623
+ this.toolMessages.clear();
624
+ this.toolInputBuffers.clear();
625
+ this.toolIndicatorLastSummary.clear();
626
+ this.toolResolvedStats.clear();
487
627
  this.sendQueue = prevQueue.catch(() => { }); // swallow errors from prev turn
488
628
  }
489
629
  }
@@ -691,17 +831,24 @@ function formatTokens(n) {
691
831
  return String(n);
692
832
  }
693
833
  /** Format usage stats as an HTML italic footer line */
694
- export function formatUsageFooter(usage) {
695
- const totalCtx = usage.inputTokens + usage.cacheReadTokens + usage.cacheCreationTokens;
696
- const ctxPct = Math.round(totalCtx / 200000 * 100);
834
+ export function formatUsageFooter(usage, _model) {
835
+ // Use per-API-call ctx tokens (from message_start) for context % β€” these are bounded by the
836
+ // context window. Fall back to cumulative result event tokens only if ctx tokens unavailable.
837
+ const ctxInput = usage.ctxInputTokens ?? usage.inputTokens;
838
+ const ctxRead = usage.ctxCacheReadTokens ?? usage.cacheReadTokens;
839
+ const ctxCreation = usage.ctxCacheCreationTokens ?? usage.cacheCreationTokens;
840
+ const totalCtx = ctxInput + ctxRead + ctxCreation;
841
+ const CONTEXT_WINDOW = 200_000;
842
+ const ctxPct = Math.round(totalCtx / CONTEXT_WINDOW * 100);
843
+ const overLimit = ctxPct > 90;
697
844
  const parts = [
698
- `↩️ ${formatTokens(usage.inputTokens)} in`,
845
+ `${formatTokens(usage.inputTokens)} in`,
699
846
  `${formatTokens(usage.outputTokens)} out`,
700
847
  ];
701
848
  if (usage.costUsd != null) {
702
849
  parts.push(`$${usage.costUsd.toFixed(4)}`);
703
850
  }
704
- parts.push(`${ctxPct}%`);
851
+ parts.push(overLimit ? `⚠️ ${ctxPct}%` : `${ctxPct}%`);
705
852
  return `<i>${parts.join(' Β· ')}</i>`;
706
853
  }
707
854
  // ── Sub-agent detection patterns ──
@@ -846,10 +993,12 @@ export class SubAgentTracker {
846
993
  continue;
847
994
  info.status = 'completed';
848
995
  const label = info.label || info.toolName;
849
- const text = `βœ… ${escapeHtml(label)} β€” see main message`;
996
+ const text = `<blockquote>πŸ€– ${escapeHtml(label)} β€” see main message</blockquote>`;
997
+ const agentMsgId = info.tgMessageId;
850
998
  this.sendQueue = this.sendQueue.then(async () => {
851
999
  try {
852
- await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
1000
+ await this.sender.editMessage(this.chatId, agentMsgId, text, 'HTML');
1001
+ await this.sender.setReaction?.(this.chatId, agentMsgId, 'πŸ‘');
853
1002
  }
854
1003
  catch (err) {
855
1004
  // Non-critical β€” edit failure on dispatched agent status
@@ -924,14 +1073,17 @@ export class SubAgentTracker {
924
1073
  return;
925
1074
  info.status = 'completed';
926
1075
  const label = info.label || info.toolName;
927
- // Truncate result for blockquote (TG message limit ~4096 chars)
928
1076
  const maxResultLen = 3500;
929
1077
  const resultText = result.length > maxResultLen ? result.slice(0, maxResultLen) + '…' : result;
930
- // Use expandable blockquote β€” collapsed shows "βœ… label" + first line, tap to expand
931
- const text = `<blockquote expandable>βœ… ${escapeHtml(label)}\n${escapeHtml(resultText)}</blockquote>`;
1078
+ // Blockquote header + expandable result (not nested β€” TG doesn't support nested blockquotes)
1079
+ const text = `<blockquote>πŸ€– ${escapeHtml(label)}</blockquote>\n<blockquote expandable>${escapeHtml(resultText)}</blockquote>`;
1080
+ const msgId = info.tgMessageId;
932
1081
  this.sendQueue = this.sendQueue.then(async () => {
933
1082
  try {
934
- await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
1083
+ await this.sender.editMessage(this.chatId, msgId, text, 'HTML');
1084
+ if (this.sender.setReaction) {
1085
+ await this.sender.setReaction(this.chatId, msgId, 'πŸ‘');
1086
+ }
935
1087
  }
936
1088
  catch {
937
1089
  // Edit failure on tool result β€” non-critical
@@ -968,7 +1120,7 @@ export class SubAgentTracker {
968
1120
  else {
969
1121
  this.sendQueue = this.sendQueue.then(async () => {
970
1122
  try {
971
- const msgId = await this.sender.sendMessage(this.chatId, 'πŸ€– Starting sub-agent…', 'HTML');
1123
+ const msgId = await this.sender.sendMessage(this.chatId, '<blockquote>πŸ€– Starting sub-agent…</blockquote>', 'HTML');
972
1124
  info.tgMessageId = msgId;
973
1125
  this.consolidatedAgentMsgId = msgId;
974
1126
  }
@@ -992,7 +1144,7 @@ export class SubAgentTracker {
992
1144
  : 'Working…';
993
1145
  lines.push(`πŸ€– ${escapeHtml(label)} β€” ${status}`);
994
1146
  }
995
- const text = lines.join('\n');
1147
+ const text = `<blockquote>${lines.join('\n')}</blockquote>`;
996
1148
  this.sendQueue = this.sendQueue.then(async () => {
997
1149
  try {
998
1150
  await this.sender.editMessage(this.chatId, msgId, text, 'HTML');
@@ -1232,7 +1384,7 @@ export class SubAgentTracker {
1232
1384
  lines.push(`πŸ€– ${escapeHtml(label)} β€” ${status}`);
1233
1385
  }
1234
1386
  }
1235
- const text = lines.join('\n');
1387
+ const text = `<blockquote>${lines.join('\n')}</blockquote>`;
1236
1388
  this.sendQueue = this.sendQueue.then(async () => {
1237
1389
  try {
1238
1390
  await this.sender.editMessage(this.chatId, msgId, text, 'HTML');
@@ -1263,7 +1415,7 @@ export class SubAgentTracker {
1263
1415
  }
1264
1416
  }
1265
1417
  // ── Utility: split a completed text into TG-sized chunks ──
1266
- export function splitText(text, maxLength = 4000) {
1418
+ export function splitText(text, maxLength = 3500) {
1267
1419
  if (text.length <= maxLength)
1268
1420
  return [text];
1269
1421
  const chunks = [];