kiro-telegram-bot 1.6.0 → 1.7.2

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.
@@ -13,6 +13,8 @@
13
13
  import type { Api } from "grammy";
14
14
  import { chunkMarkdown } from "../render/chunk.js";
15
15
  import { toTelegramMarkdown } from "../render/markdown.js";
16
+ import { extractProgress, progressBar } from "../render/progress.js";
17
+ import { estimateProgress } from "../render/progress-estimate.js";
16
18
  import { safeEdit, safeSend } from "../bot/telegram-io.js";
17
19
 
18
20
  const SOFT_LIMIT = 3500;
@@ -32,6 +34,16 @@ export class ResponseStreamer {
32
34
  private dirty = false;
33
35
  private flushing = false;
34
36
  private closed = false;
37
+ /** Latest task-progress % parsed from the agent's `{progress: N%}` markers
38
+ * (sticky across flushes; rendered as a bar on the live message). */
39
+ private progress: number | undefined;
40
+ /** True once the agent emitted a real `{progress}` marker — from then on its
41
+ * values are authoritative and the bot fallback stops contributing. */
42
+ private agentReported = false;
43
+ /** Real work signals for the fallback estimate (monotonic within a turn). */
44
+ private toolCalls = 0;
45
+ private outChars = 0;
46
+ private thoughtChars = 0;
35
47
 
36
48
  constructor(
37
49
  private readonly api: Api,
@@ -39,6 +51,11 @@ export class ResponseStreamer {
39
51
  private readonly throttleMs: number,
40
52
  private replyTo?: number,
41
53
  private footer?: string,
54
+ private readonly onProgress?: (pct: number) => void,
55
+ /** Show a bot-computed bar when the agent emits no marker. */
56
+ private readonly fallbackEnabled = false,
57
+ /** Turn start time, used by the fallback's elapsed-time signal. */
58
+ private readonly turnStartedAt = Date.now(),
42
59
  ) {}
43
60
 
44
61
  /** Replace the hashtag footer (used after a logical fork swaps the session id
@@ -52,6 +69,49 @@ export class ResponseStreamer {
52
69
  return this.footer ? `\n\n${this.footer}` : "";
53
70
  }
54
71
 
72
+ /** Strip `{progress: N%}` markers from rendered text, remembering the latest
73
+ * value (sticky across flushes) and notifying the owner when it changes. */
74
+ private captureProgress(text: string): string {
75
+ const { value, cleaned } = extractProgress(text);
76
+ if (value !== undefined) this.setProgressValue(value, true);
77
+ return cleaned;
78
+ }
79
+
80
+ /** Record a progress value, enforcing global monotonicity (never decreases)
81
+ * and notifying the owner on change. Agent markers are authoritative: once
82
+ * one arrives, the bot fallback stops contributing. */
83
+ private setProgressValue(pct: number, fromAgent: boolean): void {
84
+ if (fromAgent) this.agentReported = true;
85
+ const next = Math.max(this.progress ?? 0, Math.round(pct));
86
+ if (next === this.progress) return;
87
+ this.progress = next;
88
+ try {
89
+ this.onProgress?.(next);
90
+ } catch {
91
+ /* non-fatal */
92
+ }
93
+ }
94
+
95
+ /** Advance the fallback estimate from real activity signals, but only while
96
+ * the agent itself hasn't reported a value. No-op when fallback is off. */
97
+ private applyFallback(): void {
98
+ if (!this.fallbackEnabled || this.agentReported) return;
99
+ const est = estimateProgress({
100
+ toolCalls: this.toolCalls,
101
+ outputChars: this.outChars,
102
+ thoughtChars: this.thoughtChars,
103
+ elapsedMs: Date.now() - this.turnStartedAt,
104
+ });
105
+ if (est > 0) this.setProgressValue(est, false);
106
+ }
107
+
108
+ /** Called when the turn finishes successfully: if the agent never reported
109
+ * its own progress, fill the fallback bar to 100. No-op otherwise. */
110
+ completeFallback(): void {
111
+ if (!this.fallbackEnabled || this.agentReported) return;
112
+ this.setProgressValue(100, false);
113
+ }
114
+
55
115
  /** reply_parameters threading EVERY message of the turn to the user's prompt,
56
116
  * so the whole response (all bubbles, tool calls and continuations) stays in
57
117
  * one thread — not just the first message. */
@@ -62,18 +122,21 @@ export class ResponseStreamer {
62
122
 
63
123
  appendOutput(text: string): void {
64
124
  if (!text) return;
125
+ this.outChars += text.length;
65
126
  this.merge("out", text);
66
127
  this.schedule();
67
128
  }
68
129
 
69
130
  appendThought(text: string): void {
70
131
  if (!text) return;
132
+ this.thoughtChars += text.length;
71
133
  this.merge("think", text);
72
134
  this.schedule();
73
135
  }
74
136
 
75
137
  addTool(rawMarkdown: string): void {
76
138
  if (!rawMarkdown) return;
139
+ this.toolCalls += 1;
77
140
  this.segs.push({ kind: "tool", text: rawMarkdown });
78
141
  this.schedule();
79
142
  }
@@ -117,11 +180,16 @@ export class ResponseStreamer {
117
180
  this.dirty = false;
118
181
  try {
119
182
  await this.sealOverflow();
120
- const base = renderSegs(this.segs.slice(this.sealedIdx));
183
+ const base = this.captureProgress(renderSegs(this.segs.slice(this.sealedIdx)));
184
+ this.applyFallback();
185
+ // Never send an empty / progress-only bubble. The bar is appended only to
186
+ // real streamed content; the live status panel shows the standalone bar.
121
187
  if (!base.trim()) return;
122
- // Every bubble including the still-streaming live one carries the
123
- // hashtag footer so the thinking/first response is tagged immediately.
124
- const src = `${base}${this.footerSuffix()}`;
188
+ // The live (still-streaming) bubble carries the hashtag footer AND a fresh
189
+ // progress bar at the bottom (sealed bubbles below get neither bar).
190
+ const parts: string[] = [base];
191
+ if (this.progress !== undefined) parts.push(progressBar(this.progress));
192
+ const src = `${parts.join("\n\n")}${this.footerSuffix()}`;
125
193
  const rendered = toTelegramMarkdown(src);
126
194
  const chunks = chunkMarkdown(rendered);
127
195
  const plain = chunkMarkdown(src);
@@ -158,7 +226,7 @@ export class ResponseStreamer {
158
226
  }
159
227
 
160
228
  private async seal(from: number, to: number): Promise<void> {
161
- const base = renderSegs(this.segs.slice(from, to));
229
+ const base = this.captureProgress(renderSegs(this.segs.slice(from, to)));
162
230
  if (!base.trim()) return;
163
231
  // A sealed bubble is finished, so it carries the footer (hashtags).
164
232
  const src = `${base}${this.footerSuffix()}`;