@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.
- package/README.md +7 -4
- package/dist/agents/claude.js +72 -94
- package/dist/agents/codex.js +35 -110
- package/dist/agents/types.js +1 -2
- package/dist/channels/discord.js +18 -56
- package/dist/channels/telegram.js +54 -74
- package/dist/cli.js +49 -94
- package/dist/{lib → helpers}/scope-cache.js +4 -24
- package/dist/helpers/streaming.js +209 -0
- package/dist/helpers/telegram-format.js +39 -0
- package/dist/helpers/turn.js +40 -0
- package/dist/log.js +1 -3
- package/dist/orchestrator.js +120 -311
- package/dist/paths.js +53 -18
- package/package.json +1 -1
- package/dist/lib/dotenv.js +0 -31
- package/dist/lib/streaming.js +0 -207
package/dist/lib/streaming.js
DELETED
|
@@ -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
|
-
}
|