@stage-labs/metro 0.1.0-beta.3 → 0.1.0-beta.4

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.
@@ -1,207 +0,0 @@
1
- // Accumulates streaming response deltas + tool-call status lines from a
2
- // running agent turn and pushes them to a chat platform (Discord / Telegram)
3
- // via debounced message edits. Smooth visible progress without hammering
4
- // rate limits.
5
- //
6
- // The debounce is owned by a per-bot `StreamScheduler`, not by individual
7
- // streams. One tick (e.g. every 1500ms) flushes every dirty stream the bot
8
- // has accumulated, so two concurrent threads don't compound into 2× the
9
- // edit rate on the same bot token.
10
- //
11
- // When the agent's response grows past the platform's per-message content
12
- // cap, the body is split across multiple messages: the prior segment is
13
- // frozen at its final text, and a fresh message holds the continuation
14
- // (with the live status line, which always anchors to the latest segment).
15
- //
16
- // On agent turn completion, call finalize() to flush a final edit with the
17
- // status cleared.
18
- import { errMsg, log } from '../log.js';
19
- // Steady-state cadence: 1500ms keeps us comfortably under Discord's ~5/5s
20
- // per-channel edit cap even after the transport adds its own retry-on-429
21
- // jitter. After a quiet period, the next flush is leading-edge (LEADING_MS)
22
- // so short responses don't appear as one final dump.
23
- const DEFAULT_DEBOUNCE_MS = 1500;
24
- const LEADING_MS = 500;
25
- // Discord's bot content cap is 2000 by default (4000 for boosted/Nitro).
26
- // 1900 is universally safe and leaves headroom for the status suffix.
27
- const MAX_BODY_LEN = 1900;
28
- // Reserve for "\n\n_<status>_" + a continuation hint.
29
- const STATUS_RESERVE = 80;
30
- /**
31
- * One scheduler per bot. Coalesces edits across every active stream the
32
- * bot is serving — Discord's per-channel rate limit doesn't compound when
33
- * we run multiple threads concurrently this way.
34
- */
35
- export class StreamScheduler {
36
- debounceMs;
37
- leadingMs;
38
- dirty = new Set();
39
- timer = null;
40
- lastFlushAt = 0;
41
- constructor(debounceMs = DEFAULT_DEBOUNCE_MS, leadingMs = LEADING_MS) {
42
- this.debounceMs = debounceMs;
43
- this.leadingMs = leadingMs;
44
- }
45
- request(stream) {
46
- this.dirty.add(stream);
47
- if (this.timer)
48
- return;
49
- // Leading-edge: if we haven't flushed recently, fire fast so the first
50
- // visible content lands within `leadingMs` of the agent's first delta.
51
- // Otherwise stay at the steady-state cadence to respect rate limits.
52
- const sinceLast = Date.now() - this.lastFlushAt;
53
- const delay = sinceLast >= this.debounceMs ? this.leadingMs : this.debounceMs - sinceLast;
54
- this.timer = setTimeout(() => {
55
- this.timer = null;
56
- this.lastFlushAt = Date.now();
57
- const batch = [...this.dirty];
58
- this.dirty.clear();
59
- // Fire all in parallel — distinct channels are in distinct rate-limit
60
- // buckets, so they don't queue behind each other.
61
- for (const s of batch)
62
- void s._flushFromScheduler();
63
- }, delay);
64
- }
65
- /** Drop a stream from the queue (called when it finalizes). */
66
- forget(stream) {
67
- this.dirty.delete(stream);
68
- }
69
- }
70
- export class StreamingMessage {
71
- adapter;
72
- scheduler;
73
- segments = [{ id: null, text: '', dirty: false }];
74
- statusLine = null;
75
- flushing = false;
76
- flushAgain = false;
77
- finalized = false;
78
- constructor(adapter, scheduler) {
79
- this.adapter = adapter;
80
- this.scheduler = scheduler;
81
- }
82
- appendDelta(delta) {
83
- if (this.finalized || !delta)
84
- return;
85
- this.appendToLast(delta);
86
- this.scheduler.request(this);
87
- }
88
- setStatus(status) {
89
- if (this.finalized)
90
- return;
91
- this.statusLine = status;
92
- this.markLastDirty();
93
- this.scheduler.request(this);
94
- }
95
- /**
96
- * Append an error notice to the visible message. Renders as `⚠️ <msg>`
97
- * either on its own (no prior text) or after a blank line (preserves
98
- * whatever streamed before the failure). Clears any pending status
99
- * line since 'Thinking…' is meaningless after an error.
100
- */
101
- appendError(message) {
102
- if (this.finalized)
103
- return;
104
- const last = this.segments[this.segments.length - 1];
105
- const sep = last.text ? '\n\n' : '';
106
- this.statusLine = null;
107
- this.appendToLast(`${sep}⚠️ ${message}`);
108
- this.scheduler.request(this);
109
- }
110
- async finalize() {
111
- if (this.finalized)
112
- return;
113
- this.finalized = true;
114
- this.scheduler.forget(this);
115
- this.statusLine = null;
116
- this.markLastDirty();
117
- await this.flush();
118
- }
119
- /** Internal — called by the scheduler tick. */
120
- async _flushFromScheduler() {
121
- await this.flush();
122
- }
123
- appendToLast(delta) {
124
- const cap = MAX_BODY_LEN - STATUS_RESERVE;
125
- let remaining = delta;
126
- while (remaining) {
127
- let last = this.segments[this.segments.length - 1];
128
- const room = cap - last.text.length;
129
- if (room <= 0) {
130
- // Previous last loses status anchor — re-edit without it.
131
- last.dirty = true;
132
- last = { id: null, text: '', dirty: false };
133
- this.segments.push(last);
134
- continue;
135
- }
136
- const take = this.sliceAtBoundary(remaining, room);
137
- last.text += take;
138
- last.dirty = true;
139
- remaining = remaining.slice(take.length);
140
- }
141
- }
142
- // Prefer splitting at the last newline / space / sentence end within the
143
- // allowed slice, so continuation messages don't cut words in half. Falls
144
- // back to a hard slice if no boundary is in reach.
145
- sliceAtBoundary(s, room) {
146
- if (s.length <= room)
147
- return s;
148
- const candidate = s.slice(0, room);
149
- const breakers = ['\n\n', '\n', '. ', ' '];
150
- for (const b of breakers) {
151
- const i = candidate.lastIndexOf(b);
152
- if (i > room * 0.5)
153
- return candidate.slice(0, i + b.length);
154
- }
155
- return candidate;
156
- }
157
- markLastDirty() {
158
- this.segments[this.segments.length - 1].dirty = true;
159
- }
160
- async flush() {
161
- if (this.flushing) {
162
- this.flushAgain = true;
163
- return;
164
- }
165
- this.flushing = true;
166
- try {
167
- do {
168
- this.flushAgain = false;
169
- for (let i = 0; i < this.segments.length; i++) {
170
- const s = this.segments[i];
171
- const isLast = i === this.segments.length - 1;
172
- const body = this.render(s, isLast);
173
- if (!body)
174
- continue;
175
- try {
176
- if (s.id === null) {
177
- s.id = await this.adapter.send(body);
178
- s.dirty = false;
179
- }
180
- else if (s.dirty) {
181
- await this.adapter.edit(s.id, body);
182
- s.dirty = false;
183
- }
184
- }
185
- catch (err) {
186
- log.warn({ err: errMsg(err) }, 'streaming edit failed');
187
- // Leave dirty=true so the next tick retries.
188
- }
189
- }
190
- } while (this.flushAgain);
191
- }
192
- finally {
193
- this.flushing = false;
194
- }
195
- }
196
- render(s, isLast) {
197
- const body = s.text;
198
- const showStatus = isLast && !!this.statusLine;
199
- if (!body && !showStatus)
200
- return ''; // nothing to show yet — skip the flush
201
- if (!body)
202
- return this.statusLine;
203
- if (showStatus)
204
- return `${body}\n\n${this.statusLine}`;
205
- return body;
206
- }
207
- }