@stage-labs/metro 0.1.0-beta.4 → 0.1.0-beta.6
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 +138 -63
- package/dist/cache.js +74 -0
- package/dist/cli/actions.js +156 -0
- package/dist/cli/config.js +184 -0
- package/dist/cli/index.js +162 -0
- package/dist/cli/skill.js +62 -0
- package/dist/cli/util.js +72 -0
- package/dist/codex-rc.js +236 -0
- package/dist/dispatcher.js +112 -0
- package/dist/history.js +84 -0
- package/dist/ipc.js +71 -0
- package/dist/log.js +7 -2
- package/dist/stations/discord.js +198 -0
- package/dist/stations/index.js +73 -0
- package/dist/{helpers/telegram-format.js → stations/telegram-md.js} +9 -14
- package/dist/stations/telegram-upload.js +113 -0
- package/dist/stations/telegram.js +235 -0
- package/docs/agents.md +168 -0
- package/docs/uri-scheme.md +71 -0
- package/package.json +6 -3
- package/skills/metro/SKILL.md +220 -0
- package/dist/agents/claude.js +0 -207
- package/dist/agents/codex.js +0 -207
- package/dist/agents/types.js +0 -2
- package/dist/channels/discord.js +0 -104
- package/dist/channels/telegram.js +0 -189
- package/dist/cli.js +0 -221
- package/dist/helpers/scope-cache.js +0 -65
- package/dist/helpers/streaming.js +0 -209
- package/dist/helpers/turn.js +0 -40
- package/dist/orchestrator.js +0 -208
package/dist/helpers/turn.js
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
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/orchestrator.js
DELETED
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
/** Daemon: owns Discord gateway + Telegram poller; routes inbounds to codex/claude (suffix "with X" overrides scope default). */
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import pkg from '../package.json' with { type: 'json' };
|
|
4
|
-
import * as discord from './channels/discord.js';
|
|
5
|
-
import * as telegram from './channels/telegram.js';
|
|
6
|
-
import { CodexAgent } from './agents/codex.js';
|
|
7
|
-
import { ClaudeAgent } from './agents/claude.js';
|
|
8
|
-
import { discordChannelFromScopeKey, discordScopeKey, getAgentThread, getLastAgent, listScopes, setAgentThread, setLastAgent, setLastSeen, telegramScopeKey, } from './helpers/scope-cache.js';
|
|
9
|
-
import { StreamScheduler } from './helpers/streaming.js';
|
|
10
|
-
import { runTurn } from './helpers/turn.js';
|
|
11
|
-
import { errMsg, log } from './log.js';
|
|
12
|
-
import { acquireLock, configuredPlatforms, loadMetroEnv, STATE_DIR, requireConfiguredPlatform } from './paths.js';
|
|
13
|
-
loadMetroEnv();
|
|
14
|
-
const platforms = configuredPlatforms();
|
|
15
|
-
requireConfiguredPlatform(platforms);
|
|
16
|
-
acquireLock(join(STATE_DIR, '.tail-lock'));
|
|
17
|
-
const bootstrapped = new Set();
|
|
18
|
-
const codexAgent = new CodexAgent(pkg.version);
|
|
19
|
-
const claudeAgent = new ClaudeAgent();
|
|
20
|
-
const available = {};
|
|
21
|
-
const discordScheduler = new StreamScheduler();
|
|
22
|
-
const telegramScheduler = new StreamScheduler();
|
|
23
|
-
async function startAgents() {
|
|
24
|
-
await Promise.allSettled([
|
|
25
|
-
codexAgent.start().then(() => { available.codex = codexAgent; }).catch(err => log.warn({ err: errMsg(err) }, 'codex unavailable')),
|
|
26
|
-
claudeAgent.start().then(() => { available.claude = claudeAgent; }).catch(err => log.warn({ err: errMsg(err) }, 'claude unavailable')),
|
|
27
|
-
]);
|
|
28
|
-
if (!Object.keys(available).length) {
|
|
29
|
-
log.fatal('no agents available');
|
|
30
|
-
process.exit(2);
|
|
31
|
-
}
|
|
32
|
-
log.info({ agents: Object.keys(available) }, 'agents ready');
|
|
33
|
-
}
|
|
34
|
-
const SUFFIX_RE = /(?:^|\s)with\s+(claude|codex)\s*$/i;
|
|
35
|
-
function parseAgentSuffix(text) {
|
|
36
|
-
const t = text.trimEnd();
|
|
37
|
-
const m = SUFFIX_RE.exec(t);
|
|
38
|
-
return m ? { kind: m[1].toLowerCase(), cleanText: t.slice(0, m.index).trimEnd() } : { kind: null, cleanText: text };
|
|
39
|
-
}
|
|
40
|
-
function pickAgent(scopeKey, req) {
|
|
41
|
-
if (req)
|
|
42
|
-
return available[req] ? { kind: req } : { error: `${req} is not available on this metro instance` };
|
|
43
|
-
const last = scopeKey ? getLastAgent(scopeKey) : undefined;
|
|
44
|
-
if (last && available[last])
|
|
45
|
-
return { kind: last };
|
|
46
|
-
if (available.claude)
|
|
47
|
-
return { kind: 'claude' };
|
|
48
|
-
if (available.codex)
|
|
49
|
-
return { kind: 'codex' };
|
|
50
|
-
return { error: 'no agents available' };
|
|
51
|
-
}
|
|
52
|
-
/** Resolve agent session for `scopeKey`, allocating if new, then run the turn. */
|
|
53
|
-
async function dispatch(scopeKey, text, kind, messageId, adapter, scheduler) {
|
|
54
|
-
setLastSeen(scopeKey, messageId);
|
|
55
|
-
let threadId = getAgentThread(scopeKey, kind);
|
|
56
|
-
if (!threadId) {
|
|
57
|
-
threadId = await available[kind].createThread();
|
|
58
|
-
setAgentThread(scopeKey, kind, threadId);
|
|
59
|
-
log.info({ scope: scopeKey, agent: kind, thread: threadId }, 'allocated agent session');
|
|
60
|
-
}
|
|
61
|
-
else
|
|
62
|
-
setLastAgent(scopeKey, kind);
|
|
63
|
-
await runTurn(available[kind], threadId, text, adapter, scheduler);
|
|
64
|
-
}
|
|
65
|
-
async function onDiscordInbound(m) {
|
|
66
|
-
if (!m.in_guild)
|
|
67
|
-
return;
|
|
68
|
-
const scope = discordScopeKey(m.channel_id);
|
|
69
|
-
const hasAgent = !!(getAgentThread(scope, 'codex') ?? getAgentThread(scope, 'claude'));
|
|
70
|
-
const { kind: req, cleanText } = parseAgentSuffix(m.text);
|
|
71
|
-
const postErr = (msg) => discord.sendMessage(m.channel_id, `⚠️ ${msg}`).then(() => { }).catch(err => log.warn({ err: errMsg(err) }, 'discord error post failed'));
|
|
72
|
-
if (hasAgent) {
|
|
73
|
-
const choice = pickAgent(scope, req);
|
|
74
|
-
if ('error' in choice)
|
|
75
|
-
return postErr(choice.error);
|
|
76
|
-
return dispatch(scope, cleanText, choice.kind, m.message_id, discordAdapter(m.channel_id), discordScheduler);
|
|
77
|
-
}
|
|
78
|
-
if (!m.mentions_bot || bootstrapped.has(m.message_id))
|
|
79
|
-
return;
|
|
80
|
-
bootstrapped.add(m.message_id);
|
|
81
|
-
const choice = pickAgent(null, req);
|
|
82
|
-
if ('error' in choice)
|
|
83
|
-
return postErr(choice.error);
|
|
84
|
-
const threadId = await available[choice.kind].createThread();
|
|
85
|
-
const ch = await discord.createThreadFromMessage(m.channel_id, m.message_id, makeThreadName(cleanText, threadId));
|
|
86
|
-
setAgentThread(discordScopeKey(ch), choice.kind, threadId);
|
|
87
|
-
log.info({ discord: ch, agent: choice.kind, thread: threadId }, 'scope created');
|
|
88
|
-
await runTurn(available[choice.kind], threadId, cleanText, discordAdapter(ch), discordScheduler);
|
|
89
|
-
}
|
|
90
|
-
async function onTelegramInbound(m) {
|
|
91
|
-
if (!m.is_private && !m.in_forum)
|
|
92
|
-
return;
|
|
93
|
-
if (m.in_forum && !m.is_forum_topic)
|
|
94
|
-
return bootstrapForumTopic(m);
|
|
95
|
-
const scope = telegramScopeKey(m.chat_id, m.message_thread_id);
|
|
96
|
-
const hasAgent = !!(getAgentThread(scope, 'codex') ?? getAgentThread(scope, 'claude'));
|
|
97
|
-
if (!hasAgent && !m.is_private && !m.mentions_bot)
|
|
98
|
-
return;
|
|
99
|
-
const { kind: req, cleanText } = parseAgentSuffix(m.text);
|
|
100
|
-
const choice = pickAgent(hasAgent ? scope : null, req);
|
|
101
|
-
if ('error' in choice)
|
|
102
|
-
return postTelegramError(m.chat_id, m.message_thread_id, choice.error);
|
|
103
|
-
await dispatch(scope, cleanText, choice.kind, String(m.message_id), telegramAdapter(m.chat_id, m.message_thread_id), telegramScheduler);
|
|
104
|
-
}
|
|
105
|
-
async function bootstrapForumTopic(m) {
|
|
106
|
-
if (!m.mentions_bot || bootstrapped.has(String(m.message_id)))
|
|
107
|
-
return;
|
|
108
|
-
bootstrapped.add(String(m.message_id));
|
|
109
|
-
const { kind: req, cleanText } = parseAgentSuffix(m.text);
|
|
110
|
-
const choice = pickAgent(null, req);
|
|
111
|
-
if ('error' in choice)
|
|
112
|
-
return postTelegramError(m.chat_id, undefined, choice.error);
|
|
113
|
-
let topicId;
|
|
114
|
-
const topicName = makeThreadName(cleanText, 'metro');
|
|
115
|
-
try {
|
|
116
|
-
topicId = await telegram.createForumTopic(m.chat_id, topicName);
|
|
117
|
-
}
|
|
118
|
-
catch (err) {
|
|
119
|
-
return postTelegramError(m.chat_id, undefined, `couldn't create topic — bot needs Manage Topics admin permission. (${errMsg(err)})`);
|
|
120
|
-
}
|
|
121
|
-
const threadId = await available[choice.kind].createThread();
|
|
122
|
-
const scope = telegramScopeKey(m.chat_id, topicId);
|
|
123
|
-
setAgentThread(scope, choice.kind, threadId);
|
|
124
|
-
setLastSeen(scope, String(m.message_id));
|
|
125
|
-
log.info({ scope, agent: choice.kind, thread: threadId }, 'telegram: scope created');
|
|
126
|
-
/** Post a deep link back in General as a reply to the @-mention so it threads visually. */
|
|
127
|
-
await telegram.sendMessage(m.chat_id, undefined, `→ [${topicName}](${telegram.topicLink(m.chat_id, topicId)})`, m.message_id)
|
|
128
|
-
.catch(err => log.warn({ err: errMsg(err) }, 'telegram: failed to post topic link in General'));
|
|
129
|
-
await runTurn(available[choice.kind], threadId, cleanText, telegramAdapter(m.chat_id, topicId), telegramScheduler);
|
|
130
|
-
}
|
|
131
|
-
async function postTelegramError(chatId, threadId, message) {
|
|
132
|
-
try {
|
|
133
|
-
await telegram.sendMessage(chatId, threadId, `⚠️ ${message}`);
|
|
134
|
-
}
|
|
135
|
-
catch (err) {
|
|
136
|
-
log.warn({ err: errMsg(err) }, 'failed to post telegram error');
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
/** Strip mention syntax + normalize whitespace; cap at 100 chars (Discord limit). */
|
|
140
|
-
function makeThreadName(rawText, fallback) {
|
|
141
|
-
const cleaned = rawText.replace(/<@!?\d+>|<@&\d+>|<#\d+>|<a?:[^:]+:\d+>|@\w+/g, '').replace(/\s+/g, ' ').trim();
|
|
142
|
-
if (!cleaned)
|
|
143
|
-
return fallback.slice(0, 100);
|
|
144
|
-
return cleaned.length <= 100 ? cleaned : cleaned.slice(0, 99) + '…';
|
|
145
|
-
}
|
|
146
|
-
function discordAdapter(channelId) {
|
|
147
|
-
return {
|
|
148
|
-
send: t => discord.sendMessage(channelId, t),
|
|
149
|
-
edit: async (id, t) => { await discord.editMessage(channelId, id, t); },
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
function telegramAdapter(chatId, topicId) {
|
|
153
|
-
return {
|
|
154
|
-
send: async (t) => String(await telegram.sendMessage(chatId, topicId, t)),
|
|
155
|
-
edit: async (id, t) => { await telegram.editMessageText(chatId, Number(id), t); },
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
async function catchupDiscord() {
|
|
159
|
-
for (const { scopeKey, entry } of listScopes()) {
|
|
160
|
-
const channelId = discordChannelFromScopeKey(scopeKey);
|
|
161
|
-
if (!channelId || !entry.lastSeenMessageId)
|
|
162
|
-
continue;
|
|
163
|
-
try {
|
|
164
|
-
const missed = (await discord.fetchMessagesSince(channelId, entry.lastSeenMessageId)).filter(m => !m.author_is_bot && m.text);
|
|
165
|
-
if (!missed.length)
|
|
166
|
-
continue;
|
|
167
|
-
log.info({ channel: channelId, count: missed.length }, 'discord catchup');
|
|
168
|
-
for (const m of missed)
|
|
169
|
-
await onDiscordInbound({ channel_id: channelId, message_id: m.message_id, text: m.text, in_guild: true, mentions_bot: false });
|
|
170
|
-
}
|
|
171
|
-
catch (err) {
|
|
172
|
-
log.warn({ err: errMsg(err), channel: channelId }, 'discord catchup skipped');
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
async function main() {
|
|
177
|
-
await startAgents();
|
|
178
|
-
if (platforms.discord) {
|
|
179
|
-
await discord.startGateway();
|
|
180
|
-
log.info({ bot: (await discord.getMe()).username }, 'discord ready');
|
|
181
|
-
discord.onInbound(m => void onDiscordInbound(m).catch(err => log.warn({ err: errMsg(err) }, 'discord inbound failed')));
|
|
182
|
-
void catchupDiscord().catch(err => log.warn({ err: errMsg(err) }, 'discord catchup failed'));
|
|
183
|
-
}
|
|
184
|
-
if (platforms.telegram) {
|
|
185
|
-
log.info({ bot: `@${(await telegram.getMe()).username}` }, 'telegram ready');
|
|
186
|
-
telegram.onInbound(m => void onTelegramInbound(m).catch(err => log.warn({ err: errMsg(err) }, 'telegram inbound failed')));
|
|
187
|
-
await telegram.startPolling();
|
|
188
|
-
}
|
|
189
|
-
log.info('orchestrator ready');
|
|
190
|
-
}
|
|
191
|
-
let shuttingDown = false;
|
|
192
|
-
async function shutdown() {
|
|
193
|
-
if (shuttingDown)
|
|
194
|
-
return;
|
|
195
|
-
shuttingDown = true;
|
|
196
|
-
log.info('orchestrator shutting down');
|
|
197
|
-
await Promise.allSettled([available.codex?.stop(), available.claude?.stop()]);
|
|
198
|
-
if (platforms.discord)
|
|
199
|
-
await discord.shutdownGateway().catch(() => { });
|
|
200
|
-
if (platforms.telegram)
|
|
201
|
-
await telegram.shutdownPolling().catch(() => { });
|
|
202
|
-
process.exit(0);
|
|
203
|
-
}
|
|
204
|
-
process.stdin.on('end', shutdown);
|
|
205
|
-
process.stdin.on('close', shutdown);
|
|
206
|
-
process.on('SIGINT', shutdown);
|
|
207
|
-
process.on('SIGTERM', shutdown);
|
|
208
|
-
await main();
|