@fonz/tgcc 0.6.13 → 0.6.14

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
@@ -34,6 +34,8 @@ export class StreamAccumulator {
34
34
  sender;
35
35
  editIntervalMs;
36
36
  splitThreshold;
37
+ logger;
38
+ onError;
37
39
  // State
38
40
  tgMessageId = null;
39
41
  buffer = '';
@@ -43,16 +45,21 @@ export class StreamAccumulator {
43
45
  lastEditTime = 0;
44
46
  editTimer = null;
45
47
  thinkingIndicatorShown = false;
46
- toolIndicators = [];
47
48
  messageIds = []; // all message IDs sent during this turn
48
49
  finished = false;
49
50
  sendQueue = Promise.resolve();
50
51
  turnUsage = null;
52
+ // Per-tool-use independent indicator messages (persists across resets)
53
+ toolMessages = new Map();
54
+ toolInputBuffers = new Map(); // tool block ID → accumulated input JSON
55
+ currentToolBlockId = null;
51
56
  constructor(options) {
52
57
  this.chatId = options.chatId;
53
58
  this.sender = options.sender;
54
59
  this.editIntervalMs = options.editIntervalMs ?? 1000;
55
60
  this.splitThreshold = options.splitThreshold ?? 4000;
61
+ this.logger = options.logger;
62
+ this.onError = options.onError;
56
63
  }
57
64
  get allMessageIds() { return [...this.messageIds]; }
58
65
  /** Set usage stats for the current turn (called from bridge on result event) */
@@ -91,20 +98,23 @@ export class StreamAccumulator {
91
98
  if (blockType === 'thinking') {
92
99
  this.currentBlockType = 'thinking';
93
100
  if (!this.thinkingIndicatorShown && !this.buffer) {
94
- await this.sendOrEdit('<i>💭 Thinking...</i>', true);
101
+ await this.sendOrEdit('<blockquote>💭 Thinking...</blockquote>', true);
95
102
  this.thinkingIndicatorShown = true;
96
103
  }
97
104
  }
98
105
  else if (blockType === 'text') {
99
106
  this.currentBlockType = 'text';
100
- // Clear tool indicators when real text starts
101
- this.toolIndicators = [];
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
110
+ }
102
111
  }
103
112
  else if (blockType === 'tool_use') {
104
113
  this.currentBlockType = 'tool_use';
105
- const name = event.content_block.name;
106
- this.toolIndicators.push(name);
107
- await this.showToolIndicator(name);
114
+ const block = event.content_block;
115
+ this.currentToolBlockId = block.id;
116
+ // Send an independent indicator message for this tool_use block
117
+ await this.sendToolIndicator(block.id, block.name);
108
118
  }
109
119
  else if (blockType === 'image') {
110
120
  this.currentBlockType = 'image';
@@ -130,22 +140,37 @@ export class StreamAccumulator {
130
140
  this.thinkingBuffer += delta.thinking;
131
141
  }
132
142
  }
143
+ else if (this.currentBlockType === 'tool_use' && 'delta' in event) {
144
+ const delta = event.delta;
145
+ if (delta?.type === 'input_json_delta' && delta.partial_json && this.currentToolBlockId) {
146
+ const blockId = this.currentToolBlockId;
147
+ const prev = this.toolInputBuffers.get(blockId) ?? '';
148
+ this.toolInputBuffers.set(blockId, prev + delta.partial_json);
149
+ // Update indicator with input preview once we have enough
150
+ await this.updateToolIndicatorWithInput(blockId);
151
+ }
152
+ }
133
153
  else if (this.currentBlockType === 'image' && 'delta' in event) {
134
154
  const delta = event.delta;
135
155
  if (delta?.type === 'image_delta' && delta.data) {
136
156
  this.imageBase64Buffer += delta.data;
137
157
  }
138
158
  }
139
- // Ignore input_json_delta content
140
159
  }
141
160
  // ── TG message management ──
142
161
  /** Send or edit a message. If rawHtml is true, text is already HTML-safe. */
143
162
  async sendOrEdit(text, rawHtml = false) {
144
- this.sendQueue = this.sendQueue.then(() => this._doSendOrEdit(text, rawHtml));
163
+ this.sendQueue = this.sendQueue.then(() => this._doSendOrEdit(text, rawHtml)).catch(err => {
164
+ this.logger?.error?.({ err }, 'sendOrEdit failed');
165
+ this.onError?.(err, 'Failed to send/edit message');
166
+ });
145
167
  return this.sendQueue;
146
168
  }
147
169
  async _doSendOrEdit(text, rawHtml = false) {
148
- const safeText = (rawHtml ? text : makeHtmlSafe(text)) || '...';
170
+ let safeText = (rawHtml ? text : makeHtmlSafe(text)) || '...';
171
+ // Guard against HTML that has tags but no visible text content (e.g. "<b></b>")
172
+ if (!safeText.replace(/<[^>]*>/g, '').trim())
173
+ safeText = '...';
149
174
  // Update timing BEFORE API call to prevent races
150
175
  this.lastEditTime = Date.now();
151
176
  try {
@@ -158,17 +183,20 @@ export class StreamAccumulator {
158
183
  }
159
184
  }
160
185
  catch (err) {
161
- // Handle TG rate limit (429)
162
- if (err && typeof err === 'object' && 'error_code' in err && err.error_code === 429) {
186
+ const errorCode = err && typeof err === 'object' && 'error_code' in err
187
+ ? err.error_code : 0;
188
+ // Handle TG rate limit (429) — retry
189
+ if (errorCode === 429) {
163
190
  const retryAfter = err.parameters?.retry_after ?? 5;
164
191
  this.editIntervalMs = Math.min(this.editIntervalMs * 2, 5000);
165
192
  await sleep(retryAfter * 1000);
166
193
  return this._doSendOrEdit(text);
167
194
  }
168
- // Ignore "message is not modified" errors
195
+ // Ignore "message is not modified" errors (harmless)
169
196
  if (err instanceof Error && err.message.includes('message is not modified'))
170
197
  return;
171
- throw err;
198
+ // 400 (bad request), 403 (forbidden), and all other errors — log and skip, never throw
199
+ this.logger?.error?.({ err, errorCode }, 'Telegram API error in _doSendOrEdit — skipping');
172
200
  }
173
201
  }
174
202
  async sendImage() {
@@ -181,16 +209,149 @@ export class StreamAccumulator {
181
209
  }
182
210
  catch (err) {
183
211
  // Fall back to text indicator on failure
212
+ this.logger?.error?.({ err }, 'Failed to send image');
184
213
  this.buffer += '\n[Image could not be sent]';
185
214
  }
186
215
  this.imageBase64Buffer = '';
187
216
  }
188
- async showToolIndicator(toolName) {
189
- const bufferHtml = this.buffer ? makeHtmlSafe(this.buffer) : '';
190
- const indicator = bufferHtml
191
- ? `${bufferHtml}\n\n<i>Using ${escapeHtml(toolName)}...</i>`
192
- : `<i>Using ${escapeHtml(toolName)}...</i>`;
193
- await this.sendOrEdit(indicator, true);
217
+ /** Send an independent tool indicator message (not through the accumulator's sendOrEdit). */
218
+ async sendToolIndicator(blockId, toolName) {
219
+ const startTime = Date.now();
220
+ this.sendQueue = this.sendQueue.then(async () => {
221
+ 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 });
225
+ }
226
+ catch (err) {
227
+ // Tool indicator is non-critical — log and continue
228
+ this.logger?.debug?.({ err, toolName }, 'Failed to send tool indicator');
229
+ }
230
+ }).catch(err => {
231
+ this.logger?.error?.({ err }, 'sendToolIndicator queue error');
232
+ });
233
+ return this.sendQueue;
234
+ }
235
+ /** Update a tool indicator message with input preview once the JSON value is complete. */
236
+ toolIndicatorLastSummary = new Map(); // blockId → last rendered summary
237
+ async updateToolIndicatorWithInput(blockId) {
238
+ const entry = this.toolMessages.get(blockId);
239
+ if (!entry)
240
+ return;
241
+ const inputJson = this.toolInputBuffers.get(blockId) ?? '';
242
+ // Only extract from complete JSON (try parse succeeds) or complete regex match
243
+ // (the value must have a closing quote to avoid truncated paths)
244
+ const summary = extractToolInputSummary(entry.toolName, inputJson, 120, true);
245
+ if (!summary)
246
+ return; // not enough input yet or value still streaming
247
+ // Skip if summary hasn't changed since last edit
248
+ if (this.toolIndicatorLastSummary.get(blockId) === summary)
249
+ return;
250
+ 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;
265
+ }
266
+ /** Resolve a tool indicator with success/failure status. Edits to a compact summary with input detail. */
267
+ async resolveToolMessage(blockId, isError, errorMessage, resultContent) {
268
+ const entry = this.toolMessages.get(blockId);
269
+ if (!entry)
270
+ return;
271
+ const { msgId, toolName, startTime } = entry;
272
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
273
+ const inputJson = this.toolInputBuffers.get(blockId) ?? '';
274
+ const summary = extractToolInputSummary(toolName, inputJson);
275
+ const resultStat = resultContent ? extractToolResultStat(toolName, resultContent) : '';
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>`;
280
+ // Clean up input buffer
281
+ 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;
295
+ }
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)
300
+ 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;
313
+ }
314
+ /** Delete a specific tool indicator message by block ID. */
315
+ async deleteToolMessage(blockId) {
316
+ const entry = this.toolMessages.get(blockId);
317
+ if (!entry)
318
+ return;
319
+ 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);
324
+ }
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;
334
+ }
335
+ /** Delete all tool indicator messages. */
336
+ async deleteAllToolMessages() {
337
+ const ids = [...this.toolMessages.values()];
338
+ this.toolMessages.clear();
339
+ if (!this.sender.deleteMessage)
340
+ return;
341
+ 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
+ }
350
+ }
351
+ }).catch(err => {
352
+ this.logger?.error?.({ err }, 'deleteAllToolMessages queue error');
353
+ });
354
+ return this.sendQueue;
194
355
  }
195
356
  async throttledEdit() {
196
357
  const now = Date.now();
@@ -301,24 +462,24 @@ export class StreamAccumulator {
301
462
  this.editTimer = null;
302
463
  }
303
464
  }
304
- /** Soft reset: clear buffer/state but keep tgMessageId so next turn edits the same message */
465
+ /** Soft reset: clear buffer/state but keep tgMessageId so next turn edits the same message.
466
+ * toolMessages persists across resets — they are independent of the text accumulator. */
305
467
  softReset() {
306
468
  this.buffer = '';
307
469
  this.thinkingBuffer = '';
308
470
  this.imageBase64Buffer = '';
309
471
  this.currentBlockType = null;
472
+ this.currentToolBlockId = null;
310
473
  this.lastEditTime = 0;
311
474
  this.thinkingIndicatorShown = false;
312
- this.toolIndicators = [];
313
475
  this.finished = false;
314
476
  this.turnUsage = null;
315
- this.clearEditTimer(); // Ensure cleanup
477
+ this.clearEditTimer();
316
478
  }
317
479
  /** Full reset: also clears tgMessageId (next send creates a new message).
318
- * Chains on the existing sendQueue so any pending finalize() edits complete first. */
480
+ * Chains on the existing sendQueue so any pending finalize() edits complete first.
481
+ * toolMessages persists — they are independent fire-and-forget messages. */
319
482
  reset() {
320
- // Chain on the existing queue so pending sends (e.g. finalize) complete
321
- // before the new turn starts sending.
322
483
  const prevQueue = this.sendQueue;
323
484
  this.softReset();
324
485
  this.tgMessageId = null;
@@ -327,6 +488,130 @@ export class StreamAccumulator {
327
488
  }
328
489
  }
329
490
  // ── Helpers ──
491
+ /** Extract a human-readable summary from a tool's input JSON (may be partial/incomplete). */
492
+ /** Shorten absolute paths to relative-ish display: /home/fonz/Botverse/KYO/src/foo.ts → KYO/src/foo.ts */
493
+ function shortenPath(p) {
494
+ // Strip common prefixes
495
+ return p
496
+ .replace(/^\/home\/[^/]+\/Botverse\//, '')
497
+ .replace(/^\/home\/[^/]+\/Projects\//, '')
498
+ .replace(/^\/home\/[^/]+\//, '~/');
499
+ }
500
+ function extractToolInputSummary(toolName, inputJson, maxLen = 120, requireComplete = false) {
501
+ if (!inputJson)
502
+ return null;
503
+ // Determine which field(s) to look for based on tool name
504
+ const fieldsByTool = {
505
+ Bash: ['command'],
506
+ Read: ['file_path', 'path'],
507
+ Write: ['file_path', 'path'],
508
+ Edit: ['file_path', 'path'],
509
+ MultiEdit: ['file_path', 'path'],
510
+ Search: ['pattern', 'query'],
511
+ Grep: ['pattern', 'query'],
512
+ Glob: ['pattern'],
513
+ TodoWrite: ['content', 'task', 'text'],
514
+ };
515
+ const skipTools = new Set(['TodoRead']);
516
+ if (skipTools.has(toolName))
517
+ return null;
518
+ const fields = fieldsByTool[toolName];
519
+ const isPathTool = ['Read', 'Write', 'Edit', 'MultiEdit'].includes(toolName);
520
+ // Try parsing complete JSON first
521
+ try {
522
+ const parsed = JSON.parse(inputJson);
523
+ if (fields) {
524
+ for (const f of fields) {
525
+ if (typeof parsed[f] === 'string' && parsed[f].trim()) {
526
+ let val = parsed[f].trim();
527
+ if (isPathTool)
528
+ val = shortenPath(val);
529
+ return val.length > maxLen ? val.slice(0, maxLen) + '…' : val;
530
+ }
531
+ }
532
+ }
533
+ // Default: first string value
534
+ for (const val of Object.values(parsed)) {
535
+ if (typeof val === 'string' && val.trim()) {
536
+ const v = val.trim();
537
+ return v.length > maxLen ? v.slice(0, maxLen) + '…' : v;
538
+ }
539
+ }
540
+ return null;
541
+ }
542
+ catch {
543
+ if (requireComplete)
544
+ return null; // Only use fully-parsed JSON when requireComplete
545
+ // Partial JSON — regex extraction (only used for final resolve, not live preview)
546
+ const targetFields = fields ?? ['command', 'file_path', 'path', 'pattern', 'query'];
547
+ for (const key of targetFields) {
548
+ const re = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`, 'i'); // require closing quote
549
+ const m = inputJson.match(re);
550
+ if (m?.[1]) {
551
+ let val = m[1].replace(/\\n/g, ' ').replace(/\\t/g, ' ').replace(/\\"/g, '"');
552
+ if (isPathTool)
553
+ val = shortenPath(val);
554
+ return val.length > maxLen ? val.slice(0, maxLen) + '…' : val;
555
+ }
556
+ }
557
+ return null;
558
+ }
559
+ }
560
+ /** Extract a compact stat from a tool result for display in the indicator. */
561
+ function extractToolResultStat(toolName, content) {
562
+ if (!content)
563
+ return '';
564
+ const first = content.split('\n')[0].trim();
565
+ switch (toolName) {
566
+ case 'Write': {
567
+ // "Successfully wrote 42 lines to src/foo.ts" or byte count
568
+ const lines = content.match(/(\d+)\s*lines?/i);
569
+ const bytes = content.match(/(\d[\d,.]*)\s*(bytes?|KB|MB)/i);
570
+ if (lines)
571
+ return `${lines[1]} lines`;
572
+ if (bytes)
573
+ return `${bytes[1]} ${bytes[2]}`;
574
+ return first.length > 60 ? first.slice(0, 60) + '…' : first;
575
+ }
576
+ case 'Edit':
577
+ case 'MultiEdit': {
578
+ // "Edited src/foo.ts: replaced 3 lines" or snippet
579
+ const replaced = content.match(/replaced\s+(\d+)\s*lines?/i);
580
+ const chars = content.match(/(\d+)\s*characters?/i);
581
+ if (replaced)
582
+ return `${replaced[1]} lines replaced`;
583
+ if (chars)
584
+ return `${chars[1]} chars changed`;
585
+ return first.length > 60 ? first.slice(0, 60) + '…' : first;
586
+ }
587
+ case 'Bash': {
588
+ // Show exit code or first line of output (truncated)
589
+ const exit = content.match(/exit\s*code\s*[:=]?\s*(\d+)/i);
590
+ if (exit && exit[1] !== '0')
591
+ return `exit ${exit[1]}`;
592
+ // Count output lines
593
+ const outputLines = content.split('\n').filter(l => l.trim()).length;
594
+ if (outputLines > 3)
595
+ return `${outputLines} lines output`;
596
+ return first.length > 60 ? first.slice(0, 60) + '…' : first;
597
+ }
598
+ case 'Read': {
599
+ const lines = content.split('\n').length;
600
+ return `${lines} lines`;
601
+ }
602
+ case 'Search':
603
+ case 'Grep':
604
+ case 'Glob': {
605
+ const matches = content.match(/(\d+)\s*(match|result|file)/i);
606
+ if (matches)
607
+ return `${matches[1]} ${matches[2]}s`;
608
+ const resultLines = content.split('\n').filter(l => l.trim()).length;
609
+ return `${resultLines} results`;
610
+ }
611
+ default:
612
+ return first.length > 60 ? first.slice(0, 60) + '…' : first;
613
+ }
614
+ }
330
615
  function findSplitPoint(text, threshold) {
331
616
  // Try to split at paragraph break
332
617
  const paragraphBreak = text.lastIndexOf('\n\n', threshold);
@@ -510,8 +795,10 @@ export class SubAgentTracker {
510
795
  try {
511
796
  await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
512
797
  }
513
- catch { /* ignore */ }
514
- });
798
+ catch (err) {
799
+ // Non-critical — edit failure on dispatched agent status
800
+ }
801
+ }).catch(() => { });
515
802
  }
