@grinev/opencode-telegram-bot 0.12.0 → 0.13.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.
@@ -17,6 +17,7 @@ class PinnedMessageManager {
17
17
  tokensLimit: 0,
18
18
  lastUpdated: 0,
19
19
  changedFiles: [],
20
+ cost: 0,
20
21
  };
21
22
  contextLimit = null;
22
23
  onKeyboardUpdateCallback;
@@ -40,6 +41,7 @@ class PinnedMessageManager {
40
41
  logger.info(`[PinnedManager] Session changed: ${sessionId}, title: ${sessionTitle}`);
41
42
  // Reset tokens for new session
42
43
  this.state.tokensUsed = 0;
44
+ this.state.cost = 0;
43
45
  // Update state
44
46
  this.state.sessionId = sessionId;
45
47
  this.state.sessionTitle = sessionTitle || t("pinned.default_session_title");
@@ -84,9 +86,10 @@ class PinnedMessageManager {
84
86
  logger.warn("[PinnedManager] Failed to load session history:", error);
85
87
  return;
86
88
  }
87
- // Get the maximum context size from session history
89
+ // Get the maximum context size and total cost from session history
88
90
  // Context = input + cache.read (cache.read contains previously cached context)
89
91
  let maxContextSize = 0;
92
+ let totalCost = 0;
90
93
  logger.debug(`[PinnedManager] Processing ${messagesData.length} messages from history`);
91
94
  messagesData.forEach(({ info }) => {
92
95
  if (info.role === "assistant") {
@@ -99,16 +102,20 @@ class PinnedMessageManager {
99
102
  const input = assistantInfo.tokens?.input || 0;
100
103
  const cacheRead = assistantInfo.tokens?.cache?.read || 0;
101
104
  const contextSize = input + cacheRead;
102
- logger.debug(`[PinnedManager] Assistant message: input=${input}, cache.read=${cacheRead}, total=${contextSize}`);
105
+ const cost = assistantInfo.cost || 0;
106
+ logger.debug(`[PinnedManager] Assistant message: input=${input}, cache.read=${cacheRead}, total=${contextSize}, cost=$${cost.toFixed(2)}`);
103
107
  // Keep track of maximum context size (peak usage in session)
104
108
  if (contextSize > maxContextSize) {
105
109
  maxContextSize = contextSize;
106
110
  }
111
+ // Accumulate total session cost
112
+ totalCost += cost;
107
113
  }
108
114
  });
109
115
  this.state.tokensUsed = maxContextSize;
116
+ this.state.cost = totalCost;
110
117
  this.state.sessionId = sessionId;
111
- logger.info(`[PinnedManager] Loaded context from history: ${this.state.tokensUsed} tokens`);
118
+ logger.info(`[PinnedManager] Loaded context from history: ${this.state.tokensUsed} tokens, cost: $${this.state.cost.toFixed(2)}`);
112
119
  await this.updatePinnedMessage();
113
120
  }
114
121
  catch (err) {
@@ -140,6 +147,15 @@ class PinnedMessageManager {
140
147
  await this.refreshSessionTitle();
141
148
  await this.updatePinnedMessage();
142
149
  }
150
+ /**
151
+ * Called when cost info is received from SSE events
152
+ */
153
+ async onCostUpdate(cost) {
154
+ const currentCost = this.state.cost || 0;
155
+ this.state.cost = currentCost + cost;
156
+ logger.debug(`[PinnedManager] Cost added: $${cost.toFixed(2)}, total session: $${(this.state.cost || 0).toFixed(2)}`);
157
+ await this.updatePinnedMessage();
158
+ }
143
159
  /**
144
160
  * Set callback for keyboard updates when context changes
145
161
  */
@@ -455,6 +471,9 @@ class PinnedMessageManager {
455
471
  percent: percentage,
456
472
  }),
457
473
  ];
