@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.
@@ -0,0 +1,209 @@
1
+ /** Streams agent deltas + tool calls to chat via debounced edits; splits past MAX_BODY_LEN. */
2
+ import { errMsg, log } from '../log.js';
3
+ /** 1500ms keeps us under Discord's ~5/5s per-channel edit cap; leading-edge 500ms for first delta. */
4
+ const DEFAULT_DEBOUNCE_MS = 1500;
5
+ const LEADING_MS = 500;
6
+ /** Discord cap is 2000; 1900 leaves headroom for status suffix. */
7
+ const MAX_BODY_LEN = 1900;
8
+ const STATUS_RESERVE = 80;
9
+ /** Cap result so a 1000-line file dump doesn't blow the per-message char budget. */
10
+ const MAX_RESULT_LINES = 50;
11
+ const MAX_RESULT_CHARS = 1500;
12
+ /** One per bot. Coalesces edits so concurrent threads don't compound rate limits. */
13
+ export class StreamScheduler {
14
+ debounceMs;
15
+ leadingMs;
16
+ dirty = new Set();
17
+ timer = null;
18
+ lastFlushAt = 0;
19
+ constructor(debounceMs = DEFAULT_DEBOUNCE_MS, leadingMs = LEADING_MS) {
20
+ this.debounceMs = debounceMs;
21
+ this.leadingMs = leadingMs;
22
+ }
23
+ request(stream) {
24
+ this.dirty.add(stream);
25
+ if (this.timer)
26
+ return;
27
+ const sinceLast = Date.now() - this.lastFlushAt;
28
+ const delay = sinceLast >= this.debounceMs ? this.leadingMs : this.debounceMs - sinceLast;
29
+ this.timer = setTimeout(() => {
30
+ this.timer = null;
31
+ this.lastFlushAt = Date.now();
32
+ const batch = [...this.dirty];
33
+ this.dirty.clear();
34
+ for (const s of batch)
35
+ void s._flushFromScheduler();
36
+ }, delay);
37
+ }
38
+ forget(stream) { this.dirty.delete(stream); }
39
+ }
40
+ /** Break embedded triple backticks so they can't close our fenced block early. */
41
+ const escapeFence = (s) => s.replace(/```/g, '`​`​`');
42
+ /** Cap output by lines + chars; return `body` to embed and a `_(N more …)_` overflow note (or ''). */
43
+ function truncateResult(s) {
44
+ const lines = s.split('\n');
45
+ let body = lines.slice(0, MAX_RESULT_LINES).join('\n');
46
+ const droppedLines = Math.max(0, lines.length - MAX_RESULT_LINES);
47
+ if (body.length > MAX_RESULT_CHARS)
48
+ body = body.slice(0, MAX_RESULT_CHARS) + '…';
49
+ if (droppedLines === 0 && body === s)
50
+ return { body: s, overflow: '' };
51
+ const noun = droppedLines > 0 ? `${droppedLines} more line${droppedLines === 1 ? '' : 's'}` : 'output truncated';
52
+ return { body, overflow: `_(${noun})_` };
53
+ }
54
+ export class StreamingMessage {
55
+ adapter;
56
+ scheduler;
57
+ blocks = [];
58
+ segments = [{ id: null, text: '', dirty: false }];
59
+ statusLine = null;
60
+ flushing = false;
61
+ flushAgain = false;
62
+ finalized = false;
63
+ constructor(adapter, scheduler) {
64
+ this.adapter = adapter;
65
+ this.scheduler = scheduler;
66
+ }
67
+ appendDelta(delta) {
68
+ if (this.finalized || !delta)
69
+ return;
70
+ const last = this.blocks.at(-1);
71
+ if (last?.kind === 'text')
72
+ last.text += delta;
73
+ else
74
+ this.blocks.push({ kind: 'text', text: delta });
75
+ this.scheduler.request(this);
76
+ }
77
+ setStatus(status) {
78
+ if (this.finalized)
79
+ return;
80
+ this.statusLine = status;
81
+ this.scheduler.request(this);
82
+ }
83
+ /** Add a tool block keyed by `id`; rendered immediately as a header, output filled in via appendToolResult. */
84
+ appendToolCall(id, name, detail) {
85
+ if (this.finalized)
86
+ return;
87
+ this.blocks.push({ kind: 'tool', id, name, detail });
88
+ this.scheduler.request(this);
89
+ }
90
+ /** Set the matching tool block's result; renders truncated output under the header. */
91
+ appendToolResult(id, result) {
92
+ if (this.finalized || !result)
93
+ return;
94
+ const tool = this.blocks.find((b) => b.kind === 'tool' && b.id === id);
95
+ if (tool)
96
+ tool.result = result;
97
+ this.scheduler.request(this);
98
+ }
99
+ appendError(message) {
100
+ if (this.finalized)
101
+ return;
102
+ this.statusLine = null;
103
+ this.blocks.push({ kind: 'text', text: `⚠️ ${message}` });
104
+ this.scheduler.request(this);
105
+ }
106
+ async finalize() {
107
+ if (this.finalized)
108
+ return;
109
+ this.finalized = true;
110
+ this.scheduler.forget(this);
111
+ this.statusLine = null;
112
+ await this.flush();
113
+ }
114
+ async _flushFromScheduler() { await this.flush(); }
115
+ /** Render the block list into a single markdown body. */
116
+ renderBody() {
117
+ return this.blocks.map(b => b.kind === 'text' ? b.text : this.renderToolBlock(b)).join('\n\n').trim();
118
+ }
119
+ /** Plain header + fenced input block + fenced output block. Each fence is fully visible (no collapse). */
120
+ renderToolBlock(b) {
121
+ const parts = [`🛠 **${b.name}**`];
122
+ if (b.detail)
123
+ parts.push('```\n' + escapeFence(b.detail) + '\n```');
124
+ if (b.result) {
125
+ const { body, overflow } = truncateResult(b.result);
126
+ parts.push('```\n' + escapeFence(body) + '\n```');
127
+ if (overflow)
128
+ parts.push(overflow);
129
+ }
130
+ return parts.join('\n');
131
+ }
132
+ /** Redistribute the rendered body across segments, keeping existing segment ids stable. */
133
+ redistribute() {
134
+ const fullBody = this.renderBody();
135
+ const chunks = this.chunkify(fullBody, MAX_BODY_LEN - STATUS_RESERVE);
136
+ if (!chunks.length)
137
+ chunks.push('');
138
+ for (let i = 0; i < chunks.length; i++) {
139
+ if (i >= this.segments.length) {
140
+ this.segments.push({ id: null, text: chunks[i], dirty: true });
141
+ }
142
+ else if (this.segments[i].text !== chunks[i]) {
143
+ this.segments[i].text = chunks[i];
144
+ this.segments[i].dirty = true;
145
+ }
146
+ }
147
+ }
148
+ chunkify(s, cap) {
149
+ if (s.length <= cap)
150
+ return [s];
151
+ const out = [];
152
+ for (let r = s; r.length > 0;) {
153
+ const take = r.length > cap ? this.sliceAtBoundary(r, cap) : r;
154
+ out.push(take);
155
+ r = r.slice(take.length);
156
+ }
157
+ return out;
158
+ }
159
+ /** Split at the last paragraph/line/sentence/word break in range; hard slice otherwise. */
160
+ sliceAtBoundary(s, room) {
161
+ if (s.length <= room)
162
+ return s;
163
+ const candidate = s.slice(0, room);
164
+ for (const b of ['\n\n', '\n', '. ', ' ']) {
165
+ const i = candidate.lastIndexOf(b);
166
+ if (i > room * 0.5)
167
+ return candidate.slice(0, i + b.length);
168
+ }
169
+ return candidate;
170
+ }
171
+ async flush() {
172
+ if (this.flushing) {
173
+ this.flushAgain = true;
174
+ return;
175
+ }
176
+ this.flushing = true;
177
+ try {
178
+ do {
179
+ this.flushAgain = false;
180
+ this.redistribute();
181
+ for (let i = 0; i < this.segments.length; i++) {
182
+ const s = this.segments[i];
183
+ const body = this.render(s, i === this.segments.length - 1);
184
+ if (!body)
185
+ continue;
186
+ try {
187
+ if (s.id === null)
188
+ s.id = await this.adapter.send(body);
189
+ else if (s.dirty)
190
+ await this.adapter.edit(s.id, body);
191
+ s.dirty = false;
192
+ }
193
+ catch (err) {
194
+ log.warn({ err: errMsg(err) }, 'streaming edit failed');
195
+ }
196
+ }
197
+ } while (this.flushAgain);
198
+ }
199
+ finally {
200
+ this.flushing = false;
201
+ }
202
+ }
203
+ render(s, isLast) {
204
+ const showStatus = isLast && !!this.statusLine;
205
+ if (!s.text)
206
+ return showStatus ? this.statusLine : '';
207
+ return showStatus ? `${s.text}\n\n${this.statusLine}` : s.text;
208
+ }
209
+ }
@@ -0,0 +1,39 @@
1
+ /** Convert markdown → Telegram HTML. Stream-safe: unmatched markers fall through as literal text. */
2
+ const ENTITY_MAP = { '&': '&amp;', '<': '&lt;', '>': '&gt;' };
3
+ const esc = (s) => s.replace(/[&<>]/g, c => ENTITY_MAP[c]);
4
+ /** SOH — invalid in Telegram text + never in agent output, so collisions are impossible. */
5
+ const SENT = '\x01';
6
+ export function mdToTelegramHtml(md) {
7
+ const slots = [];
8
+ const stash = (html) => {
9
+ slots.push(html);
10
+ return `${SENT}${slots.length - 1}${SENT}`;
11
+ };
12
+ /** Fenced code first so its contents aren't touched by other rules. */
13
+ let work = md.replace(/```([A-Za-z0-9_+\-.]*)\n?([\s\S]*?)```/g, (_m, lang, code) => {
14
+ const inner = lang
15
+ ? `<pre><code class="language-${esc(lang)}">${esc(code)}</code></pre>`
16
+ : `<pre>${esc(code)}</pre>`;
17
+ return stash(inner);
18
+ });
19
+ /** Inline code; skip multi-line spans (likely an unclosed mid-stream fence). */
20
+ work = work.replace(/`([^`\n]+)`/g, (_m, code) => stash(`<code>${esc(code)}</code>`));
21
+ /** Escape outside stashes before running tag-emitting rules. */
22
+ work = esc(work);
23
+ work = work.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_m, text, url) => stash(`<a href="${url.replace(/"/g, '%22')}">${text}</a>`));
24
+ /** Bold before italic so single-`*` rule doesn't eat half of `**bold**`. */
25
+ work = work.replace(/\*\*([^*\n]+?)\*\*/g, '<b>$1</b>').replace(/__([^_\n]+?)__/g, '<b>$1</b>');
26
+ /** Italic: \S guards prevent matching `2 * 3` arithmetic or `foo_bar` identifiers. */
27
+ work = work
28
+ .replace(/(^|[^*\w])\*(\S[^*\n]*?\S|\S)\*(?!\w)/g, '$1<i>$2</i>')
29
+ .replace(/(^|[^_\w])_(\S[^_\n]*?\S|\S)_(?!\w)/g, '$1<i>$2</i>');
30
+ work = work.replace(/~~([^~\n]+?)~~/g, '<s>$1</s>');
31
+ /** Headings → bold (Telegram has no heading element). */
32
+ work = work.replace(/^#{1,6}\s+(.+?)\s*$/gm, '<b>$1</b>');
33
+ /** Collapse consecutive `> ` lines into one <blockquote> for prose-level quotes. */
34
+ work = work.replace(/(^|\n)((?:&gt;\s?[^\n]*\n?)+)/g, (_m, lead, block) => {
35
+ const inner = block.replace(/^&gt;\s?/gm, '').replace(/\n+$/, '');
36
+ return `${lead}<blockquote>${inner}</blockquote>${block.endsWith('\n') ? '\n' : ''}`;
37
+ });
38
+ return work.replace(new RegExp(`${SENT}(\\d+)${SENT}`, 'g'), (_m, idx) => slots[Number(idx)]);
39
+ }
@@ -0,0 +1,40 @@
1
+ /** Run a turn; stream response via adapter. In-flight follow-ups queue and drain as one combined turn. */
2
+ import { errMsg, log } from '../log.js';
3
+ import { StreamingMessage } from './streaming.js';
4
+ const inFlight = new Set();
5
+ const queued = new Map();
6
+ export async function runTurn(agent, threadId, text, adapter, scheduler) {
7
+ const dispatch = (t) => runTurn(agent, threadId, t, adapter, scheduler);
8
+ if (inFlight.has(threadId)) {
9
+ const q = queued.get(threadId);
10
+ if (q)
11
+ q.texts.push(text);
12
+ else
13
+ queued.set(threadId, { texts: [text], dispatch });
14
+ return;
15
+ }
16
+ inFlight.add(threadId);
17
+ const stream = new StreamingMessage(adapter, scheduler);
18
+ const finishAndDrain = async () => {
19
+ await stream.finalize();
20
+ inFlight.delete(threadId);
21
+ const q = queued.get(threadId);
22
+ if (!q?.texts.length)
23
+ return;
24
+ queued.delete(threadId);
25
+ await q.dispatch(q.texts.join('\n\n')).catch(err => log.warn({ err: errMsg(err) }, 'queued turn failed'));
26
+ };
27
+ const callbacks = {
28
+ onDelta: d => stream.appendDelta(d),
29
+ onToolStart: a => a.transient ? stream.setStatus(a.name) : stream.appendToolCall(a.id, a.name, a.detail),
30
+ onToolEnd: (id, result) => { if (result)
31
+ stream.appendToolResult(id, result); stream.setStatus(null); },
32
+ onComplete: () => { void finishAndDrain(); },
33
+ onError: err => {
34
+ log.warn({ err: errMsg(err) }, 'agent turn failed');
35
+ stream.appendError(errMsg(err) || 'agent turn failed');
36
+ void finishAndDrain();
37
+ },
38
+ };
39
+ await agent.sendTurn(threadId, text, callbacks);
40
+ }
package/dist/log.js CHANGED
@@ -1,6 +1,4 @@
1
- // Pino → stderr. Stdout is reserved for command output (`metro`'s inbound
2
- // JSON lines, subcommand results, --json) — any stray write there breaks
3
- // parsing. Override level with METRO_LOG_LEVEL.
1
+ /** Pino → stderr. Stdout reserved for command output / --json. */
4
2
  import pino from 'pino';
5
3
  export const log = pino({ name: 'metro', level: process.env.METRO_LOG_LEVEL || 'info' }, pino.destination(2));
6
4
  export const errMsg = (err) => {