@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
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { STATE_DIR } from '../paths.js';
|
|
4
|
-
import { errMsg, log } from '../log.js';
|
|
5
|
-
import { mdToTelegramHtml } from '../helpers/telegram-format.js';
|
|
6
|
-
const API_BASE = 'https://api.telegram.org';
|
|
7
|
-
function token() {
|
|
8
|
-
const t = process.env.TELEGRAM_BOT_TOKEN;
|
|
9
|
-
if (!t)
|
|
10
|
-
throw new Error('TELEGRAM_BOT_TOKEN is not set');
|
|
11
|
-
return t;
|
|
12
|
-
}
|
|
13
|
-
async function tg(method, body, opts = {}) {
|
|
14
|
-
const signals = [AbortSignal.timeout(opts.timeoutMs ?? 30_000)];
|
|
15
|
-
if (opts.signal)
|
|
16
|
-
signals.push(opts.signal);
|
|
17
|
-
const res = await fetch(`${API_BASE}/bot${token()}/${method}`, {
|
|
18
|
-
method: 'POST',
|
|
19
|
-
headers: { 'Content-Type': 'application/json' },
|
|
20
|
-
body: JSON.stringify(body),
|
|
21
|
-
signal: AbortSignal.any(signals),
|
|
22
|
-
});
|
|
23
|
-
const json = (await res.json());
|
|
24
|
-
if (!json.ok)
|
|
25
|
-
throw new Error(`telegram ${method}: ${json.description ?? 'unknown error'}`);
|
|
26
|
-
return json.result;
|
|
27
|
-
}
|
|
28
|
-
let botUsername = null;
|
|
29
|
-
let botUserId = null;
|
|
30
|
-
export async function getMe() {
|
|
31
|
-
const me = await tg('getMe', {});
|
|
32
|
-
botUsername = me.username;
|
|
33
|
-
botUserId = me.id;
|
|
34
|
-
return me;
|
|
35
|
-
}
|
|
36
|
-
let onInboundHandler = () => { };
|
|
37
|
-
export const onInbound = (handler) => { onInboundHandler = handler; };
|
|
38
|
-
const offsetFile = join(STATE_DIR, 'telegram-offset.json');
|
|
39
|
-
let pollOffset = 0;
|
|
40
|
-
let pollAbort = null;
|
|
41
|
-
function loadOffset() {
|
|
42
|
-
try {
|
|
43
|
-
return existsSync(offsetFile) ? Number(readFileSync(offsetFile, 'utf8').trim()) || 0 : 0;
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
return 0;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
function saveOffset(o) {
|
|
50
|
-
try {
|
|
51
|
-
writeFileSync(offsetFile, String(o));
|
|
52
|
-
}
|
|
53
|
-
catch (err) {
|
|
54
|
-
log.warn({ err: errMsg(err) }, 'telegram offset save failed');
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
export async function startPolling() {
|
|
58
|
-
await tg('deleteWebhook', { drop_pending_updates: false }).catch(() => { });
|
|
59
|
-
const persisted = loadOffset();
|
|
60
|
-
if (persisted > 0) {
|
|
61
|
-
pollOffset = persisted;
|
|
62
|
-
log.info({ offset: pollOffset }, 'telegram polling: resuming from persisted offset');
|
|
63
|
-
}
|
|
64
|
-
else {
|
|
65
|
-
/** First run: anchor on latest update id (-1 returns most recent without consuming). */
|
|
66
|
-
const initial = await tg('getUpdates', { offset: -1, timeout: 0 });
|
|
67
|
-
pollOffset = initial.length ? initial[0].update_id + 1 : 0;
|
|
68
|
-
saveOffset(pollOffset);
|
|
69
|
-
log.info({ offset: pollOffset }, 'telegram polling: starting fresh');
|
|
70
|
-
}
|
|
71
|
-
pollAbort = new AbortController();
|
|
72
|
-
void pollLoop();
|
|
73
|
-
}
|
|
74
|
-
async function pollLoop() {
|
|
75
|
-
const abortSignal = pollAbort?.signal;
|
|
76
|
-
while (pollAbort && !pollAbort.signal.aborted) {
|
|
77
|
-
try {
|
|
78
|
-
const updates = await tg('getUpdates', { offset: pollOffset, timeout: 25 }, { timeoutMs: 60_000, signal: abortSignal });
|
|
79
|
-
for (const u of updates) {
|
|
80
|
-
pollOffset = u.update_id + 1;
|
|
81
|
-
await dispatchUpdate(u);
|
|
82
|
-
}
|
|
83
|
-
if (updates.length)
|
|
84
|
-
saveOffset(pollOffset);
|
|
85
|
-
}
|
|
86
|
-
catch (err) {
|
|
87
|
-
if (pollAbort?.signal.aborted)
|
|
88
|
-
break;
|
|
89
|
-
log.warn({ err: errMsg(err) }, 'telegram poll error; backing off');
|
|
90
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
log.info('telegram polling stopped');
|
|
94
|
-
}
|
|
95
|
-
export async function shutdownPolling() { pollAbort?.abort(); pollAbort = null; }
|
|
96
|
-
async function dispatchUpdate(u) {
|
|
97
|
-
const m = u.message;
|
|
98
|
-
if (!m?.chat?.id || typeof m.message_id !== 'number' || m.from?.is_bot)
|
|
99
|
-
return;
|
|
100
|
-
const text = messageToText(m);
|
|
101
|
-
if (!text)
|
|
102
|
-
return;
|
|
103
|
-
onInboundHandler({
|
|
104
|
-
chat_id: m.chat.id,
|
|
105
|
-
message_id: m.message_id,
|
|
106
|
-
message_thread_id: m.is_topic_message ? m.message_thread_id : undefined,
|
|
107
|
-
text,
|
|
108
|
-
is_private: m.chat.type === 'private',
|
|
109
|
-
is_forum_topic: !!m.is_topic_message,
|
|
110
|
-
in_forum: !!m.chat.is_forum,
|
|
111
|
-
mentions_bot: detectMentionsBot(m),
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
function detectMentionsBot(m) {
|
|
115
|
-
if (m.chat?.type === 'private')
|
|
116
|
-
return true;
|
|
117
|
-
const text = m.text ?? m.caption ?? '';
|
|
118
|
-
for (const e of m.entities ?? m.caption_entities ?? []) {
|
|
119
|
-
if (e.type === 'mention' && botUsername) {
|
|
120
|
-
if (text.substring(e.offset, e.offset + e.length).toLowerCase() === `@${botUsername.toLowerCase()}`)
|
|
121
|
-
return true;
|
|
122
|
-
}
|
|
123
|
-
else if (e.type === 'text_mention' && e.user?.id === botUserId)
|
|
124
|
-
return true;
|
|
125
|
-
}
|
|
126
|
-
return false;
|
|
127
|
-
}
|
|
128
|
-
function messageToText(m) {
|
|
129
|
-
if (m.text)
|
|
130
|
-
return m.text;
|
|
131
|
-
const caption = m.caption ?? '';
|
|
132
|
-
if (m.photo?.length || m.document?.mime_type?.startsWith('image/'))
|
|
133
|
-
return [caption, '[image]'].filter(Boolean).join(' ');
|
|
134
|
-
if (m.voice)
|
|
135
|
-
return [caption, '[voice]'].filter(Boolean).join(' ');
|
|
136
|
-
if (m.audio)
|
|
137
|
-
return [caption, '[audio]'].filter(Boolean).join(' ');
|
|
138
|
-
return caption || null;
|
|
139
|
-
}
|
|
140
|
-
/** Create a forum topic; requires `can_manage_topics` admin permission. */
|
|
141
|
-
export async function createForumTopic(chatId, name) {
|
|
142
|
-
const r = await tg('createForumTopic', { chat_id: chatId, name: name.slice(0, 128) || 'metro' });
|
|
143
|
-
return r.message_thread_id;
|
|
144
|
-
}
|
|
145
|
-
/** Deep link `t.me/c/<id>/<topic>`; strips supergroup's `-100` prefix from chat id. */
|
|
146
|
-
export function topicLink(chatId, topicId) {
|
|
147
|
-
const id = String(Math.abs(chatId)).replace(/^100/, '');
|
|
148
|
-
return `https://t.me/c/${id}/${topicId}`;
|
|
149
|
-
}
|
|
150
|
-
const isParseError = (err) => errMsg(err).includes("can't parse entities");
|
|
151
|
-
const NO_PREVIEW = { link_preview_options: { is_disabled: true } };
|
|
152
|
-
/** Send agent-style markdown as Telegram HTML, falling back to plain text on parse errors. */
|
|
153
|
-
export async function sendMessage(chatId, threadId, text, replyToMessageId) {
|
|
154
|
-
const base = { chat_id: chatId, ...NO_PREVIEW };
|
|
155
|
-
if (threadId !== undefined)
|
|
156
|
-
base.message_thread_id = threadId;
|
|
157
|
-
if (replyToMessageId !== undefined)
|
|
158
|
-
base.reply_parameters = { message_id: replyToMessageId };
|
|
159
|
-
try {
|
|
160
|
-
return (await tg('sendMessage', { ...base, text: mdToTelegramHtml(text), parse_mode: 'HTML' })).message_id;
|
|
161
|
-
}
|
|
162
|
-
catch (err) {
|
|
163
|
-
if (!isParseError(err))
|
|
164
|
-
throw err;
|
|
165
|
-
log.warn({ err: errMsg(err) }, 'telegram: HTML rejected, sending plain');
|
|
166
|
-
return (await tg('sendMessage', { ...base, text })).message_id;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
export async function editMessageText(chatId, messageId, text) {
|
|
170
|
-
const base = { chat_id: chatId, message_id: messageId, ...NO_PREVIEW };
|
|
171
|
-
const swallow = (err) => { if (!errMsg(err).includes('message is not modified'))
|
|
172
|
-
throw err; };
|
|
173
|
-
try {
|
|
174
|
-
await tg('editMessageText', { ...base, text: mdToTelegramHtml(text), parse_mode: 'HTML' });
|
|
175
|
-
}
|
|
176
|
-
catch (err) {
|
|
177
|
-
if (errMsg(err).includes('message is not modified'))
|
|
178
|
-
return;
|
|
179
|
-
if (!isParseError(err))
|
|
180
|
-
throw err;
|
|
181
|
-
log.warn({ err: errMsg(err) }, 'telegram: HTML edit rejected, retrying plain');
|
|
182
|
-
try {
|
|
183
|
-
await tg('editMessageText', { ...base, text });
|
|
184
|
-
}
|
|
185
|
-
catch (e) {
|
|
186
|
-
swallow(e);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
package/dist/cli.js
DELETED
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { spawn } from 'node:child_process';
|
|
3
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import pkg from '../package.json' with { type: 'json' };
|
|
6
|
-
import * as discord from './channels/discord.js';
|
|
7
|
-
import * as telegram from './channels/telegram.js';
|
|
8
|
-
import { errMsg } from './log.js';
|
|
9
|
-
import { CONFIG_ENV_FILE, configuredPlatforms, loadMetroEnv, readDotenv, STATE_DIR, writeDotenv } from './paths.js';
|
|
10
|
-
const USAGE = `metro — Telegram + Discord bridge for your Claude Code / Codex agent
|
|
11
|
-
|
|
12
|
-
Usage:
|
|
13
|
-
metro Run the orchestrator daemon.
|
|
14
|
-
metro setup [telegram|discord <token>] Save token, or show status with no args.
|
|
15
|
-
metro setup clear [telegram|discord|all] Remove tokens.
|
|
16
|
-
metro doctor Health check.
|
|
17
|
-
metro update Upgrade in place.
|
|
18
|
-
metro --version | --help
|
|
19
|
-
|
|
20
|
-
Exit codes: 0 success · 1 usage · 2 config · 3 upstream
|
|
21
|
-
`;
|
|
22
|
-
const exitErr = (msg, code) => Object.assign(new Error(msg), { code });
|
|
23
|
-
const isJson = (f) => f.json === true;
|
|
24
|
-
const emit = (f, human, structured) => {
|
|
25
|
-
process.stdout.write(isJson(f) ? JSON.stringify(structured) + '\n' : human + '\n');
|
|
26
|
-
};
|
|
27
|
-
const maskToken = (t) => !t ? '' : t.length <= 8 ? '••••' : `${t.slice(0, 6)}…${t.slice(-2)}`;
|
|
28
|
-
const TOKEN_KEYS = { telegram: 'TELEGRAM_BOT_TOKEN', discord: 'DISCORD_BOT_TOKEN' };
|
|
29
|
-
function parseArgs(argv) {
|
|
30
|
-
const positional = [];
|
|
31
|
-
const flags = {};
|
|
32
|
-
for (let i = 0; i < argv.length; i++) {
|
|
33
|
-
const a = argv[i];
|
|
34
|
-
if (!a.startsWith('--')) {
|
|
35
|
-
positional.push(a);
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
const eq = a.indexOf('=');
|
|
39
|
-
if (eq !== -1) {
|
|
40
|
-
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
const key = a.slice(2);
|
|
44
|
-
const next = argv[i + 1];
|
|
45
|
-
if (next !== undefined && !next.startsWith('--')) {
|
|
46
|
-
flags[key] = next;
|
|
47
|
-
i++;
|
|
48
|
-
}
|
|
49
|
-
else
|
|
50
|
-
flags[key] = true;
|
|
51
|
-
}
|
|
52
|
-
return { positional, flags };
|
|
53
|
-
}
|
|
54
|
-
async function cmdSetup(positional, flags) {
|
|
55
|
-
const [sub, value] = positional;
|
|
56
|
-
if (!sub)
|
|
57
|
-
return cmdSetupStatus(flags);
|
|
58
|
-
if (sub === 'telegram' || sub === 'discord') {
|
|
59
|
-
if (!value)
|
|
60
|
-
throw new Error(`metro setup ${sub} <token> — token is required`);
|
|
61
|
-
const trimmed = value.trim();
|
|
62
|
-
let identity;
|
|
63
|
-
if (!flags['no-validate']) {
|
|
64
|
-
process.env[TOKEN_KEYS[sub]] = trimmed;
|
|
65
|
-
try {
|
|
66
|
-
identity = sub === 'telegram' ? `@${(await telegram.getMe()).username}` : (await discord.getMe()).username;
|
|
67
|
-
}
|
|
68
|
-
catch (err) {
|
|
69
|
-
delete process.env[TOKEN_KEYS[sub]];
|
|
70
|
-
throw exitErr(`token rejected by ${sub}: ${errMsg(err)} (use --no-validate to save anyway)`, 3);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
const env = readDotenv(CONFIG_ENV_FILE);
|
|
74
|
-
env[TOKEN_KEYS[sub]] = trimmed;
|
|
75
|
-
writeDotenv(CONFIG_ENV_FILE, env);
|
|
76
|
-
const human = `saved ${TOKEN_KEYS[sub]}${identity ? ` (verified as ${identity})` : ''} to ${CONFIG_ENV_FILE} (chmod 0600)`;
|
|
77
|
-
emit(flags, human, { ok: true, saved: TOKEN_KEYS[sub], verified_as: identity ?? null });
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
if (sub === 'clear') {
|
|
81
|
-
const target = value ?? 'all';
|
|
82
|
-
const env = readDotenv(CONFIG_ENV_FILE);
|
|
83
|
-
if (target === 'all') {
|
|
84
|
-
delete env.TELEGRAM_BOT_TOKEN;
|
|
85
|
-
delete env.DISCORD_BOT_TOKEN;
|
|
86
|
-
}
|
|
87
|
-
else if (target === 'telegram' || target === 'discord')
|
|
88
|
-
delete env[TOKEN_KEYS[target]];
|
|
89
|
-
else
|
|
90
|
-
throw new Error(`metro setup clear <telegram|discord|all> — got '${target}'`);
|
|
91
|
-
writeDotenv(CONFIG_ENV_FILE, env);
|
|
92
|
-
emit(flags, `cleared ${target === 'all' ? 'all metro tokens' : TOKEN_KEYS[target]}`, { ok: true, cleared: target });
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
throw new Error(`unknown setup subcommand '${sub}' (try: telegram, discord, clear)`);
|
|
96
|
-
}
|
|
97
|
-
async function cmdSetupStatus(flags) {
|
|
98
|
-
loadMetroEnv();
|
|
99
|
-
const tg = process.env.TELEGRAM_BOT_TOKEN ?? '';
|
|
100
|
-
const dc = process.env.DISCORD_BOT_TOKEN ?? '';
|
|
101
|
-
if (isJson(flags)) {
|
|
102
|
-
process.stdout.write(JSON.stringify({
|
|
103
|
-
version: pkg.version,
|
|
104
|
-
config_env_file: CONFIG_ENV_FILE,
|
|
105
|
-
tokens: { telegram: { set: !!tg, masked: maskToken(tg) }, discord: { set: !!dc, masked: maskToken(dc) } },
|
|
106
|
-
}) + '\n');
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
const cfgState = existsSync(CONFIG_ENV_FILE) ? '' : ' (not yet written)';
|
|
110
|
-
process.stdout.write(`metro ${pkg.version}\n\nconfig: ${CONFIG_ENV_FILE}${cfgState}\n\n` +
|
|
111
|
-
` TELEGRAM_BOT_TOKEN ${tg ? `set (${maskToken(tg)})` : 'not set'}\n` +
|
|
112
|
-
` DISCORD_BOT_TOKEN ${dc ? `set (${maskToken(dc)})` : 'not set'}\n\n`);
|
|
113
|
-
process.stdout.write(!tg && !dc
|
|
114
|
-
? 'Get started:\n 1. metro setup telegram <token> # https://t.me/BotFather\n metro setup discord <token> # https://discord.com/developers/applications\n 2. metro doctor\n 3. metro\n'
|
|
115
|
-
: 'Run `metro` to start the orchestrator, or `metro doctor` to verify.\n');
|
|
116
|
-
}
|
|
117
|
-
async function cmdDoctor(flags) {
|
|
118
|
-
loadMetroEnv();
|
|
119
|
-
const cfg = configuredPlatforms();
|
|
120
|
-
const checks = [{
|
|
121
|
-
name: 'tokens',
|
|
122
|
-
ok: cfg.telegram || cfg.discord,
|
|
123
|
-
detail: cfg.telegram || cfg.discord ? `loaded from ${existsSync(CONFIG_ENV_FILE) ? CONFIG_ENV_FILE : 'process env'}` : 'no platform configured — run `metro setup telegram|discord <token>`',
|
|
124
|
-
}];
|
|
125
|
-
for (const [p, getMe] of [['telegram', telegram.getMe], ['discord', discord.getMe]]) {
|
|
126
|
-
if (!cfg[p]) {
|
|
127
|
-
checks.push({ name: p, ok: null, detail: 'not configured' });
|
|
128
|
-
continue;
|
|
129
|
-
}
|
|
130
|
-
try {
|
|
131
|
-
const me = await getMe();
|
|
132
|
-
checks.push({ name: p, ok: true, detail: `getMe → ${p === 'telegram' ? '@' : ''}${me.username}` });
|
|
133
|
-
}
|
|
134
|
-
catch (err) {
|
|
135
|
-
checks.push({ name: p, ok: false, detail: errMsg(err) });
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
const lockFile = join(STATE_DIR, '.tail-lock');
|
|
139
|
-
if (!existsSync(lockFile))
|
|
140
|
-
checks.push({ name: 'orchestrator', ok: null, detail: 'not running' });
|
|
141
|
-
else
|
|
142
|
-
try {
|
|
143
|
-
const pid = Number(readFileSync(lockFile, 'utf8').trim());
|
|
144
|
-
if (!Number.isInteger(pid) || pid <= 0)
|
|
145
|
-
throw new Error('invalid pid');
|
|
146
|
-
process.kill(pid, 0);
|
|
147
|
-
checks.push({ name: 'orchestrator', ok: true, detail: `running (pid ${pid})` });
|
|
148
|
-
}
|
|
149
|
-
catch {
|
|
150
|
-
checks.push({ name: 'orchestrator', ok: null, detail: 'stale lockfile (will auto-reclaim on next start)' });
|
|
151
|
-
}
|
|
152
|
-
if (isJson(flags))
|
|
153
|
-
process.stdout.write(JSON.stringify({ checks }) + '\n');
|
|
154
|
-
else {
|
|
155
|
-
process.stdout.write('metro doctor\n\n');
|
|
156
|
-
for (const c of checks)
|
|
157
|
-
process.stdout.write(` ${c.ok === true ? '✓' : c.ok === false ? '✗' : '–'} ${c.name.padEnd(15)} ${c.detail}\n`);
|
|
158
|
-
process.stdout.write('\n');
|
|
159
|
-
}
|
|
160
|
-
if (checks.some(c => c.ok === false))
|
|
161
|
-
throw exitErr('one or more checks failed', 3);
|
|
162
|
-
}
|
|
163
|
-
async function cmdUpdate(flags) {
|
|
164
|
-
const tag = pkg.version.includes('-') ? 'beta' : 'latest';
|
|
165
|
-
const res = await fetch('https://registry.npmjs.org/@stage-labs/metro', { signal: AbortSignal.timeout(15_000) });
|
|
166
|
-
if (!res.ok)
|
|
167
|
-
throw new Error(`npm registry: ${res.status}`);
|
|
168
|
-
const latest = (await res.json())['dist-tags']?.[tag];
|
|
169
|
-
if (!latest)
|
|
170
|
-
throw new Error(`no '${tag}' dist-tag for @stage-labs/metro`);
|
|
171
|
-
if (latest === pkg.version)
|
|
172
|
-
return emit(flags, `already on ${pkg.version} (latest ${tag})`, { ok: true, current: pkg.version, latest, upgraded: false });
|
|
173
|
-
const argv1 = process.argv[1] ?? '';
|
|
174
|
-
const spec = `@stage-labs/metro@${tag}`;
|
|
175
|
-
const argv = argv1.includes('/.bun/') || argv1.includes('\\bun\\') ? ['bun', 'add', '-g', spec]
|
|
176
|
-
: argv1.includes('/pnpm/') || argv1.includes('\\pnpm\\') ? ['pnpm', 'add', '-g', spec]
|
|
177
|
-
: ['npm', 'install', '-g', spec];
|
|
178
|
-
if (isJson(flags))
|
|
179
|
-
process.stdout.write(JSON.stringify({ ok: true, current: pkg.version, latest, command: argv.join(' ') }) + '\n');
|
|
180
|
-
else
|
|
181
|
-
process.stdout.write(`metro ${pkg.version} → ${latest}\n$ ${argv.join(' ')}\n`);
|
|
182
|
-
await new Promise((resolve, reject) => {
|
|
183
|
-
const child = spawn(argv[0], argv.slice(1), { stdio: isJson(flags) ? 'ignore' : 'inherit' });
|
|
184
|
-
child.on('exit', code => code === 0 ? resolve() : reject(new Error(`${argv[0]} exited ${code}`)));
|
|
185
|
-
child.on('error', reject);
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
const COMMANDS = {
|
|
189
|
-
setup: cmdSetup,
|
|
190
|
-
doctor: (_, f) => cmdDoctor(f),
|
|
191
|
-
update: (_, f) => cmdUpdate(f),
|
|
192
|
-
};
|
|
193
|
-
async function main() {
|
|
194
|
-
const cmd = process.argv[2];
|
|
195
|
-
if (cmd === '--version' || cmd === '-v')
|
|
196
|
-
return void process.stdout.write(`${pkg.version}\n`);
|
|
197
|
-
if (cmd === '--help' || cmd === '-h')
|
|
198
|
-
return void process.stdout.write(USAGE);
|
|
199
|
-
if (!cmd) {
|
|
200
|
-
await import('./orchestrator.js');
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
const handler = COMMANDS[cmd];
|
|
204
|
-
if (!handler) {
|
|
205
|
-
process.stderr.write(`unknown command '${cmd}'\n\n${USAGE}`);
|
|
206
|
-
process.exit(1);
|
|
207
|
-
}
|
|
208
|
-
const { positional, flags } = parseArgs(process.argv.slice(3));
|
|
209
|
-
try {
|
|
210
|
-
await handler(positional, flags);
|
|
211
|
-
}
|
|
212
|
-
catch (err) {
|
|
213
|
-
const code = err.code;
|
|
214
|
-
if (isJson(flags))
|
|
215
|
-
process.stdout.write(JSON.stringify({ ok: false, error: errMsg(err), code: code ?? 1 }) + '\n');
|
|
216
|
-
else
|
|
217
|
-
process.stderr.write(`error: ${errMsg(err)}\n`);
|
|
218
|
-
process.exit(typeof code === 'number' ? code : 1);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
await main();
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/** Per-machine scope→{thread ids,last-used} cache. Keys: `discord:<id>` / `telegram:<chat>:<topic>`. */
|
|
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
|
-
const cacheFile = join(STATE_DIR, 'scopes.json');
|
|
7
|
-
function read() {
|
|
8
|
-
if (!existsSync(cacheFile))
|
|
9
|
-
return {};
|
|
10
|
-
try {
|
|
11
|
-
return JSON.parse(readFileSync(cacheFile, 'utf8'));
|
|
12
|
-
}
|
|
13
|
-
catch (err) {
|
|
14
|
-
log.warn({ err: errMsg(err), path: cacheFile }, 'scope cache read failed; treating as empty');
|
|
15
|
-
return {};
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
function write(cache) {
|
|
19
|
-
try {
|
|
20
|
-
writeFileSync(cacheFile, JSON.stringify(cache, null, 2));
|
|
21
|
-
}
|
|
22
|
-
catch (err) {
|
|
23
|
-
log.warn({ err: errMsg(err), path: cacheFile }, 'scope cache write failed');
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
function ensure(cache, scopeKey) {
|
|
27
|
-
if (!cache[scopeKey])
|
|
28
|
-
cache[scopeKey] = { createdAt: new Date().toISOString(), agents: {} };
|
|
29
|
-
if (!cache[scopeKey].agents)
|
|
30
|
-
cache[scopeKey].agents = {};
|
|
31
|
-
return cache[scopeKey];
|
|
32
|
-
}
|
|
33
|
-
export function getAgentThread(scopeKey, kind) {
|
|
34
|
-
return read()[scopeKey]?.agents?.[kind];
|
|
35
|
-
}
|
|
36
|
-
export function setAgentThread(scopeKey, kind, threadId) {
|
|
37
|
-
const cache = read();
|
|
38
|
-
const entry = ensure(cache, scopeKey);
|
|
39
|
-
entry.agents[kind] = threadId;
|
|
40
|
-
entry.lastAgent = kind;
|
|
41
|
-
write(cache);
|
|
42
|
-
}
|
|
43
|
-
export function getLastAgent(scopeKey) {
|
|
44
|
-
return read()[scopeKey]?.lastAgent;
|
|
45
|
-
}
|
|
46
|
-
export function setLastAgent(scopeKey, kind) {
|
|
47
|
-
const cache = read();
|
|
48
|
-
if (!cache[scopeKey])
|
|
49
|
-
return;
|
|
50
|
-
cache[scopeKey].lastAgent = kind;
|
|
51
|
-
write(cache);
|
|
52
|
-
}
|
|
53
|
-
export function setLastSeen(scopeKey, messageId) {
|
|
54
|
-
const cache = read();
|
|
55
|
-
if (!cache[scopeKey])
|
|
56
|
-
return;
|
|
57
|
-
cache[scopeKey].lastSeenMessageId = messageId;
|
|
58
|
-
write(cache);
|
|
59
|
-
}
|
|
60
|
-
export function listScopes() {
|
|
61
|
-
return Object.entries(read()).map(([scopeKey, entry]) => ({ scopeKey, entry }));
|
|
62
|
-
}
|
|
63
|
-
export const discordScopeKey = (threadChannelId) => `discord:${threadChannelId}`;
|
|
64
|
-
export const discordChannelFromScopeKey = (scopeKey) => scopeKey.startsWith('discord:') ? scopeKey.slice('discord:'.length) : null;
|
|
65
|
-
export const telegramScopeKey = (chatId, topicId) => `telegram:${chatId}:${topicId ?? 'main'}`;
|
|
@@ -1,209 +0,0 @@
|
|
|
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
|
-
}
|