@stage-labs/metro 0.1.0-beta.259 → 0.1.0-beta.260
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/dist/agent.js +128 -0
- package/dist/background.js +19 -1
- package/dist/claude.js +11 -7
- package/dist/cli.js +8 -0
- package/dist/route.js +17 -9
- package/dist/runtime-install.js +3 -2
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/node_modules/@metro-labs/core/src/ids.ts +5 -1
- package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +103 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/agent-fs.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +17 -120
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-activity.ts +17 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-process.ts +50 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +45 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +48 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +90 -31
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +161 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-state.ts +18 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +21 -32
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +12 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +43 -5
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +15 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +1 -6
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +1 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +52 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/endpoints.ts +7 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/events.ts +121 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/ids.ts +28 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/is-record.ts +2 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/lines.ts +78 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/log.ts +42 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/protocol.ts +227 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +103 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/secure-fs.ts +74 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/station-names.ts +14 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-files.ts +41 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-store.ts +83 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/attachments.ts +195 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail-text.ts +72 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail-trust.ts +29 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail.ts +212 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/messaging-normalize.ts +16 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/oauth.ts +126 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/profile.ts +58 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/sender-profile.ts +64 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/station-runtime.ts +59 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-boot.ts +42 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-events.ts +84 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/types.ts +125 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/str.ts +5 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/tickets.ts +37 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/train-error.ts +53 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/version.ts +2 -0
- package/runtime/sdk-runner/runner.json +10 -0
- package/runtime/sdk-runner/src/activity-tasks.ts +142 -0
- package/runtime/sdk-runner/src/activity.ts +293 -0
- package/runtime/sdk-runner/src/app.ts +77 -0
- package/runtime/sdk-runner/src/approvals.ts +28 -0
- package/runtime/sdk-runner/src/channel-text.ts +26 -0
- package/runtime/sdk-runner/src/config.ts +50 -0
- package/runtime/sdk-runner/src/inbox.ts +94 -0
- package/runtime/sdk-runner/src/link.ts +130 -0
- package/runtime/sdk-runner/src/main.ts +44 -0
- package/runtime/sdk-runner/src/rules.ts +11 -0
- package/runtime/sdk-runner/src/runner.ts +251 -0
- package/runtime/sdk-runner/src/session-store.ts +71 -0
- package/runtime/sdk-runner/src/session-watch.ts +111 -0
- package/runtime/sdk-runner/src/singleton.ts +15 -0
- package/runtime/sdk-runner/src/speech.ts +79 -0
- package/runtime/sdk-runner/src/tool-proxy.ts +40 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export class SignInError extends Error {}
|
|
4
|
+
|
|
5
|
+
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
6
|
+
|
|
7
|
+
export type OAuthBody = Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
export interface Tokens {
|
|
10
|
+
accessToken: string;
|
|
11
|
+
refreshToken: string;
|
|
12
|
+
expiresAt: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Pkce {
|
|
16
|
+
verifier: string;
|
|
17
|
+
challenge: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const RENEW_BEFORE_MS = 5 * 60_000;
|
|
21
|
+
const MAILBOX_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
22
|
+
|
|
23
|
+
const text = (v: unknown): string => (typeof v === 'string' ? v : '');
|
|
24
|
+
|
|
25
|
+
export const seconds = (v: unknown, fallback: number): number =>
|
|
26
|
+
typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback;
|
|
27
|
+
|
|
28
|
+
export function envValue(name: string, fallback: string): string {
|
|
29
|
+
const value = (process.env[name] ?? '').trim();
|
|
30
|
+
return value === '' ? fallback : value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const envUrl = (name: string, fallback: string): string => envValue(name, fallback).replace(/\/+$/, '');
|
|
34
|
+
|
|
35
|
+
export function pkcePair(verifier = randomBytes(32).toString('base64url')): Pkce {
|
|
36
|
+
return { verifier, challenge: createHash('sha256').update(verifier).digest('base64url') };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const newState = (): string => randomBytes(24).toString('base64url');
|
|
40
|
+
|
|
41
|
+
export function parseMailbox(value: unknown): string | null {
|
|
42
|
+
const mailbox = (typeof value === 'string' ? value.trim() : '').toLowerCase();
|
|
43
|
+
if (mailbox === '') return null;
|
|
44
|
+
if (!MAILBOX_RE.test(mailbox))
|
|
45
|
+
throw new SignInError('Type the whole address of the mailbox, like andy@company.com, or leave it blank.');
|
|
46
|
+
return mailbox;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function postForm(url: string, fields: Record<string, string>, fetchImpl: FetchLike, provider: string): Promise<{ status: number; body: OAuthBody }> {
|
|
50
|
+
let res: Response;
|
|
51
|
+
try {
|
|
52
|
+
res = await fetchImpl(url, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
55
|
+
body: new URLSearchParams(fields).toString(),
|
|
56
|
+
});
|
|
57
|
+
} catch (err) {
|
|
58
|
+
throw new SignInError(`Metro could not reach ${provider}: ${err instanceof Error ? err.message : String(err)}`);
|
|
59
|
+
}
|
|
60
|
+
const raw: unknown = await res.json().catch(() => ({}));
|
|
61
|
+
return { status: res.status, body: typeof raw === 'object' && raw !== null ? (raw as OAuthBody) : {} };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function tokensOf(body: OAuthBody, now: number, provider: string, previous?: string): Tokens {
|
|
65
|
+
const accessToken = text(body.access_token);
|
|
66
|
+
const refreshToken = text(body.refresh_token) || (previous ?? '');
|
|
67
|
+
if (accessToken === '' || refreshToken === '') throw new SignInError(`${provider} answered without the tokens Metro needs.`);
|
|
68
|
+
return { accessToken, refreshToken, expiresAt: now + seconds(body.expires_in, 3600) * 1000 };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function tokenStateOf(saved: OAuthBody, seed: Partial<Tokens>): Tokens {
|
|
72
|
+
const fromFile = text(saved.refreshToken) !== '';
|
|
73
|
+
return {
|
|
74
|
+
refreshToken: fromFile ? text(saved.refreshToken) : text(seed.refreshToken),
|
|
75
|
+
accessToken: fromFile ? text(saved.accessToken) : text(seed.accessToken),
|
|
76
|
+
expiresAt: fromFile ? Number(saved.expiresAt) || 0 : Number(seed.expiresAt) || 0,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class TokenKeeper {
|
|
81
|
+
private refreshing: Promise<string> | null = null;
|
|
82
|
+
|
|
83
|
+
constructor(
|
|
84
|
+
private readonly state: Tokens,
|
|
85
|
+
private readonly renewWith: (refreshToken: string) => Promise<Tokens>,
|
|
86
|
+
private readonly renewed: () => void,
|
|
87
|
+
) {}
|
|
88
|
+
|
|
89
|
+
token(force = false): Promise<string> {
|
|
90
|
+
const fresh = this.state.accessToken !== '' && this.state.expiresAt - RENEW_BEFORE_MS > Date.now();
|
|
91
|
+
if (fresh && !force) return Promise.resolve(this.state.accessToken);
|
|
92
|
+
this.refreshing ??= this.renew().finally(() => {
|
|
93
|
+
this.refreshing = null;
|
|
94
|
+
});
|
|
95
|
+
return this.refreshing;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private async renew(): Promise<string> {
|
|
99
|
+
Object.assign(this.state, await this.renewWith(this.state.refreshToken));
|
|
100
|
+
this.renewed();
|
|
101
|
+
return this.state.accessToken;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface BrowserFlow<R> {
|
|
106
|
+
authorize: (challenge: string, state: string) => string;
|
|
107
|
+
redeem: (code: string, verifier: string) => Promise<R>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class BrowserSignIn<R> {
|
|
111
|
+
readonly state = newState();
|
|
112
|
+
readonly authorizeUrl: string;
|
|
113
|
+
private readonly pkce = pkcePair();
|
|
114
|
+
private used = false;
|
|
115
|
+
|
|
116
|
+
constructor(private readonly flow: BrowserFlow<R>) {
|
|
117
|
+
this.authorizeUrl = flow.authorize(this.pkce.challenge, this.state);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async finish(code: string, state: string): Promise<R> {
|
|
121
|
+
if (state !== this.state) throw new SignInError('This sign-in link belongs to another attempt. Start again from the Channels page.');
|
|
122
|
+
if (this.used) throw new SignInError('This sign-in was already used. Start again from the Channels page.');
|
|
123
|
+
this.used = true;
|
|
124
|
+
return this.flow.redeem(code, this.pkce.verifier);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { TrainError } from '../train-error.js';
|
|
2
|
+
|
|
3
|
+
export const PROFILE_FIELDS = ['name', 'bio', 'avatar'] as const;
|
|
4
|
+
export type ProfileField = (typeof PROFILE_FIELDS)[number];
|
|
5
|
+
|
|
6
|
+
export const NAME_MAX = 64;
|
|
7
|
+
export const BIO_MAX = 512;
|
|
8
|
+
|
|
9
|
+
export interface ProfileAvatar {
|
|
10
|
+
path: string;
|
|
11
|
+
mime: string;
|
|
12
|
+
name: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ProfileChange {
|
|
16
|
+
name?: string;
|
|
17
|
+
bio?: string;
|
|
18
|
+
avatar?: ProfileAvatar;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ProfileApplied {
|
|
22
|
+
account: string;
|
|
23
|
+
applied: ProfileField[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const text = (value: unknown, field: string, max: number): string | undefined => {
|
|
27
|
+
if (value === undefined) return undefined;
|
|
28
|
+
if (typeof value !== 'string') throw new TrainError('bad_request', `${field} must be a string`);
|
|
29
|
+
const trimmed = value.trim();
|
|
30
|
+
if (trimmed === '') throw new TrainError('bad_request', `${field} cannot be empty`);
|
|
31
|
+
if (trimmed.length > max) throw new TrainError('bad_request', `${field} is over ${String(max)} characters`);
|
|
32
|
+
return trimmed;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function avatarOf(value: unknown): ProfileAvatar | undefined {
|
|
36
|
+
if (value === undefined) return undefined;
|
|
37
|
+
const raw = (value ?? {}) as Record<string, unknown>;
|
|
38
|
+
if (typeof raw.path !== 'string' || raw.path === '') throw new TrainError('bad_request', 'avatar needs a local file');
|
|
39
|
+
return { path: raw.path, mime: typeof raw.mime === 'string' ? raw.mime : 'application/octet-stream', name: typeof raw.name === 'string' ? raw.name : 'avatar' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseProfileChange(args: Record<string, unknown>): ProfileChange {
|
|
43
|
+
const change: ProfileChange = {};
|
|
44
|
+
const name = text(args.name, 'name', NAME_MAX);
|
|
45
|
+
const bio = text(args.bio, 'bio', BIO_MAX);
|
|
46
|
+
const avatar = avatarOf(args.avatar);
|
|
47
|
+
if (name !== undefined) change.name = name;
|
|
48
|
+
if (bio !== undefined) change.bio = bio;
|
|
49
|
+
if (avatar !== undefined) change.avatar = avatar;
|
|
50
|
+
if (Object.keys(change).length === 0) throw new TrainError('bad_request', 'set_profile needs at least one of name, bio, avatar');
|
|
51
|
+
return change;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const fieldsOf = (change: ProfileChange): ProfileField[] => PROFILE_FIELDS.filter((f) => change[f] !== undefined);
|
|
55
|
+
|
|
56
|
+
export const assertImage = (avatar: ProfileAvatar): void => {
|
|
57
|
+
if (!avatar.mime.startsWith('image/')) throw new TrainError('bad_request', `avatar must be an image, not ${avatar.mime}`);
|
|
58
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export interface SenderProfile {
|
|
2
|
+
id: string;
|
|
3
|
+
name?: string;
|
|
4
|
+
display_name?: string;
|
|
5
|
+
about?: string;
|
|
6
|
+
avatar?: string;
|
|
7
|
+
address?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ProfileCacheOptions {
|
|
11
|
+
ttlMs?: number;
|
|
12
|
+
max?: number;
|
|
13
|
+
onError: (key: string, err: unknown) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ProfileCache<T> {
|
|
17
|
+
get(key: string): Promise<T | null>;
|
|
18
|
+
peek(key: string): T | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const TTL_MS = 10 * 60_000;
|
|
22
|
+
const MAX = 2000;
|
|
23
|
+
|
|
24
|
+
interface Held<T> {
|
|
25
|
+
at: number;
|
|
26
|
+
value: Promise<T | null>;
|
|
27
|
+
settled: T | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const nonEmpty = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined);
|
|
31
|
+
|
|
32
|
+
export function makeProfileCache<T>(lookup: (key: string) => Promise<T | null>, opts: ProfileCacheOptions): ProfileCache<T> {
|
|
33
|
+
const ttl = opts.ttlMs ?? TTL_MS;
|
|
34
|
+
const max = opts.max ?? MAX;
|
|
35
|
+
const cache = new Map<string, Held<T>>();
|
|
36
|
+
const fresh = (key: string): Held<T> | undefined => {
|
|
37
|
+
const held = cache.get(key);
|
|
38
|
+
return held !== undefined && Date.now() - held.at < ttl ? held : undefined;
|
|
39
|
+
};
|
|
40
|
+
const start = (key: string): Held<T> => {
|
|
41
|
+
const held: Held<T> = { at: Date.now(), settled: null, value: Promise.resolve(null) };
|
|
42
|
+
held.value = lookup(key)
|
|
43
|
+
.then((value) => {
|
|
44
|
+
held.settled = value;
|
|
45
|
+
return value;
|
|
46
|
+
})
|
|
47
|
+
.catch((err: unknown) => {
|
|
48
|
+
opts.onError(key, err);
|
|
49
|
+
cache.delete(key);
|
|
50
|
+
return null;
|
|
51
|
+
});
|
|
52
|
+
cache.set(key, held);
|
|
53
|
+
while (cache.size > max) {
|
|
54
|
+
const oldest = cache.keys().next();
|
|
55
|
+
if (oldest.done) break;
|
|
56
|
+
cache.delete(oldest.value);
|
|
57
|
+
}
|
|
58
|
+
return held;
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
get: (key) => (fresh(key) ?? start(key)).value,
|
|
62
|
+
peek: (key) => (fresh(key) ?? start(key)).settled,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { serializeTrainError, type TrainErrorInfo } from '../train-error.js';
|
|
2
|
+
import type { Normalize } from './messaging-normalize.js';
|
|
3
|
+
|
|
4
|
+
export const emit = (e: unknown): void =>
|
|
5
|
+
void process.stdout.write(JSON.stringify(e) + '\n');
|
|
6
|
+
|
|
7
|
+
export const respond = (
|
|
8
|
+
id: string,
|
|
9
|
+
body: { result?: unknown; error?: string; errorInfo?: TrainErrorInfo },
|
|
10
|
+
): void =>
|
|
11
|
+
void process.stdout.write(
|
|
12
|
+
JSON.stringify({ op: 'response', id, ...body }) + '\n',
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
export { mintId } from '../ids.js';
|
|
16
|
+
|
|
17
|
+
type Args = Record<string, unknown>;
|
|
18
|
+
|
|
19
|
+
export interface CallMsg {
|
|
20
|
+
op: 'call';
|
|
21
|
+
id: string;
|
|
22
|
+
action: string;
|
|
23
|
+
args: Args;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type StationHandler = (id: string, args: Args) => void | Promise<void>;
|
|
27
|
+
|
|
28
|
+
export interface StationConfig {
|
|
29
|
+
handlers: Record<string, StationHandler>;
|
|
30
|
+
normalize?: Normalize;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const lineTag = (args: Args): string =>
|
|
34
|
+
typeof args.line === 'string' ? args.line : '?';
|
|
35
|
+
|
|
36
|
+
export function makeStation({ handlers, normalize }: StationConfig) {
|
|
37
|
+
const known = Object.keys(handlers).join(', ');
|
|
38
|
+
return async function handleCall(msg: CallMsg): Promise<void> {
|
|
39
|
+
const { id } = msg;
|
|
40
|
+
let action = msg.action;
|
|
41
|
+
let args: Args = msg.args;
|
|
42
|
+
try {
|
|
43
|
+
if (normalize && !handlers[action]) ({ action, args } = normalize(action, args));
|
|
44
|
+
const handler = handlers[action];
|
|
45
|
+
if (!handler) {
|
|
46
|
+
respond(id, { error: `unknown action '${action}' (have: ${known})` });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
await handler(id, args);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
const info = serializeTrainError(err);
|
|
52
|
+
emit({
|
|
53
|
+
op: 'log',
|
|
54
|
+
text: `call ${action} FAILED (line=${lineTag(args)}): ${info.error}`,
|
|
55
|
+
});
|
|
56
|
+
respond(id, info);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { errMsg } from '../log.js';
|
|
2
|
+
import { readCalls } from '../protocol.js';
|
|
3
|
+
import { TrainError } from '../train-error.js';
|
|
4
|
+
import type { CallMsg } from './station-runtime.js';
|
|
5
|
+
|
|
6
|
+
export function announceAccounts(station: string, ids: Iterable<string>): void {
|
|
7
|
+
const list = [...ids];
|
|
8
|
+
if (list.length === 0) {
|
|
9
|
+
process.stderr.write(`${station}: no accounts booted, exiting\n`);
|
|
10
|
+
process.exit(2);
|
|
11
|
+
}
|
|
12
|
+
process.stderr.write(`${station} train ready — ${String(list.length)} account(s): ${list.join(', ')}\n`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ClientTrain<A extends { id: string }, C> {
|
|
16
|
+
station: string;
|
|
17
|
+
accounts: Map<string, A>;
|
|
18
|
+
loadAccounts: () => A[];
|
|
19
|
+
createClient: (account: A) => C;
|
|
20
|
+
startInbound: (client: C) => Promise<void>;
|
|
21
|
+
makeHandleCall: (clientFor: (accountId: string) => C) => (msg: CallMsg) => Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function runClientTrain<A extends { id: string }, C>(train: ClientTrain<A, C>): void {
|
|
25
|
+
const clients = new Map<string, C>();
|
|
26
|
+
const clientFor = (accountId: string): C => {
|
|
27
|
+
const known = clients.get(accountId);
|
|
28
|
+
if (known !== undefined) return known;
|
|
29
|
+
const account = train.accounts.get(accountId);
|
|
30
|
+
if (!account) throw new TrainError('not_implemented', `unknown account '${accountId}'`);
|
|
31
|
+
const client = train.createClient(account);
|
|
32
|
+
clients.set(accountId, client);
|
|
33
|
+
return client;
|
|
34
|
+
};
|
|
35
|
+
readCalls(train.station, train.makeHandleCall(clientFor));
|
|
36
|
+
for (const cfg of train.loadAccounts()) train.accounts.set(cfg.id, cfg);
|
|
37
|
+
for (const id of train.accounts.keys())
|
|
38
|
+
train.startInbound(clientFor(id)).catch((err: unknown) => {
|
|
39
|
+
process.stderr.write(`${train.station}[${id}] inbound failed: ${errMsg(err)}\n`);
|
|
40
|
+
});
|
|
41
|
+
announceAccounts(train.station, train.accounts.keys());
|
|
42
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { mintId } from '../ids.js';
|
|
2
|
+
import { emit } from './station-runtime.js';
|
|
3
|
+
|
|
4
|
+
type Fields = Record<string, unknown>;
|
|
5
|
+
type Out = (e: unknown) => void;
|
|
6
|
+
|
|
7
|
+
export const selfUri = (): string => process.env.METRO_SELF_URI ?? 'metro://user';
|
|
8
|
+
|
|
9
|
+
export function emitInbound(account: string, e: Fields, out: Out = emit): void {
|
|
10
|
+
out({
|
|
11
|
+
...e,
|
|
12
|
+
payload: { ...(e.payload as Fields | undefined), account },
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AttachmentAt {
|
|
17
|
+
station: string;
|
|
18
|
+
account: string;
|
|
19
|
+
line: string;
|
|
20
|
+
forId: string;
|
|
21
|
+
index: number;
|
|
22
|
+
from?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SavedFile {
|
|
26
|
+
path: string;
|
|
27
|
+
mime?: string;
|
|
28
|
+
name?: string;
|
|
29
|
+
bytes?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function attachmentEvent(at: AttachmentAt, text: string, payload: Fields): Fields {
|
|
33
|
+
return {
|
|
34
|
+
id: mintId(),
|
|
35
|
+
ts: new Date().toISOString(),
|
|
36
|
+
station: at.station,
|
|
37
|
+
line: at.line,
|
|
38
|
+
from: at.from ?? selfUri(),
|
|
39
|
+
text,
|
|
40
|
+
payload: { account: at.account, attachmentFor: at.forId, index: at.index, ...payload },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const attachmentSavedEvent = (at: AttachmentAt & { saved: SavedFile; extra?: Fields }): Fields =>
|
|
45
|
+
attachmentEvent(at, `📎 saved: ${at.saved.path}`, {
|
|
46
|
+
contentType: 'attachmentSaved',
|
|
47
|
+
...(at.saved.bytes === undefined ? {} : { size: at.saved.bytes }),
|
|
48
|
+
...at.extra,
|
|
49
|
+
attachmentPath: at.saved.path,
|
|
50
|
+
mime: at.saved.mime,
|
|
51
|
+
name: at.saved.name,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export const attachmentFailedEvent = (at: AttachmentAt & { reason: string; extra?: Fields }): Fields =>
|
|
55
|
+
attachmentEvent(at, `📎 not fetched: ${at.reason}`, {
|
|
56
|
+
contentType: 'attachmentFailed',
|
|
57
|
+
...at.extra,
|
|
58
|
+
reason: at.reason,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const reasonOf = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
|
62
|
+
|
|
63
|
+
export function reportAttachment(
|
|
64
|
+
save: Promise<SavedFile>,
|
|
65
|
+
at: AttachmentAt,
|
|
66
|
+
extra: { saved?: Fields; failed?: Fields } = {},
|
|
67
|
+
out: Out = emit,
|
|
68
|
+
): void {
|
|
69
|
+
const tag = `${at.station}[${at.account}]`;
|
|
70
|
+
save
|
|
71
|
+
.then(
|
|
72
|
+
(saved) => {
|
|
73
|
+
out(attachmentSavedEvent({ ...at, saved, extra: extra.saved }));
|
|
74
|
+
},
|
|
75
|
+
(err: unknown) => {
|
|
76
|
+
const reason = reasonOf(err);
|
|
77
|
+
process.stderr.write(`${tag} attachment save failed: ${reason}\n`);
|
|
78
|
+
out(attachmentFailedEvent({ ...at, reason, extra: extra.failed }));
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
.catch((err: unknown) => {
|
|
82
|
+
process.stderr.write(`${tag} attachment event not emitted: ${reasonOf(err)}\n`);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { ProfileField } from './profile.js';
|
|
2
|
+
|
|
3
|
+
export type Verb =
|
|
4
|
+
| 'send'
|
|
5
|
+
| 'reply'
|
|
6
|
+
| 'react'
|
|
7
|
+
| 'unreact'
|
|
8
|
+
| 'edit'
|
|
9
|
+
| 'delete'
|
|
10
|
+
| 'read'
|
|
11
|
+
| 'typing';
|
|
12
|
+
|
|
13
|
+
export interface ToolResult {
|
|
14
|
+
content: { type: 'text'; text: string }[];
|
|
15
|
+
isError?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CanonicalAttachment {
|
|
19
|
+
path?: string;
|
|
20
|
+
url?: string;
|
|
21
|
+
data?: string;
|
|
22
|
+
upload?: string;
|
|
23
|
+
mime?: string;
|
|
24
|
+
name?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ToolContext {
|
|
28
|
+
call(
|
|
29
|
+
action: string,
|
|
30
|
+
args: Record<string, unknown>,
|
|
31
|
+
): Promise<{ result: unknown }>;
|
|
32
|
+
ok(text: string): ToolResult;
|
|
33
|
+
okJson(value: unknown): ToolResult;
|
|
34
|
+
err(text: string): ToolResult;
|
|
35
|
+
readFile(path: string): Promise<Buffer>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type ToolGroup = 'read' | 'write';
|
|
39
|
+
|
|
40
|
+
export interface StationTool {
|
|
41
|
+
name: string;
|
|
42
|
+
description: string;
|
|
43
|
+
inputSchema: Record<string, unknown>;
|
|
44
|
+
group?: ToolGroup;
|
|
45
|
+
destructive?: boolean;
|
|
46
|
+
handle(args: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface MetroMember {
|
|
50
|
+
id: string;
|
|
51
|
+
name?: string;
|
|
52
|
+
display_name?: string;
|
|
53
|
+
address?: string;
|
|
54
|
+
roles?: string[];
|
|
55
|
+
is_admin?: boolean;
|
|
56
|
+
is_bot?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface MemberCapability {
|
|
60
|
+
supported: boolean;
|
|
61
|
+
complete: boolean;
|
|
62
|
+
reason?: string;
|
|
63
|
+
total?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface MemberList {
|
|
67
|
+
members: MetroMember[];
|
|
68
|
+
capability: MemberCapability;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type GroupOp =
|
|
72
|
+
| 'create_group'
|
|
73
|
+
| 'add_members'
|
|
74
|
+
| 'remove_members'
|
|
75
|
+
| 'invite_link';
|
|
76
|
+
|
|
77
|
+
export type MemberOutcomeStatus = 'added' | 'invited' | 'removed' | 'failed';
|
|
78
|
+
|
|
79
|
+
export interface MemberOutcome {
|
|
80
|
+
id: string;
|
|
81
|
+
status: MemberOutcomeStatus;
|
|
82
|
+
reason?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface GroupCapability {
|
|
86
|
+
supported: boolean;
|
|
87
|
+
reason?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface GroupResult {
|
|
91
|
+
capability: GroupCapability;
|
|
92
|
+
line?: string;
|
|
93
|
+
id?: string;
|
|
94
|
+
name?: string;
|
|
95
|
+
members?: MemberOutcome[];
|
|
96
|
+
inviteLink?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type ReadFilter = 'account' | 'query' | 'from' | 'until' | 'unread_only' | 'message_id';
|
|
100
|
+
|
|
101
|
+
export type AttachmentMode = 'canonical' | 'native' | 'none';
|
|
102
|
+
|
|
103
|
+
export interface Station {
|
|
104
|
+
name: string;
|
|
105
|
+
hasAccounts: boolean;
|
|
106
|
+
hasTrain: boolean;
|
|
107
|
+
messageVerbs: ReadonlySet<Verb>;
|
|
108
|
+
groupOps?: ReadonlySet<GroupOp>;
|
|
109
|
+
resolvesSenders?: boolean;
|
|
110
|
+
profileFields?: ReadonlySet<ProfileField>;
|
|
111
|
+
claimsName?: boolean;
|
|
112
|
+
readsProfiles?: boolean;
|
|
113
|
+
readFilters?: ReadonlySet<ReadFilter>;
|
|
114
|
+
approvals?: boolean;
|
|
115
|
+
sendsFrames?: boolean;
|
|
116
|
+
attachmentMode: AttachmentMode;
|
|
117
|
+
forget?: (accountId: string) => void;
|
|
118
|
+
forgetExcept?: (keptAccountIds: readonly string[]) => void;
|
|
119
|
+
sendAttachments?(
|
|
120
|
+
line: string,
|
|
121
|
+
atts: CanonicalAttachment[],
|
|
122
|
+
ctx: ToolContext,
|
|
123
|
+
): Promise<string[]>;
|
|
124
|
+
tools: StationTool[];
|
|
125
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export interface TicketStore<T> {
|
|
4
|
+
mint: (value: T, now?: number) => { ticket: string; expiresAt: number };
|
|
5
|
+
take: (ticket: string, now?: number) => T | undefined;
|
|
6
|
+
size: (now?: number) => number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function ticketStore<T>(ttlMs: number, max: number): TicketStore<T> {
|
|
10
|
+
const open = new Map<string, { value: T; expiresAt: number }>();
|
|
11
|
+
const prune = (now: number): void => {
|
|
12
|
+
for (const [ticket, entry] of open) if (entry.expiresAt <= now) open.delete(ticket);
|
|
13
|
+
};
|
|
14
|
+
return {
|
|
15
|
+
mint(value, now = Date.now()) {
|
|
16
|
+
prune(now);
|
|
17
|
+
while (open.size >= max) {
|
|
18
|
+
const oldest = open.keys().next();
|
|
19
|
+
if (oldest.done === true) break;
|
|
20
|
+
open.delete(oldest.value);
|
|
21
|
+
}
|
|
22
|
+
const ticket = randomBytes(32).toString('base64url');
|
|
23
|
+
const expiresAt = now + ttlMs;
|
|
24
|
+
open.set(ticket, { value, expiresAt });
|
|
25
|
+
return { ticket, expiresAt };
|
|
26
|
+
},
|
|
27
|
+
take(ticket, now = Date.now()) {
|
|
28
|
+
const entry = open.get(ticket);
|
|
29
|
+
open.delete(ticket);
|
|
30
|
+
return entry !== undefined && entry.expiresAt > now ? entry.value : undefined;
|
|
31
|
+
},
|
|
32
|
+
size(now = Date.now()) {
|
|
33
|
+
prune(now);
|
|
34
|
+
return open.size;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export interface TrainErrorInfo {
|
|
2
|
+
code: string;
|
|
3
|
+
message: string;
|
|
4
|
+
retryable?: boolean;
|
|
5
|
+
retryAfterMs?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class TrainError extends Error {
|
|
9
|
+
readonly code: string;
|
|
10
|
+
readonly retryable?: boolean;
|
|
11
|
+
readonly retryAfterMs?: number;
|
|
12
|
+
constructor(
|
|
13
|
+
code: string,
|
|
14
|
+
message: string,
|
|
15
|
+
opts?: { retryable?: boolean; retryAfterMs?: number },
|
|
16
|
+
) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'TrainError';
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.retryable = opts?.retryable;
|
|
21
|
+
this.retryAfterMs = opts?.retryAfterMs;
|
|
22
|
+
}
|
|
23
|
+
toErrorInfo(): TrainErrorInfo {
|
|
24
|
+
return {
|
|
25
|
+
code: this.code,
|
|
26
|
+
message: this.message,
|
|
27
|
+
...(this.retryable !== undefined ? { retryable: this.retryable } : {}),
|
|
28
|
+
...(this.retryAfterMs !== undefined
|
|
29
|
+
? { retryAfterMs: this.retryAfterMs }
|
|
30
|
+
: {}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function serializeTrainError(err: unknown): {
|
|
36
|
+
error: string;
|
|
37
|
+
errorInfo?: TrainErrorInfo;
|
|
38
|
+
} {
|
|
39
|
+
if (err instanceof TrainError)
|
|
40
|
+
return { error: err.message, errorInfo: err.toErrorInfo() };
|
|
41
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function coerceErrorInfo(v: unknown): TrainErrorInfo | undefined {
|
|
45
|
+
if (!v || typeof v !== 'object') return undefined;
|
|
46
|
+
const o = v as Record<string, unknown>;
|
|
47
|
+
if (typeof o.code !== 'string' || typeof o.message !== 'string')
|
|
48
|
+
return undefined;
|
|
49
|
+
const info: TrainErrorInfo = { code: o.code, message: o.message };
|
|
50
|
+
if (typeof o.retryable === 'boolean') info.retryable = o.retryable;
|
|
51
|
+
if (typeof o.retryAfterMs === 'number') info.retryAfterMs = o.retryAfterMs;
|
|
52
|
+
return info;
|
|
53
|
+
}
|