@stage-labs/metro 0.1.0-beta.2 → 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 +70 -31
- package/dist/agents/claude.js +207 -0
- package/dist/agents/codex.js +207 -0
- package/dist/agents/types.js +2 -0
- package/dist/channels/discord.js +36 -80
- package/dist/channels/telegram.js +136 -84
- package/dist/cli.js +68 -420
- package/dist/helpers/scope-cache.js +65 -0
- 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 +208 -0
- package/dist/paths.js +52 -29
- package/package.json +2 -3
- package/dist/lib/address.js +0 -21
- package/dist/lib/codex-rc.js +0 -274
- package/dist/lib/dotenv.js +0 -31
- package/dist/tail.js +0 -161
- package/skills/metro/SKILL.md +0 -122
|
@@ -0,0 +1,65 @@
|
|
|
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'}`;
|
|
@@ -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 = { '&': '&', '<': '<', '>': '>' };
|
|
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)((?:>\s?[^\n]*\n?)+)/g, (_m, lead, block) => {
|
|
35
|
+
const inner = block.replace(/^>\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
|
-
|
|
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) => {
|
|
@@ -0,0 +1,208 @@
|
|
|
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();
|
package/dist/paths.js
CHANGED
|
@@ -1,41 +1,40 @@
|
|
|
1
|
-
import { mkdirSync } from 'node:fs';
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { loadDotenvIntoProcess } from './lib/dotenv.js';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
5
4
|
import { log } from './log.js';
|
|
6
|
-
// Lockfile, typing-stop signals, attachment cache. Override with METRO_STATE_DIR.
|
|
7
5
|
export const STATE_DIR = process.env.METRO_STATE_DIR ?? join(homedir(), '.cache', 'metro');
|
|
8
6
|
mkdirSync(STATE_DIR, { recursive: true });
|
|
9
|
-
|
|
10
|
-
// or the standard $XDG_CONFIG_HOME.
|
|
11
|
-
const CONFIG_DIR = process.env.METRO_CONFIG_DIR ??
|
|
12
|
-
join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'metro');
|
|
7
|
+
const CONFIG_DIR = process.env.METRO_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'metro');
|
|
13
8
|
export const CONFIG_ENV_FILE = join(CONFIG_DIR, '.env');
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return
|
|
9
|
+
const LINE_RE = /^\s*([A-Za-z_]\w*)\s*=\s*(.*?)\s*$/;
|
|
10
|
+
const QUOTED_RE = /^(['"])(.*)\1$/;
|
|
11
|
+
export function readDotenv(path) {
|
|
12
|
+
if (!existsSync(path))
|
|
13
|
+
return {};
|
|
14
|
+
const out = {};
|
|
15
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
16
|
+
const m = line.match(LINE_RE);
|
|
17
|
+
if (m)
|
|
18
|
+
out[m[1]] = m[2].replace(QUOTED_RE, '$2');
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
26
21
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
22
|
+
export function writeDotenv(path, env) {
|
|
23
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
24
|
+
writeFileSync(path, Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n') + '\n');
|
|
25
|
+
chmodSync(path, 0o600);
|
|
26
|
+
}
|
|
27
|
+
/** Precedence: process.env > cwd/.env > $METRO_CONFIG_DIR/.env. First-set wins. */
|
|
30
28
|
export function loadMetroEnv() {
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
for (const path of [join(process.cwd(), '.env'), CONFIG_ENV_FILE]) {
|
|
30
|
+
for (const [k, v] of Object.entries(readDotenv(path))) {
|
|
31
|
+
if (process.env[k] === undefined)
|
|
32
|
+
process.env[k] = v;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
33
35
|
}
|
|
34
36
|
export function configuredPlatforms() {
|
|
35
|
-
return {
|
|
36
|
-
telegram: !!process.env.TELEGRAM_BOT_TOKEN,
|
|
37
|
-
discord: !!process.env.DISCORD_BOT_TOKEN,
|
|
38
|
-
};
|
|
37
|
+
return { telegram: !!process.env.TELEGRAM_BOT_TOKEN, discord: !!process.env.DISCORD_BOT_TOKEN };
|
|
39
38
|
}
|
|
40
39
|
export function requireConfiguredPlatform(p) {
|
|
41
40
|
if (p.telegram || p.discord)
|
|
@@ -43,3 +42,27 @@ export function requireConfiguredPlatform(p) {
|
|
|
43
42
|
log.fatal('no platforms configured — run `metro setup telegram <token>` or `metro setup discord <token>`');
|
|
44
43
|
process.exit(2);
|
|
45
44
|
}
|
|
45
|
+
/** Singleton pidfile. Exits if another instance owns it; reclaims stale locks. */
|
|
46
|
+
export function acquireLock(lockFile) {
|
|
47
|
+
if (existsSync(lockFile)) {
|
|
48
|
+
const pid = Number(readFileSync(lockFile, 'utf8').trim());
|
|
49
|
+
try {
|
|
50
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
51
|
+
process.kill(pid, 0);
|
|
52
|
+
log.info({ pid }, 'another `metro` is running; exiting');
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch { /* stale */ }
|
|
57
|
+
try {
|
|
58
|
+
unlinkSync(lockFile);
|
|
59
|
+
}
|
|
60
|
+
catch { /* ignore */ }
|
|
61
|
+
}
|
|
62
|
+
writeFileSync(lockFile, String(process.pid));
|
|
63
|
+
process.on('exit', () => { try {
|
|
64
|
+
if (readFileSync(lockFile, 'utf8').trim() === String(process.pid))
|
|
65
|
+
unlinkSync(lockFile);
|
|
66
|
+
}
|
|
67
|
+
catch { /* ignore */ } });
|
|
68
|
+
}
|