474
+ if (this.state.cost !== undefined && this.state.cost !== null) {
475
+ lines.push(t("pinned.line.cost", { cost: `$${this.state.cost.toFixed(2)}` }));
476
+ }
458
477
  if (this.state.changedFiles.length > 0) {
459
478
  const maxFiles = 10;
460
479
  const total = this.state.changedFiles.length;
@@ -26,18 +26,19 @@ function countDiffChangesFromText(text) {
26
26
  }
27
27
  class SummaryAggregator {
28
28
  currentSessionId = null;
29
- currentMessageParts = new Map();
30
- pendingParts = new Map();
29
+ textMessageStates = new Map();
31
30
  messages = new Map();
32
31
  messageCount = 0;
33
32
  lastUpdated = 0;
34
33
  onCompleteCallback = null;
34
+ onPartialCallback = null;
35
35
  onToolCallback = null;
36
36
  onToolFileCallback = null;
37
37
  onQuestionCallback = null;
38
38
  onQuestionErrorCallback = null;
39
39
  onThinkingCallback = null;
40
40
  onTokensCallback = null;
41
+ onCostCallback = null;
41
42
  onSessionCompactedCallback = null;
42
43
  onSessionErrorCallback = null;
43
44
  onSessionRetryCallback = null;
@@ -47,9 +48,11 @@ class SummaryAggregator {
47
48
  onClearedCallback = null;
48
49
  processedToolStates = new Set();
49
50
  thinkingFiredForMessages = new Set();
51
+ knownTextPartIds = new Map();
50
52
  bot = null;
51
53
  chatId = null;
52
54
  typingTimer = null;
55
+ typingIndicatorEnabled = true;
53
56
  partHashes = new Map();
54
57
  setBotAndChatId(bot, chatId) {
55
58
  this.bot = bot;
@@ -58,6 +61,9 @@ class SummaryAggregator {
58
61
  setOnComplete(callback) {
59
62
  this.onCompleteCallback = callback;
60
63
  }
64
+ setOnPartial(callback) {
65
+ this.onPartialCallback = callback;
66
+ }
61
67
  setOnTool(callback) {
62
68
  this.onToolCallback = callback;
63
69
  }
@@ -76,6 +82,9 @@ class SummaryAggregator {
76
82
  setOnTokens(callback) {
77
83
  this.onTokensCallback = callback;
78
84
  }
85
+ setOnCost(callback) {
86
+ this.onCostCallback = callback;
87
+ }
79
88
  setOnSessionCompacted(callback) {
80
89
  this.onSessionCompactedCallback = callback;
81
90
  }
@@ -97,7 +106,16 @@ class SummaryAggregator {
97
106
  setOnCleared(callback) {
98
107
  this.onClearedCallback = callback;
99
108
  }
109
+ setTypingIndicatorEnabled(enabled) {
110
+ this.typingIndicatorEnabled = enabled;
111
+ if (!enabled) {
112
+ this.stopTypingIndicator();
113
+ }
114
+ }
100
115
  startTypingIndicator() {
116
+ if (!this.typingIndicatorEnabled) {
117
+ return;
118
+ }
101
119
  if (this.typingTimer) {
102
120
  return;
103
121
  }
@@ -118,6 +136,11 @@ class SummaryAggregator {
118
136
  }
119
137
  }
120
138
  processEvent(event) {
139
+ const eventType = event.type;
140
+ if (eventType === "message.part.delta") {
141
+ this.handleMessagePartDelta(event);
142
+ return;
143
+ }
121
144
  // Log all question-related events for debugging
122
145
  if (event.type.startsWith("question.")) {
123
146
  logger.info(`[Aggregator] Question event: ${event.type}`, JSON.stringify(event.properties, null, 2));
@@ -177,10 +200,10 @@ class SummaryAggregator {
177
200
  clear() {
178
201
  this.stopTypingIndicator();
179
202
  this.currentSessionId = null;
180
- this.currentMessageParts.clear();
181
- this.pendingParts.clear();
203
+ this.textMessageStates.clear();
182
204
  this.messages.clear();
183
205
  this.partHashes.clear();
206
+ this.knownTextPartIds.clear();
184
207
  this.processedToolStates.clear();
185
208
  this.thinkingFiredForMessages.clear();
186
209
  this.messageCount = 0;
@@ -202,21 +225,26 @@ class SummaryAggregator {
202
225
  const messageID = info.id;
203
226
  this.messages.set(messageID, { role: info.role });
204
227
  if (info.role === "assistant") {
205
- if (!this.currentMessageParts.has(messageID)) {
206
- this.currentMessageParts.set(messageID, []);
228
+ if (!this.textMessageStates.has(messageID)) {
229
+ this.textMessageStates.set(messageID, {
230
+ orderedPartIds: [],
231
+ partTexts: new Map(),
232
+ optimisticUpdateCount: 0,
233
+ });
207
234
  this.messageCount++;
208
235
  this.startTypingIndicator();
209
236
  }
210
- const pending = this.pendingParts.get(messageID) || [];
211
- const current = this.currentMessageParts.get(messageID) || [];
212
- this.currentMessageParts.set(messageID, [...current, ...pending]);
213
- this.pendingParts.delete(messageID);
237
+ const textState = this.getOrCreateTextMessageState(messageID);
214
238
  const assistantMessage = info;
215
239
  const time = assistantMessage.time;
216
- if (time?.completed) {
217
- const parts = this.currentMessageParts.get(messageID) || [];
218
- const lastPart = parts[parts.length - 1] || "";
219
- logger.debug(`[Aggregator] Message part completed: messageId=${messageID}, textLength=${lastPart.length}, totalParts=${parts.length}, session=${this.currentSessionId}`);
240
+ const isCompleted = Boolean(time?.completed);
241
+ const messageText = this.getCombinedMessageText(messageID);
242
+ if (!isCompleted && textState.optimisticUpdateCount === 1) {
243
+ this.emitPartialText(info.sessionID, messageID, messageText);
244
+ }
245
+ if (isCompleted) {
246
+ const finalText = messageText;
247
+ logger.debug(`[Aggregator] Message part completed: messageId=${messageID}, textLength=${finalText.length}, totalParts=${textState.orderedPartIds.length}, session=${this.currentSessionId}`);
220
248
  // Extract and report tokens BEFORE onComplete so keyboard context is updated
221
249
  const assistantInfo = info;
222
250
  if (this.onTokensCallback && assistantInfo.tokens) {
@@ -231,14 +259,20 @@ class SummaryAggregator {
231
259
  // Call synchronously so keyboardManager is updated before onComplete sends the reply
232
260
  this.onTokensCallback(tokens);
233
261
  }
234
- if (this.onCompleteCallback && lastPart.length > 0) {
235
- this.onCompleteCallback(this.currentSessionId, lastPart);
262
+ // Extract and report cost
263
+ if (this.onCostCallback && assistantInfo.cost !== undefined) {
264
+ logger.debug(`[Aggregator] Cost: $${assistantInfo.cost.toFixed(2)}`);
265
+ this.onCostCallback(assistantInfo.cost);
266
+ }
267
+ if (this.onCompleteCallback && finalText.length > 0) {
268
+ this.onCompleteCallback(this.currentSessionId, messageID, finalText);
236
269
  }
237
- this.currentMessageParts.delete(messageID);
270
+ this.textMessageStates.delete(messageID);
238
271
  this.messages.delete(messageID);
239
272
  this.partHashes.delete(messageID);
240
- logger.debug(`[Aggregator] Message completed cleanup: remaining messages=${this.currentMessageParts.size}`);
241
- if (this.currentMessageParts.size === 0) {
273
+ this.knownTextPartIds.delete(messageID);
274
+ logger.debug(`[Aggregator] Message completed cleanup: remaining messages=${this.textMessageStates.size}`);
275
+ if (this.textMessageStates.size === 0) {
242
276
  logger.debug("[Aggregator] No more active messages, stopping typing indicator");
243
277
  this.stopTypingIndicator();
244
278
  }
@@ -253,6 +287,18 @@ class SummaryAggregator {
253
287
  }
254
288
  const messageID = part.messageID;
255
289
  const messageInfo = this.messages.get(messageID);
290
+ if (part.type === "text") {
291
+ this.registerKnownTextPart(messageID, part.id);
292
+ this.registerTextPart(messageID, part.id);
293
+ }
294
+ const deltaFromUpdated = event.properties.delta;
295
+ if (part.type === "text" &&
296
+ typeof deltaFromUpdated === "string" &&
297
+ deltaFromUpdated.length > 0) {
298
+ this.applyTextDelta(part.sessionID, messageID, part.id, deltaFromUpdated, part.text);
299
+ this.lastUpdated = Date.now();
300
+ return;
301
+ }
256
302
  if (part.type === "reasoning") {
257
303
  // Fire the thinking callback once per message on the first reasoning part.
258
304
  // This is the signal that the model is actually doing extended thinking.
@@ -268,29 +314,23 @@ class SummaryAggregator {
268
314
  }
269
315
  }
270
316
  else if (part.type === "text" && "text" in part && part.text) {
271
- const partHash = this.hashString(part.text);
272
- if (!this.partHashes.has(messageID)) {
273
- this.partHashes.set(messageID, new Set());
274
- }
275
- const hashes = this.partHashes.get(messageID);
276
- if (hashes.has(partHash)) {
317
+ const wasUpdated = messageInfo && messageInfo.role === "assistant"
318
+ ? this.setTextPartSnapshot(messageID, part.id, part.text)
319
+ : this.setOptimisticTextSnapshot(messageID, part.id, part.text);
320
+ if (!wasUpdated) {
277
321
  return;
278
322
  }
279
- hashes.add(partHash);
323
+ const fullText = this.getCombinedMessageText(messageID);
280
324
  if (messageInfo && messageInfo.role === "assistant") {
281
- if (!this.currentMessageParts.has(messageID)) {
282
- this.currentMessageParts.set(messageID, []);
283
- this.startTypingIndicator();
284
- }
285
- const parts = this.currentMessageParts.get(messageID);
286
- parts.push(part.text);
325
+ this.startTypingIndicator();
326
+ this.emitPartialText(part.sessionID, messageID, fullText);
287
327
  }
288
328
  else {
289
- if (!this.pendingParts.has(messageID)) {
290
- this.pendingParts.set(messageID, []);
329
+ const state = this.getOrCreateTextMessageState(messageID);
330
+ state.optimisticUpdateCount++;
331
+ if (state.optimisticUpdateCount >= 2) {
332
+ this.emitPartialText(part.sessionID, messageID, fullText);
291
333
  }
292
- const pending = this.pendingParts.get(messageID);
293
- pending.push(part.text);
294
334
  }
295
335
  }
296
336
  else if (part.type === "tool") {
@@ -351,6 +391,125 @@ class SummaryAggregator {
351
391
  }
352
392
  this.lastUpdated = Date.now();
353
393
  }
394
+ handleMessagePartDelta(event) {
395
+ const part = event.properties.part;
396
+ const sessionID = part?.sessionID || event.properties.sessionID;
397
+ const messageID = part?.messageID || event.properties.messageID;
398
+ const partID = part?.id || event.properties.partID || "text";
399
+ const partType = part?.type || event.properties.type;
400
+ const delta = event.properties.delta;
401
+ if (!sessionID || !messageID || typeof delta !== "string" || delta.length === 0) {
402
+ return;
403
+ }
404
+ if (partType && partType !== "text") {
405
+ return;
406
+ }
407
+ if (partType === "text") {
408
+ this.registerKnownTextPart(messageID, partID);
409
+ this.registerTextPart(messageID, partID);
410
+ }
411
+ else {
412
+ const knownTextIds = this.knownTextPartIds.get(messageID);
413
+ const isKnownTextPart = knownTextIds?.has(partID) ?? false;
414
+ const thinkingFired = this.thinkingFiredForMessages.has(messageID);
415
+ if (thinkingFired && !isKnownTextPart) {
416
+ return;
417
+ }
418
+ if (!thinkingFired && !isKnownTextPart) {
419
+ this.registerKnownTextPart(messageID, partID);
420
+ this.registerTextPart(messageID, partID);
421
+ }
422
+ }
423
+ this.applyTextDelta(sessionID, messageID, partID, delta, part?.text);
424
+ }
425
+ applyTextDelta(sessionID, messageID, partID, delta, fullTextHint) {
426
+ if (sessionID !== this.currentSessionId) {
427
+ return;
428
+ }
429
+ this.registerTextPart(messageID, partID);
430
+ const state = this.getOrCreateTextMessageState(messageID);
431
+ const previous = state.partTexts.get(partID) || "";
432
+ let accumulated = `${previous}${delta}`;
433
+ if (typeof fullTextHint === "string" && fullTextHint.length > accumulated.length) {
434
+ accumulated = fullTextHint;
435
+ }
436
+ state.partTexts.set(partID, accumulated);
437
+ const combined = this.getCombinedMessageText(messageID);
438
+ if (!combined.trim()) {
439
+ return;
440
+ }
441
+ this.startTypingIndicator();
442
+ this.emitPartialText(sessionID, messageID, combined);
443
+ }
444
+ emitPartialText(sessionId, messageId, messageText) {
445
+ if (!this.onPartialCallback || !messageText.trim()) {
446
+ return;
447
+ }
448
+ try {
449
+ this.onPartialCallback(sessionId, messageId, messageText);
450
+ }
451
+ catch (err) {
452
+ logger.error("[Aggregator] Error in partial callback:", err);
453
+ }
454
+ }
455
+ getOrCreateTextMessageState(messageID) {
456
+ const existing = this.textMessageStates.get(messageID);
457
+ if (existing) {
458
+ return existing;
459
+ }
460
+ const state = {
461
+ orderedPartIds: [],
462
+ partTexts: new Map(),
463
+ optimisticUpdateCount: 0,
464
+ };
465
+ this.textMessageStates.set(messageID, state);
466
+ return state;
467
+ }
468
+ registerKnownTextPart(messageID, partID) {
469
+ if (!this.knownTextPartIds.has(messageID)) {
470
+ this.knownTextPartIds.set(messageID, new Set());
471
+ }
472
+ this.knownTextPartIds.get(messageID).add(partID);
473
+ }
474
+ registerTextPart(messageID, partID) {
475
+ const state = this.getOrCreateTextMessageState(messageID);
476
+ if (!state.orderedPartIds.includes(partID)) {
477
+ state.orderedPartIds.push(partID);
478
+ }
479
+ }
480
+ setTextPartSnapshot(messageID, partID, text) {
481
+ const normalized = text;
482
+ const partHash = this.hashString(`${partID}\n${normalized}`);
483
+ if (!this.partHashes.has(messageID)) {
484
+ this.partHashes.set(messageID, new Set());
485
+ }
486
+ const hashes = this.partHashes.get(messageID);
487
+ if (hashes.has(partHash)) {
488
+ return false;
489
+ }
490
+ hashes.add(partHash);
491
+ this.registerTextPart(messageID, partID);
492
+ const state = this.getOrCreateTextMessageState(messageID);
493
+ state.partTexts.set(partID, normalized);
494
+ return true;
495
+ }
496
+ setOptimisticTextSnapshot(messageID, partID, text) {
497
+ const wasUpdated = this.setTextPartSnapshot(messageID, partID, text);
498
+ if (!wasUpdated) {
499
+ return false;
500
+ }
501
+ const state = this.getOrCreateTextMessageState(messageID);
502
+ state.orderedPartIds = [partID];
503
+ state.partTexts = new Map([[partID, text]]);
504
+ return true;
505
+ }
506
+ getCombinedMessageText(messageID) {
507
+ const state = this.textMessageStates.get(messageID);
508
+ if (!state) {
509
+ return "";
510
+ }
511
+ return state.orderedPartIds.map((partID) => state.partTexts.get(partID) || "").join("");
512
+ }
354
513
  prepareToolFileContext(tool, input, title, metadata) {
355
514
  if (tool === "write" && input) {
356
515
  const filePath = typeof input.filePath === "string" ? normalizePathForDisplay(input.filePath) : "";
@@ -144,11 +144,13 @@ function formatMarkdownForTelegram(text) {
144
144
  return text;
145
145
  }
146
146
  }
147
- export function formatSummaryWithMode(text, mode) {
147
+ export function formatSummaryWithMode(text, mode, maxLength = TELEGRAM_MESSAGE_LIMIT) {
148
148
  if (!text || text.trim().length === 0) {
149
149
  return [];
150
150
  }
151
- const parts = splitText(text, TELEGRAM_MESSAGE_LIMIT);
151
+ const normalizedMaxLength = Math.max(1, Math.floor(maxLength));
152
+ const rawTextLimit = mode === "raw" ? Math.max(1, normalizedMaxLength - "```\n\n```".length) : normalizedMaxLength;
153
+ const parts = splitText(text, rawTextLimit);
152
154
  const formattedParts = [];
153
155
  for (const part of parts) {
154
156
  const trimmed = part.trim();
@@ -157,7 +159,7 @@ export function formatSummaryWithMode(text, mode) {
157
159
  }
158
160
  if (mode === "markdown") {
159
161
  const converted = formatMarkdownForTelegram(trimmed);
160
- const convertedParts = splitText(converted, TELEGRAM_MESSAGE_LIMIT);
162
+ const convertedParts = splitText(converted, normalizedMaxLength);
161
163
  for (const convertedPart of convertedParts) {
162
164
  const normalizedPart = convertedPart.trim();
163
165
  if (normalizedPart) {
@@ -46,6 +46,15 @@ export class ToolMessageBatcher {
46
46
  enqueue(sessionId, message) {
47
47
  this.enqueueTextInternal(sessionId, message);
48
48
  }
49
+ sendTextNow(sessionId, message, reason) {
50
+ const normalizedMessage = message.trim();
51
+ if (!sessionId || normalizedMessage.length === 0) {
52
+ return;
53
+ }
54
+ const expectedGeneration = this.generation;
55
+ logger.debug(`[ToolBatcher] Sending immediate text message outside queue: session=${sessionId}, reason=${reason}`);
56
+ void this.enqueueTask(sessionId, () => this.sendTextSafe(sessionId, normalizedMessage, reason, expectedGeneration));
57
+ }
49
58
  enqueueUniqueByPrefix(sessionId, message, prefix) {
50
59
  this.enqueueTextInternal(sessionId, message, prefix);
51
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",