516
803
  }
517
804
  async handleEvent(event) {
@@ -591,9 +878,9 @@ export class SubAgentTracker {
591
878
  await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
592
879
  }
593
880
  catch {
594
- // Ignore edit failures
881
+ // Edit failure on tool result — non-critical
595
882
  }
596
- });
883
+ }).catch(() => { });
597
884
  await this.sendQueue;
598
885
  }
599
886
  async onBlockStart(event) {
@@ -630,9 +917,9 @@ export class SubAgentTracker {
630
917
  this.consolidatedAgentMsgId = msgId;
631
918
  }
632
919
  catch {
633
- // Silently ignore
920
+ // Sub-agent indicator is non-critical
634
921
  }
635
- });
922
+ }).catch(() => { });
636
923
  await this.sendQueue;
637
924
  }
638
925
  }
@@ -654,8 +941,10 @@ export class SubAgentTracker {
654
941
  try {
655
942
  await this.sender.editMessage(this.chatId, msgId, text, 'HTML');
656
943
  }
657
- catch { /* ignore */ }
658
- });
944
+ catch {
945
+ // Consolidated message edit failure — non-critical
946
+ }
947
+ }).catch(() => { });
659
948
  }
660
949
  async onInputDelta(event) {
661
950
  const toolUseId = this.blockToAgent.get(event.index);
@@ -797,8 +1086,10 @@ export class SubAgentTracker {
797
1086
  try {
798
1087
  await this.sender.setReaction?.(this.chatId, msgId, emoji);
799
1088
  }
800
- catch { /* ignore — reaction might not be supported */ }
801
- });
1089
+ catch {
1090
+ // Reaction failure — non-critical, might not be supported
1091
+ }
1092
+ }).catch(() => { });
802
1093
  }
803
1094
  // Check if ALL dispatched agents are now completed
804
1095
  if (this.onAllReported && !this.hasDispatchedAgents && this.agents.size > 0) {
@@ -890,8 +1181,10 @@ export class SubAgentTracker {
890
1181
  try {
891
1182
  await this.sender.editMessage(this.chatId, msgId, text, 'HTML');
892
1183
  }
893
- catch { /* ignore */ }
894
- });
1184
+ catch {
1185
+ // Progress message edit failure — non-critical
1186
+ }
1187
+ }).catch(() => { });
895
1188
  }
896
1189
  /** Find a tracked sub-agent by tool_use_id. */
897
1190
  getAgentByToolUseId(toolUseId) {