@stage-labs/metro 0.1.0-beta.3 → 0.1.0-beta.5
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 +221 -58
- package/dist/cli/index.js +236 -0
- package/dist/cli/lines.js +40 -0
- package/dist/cli/update.js +27 -0
- package/dist/dispatcher.js +203 -0
- package/dist/helpers/async-queue.js +41 -0
- package/dist/helpers/scope-cache.js +67 -0
- package/dist/helpers/streaming.js +219 -0
- package/dist/helpers/turn.js +62 -0
- package/dist/log.js +7 -4
- package/dist/paths.js +53 -18
- package/dist/stations/claude/index.js +224 -0
- package/dist/stations/codex/index.js +226 -0
- package/dist/stations/discord/index.js +160 -0
- package/dist/stations/github/index.js +135 -0
- package/dist/stations/line.js +54 -0
- package/dist/stations/listing.js +16 -0
- package/dist/stations/send.js +19 -0
- package/dist/stations/telegram/files.js +31 -0
- package/dist/stations/telegram/format.js +39 -0
- package/dist/stations/telegram/index.js +209 -0
- package/dist/stations/types.js +2 -0
- package/docs/agents.md +92 -0
- package/docs/uri-scheme.md +52 -0
- package/package.json +5 -3
- package/dist/agents/claude.js +0 -229
- package/dist/agents/codex.js +0 -282
- package/dist/agents/types.js +0 -3
- package/dist/channels/discord.js +0 -142
- package/dist/channels/telegram.js +0 -209
- package/dist/cli.js +0 -266
- package/dist/lib/dotenv.js +0 -31
- package/dist/lib/scope-cache.js +0 -85
- package/dist/lib/streaming.js +0 -207
- package/dist/orchestrator.js +0 -399
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/** Dispatcher: owns chat stations + agent stations; routes inbounds (suffix "with X" overrides line default). */
|
|
2
|
+
import { copyFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
6
|
+
import { ClaudeStation } from './stations/claude/index.js';
|
|
7
|
+
import { CodexStation } from './stations/codex/index.js';
|
|
8
|
+
import { DiscordStation } from './stations/discord/index.js';
|
|
9
|
+
import { GitHubStation } from './stations/github/index.js';
|
|
10
|
+
import { TelegramStation } from './stations/telegram/index.js';
|
|
11
|
+
import { getAgentThread, getLastAgent, linesForStation, setAgentThread, setLastAgent, setLastSeen, setName, } from './helpers/scope-cache.js';
|
|
12
|
+
import { StreamScheduler } from './helpers/streaming.js';
|
|
13
|
+
import { runTurn, triggerStop } from './helpers/turn.js';
|
|
14
|
+
import { errMsg, log } from './log.js';
|
|
15
|
+
import { acquireLock, configuredPlatforms, loadMetroEnv, STATE_DIR, requireConfiguredPlatform } from './paths.js';
|
|
16
|
+
loadMetroEnv();
|
|
17
|
+
const platforms = configuredPlatforms();
|
|
18
|
+
requireConfiguredPlatform(platforms);
|
|
19
|
+
acquireLock(join(STATE_DIR, '.tail-lock'));
|
|
20
|
+
/** Install AGENTS.md skill into state dir so the agent has a stable path to consult. Refreshed every start so upgrades land. */
|
|
21
|
+
const AGENTS_MD = join(STATE_DIR, 'AGENTS.md');
|
|
22
|
+
try {
|
|
23
|
+
copyFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'docs', 'agents.md'), AGENTS_MD);
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
log.warn({ err: errMsg(err) }, 'failed to install agent skill');
|
|
27
|
+
}
|
|
28
|
+
const bootstrapped = new Set();
|
|
29
|
+
const codex = new CodexStation(pkg.version);
|
|
30
|
+
const claude = new ClaudeStation();
|
|
31
|
+
const discord = new DiscordStation();
|
|
32
|
+
const telegram = new TelegramStation();
|
|
33
|
+
const github = new GitHubStation();
|
|
34
|
+
const available = {};
|
|
35
|
+
const scheduler = new StreamScheduler();
|
|
36
|
+
async function startAgents() {
|
|
37
|
+
await Promise.allSettled([
|
|
38
|
+
codex.start().then(() => { available.codex = codex; }).catch(err => log.warn({ err: errMsg(err) }, 'codex unavailable')),
|
|
39
|
+
claude.start().then(() => { available.claude = claude; }).catch(err => log.warn({ err: errMsg(err) }, 'claude unavailable')),
|
|
40
|
+
]);
|
|
41
|
+
if (!Object.keys(available).length) {
|
|
42
|
+
log.fatal('no agents available');
|
|
43
|
+
process.exit(2);
|
|
44
|
+
}
|
|
45
|
+
log.info({ agents: Object.keys(available) }, 'agents ready');
|
|
46
|
+
}
|
|
47
|
+
const SUFFIX_RE = /(?:^|\s)with\s+(claude|codex)\s*$/i;
|
|
48
|
+
function parseAgentSuffix(text) {
|
|
49
|
+
const t = text.trimEnd();
|
|
50
|
+
const m = SUFFIX_RE.exec(t);
|
|
51
|
+
return m ? { kind: m[1].toLowerCase(), cleanText: t.slice(0, m.index).trimEnd() } : { kind: null, cleanText: text };
|
|
52
|
+
}
|
|
53
|
+
function pickAgent(line, req) {
|
|
54
|
+
if (req)
|
|
55
|
+
return available[req] ? { kind: req } : { error: `${req} is not available on this metro instance` };
|
|
56
|
+
const last = line ? getLastAgent(line) : undefined;
|
|
57
|
+
if (last && available[last])
|
|
58
|
+
return { kind: last };
|
|
59
|
+
if (available.claude)
|
|
60
|
+
return { kind: 'claude' };
|
|
61
|
+
if (available.codex)
|
|
62
|
+
return { kind: 'codex' };
|
|
63
|
+
return { error: 'no agents available' };
|
|
64
|
+
}
|
|
65
|
+
/** Resolve agent session for `line`, allocating if new, then run the turn. */
|
|
66
|
+
async function dispatch(line, text, attachments, kind, messageId, adapter, lineName) {
|
|
67
|
+
setLastSeen(line, messageId);
|
|
68
|
+
let threadId = getAgentThread(line, kind);
|
|
69
|
+
if (threadId)
|
|
70
|
+
setLastAgent(line, kind);
|
|
71
|
+
else {
|
|
72
|
+
threadId = await available[kind].createThread();
|
|
73
|
+
setAgentThread(line, kind, threadId);
|
|
74
|
+
log.info({ line, agent: kind, thread: threadId }, 'allocated agent session');
|
|
75
|
+
}
|
|
76
|
+
if (lineName)
|
|
77
|
+
setName(line, lineName);
|
|
78
|
+
await runTurn(available[kind], threadId, withContext(line, text), attachments, adapter, scheduler);
|
|
79
|
+
}
|
|
80
|
+
/** Tell the agent its line + the one rule: write normally to reply here, only use `metro send` for OTHER lines. */
|
|
81
|
+
const withContext = (line, text) => `[metro: this turn is on ${line}. To reply HERE just write text — metro streams it back automatically; do NOT use \`metro send\` for this line. Use \`metro send <other-line> <text>\` only to post to a DIFFERENT conversation (list with \`metro lines\`). Full guide: ${AGENTS_MD}]\n\n${text}`;
|
|
82
|
+
const adapterFor = (station, line) => ({
|
|
83
|
+
send: (t, stopId) => station.send(line, t, { stopId }),
|
|
84
|
+
edit: async (id, t, stopId) => { await station.edit(line, id, t, { stopId }); },
|
|
85
|
+
});
|
|
86
|
+
/** Dispatch into an existing agent on `m.line`, or (on `@`-mention) allocate a new session, optionally via station-specific bootstrap. */
|
|
87
|
+
async function routeInbound(m, station, bootstrap) {
|
|
88
|
+
const hasAgent = !!(getAgentThread(m.line, 'codex') ?? getAgentThread(m.line, 'claude'));
|
|
89
|
+
const { kind: req, cleanText } = parseAgentSuffix(m.text);
|
|
90
|
+
const postErr = (msg) => station.send(m.line, `⚠️ ${msg}`).then(() => { }).catch(err => log.warn({ err: errMsg(err), station: station.name }, 'error post failed'));
|
|
91
|
+
if (hasAgent) {
|
|
92
|
+
const c = pickAgent(m.line, req);
|
|
93
|
+
return 'error' in c ? postErr(c.error) : dispatch(m.line, cleanText, m.attachments, c.kind, m.messageId, adapterFor(station, m.line), m.lineName);
|
|
94
|
+
}
|
|
95
|
+
if (!m.mentionsBot || bootstrapped.has(m.messageId))
|
|
96
|
+
return;
|
|
97
|
+
bootstrapped.add(m.messageId);
|
|
98
|
+
const choice = pickAgent(null, req);
|
|
99
|
+
if ('error' in choice)
|
|
100
|
+
return postErr(choice.error);
|
|
101
|
+
/** Bootstrap creates a new chat-side scope (Discord thread / Telegram topic) — we know its name from `cleanText`. */
|
|
102
|
+
const line = bootstrap ? await bootstrap(m, cleanText).catch(err => { log.warn({ err: errMsg(err), station: station.name }, 'bootstrap failed'); return null; }) : m.line;
|
|
103
|
+
if (!line)
|
|
104
|
+
return;
|
|
105
|
+
await dispatch(line, cleanText, m.attachments, choice.kind, m.messageId, adapterFor(station, line), bootstrap ? makeThreadName(cleanText) : m.lineName);
|
|
106
|
+
}
|
|
107
|
+
const onDiscordInbound = (m) => {
|
|
108
|
+
/** DM: the channel IS the conversation, no thread to create. Guild: bootstrap a thread on @-mention. */
|
|
109
|
+
if (!m.meta.inGuild)
|
|
110
|
+
return routeInbound(m, discord, null);
|
|
111
|
+
return routeInbound(m, discord, (m, cleanText) => discord.createThreadFromMessage(m.line, m.messageId, makeThreadName(cleanText)));
|
|
112
|
+
};
|
|
113
|
+
const onTelegramInbound = (m) => {
|
|
114
|
+
if (!m.meta.isPrivate && !m.meta.inForum)
|
|
115
|
+
return Promise.resolve();
|
|
116
|
+
/** Forum General → bootstrap a new topic; private/topic → route into `m.line`. */
|
|
117
|
+
if (m.meta.inForum && !m.meta.isForumTopic)
|
|
118
|
+
return routeInbound(m, telegram, telegramTopicBootstrap);
|
|
119
|
+
return routeInbound(m, telegram, null);
|
|
120
|
+
};
|
|
121
|
+
const telegramTopicBootstrap = async (m, cleanText) => {
|
|
122
|
+
const topicName = makeThreadName(cleanText);
|
|
123
|
+
let topicLine;
|
|
124
|
+
try {
|
|
125
|
+
topicLine = await telegram.createForumTopic(m.line, topicName);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
await telegram.send(m.line, `⚠️ couldn't create topic — bot needs Manage Topics admin permission. (${errMsg(err)})`).catch(() => { });
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
/** Post a deep link back in General as a reply to the @-mention so it threads visually. */
|
|
132
|
+
const link = telegram.topicLink(topicLine);
|
|
133
|
+
if (link)
|
|
134
|
+
await telegram.send(m.line, `→ [${topicName}](${link})`, { replyTo: m.messageId }).catch(err => log.warn({ err: errMsg(err) }, 'telegram: failed to post topic link in General'));
|
|
135
|
+
return topicLine;
|
|
136
|
+
};
|
|
137
|
+
const onGithubInbound = (m) => {
|
|
138
|
+
/** Prepend issue/PR context so the agent has it without us bridging to a different chat. */
|
|
139
|
+
const header = `**@${m.meta.authorUsername}** on GitHub ${m.meta.isPR ? 'PR' : 'issue'} ${m.meta.repoFullName}#${m.meta.issueNumber} (<${m.meta.url}>):`;
|
|
140
|
+
return routeInbound({ ...m, text: `${header}\n\n${m.text}` }, github, null);
|
|
141
|
+
};
|
|
142
|
+
/** Strip mention syntax + normalize whitespace; cap at 100 chars (Discord limit). */
|
|
143
|
+
function makeThreadName(rawText) {
|
|
144
|
+
const cleaned = rawText.replace(/<@!?\d+>|<@&\d+>|<#\d+>|<a?:[^:]+:\d+>|@\w+/g, '').replace(/\s+/g, ' ').trim() || 'metro';
|
|
145
|
+
return cleaned.length <= 100 ? cleaned : cleaned.slice(0, 99) + '…';
|
|
146
|
+
}
|
|
147
|
+
async function catchupDiscord() {
|
|
148
|
+
for (const { line, entry } of linesForStation('discord')) {
|
|
149
|
+
if (!entry.lastSeenMessageId)
|
|
150
|
+
continue;
|
|
151
|
+
try {
|
|
152
|
+
const missed = (await discord.fetchMessagesSince(line, entry.lastSeenMessageId)).filter(m => !m.authorIsBot && m.text);
|
|
153
|
+
if (!missed.length)
|
|
154
|
+
continue;
|
|
155
|
+
log.info({ line, count: missed.length }, 'discord catchup');
|
|
156
|
+
for (const m of missed)
|
|
157
|
+
await onDiscordInbound({ station: 'discord', line, messageId: m.messageId, text: m.text, attachments: [], mentionsBot: false, meta: { inGuild: true } });
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
log.warn({ err: errMsg(err), line }, 'discord catchup skipped');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async function main() {
|
|
165
|
+
await startAgents();
|
|
166
|
+
if (platforms.discord) {
|
|
167
|
+
await discord.start();
|
|
168
|
+
log.info({ bot: (await discord.getMe()).username }, 'discord ready');
|
|
169
|
+
discord.onMessage(m => void onDiscordInbound(m).catch(err => log.warn({ err: errMsg(err) }, 'discord inbound failed')));
|
|
170
|
+
discord.onStop(triggerStop);
|
|
171
|
+
void catchupDiscord().catch(err => log.warn({ err: errMsg(err) }, 'discord catchup failed'));
|
|
172
|
+
}
|
|
173
|
+
if (platforms.telegram) {
|
|
174
|
+
log.info({ bot: `@${(await telegram.getMe()).username}` }, 'telegram ready');
|
|
175
|
+
telegram.onMessage(m => void onTelegramInbound(m).catch(err => log.warn({ err: errMsg(err) }, 'telegram inbound failed')));
|
|
176
|
+
telegram.onStop(triggerStop);
|
|
177
|
+
await telegram.start();
|
|
178
|
+
}
|
|
179
|
+
if (github.isConfigured()) {
|
|
180
|
+
await github.start();
|
|
181
|
+
github.onMessage(m => void onGithubInbound(m).catch(err => log.warn({ err: errMsg(err) }, 'github inbound failed')));
|
|
182
|
+
}
|
|
183
|
+
log.info('dispatcher ready');
|
|
184
|
+
}
|
|
185
|
+
let shuttingDown = false;
|
|
186
|
+
async function shutdown() {
|
|
187
|
+
if (shuttingDown)
|
|
188
|
+
return;
|
|
189
|
+
shuttingDown = true;
|
|
190
|
+
log.info('dispatcher shutting down');
|
|
191
|
+
await Promise.allSettled([available.codex?.stop(), available.claude?.stop()]);
|
|
192
|
+
if (platforms.discord)
|
|
193
|
+
await discord.stop().catch(() => { });
|
|
194
|
+
if (platforms.telegram)
|
|
195
|
+
await telegram.stop().catch(() => { });
|
|
196
|
+
await github.stop().catch(() => { });
|
|
197
|
+
process.exit(0);
|
|
198
|
+
}
|
|
199
|
+
process.stdin.on('end', shutdown);
|
|
200
|
+
process.stdin.on('close', shutdown);
|
|
201
|
+
process.on('SIGINT', shutdown);
|
|
202
|
+
process.on('SIGTERM', shutdown);
|
|
203
|
+
await main();
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Bridge a push-based event source (callbacks) into a pull-based AsyncIterable. */
|
|
2
|
+
export class AsyncQueue {
|
|
3
|
+
queue = [];
|
|
4
|
+
resolveNext = null;
|
|
5
|
+
done = false;
|
|
6
|
+
error = null;
|
|
7
|
+
push(item) {
|
|
8
|
+
if (this.done)
|
|
9
|
+
return;
|
|
10
|
+
this.queue.push(item);
|
|
11
|
+
this.flush();
|
|
12
|
+
}
|
|
13
|
+
finish() {
|
|
14
|
+
if (this.done)
|
|
15
|
+
return;
|
|
16
|
+
this.done = true;
|
|
17
|
+
this.flush();
|
|
18
|
+
}
|
|
19
|
+
fail(err) {
|
|
20
|
+
if (this.done)
|
|
21
|
+
return;
|
|
22
|
+
this.error = err;
|
|
23
|
+
this.done = true;
|
|
24
|
+
this.flush();
|
|
25
|
+
}
|
|
26
|
+
flush() { const r = this.resolveNext; this.resolveNext = null; r?.(); }
|
|
27
|
+
async *[Symbol.asyncIterator]() {
|
|
28
|
+
while (true) {
|
|
29
|
+
if (this.queue.length) {
|
|
30
|
+
yield this.queue.shift();
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (this.done) {
|
|
34
|
+
if (this.error)
|
|
35
|
+
throw this.error;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
await new Promise(r => { this.resolveNext = r; });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Per-machine Line → {agent threads, last-used} cache. Keys are URI lines (see docs/uri-scheme.md). */
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { errMsg, log } from '../log.js';
|
|
5
|
+
import { STATE_DIR } from '../paths.js';
|
|
6
|
+
import { station as stationOf } from '../stations/line.js';
|
|
7
|
+
const cacheFile = join(STATE_DIR, 'scopes.json');
|
|
8
|
+
function read() {
|
|
9
|
+
if (!existsSync(cacheFile))
|
|
10
|
+
return {};
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(cacheFile, 'utf8'));
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
log.warn({ err: errMsg(err), path: cacheFile }, 'scope cache read failed; treating as empty');
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function write(cache) {
|
|
20
|
+
try {
|
|
21
|
+
writeFileSync(cacheFile, JSON.stringify(cache, null, 2));
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
log.warn({ err: errMsg(err), path: cacheFile }, 'scope cache write failed');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function ensure(cache, line) {
|
|
28
|
+
if (!cache[line])
|
|
29
|
+
cache[line] = { createdAt: new Date().toISOString(), agents: {} };
|
|
30
|
+
if (!cache[line].agents)
|
|
31
|
+
cache[line].agents = {};
|
|
32
|
+
return cache[line];
|
|
33
|
+
}
|
|
34
|
+
export const getAgentThread = (line, kind) => read()[line]?.agents?.[kind];
|
|
35
|
+
export function setAgentThread(line, kind, threadId) {
|
|
36
|
+
const cache = read();
|
|
37
|
+
const entry = ensure(cache, line);
|
|
38
|
+
entry.agents[kind] = threadId;
|
|
39
|
+
entry.lastAgent = kind;
|
|
40
|
+
write(cache);
|
|
41
|
+
}
|
|
42
|
+
export const getLastAgent = (line) => read()[line]?.lastAgent;
|
|
43
|
+
export function setLastAgent(line, kind) {
|
|
44
|
+
const cache = read();
|
|
45
|
+
if (!cache[line])
|
|
46
|
+
return;
|
|
47
|
+
cache[line].lastAgent = kind;
|
|
48
|
+
write(cache);
|
|
49
|
+
}
|
|
50
|
+
export function setLastSeen(line, messageId) {
|
|
51
|
+
const cache = read();
|
|
52
|
+
if (!cache[line])
|
|
53
|
+
return;
|
|
54
|
+
cache[line].lastSeenMessageId = messageId;
|
|
55
|
+
cache[line].lastSeenAt = new Date().toISOString();
|
|
56
|
+
write(cache);
|
|
57
|
+
}
|
|
58
|
+
export function setName(line, name) {
|
|
59
|
+
const cache = read();
|
|
60
|
+
const entry = ensure(cache, line);
|
|
61
|
+
if (entry.name === name)
|
|
62
|
+
return;
|
|
63
|
+
entry.name = name;
|
|
64
|
+
write(cache);
|
|
65
|
+
}
|
|
66
|
+
export const listLines = () => Object.entries(read()).map(([line, entry]) => ({ line: line, entry }));
|
|
67
|
+
export const linesForStation = (name) => listLines().filter(({ line }) => stationOf(line) === name);
|
|
@@ -0,0 +1,219 @@
|
|
|
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; returns the truncated body and an `_(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 dropped = 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 (dropped === 0 && body === s)
|
|
50
|
+
return { body: s, overflow: '' };
|
|
51
|
+
return { body, overflow: `_(${dropped > 0 ? `${dropped} more line${dropped === 1 ? '' : 's'}` : 'output truncated'})_` };
|
|
52
|
+
}
|
|
53
|
+
export class StreamingMessage {
|
|
54
|
+
adapter;
|
|
55
|
+
scheduler;
|
|
56
|
+
blocks = [];
|
|
57
|
+
segments = [{ id: null, text: '', dirty: false }];
|
|
58
|
+
statusLine = null;
|
|
59
|
+
flushing = false;
|
|
60
|
+
flushAgain = false;
|
|
61
|
+
finalized = false;
|
|
62
|
+
/** Non-null while a stop button should be shown on the last segment. */
|
|
63
|
+
stopId = null;
|
|
64
|
+
constructor(adapter, scheduler) {
|
|
65
|
+
this.adapter = adapter;
|
|
66
|
+
this.scheduler = scheduler;
|
|
67
|
+
}
|
|
68
|
+
appendDelta(delta) {
|
|
69
|
+
if (this.finalized || !delta)
|
|
70
|
+
return;
|
|
71
|
+
const last = this.blocks.at(-1);
|
|
72
|
+
if (last?.kind === 'text')
|
|
73
|
+
last.text += delta;
|
|
74
|
+
else
|
|
75
|
+
this.blocks.push({ kind: 'text', text: delta });
|
|
76
|
+
this.scheduler.request(this);
|
|
77
|
+
}
|
|
78
|
+
/** Transient status (Thinking…, Reasoning…). Italic+bold so it reads as ambient, not a header. */
|
|
79
|
+
setStatus(status) {
|
|
80
|
+
if (this.finalized)
|
|
81
|
+
return;
|
|
82
|
+
this.statusLine = status ? `_**${status}**_` : null;
|
|
83
|
+
this.scheduler.request(this);
|
|
84
|
+
}
|
|
85
|
+
/** Set/clear the stop-button id rendered on the last segment by the adapter. */
|
|
86
|
+
setStopId(id) {
|
|
87
|
+
if (this.finalized || this.stopId === id)
|
|
88
|
+
return;
|
|
89
|
+
this.stopId = id;
|
|
90
|
+
this.segments[this.segments.length - 1].dirty = true;
|
|
91
|
+
this.scheduler.request(this);
|
|
92
|
+
}
|
|
93
|
+
/** Add a tool block keyed by `id`; rendered immediately as a header, output filled in via appendToolResult. */
|
|
94
|
+
appendToolCall(id, name, detail) {
|
|
95
|
+
if (this.finalized)
|
|
96
|
+
return;
|
|
97
|
+
this.blocks.push({ kind: 'tool', id, name, detail });
|
|
98
|
+
this.scheduler.request(this);
|
|
99
|
+
}
|
|
100
|
+
/** Set the matching tool block's result; renders truncated output under the header. */
|
|
101
|
+
appendToolResult(id, result) {
|
|
102
|
+
if (this.finalized || !result)
|
|
103
|
+
return;
|
|
104
|
+
const tool = this.blocks.find((b) => b.kind === 'tool' && b.id === id);
|
|
105
|
+
if (tool)
|
|
106
|
+
tool.result = result;
|
|
107
|
+
this.scheduler.request(this);
|
|
108
|
+
}
|
|
109
|
+
/** "Interrupted" is a user-triggered stop — render as a calm italic+bold notice, not a `⚠️` crash. */
|
|
110
|
+
appendError(message) {
|
|
111
|
+
if (this.finalized)
|
|
112
|
+
return;
|
|
113
|
+
this.statusLine = null;
|
|
114
|
+
this.blocks.push({ kind: 'text', text: message === 'Interrupted' ? '_**Interrupted**_' : `⚠️ ${message}` });
|
|
115
|
+
this.scheduler.request(this);
|
|
116
|
+
}
|
|
117
|
+
async finalize() {
|
|
118
|
+
if (this.finalized)
|
|
119
|
+
return;
|
|
120
|
+
this.finalized = true;
|
|
121
|
+
this.scheduler.forget(this);
|
|
122
|
+
this.statusLine = null;
|
|
123
|
+
await this.flush();
|
|
124
|
+
}
|
|
125
|
+
async _flushFromScheduler() { await this.flush(); }
|
|
126
|
+
/** Render the block list into a single markdown body. */
|
|
127
|
+
renderBody() {
|
|
128
|
+
return this.blocks.map(b => b.kind === 'text' ? b.text : this.renderToolBlock(b)).join('\n\n').trim();
|
|
129
|
+
}
|
|
130
|
+
/** Plain header + fenced input block + fenced output block (each fully visible — no collapse). */
|
|
131
|
+
renderToolBlock(b) {
|
|
132
|
+
const parts = [`🛠 **${b.name}**`];
|
|
133
|
+
if (b.detail)
|
|
134
|
+
parts.push('```\n' + escapeFence(b.detail) + '\n```');
|
|
135
|
+
if (b.result) {
|
|
136
|
+
const { body, overflow } = truncateResult(b.result);
|
|
137
|
+
parts.push('```\n' + escapeFence(body) + '\n```');
|
|
138
|
+
if (overflow)
|
|
139
|
+
parts.push(overflow);
|
|
140
|
+
}
|
|
141
|
+
return parts.join('\n');
|
|
142
|
+
}
|
|
143
|
+
/** Redistribute the rendered body across segments, keeping existing segment ids stable. */
|
|
144
|
+
redistribute() {
|
|
145
|
+
const chunks = this.chunkify(this.renderBody(), MAX_BODY_LEN - STATUS_RESERVE);
|
|
146
|
+
if (!chunks.length)
|
|
147
|
+
chunks.push('');
|
|
148
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
149
|
+
if (i >= this.segments.length)
|
|
150
|
+
this.segments.push({ id: null, text: chunks[i], dirty: true });
|
|
151
|
+
else if (this.segments[i].text !== chunks[i]) {
|
|
152
|
+
this.segments[i].text = chunks[i];
|
|
153
|
+
this.segments[i].dirty = true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
chunkify(s, cap) {
|
|
158
|
+
if (s.length <= cap)
|
|
159
|
+
return [s];
|
|
160
|
+
const out = [];
|
|
161
|
+
for (let r = s; r.length > 0; r = r.slice(out.at(-1).length)) {
|
|
162
|
+
out.push(r.length > cap ? this.sliceAtBoundary(r, cap) : r);
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
/** Split at the last paragraph/line/sentence/word break in range; hard slice otherwise. */
|
|
167
|
+
sliceAtBoundary(s, room) {
|
|
168
|
+
if (s.length <= room)
|
|
169
|
+
return s;
|
|
170
|
+
const candidate = s.slice(0, room);
|
|
171
|
+
for (const b of ['\n\n', '\n', '. ', ' ']) {
|
|
172
|
+
const i = candidate.lastIndexOf(b);
|
|
173
|
+
if (i > room * 0.5)
|
|
174
|
+
return candidate.slice(0, i + b.length);
|
|
175
|
+
}
|
|
176
|
+
return candidate;
|
|
177
|
+
}
|
|
178
|
+
async flush() {
|
|
179
|
+
if (this.flushing) {
|
|
180
|
+
this.flushAgain = true;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
this.flushing = true;
|
|
184
|
+
try {
|
|
185
|
+
do {
|
|
186
|
+
this.flushAgain = false;
|
|
187
|
+
this.redistribute();
|
|
188
|
+
for (let i = 0; i < this.segments.length; i++) {
|
|
189
|
+
const s = this.segments[i];
|
|
190
|
+
const isLast = i === this.segments.length - 1;
|
|
191
|
+
const body = this.render(s, isLast);
|
|
192
|
+
if (!body)
|
|
193
|
+
continue;
|
|
194
|
+
/** Stop button only lives on the final segment; frozen segments never carry it. */
|
|
195
|
+
const stopId = isLast ? this.stopId : null;
|
|
196
|
+
try {
|
|
197
|
+
if (s.id === null)
|
|
198
|
+
s.id = await this.adapter.send(body, stopId);
|
|
199
|
+
else if (s.dirty)
|
|
200
|
+
await this.adapter.edit(s.id, body, stopId);
|
|
201
|
+
s.dirty = false;
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
log.warn({ err: errMsg(err) }, 'streaming edit failed');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
} while (this.flushAgain);
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
this.flushing = false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
render(s, isLast) {
|
|
214
|
+
const showStatus = isLast && !!this.statusLine;
|
|
215
|
+
if (!s.text)
|
|
216
|
+
return showStatus ? this.statusLine : '';
|
|
217
|
+
return showStatus ? `${s.text}\n\n${this.statusLine}` : s.text;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Run a turn; stream response via adapter. In-flight follow-ups queue and drain as one combined turn. */
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { errMsg, log } from '../log.js';
|
|
4
|
+
import { StreamingMessage } from './streaming.js';
|
|
5
|
+
/** Per-threadId active-turn state. Presence in the map means a turn is running; `texts` are queued follow-ups. */
|
|
6
|
+
const inFlight = new Map();
|
|
7
|
+
/** stopId → AbortController. Populated at turn start; fired by `triggerStop` on platform button clicks. */
|
|
8
|
+
const stoppers = new Map();
|
|
9
|
+
/** Invoked by chat stations when a stop button is pressed. Returns true if a turn was actually cancelled. */
|
|
10
|
+
export async function triggerStop(stopId) {
|
|
11
|
+
const ctrl = stoppers.get(stopId);
|
|
12
|
+
if (!ctrl)
|
|
13
|
+
return false;
|
|
14
|
+
stoppers.delete(stopId);
|
|
15
|
+
ctrl.abort();
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
export async function runTurn(agent, threadId, text, attachments, adapter, scheduler) {
|
|
19
|
+
/** Queued follow-ups carry only text (next turn re-fetches its own attachments). */
|
|
20
|
+
const dispatch = (t) => runTurn(agent, threadId, t, [], adapter, scheduler);
|
|
21
|
+
const existing = inFlight.get(threadId);
|
|
22
|
+
if (existing) {
|
|
23
|
+
existing.texts.push(text);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
inFlight.set(threadId, { texts: [], dispatch });
|
|
27
|
+
const stream = new StreamingMessage(adapter, scheduler);
|
|
28
|
+
const stopId = `stop-${randomUUID()}`;
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
stoppers.set(stopId, controller);
|
|
31
|
+
stream.setStopId(stopId);
|
|
32
|
+
try {
|
|
33
|
+
for await (const ev of agent.sendTurn({ threadId, text, attachments, signal: controller.signal })) {
|
|
34
|
+
if (ev.type === 'delta')
|
|
35
|
+
stream.appendDelta(ev.text);
|
|
36
|
+
else if (ev.type === 'tool-start') {
|
|
37
|
+
if (ev.activity.transient)
|
|
38
|
+
stream.setStatus(ev.activity.name);
|
|
39
|
+
else
|
|
40
|
+
stream.appendToolCall(ev.activity.id, ev.activity.name, ev.activity.detail);
|
|
41
|
+
}
|
|
42
|
+
else if (ev.type === 'tool-end') {
|
|
43
|
+
if (ev.result)
|
|
44
|
+
stream.appendToolResult(ev.id, ev.result);
|
|
45
|
+
stream.setStatus(null);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
log.warn({ err: errMsg(err) }, 'agent turn failed');
|
|
51
|
+
stream.appendError(errMsg(err) || 'agent turn failed');
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
stoppers.delete(stopId);
|
|
55
|
+
stream.setStopId(null);
|
|
56
|
+
await stream.finalize();
|
|
57
|
+
const entry = inFlight.get(threadId);
|
|
58
|
+
inFlight.delete(threadId);
|
|
59
|
+
if (entry?.texts.length)
|
|
60
|
+
await entry.dispatch(entry.texts.join('\n\n')).catch(err => log.warn({ err: errMsg(err) }, 'queued turn failed'));
|
|
61
|
+
}
|
|
62
|
+
}
|
package/dist/log.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
// JSON lines, subcommand results, --json) — any stray write there breaks
|
|
3
|
-
// parsing. Override level with METRO_LOG_LEVEL.
|
|
1
|
+
/** Pino → stderr. TTY → pino-pretty (colorized, hostname/pid stripped); else JSON. */
|
|
4
2
|
import pino from 'pino';
|
|
5
|
-
|
|
3
|
+
import pinoPretty from 'pino-pretty';
|
|
4
|
+
const stream = process.stderr.isTTY
|
|
5
|
+
? pinoPretty({ colorize: true, translateTime: 'HH:MM:ss', ignore: 'pid,hostname,name', destination: 2 })
|
|
6
|
+
: pino.destination(2);
|
|
7
|
+
/** `base: { name }` strips pino's default pid+hostname from JSON output too (not just from the TTY pretty stream). */
|
|
8
|
+
export const log = pino({ base: { name: 'metro' }, level: process.env.METRO_LOG_LEVEL || 'info' }, stream);
|
|
6
9
|
export const errMsg = (err) => {
|
|
7
10
|
if (err instanceof Error)
|
|
8
11
|
return err.message;
|