@stage-labs/metro 0.1.0-beta.276 → 0.1.0-beta.277
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/claude.js +2 -1
- package/dist/native-recovery-scan.js +246 -0
- package/dist/native-recovery-state.js +154 -0
- package/dist/native-recovery.js +164 -0
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/bin/policy.mjs +4 -0
- package/runtime/node_modules/@metro-labs/core/package.json +2 -0
- package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +2 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/channel-directory.ts +159 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/channel-pages.ts +129 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-process.ts +6 -4
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/channel-tool.ts +80 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +12 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-catalog.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +7 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/actions.ts +2 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/channel-source.ts +79 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/channels.ts +186 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/station.ts +1 -0
- package/runtime/node_modules/@metro-labs/gmail/src/actions.ts +2 -1
- package/runtime/node_modules/@metro-labs/gmail/src/channels.ts +66 -0
- package/runtime/node_modules/@metro-labs/gmail/src/station.ts +1 -0
- package/runtime/node_modules/@metro-labs/outlook/src/actions.ts +2 -1
- package/runtime/node_modules/@metro-labs/outlook/src/channels.ts +60 -0
- package/runtime/node_modules/@metro-labs/outlook/src/station.ts +1 -0
- package/runtime/node_modules/@metro-labs/telegram/src/actions.ts +19 -0
- package/runtime/node_modules/@metro-labs/telegram/src/channels.ts +58 -0
- package/runtime/node_modules/@metro-labs/telegram/src/station.ts +1 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/actions.ts +2 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/channel-files.ts +3 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/channels.ts +43 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/index.ts +5 -34
- package/runtime/node_modules/@metro-labs/telegram-bot/src/observed-chats.ts +172 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/station.ts +4 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/types.ts +30 -3
- package/runtime/node_modules/@metro-labs/telegram-bot/src/updates.ts +26 -0
- package/runtime/node_modules/@metro-labs/threema/src/accounts.ts +13 -1
- package/runtime/node_modules/@metro-labs/threema/src/actions.ts +7 -1
- package/runtime/node_modules/@metro-labs/threema/src/channels.ts +37 -0
- package/runtime/node_modules/@metro-labs/threema/src/chats.ts +88 -0
- package/runtime/node_modules/@metro-labs/threema/src/inbound.ts +5 -0
- package/runtime/node_modules/@metro-labs/threema/src/outbound.ts +1 -0
- package/runtime/node_modules/@metro-labs/threema/src/station.ts +10 -2
- package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +11 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/channel-chats.ts +137 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/channels.ts +48 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client-types.ts +2 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +8 -6
- package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +4 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +3 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/channels.ts +56 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/network.ts +4 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/station.ts +1 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +2 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +2 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/channel-directory.ts +159 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/channel-pages.ts +129 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/types.ts +1 -0
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/sdk-runner/src/activity-tasks.ts +7 -1
- package/runtime/sdk-runner/src/activity.ts +33 -3
- package/runtime/sdk-runner/src/app.ts +5 -2
- package/runtime/sdk-runner/src/inbox.ts +10 -1
- package/runtime/sdk-runner/src/input-queue.ts +6 -4
- package/runtime/sdk-runner/src/main.ts +21 -8
- package/runtime/sdk-runner/src/rules.ts +1 -0
- package/runtime/sdk-runner/src/runner.ts +18 -4
- package/runtime/sdk-runner/src/session-store.ts +19 -4
- package/runtime/sdk-runner/src/session-watch.ts +5 -1
- package/runtime/sdk-runner/src/task-ledger.ts +229 -0
- package/runtime/sdk-runner/src/task-recovery.ts +203 -0
- package/runtime/sdk-runner/src/task-state.ts +71 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Line } from '../lines.js';
|
|
2
|
+
import { ticketStore } from '../tickets.js';
|
|
3
|
+
import { TrainError } from '../train-error.js';
|
|
4
|
+
|
|
5
|
+
export const DIRECTORY_LIMIT = 5000;
|
|
6
|
+
const SNAPSHOT_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
const CURSOR_TTL = 5 * 60 * 1000;
|
|
8
|
+
const KINDS = new Set(['direct', 'group', 'channel', 'thread']);
|
|
9
|
+
|
|
10
|
+
export interface ChannelEntry {
|
|
11
|
+
id: string;
|
|
12
|
+
line: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
kind: 'direct' | 'group' | 'channel' | 'thread';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ChannelCapability {
|
|
18
|
+
supported: boolean;
|
|
19
|
+
complete: boolean;
|
|
20
|
+
source: 'remote' | 'local' | 'mixed' | 'unsupported';
|
|
21
|
+
reason?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ChannelSnapshot {
|
|
25
|
+
channels: ChannelEntry[];
|
|
26
|
+
capability: ChannelCapability;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ChannelList extends ChannelSnapshot {
|
|
30
|
+
next_cursor?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ChannelOptions {
|
|
34
|
+
query: string;
|
|
35
|
+
limit: number;
|
|
36
|
+
cursor?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function cursorOf(cursor: unknown): string | undefined {
|
|
40
|
+
if (cursor === undefined) return undefined;
|
|
41
|
+
if (typeof cursor !== 'string' || !cursor || cursor.length > 2048)
|
|
42
|
+
throw new TrainError('bad_request', 'cursor must be a nonempty string of at most 2048 characters');
|
|
43
|
+
return cursor;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function channelOptions(args: Record<string, unknown>): ChannelOptions {
|
|
47
|
+
const { query = '', limit = 50 } = args;
|
|
48
|
+
if (typeof query !== 'string' || query.length > 200)
|
|
49
|
+
throw new TrainError('bad_request', 'query must be a string of at most 200 characters');
|
|
50
|
+
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
51
|
+
throw new TrainError('bad_request', 'limit must be an integer from 1 to 100');
|
|
52
|
+
const cursor = cursorOf(args.cursor);
|
|
53
|
+
return { query: query.trim().toLowerCase(), limit, ...(cursor === undefined ? {} : { cursor }) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function channelMatches(entry: ChannelEntry, query: string): boolean {
|
|
57
|
+
return [entry.id, entry.line, entry.name ?? ''].some((value) => value.toLowerCase().includes(query));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function channelEntry(entry: ChannelEntry, account: string): ChannelEntry | undefined {
|
|
61
|
+
if (!entry.id || entry.id.length > 1024 || entry.line.length > 2048 || !KINDS.has(entry.kind))
|
|
62
|
+
return undefined;
|
|
63
|
+
const parsed = Line.parse(entry.line);
|
|
64
|
+
if (parsed?.path[0] !== account || parsed.path.length < 2) return undefined;
|
|
65
|
+
return {
|
|
66
|
+
id: entry.id,
|
|
67
|
+
line: entry.line,
|
|
68
|
+
kind: entry.kind,
|
|
69
|
+
...(entry.name ? { name: Array.from(entry.name).slice(0, 256).join('') } : {}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function limitedCapability(capability: ChannelCapability): ChannelCapability {
|
|
74
|
+
return {
|
|
75
|
+
...capability,
|
|
76
|
+
complete: false,
|
|
77
|
+
reason: [capability.reason, 'Directory metadata exceeded the local size or entry limit; results are partial.']
|
|
78
|
+
.filter(Boolean).join(' '),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function bounded(snapshot: ChannelSnapshot, account: string, query: string): ChannelSnapshot {
|
|
83
|
+
const seen = new Set<string>();
|
|
84
|
+
const channels: ChannelEntry[] = [];
|
|
85
|
+
let bytes = 0;
|
|
86
|
+
let clipped = snapshot.channels.length > DIRECTORY_LIMIT;
|
|
87
|
+
for (const raw of snapshot.channels.slice(0, DIRECTORY_LIMIT)) {
|
|
88
|
+
const entry = channelEntry(raw, account);
|
|
89
|
+
if (!entry) {
|
|
90
|
+
clipped = true;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (seen.has(entry.line)) continue;
|
|
94
|
+
bytes += Buffer.byteLength(JSON.stringify(entry));
|
|
95
|
+
if (bytes > SNAPSHOT_BYTES) {
|
|
96
|
+
clipped = true;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
seen.add(entry.line);
|
|
100
|
+
if (channelMatches(raw, query)) channels.push(entry);
|
|
101
|
+
}
|
|
102
|
+
channels.sort((a, b) => a.line < b.line ? -1 : Number(a.line > b.line));
|
|
103
|
+
return { channels, capability: clipped ? limitedCapability(snapshot.capability) : { ...snapshot.capability } };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface Cursor {
|
|
107
|
+
account: string;
|
|
108
|
+
query: string;
|
|
109
|
+
snapshot: ChannelSnapshot;
|
|
110
|
+
offset: number;
|
|
111
|
+
expiresAt: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class ChannelDirectory {
|
|
115
|
+
private readonly cursors = ticketStore<Cursor>(CURSOR_TTL, 32);
|
|
116
|
+
|
|
117
|
+
async list(
|
|
118
|
+
account: string,
|
|
119
|
+
args: Record<string, unknown>,
|
|
120
|
+
load: () => Promise<ChannelSnapshot>,
|
|
121
|
+
): Promise<ChannelList> {
|
|
122
|
+
const options = channelOptions(args);
|
|
123
|
+
const cursor = options.cursor === undefined
|
|
124
|
+
? {
|
|
125
|
+
account,
|
|
126
|
+
query: options.query,
|
|
127
|
+
snapshot: bounded(await load(), account, options.query),
|
|
128
|
+
offset: 0,
|
|
129
|
+
expiresAt: Date.now() + CURSOR_TTL,
|
|
130
|
+
}
|
|
131
|
+
: this.resume(options.cursor, account, options.query);
|
|
132
|
+
return this.page(cursor, options.limit);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private resume(ticket: string, account: string, query: string): Cursor {
|
|
136
|
+
const cursor = this.cursors.peek(ticket);
|
|
137
|
+
if (!cursor || cursor.account !== account || cursor.query !== query || cursor.expiresAt <= Date.now())
|
|
138
|
+
throw new TrainError('channel_cursor', 'Invalid or expired channel cursor; start a new listing for this account and query.');
|
|
139
|
+
return cursor;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private page(cursor: Cursor, limit: number): ChannelList {
|
|
143
|
+
const { snapshot, offset } = cursor;
|
|
144
|
+
const channels = snapshot.channels.slice(offset, offset + limit).map((entry) => ({ ...entry }));
|
|
145
|
+
const nextOffset = offset + channels.length;
|
|
146
|
+
if (nextOffset >= snapshot.channels.length)
|
|
147
|
+
return { channels, capability: { ...snapshot.capability } };
|
|
148
|
+
const { ticket } = this.cursors.mint({ ...cursor, offset: nextOffset });
|
|
149
|
+
return {
|
|
150
|
+
channels,
|
|
151
|
+
capability: {
|
|
152
|
+
...snapshot.capability,
|
|
153
|
+
complete: false,
|
|
154
|
+
reason: [snapshot.capability.reason, 'More channels are available; follow next_cursor.'].filter(Boolean).join(' '),
|
|
155
|
+
},
|
|
156
|
+
next_cursor: ticket,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { ticketStore } from '../tickets.js';
|
|
2
|
+
import { TrainError } from '../train-error.js';
|
|
3
|
+
import { channelEntry, channelMatches, channelOptions, DIRECTORY_LIMIT, type ChannelEntry, type ChannelList } from './channel-directory.js';
|
|
4
|
+
|
|
5
|
+
const TTL = 5 * 60 * 1000;
|
|
6
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
const CAP_REASON = 'Stopped after 5000 metadata records or pages; search does not cover the whole mailbox.';
|
|
8
|
+
const SIZE_REASON = 'Directory metadata exceeded the local size or entry limit; search is partial.';
|
|
9
|
+
|
|
10
|
+
export interface ChannelPage {
|
|
11
|
+
channels: ChannelEntry[];
|
|
12
|
+
scanned: number;
|
|
13
|
+
next?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type ChannelPageLoader = (token: string | undefined, limit: number, seen: ReadonlySet<string>) => Promise<ChannelPage>;
|
|
17
|
+
|
|
18
|
+
interface Scan {
|
|
19
|
+
next?: string;
|
|
20
|
+
channels: ChannelEntry[];
|
|
21
|
+
seen: Set<string>;
|
|
22
|
+
visited: Set<string>;
|
|
23
|
+
scanned: number;
|
|
24
|
+
bytes: number;
|
|
25
|
+
complete: boolean;
|
|
26
|
+
stopped?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Cursor {
|
|
30
|
+
account: string;
|
|
31
|
+
query: string;
|
|
32
|
+
scope: string;
|
|
33
|
+
expiresAt: number;
|
|
34
|
+
scan: Scan;
|
|
35
|
+
loaded?: Promise<Scan>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function addEntries(scan: Scan, rows: ChannelEntry[], account: string, query: string): void {
|
|
39
|
+
for (const raw of rows) {
|
|
40
|
+
const entry = channelEntry(raw, account);
|
|
41
|
+
if (!entry) {
|
|
42
|
+
scan.stopped = SIZE_REASON;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (scan.seen.has(entry.id)) continue;
|
|
46
|
+
const size = Buffer.byteLength(JSON.stringify(entry)) + Buffer.byteLength(JSON.stringify(entry.id));
|
|
47
|
+
if (scan.bytes + size > MAX_BYTES) {
|
|
48
|
+
scan.stopped = SIZE_REASON;
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
scan.bytes += size;
|
|
52
|
+
scan.seen.add(entry.id);
|
|
53
|
+
if (channelMatches(raw, query)) scan.channels.push(entry);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function project(cursor: Cursor, page: ChannelPage): Scan {
|
|
58
|
+
const scan: Scan = {
|
|
59
|
+
next: page.next, channels: [], seen: new Set(cursor.scan.seen),
|
|
60
|
+
visited: new Set(cursor.scan.visited).add(cursor.scan.next ?? ''),
|
|
61
|
+
scanned: cursor.scan.scanned + page.scanned,
|
|
62
|
+
bytes: cursor.scan.bytes + Buffer.byteLength(page.next ?? ''), complete: !page.next,
|
|
63
|
+
};
|
|
64
|
+
if (page.next && scan.visited.has(page.next)) throw new TrainError('channel_page', 'The mailbox repeated a channel page token.');
|
|
65
|
+
addEntries(scan, page.channels, cursor.account, cursor.query);
|
|
66
|
+
if (scan.bytes > MAX_BYTES) scan.stopped = SIZE_REASON;
|
|
67
|
+
if (page.next && Math.max(scan.scanned, scan.visited.size) >= DIRECTORY_LIMIT) scan.stopped ??= CAP_REASON;
|
|
68
|
+
if (scan.stopped) {
|
|
69
|
+
scan.next = undefined;
|
|
70
|
+
scan.complete = false;
|
|
71
|
+
}
|
|
72
|
+
return scan;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class ChannelPages {
|
|
76
|
+
private readonly cursors = ticketStore<Cursor>(TTL, 32);
|
|
77
|
+
|
|
78
|
+
async list(account: string, args: Record<string, unknown>, load: ChannelPageLoader, scope: string): Promise<ChannelList> {
|
|
79
|
+
const { query, limit, cursor: ticket } = channelOptions(args);
|
|
80
|
+
const cursor: Cursor = ticket === undefined
|
|
81
|
+
? {
|
|
82
|
+
account, query, scope, expiresAt: Date.now() + TTL,
|
|
83
|
+
scan: { channels: [], seen: new Set(), visited: new Set(), scanned: 0, bytes: Buffer.byteLength(account + query + scope), complete: false },
|
|
84
|
+
}
|
|
85
|
+
: this.resume(ticket, account, query);
|
|
86
|
+
const scan = await this.load(cursor, load);
|
|
87
|
+
if (cursor.expiresAt <= Date.now()) throw new TrainError('channel_cursor', 'Channel listing expired; start a new listing.');
|
|
88
|
+
const channels = scan.channels.slice(0, limit);
|
|
89
|
+
const remaining = scan.channels.slice(channels.length);
|
|
90
|
+
const more = remaining.length > 0 || (!scan.complete && !scan.stopped);
|
|
91
|
+
const next = more ? this.cursors.mint({ ...cursor, scan: { ...scan, channels: remaining }, loaded: undefined }).ticket : undefined;
|
|
92
|
+
return {
|
|
93
|
+
channels: channels.map((entry) => ({ ...entry })),
|
|
94
|
+
capability: {
|
|
95
|
+
supported: true, complete: scan.complete && !more, source: 'remote',
|
|
96
|
+
reason: [cursor.scope, scan.stopped, more ? 'More metadata remains; follow next_cursor, even after an empty page.' : ''].filter(Boolean).join(' '),
|
|
97
|
+
},
|
|
98
|
+
...(next === undefined ? {} : { next_cursor: next }),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private resume(ticket: string, account: string, query: string): Cursor {
|
|
103
|
+
const cursor = this.cursors.peek(ticket);
|
|
104
|
+
if (!cursor || cursor.account !== account || cursor.query !== query || cursor.expiresAt <= Date.now())
|
|
105
|
+
throw new TrainError('channel_cursor', 'Invalid or expired channel cursor; start a new listing for this account and query.');
|
|
106
|
+
return cursor;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private async load(cursor: Cursor, load: ChannelPageLoader): Promise<Scan> {
|
|
110
|
+
if (cursor.scan.channels.length || cursor.scan.complete || cursor.scan.stopped) return cursor.scan;
|
|
111
|
+
cursor.loaded ??= this.fetch(cursor, load);
|
|
112
|
+
try {
|
|
113
|
+
return await cursor.loaded;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
cursor.loaded = undefined;
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private async fetch(cursor: Cursor, load: ChannelPageLoader): Promise<Scan> {
|
|
121
|
+
const count = Math.min(100, DIRECTORY_LIMIT - cursor.scan.scanned);
|
|
122
|
+
const page = await load(cursor.scan.next, count, new Set(cursor.scan.seen));
|
|
123
|
+
if (!Number.isInteger(page.scanned) || page.scanned < 0 || page.scanned > count || page.channels.length > page.scanned)
|
|
124
|
+
throw new TrainError('channel_page', 'The mailbox returned more channel metadata than requested.');
|
|
125
|
+
if (page.next !== undefined && (typeof page.next !== 'string' || !page.next || page.next.length > 16384))
|
|
126
|
+
throw new TrainError('channel_page', 'The mailbox returned an invalid channel page token.');
|
|
127
|
+
return project(cursor, page);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -110,6 +110,7 @@ export interface Station {
|
|
|
110
110
|
profileFields?: ReadonlySet<ProfileField>;
|
|
111
111
|
claimsName?: boolean;
|
|
112
112
|
readsProfiles?: boolean;
|
|
113
|
+
discoversChannels?: boolean;
|
|
113
114
|
readFilters?: ReadonlySet<ReadFilter>;
|
|
114
115
|
approvals?: boolean;
|
|
115
116
|
sendsFrames?: boolean;
|
|
@@ -148,7 +148,7 @@ function sessionCommand(body: Record<string, unknown>, session: SessionDeps): un
|
|
|
148
148
|
}
|
|
149
149
|
if (body.action === 'stop') {
|
|
150
150
|
setAutostart(false, session.agents);
|
|
151
|
-
return stopSession(session);
|
|
151
|
+
return stopSession(session, true);
|
|
152
152
|
}
|
|
153
153
|
if (body.action !== undefined) throw new ApiError('action must be start or stop', 400);
|
|
154
154
|
if (typeof body.autostart === 'boolean') setAutostart(body.autostart, session.agents);
|
|
@@ -25,7 +25,7 @@ function pidAlive(pid: number): boolean {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export function sdkAlive(activity: RunnerActivity | null):
|
|
28
|
+
export function sdkAlive(activity: RunnerActivity | null): activity is RunnerActivity {
|
|
29
29
|
if (activity === null) return false;
|
|
30
30
|
const found = identity(activity.pid);
|
|
31
31
|
if (found?.zombie === true) return false;
|
|
@@ -40,11 +40,13 @@ function assertIdentity(activity: RunnerActivity): void {
|
|
|
40
40
|
throw new ApiError('The Agent SDK is still running outside tmux, but its process identity cannot be verified safely. Stop that metro agent process from its terminal.', 503);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export function stopSdkRunner(home: string):
|
|
43
|
+
export function stopSdkRunner(home: string, cancelActive = false): boolean {
|
|
44
44
|
const activity = readAgentActivity(home);
|
|
45
|
-
if (
|
|
45
|
+
if (!sdkAlive(activity)) return false;
|
|
46
46
|
assertIdentity(activity);
|
|
47
|
-
const
|
|
47
|
+
const signal = cancelActive && activity.cancelSignal === 'SIGUSR2' ? '-USR2' : '-TERM';
|
|
48
|
+
const run = spawnSync(...asAgent('kill', [signal, String(activity.pid)]), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
48
49
|
if ((run.error !== undefined || run.status !== 0) && sdkAlive(activity))
|
|
49
50
|
throw new ApiError(`The Agent SDK could not be stopped: ${run.error?.message ?? run.stderr.trim()}`, 503);
|
|
51
|
+
return true;
|
|
50
52
|
}
|
|
@@ -265,9 +265,9 @@ export function startSession(deps: SessionDeps = {}): SessionStatus {
|
|
|
265
265
|
return sessionStatus(deps);
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
-
export function stopSession(deps: SessionDeps = {}): SessionStatus {
|
|
269
|
-
|
|
270
|
-
|
|
268
|
+
export function stopSession(deps: SessionDeps = {}, cancelActive = false): SessionStatus {
|
|
269
|
+
if (!stopSdkRunner(sessionHome(deps), cancelActive))
|
|
270
|
+
spawnSync(...asAgent(deps.tmux ?? 'tmux', ['kill-session', '-t', SESSION_NAME]), { stdio: 'ignore' });
|
|
271
271
|
return sessionStatus(deps);
|
|
272
272
|
}
|
|
273
273
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { channelOptions, type ChannelList } from '@metro-labs/core/stations/channel-directory';
|
|
2
|
+
import type { ToolResult } from '@metro-labs/core/stations/types';
|
|
3
|
+
import { str } from '@metro-labs/core/str';
|
|
4
|
+
import { stationByName } from '../stations/registry.js';
|
|
5
|
+
import { callTargetDenied } from '../agents/scope.js';
|
|
6
|
+
import { errResult, makeCtx, okJson, toErr } from './ctx.js';
|
|
7
|
+
import { stationOfAccount } from './read-tool.js';
|
|
8
|
+
import type { ToolDef } from './tool-def.js';
|
|
9
|
+
|
|
10
|
+
export const LIST_CHANNELS_TOOL: ToolDef = {
|
|
11
|
+
name: 'list_channels',
|
|
12
|
+
group: 'read',
|
|
13
|
+
description:
|
|
14
|
+
'Discover existing conversations for one account from list_accounts, returning metadata only. ' +
|
|
15
|
+
'Args: account (required), query?, limit? (1 to 100, default 50), cursor?. Query matches names, ids or lines ' +
|
|
16
|
+
'case-insensitively. Returns {account, station, channels:[{id, line, name?, kind:direct|group|channel|thread}], ' +
|
|
17
|
+
'capability:{supported, complete, source:remote|local|mixed|unsupported, reason?}, next_cursor?}. ' +
|
|
18
|
+
'Use each returned line with other Metro tools. Follow next_cursor with the same account and query. ' +
|
|
19
|
+
'Check capability: local or partial directories are not exhaustive, and unsupported is not an empty account. ' +
|
|
20
|
+
'No recent message is required. Receive messages Off does not disable discovery; read policies still apply.',
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {
|
|
24
|
+
account: { type: 'string', minLength: 1, description: 'An account id from list_accounts.' },
|
|
25
|
+
query: { type: 'string', maxLength: 200, description: 'Case-insensitive name, id or line substring.' },
|
|
26
|
+
limit: { type: 'integer', minimum: 1, maximum: 100, default: 50, description: 'Maximum channels per page.' },
|
|
27
|
+
cursor: { type: 'string', minLength: 1, maxLength: 2048, description: 'The next_cursor from the previous page.' },
|
|
28
|
+
},
|
|
29
|
+
required: ['account'],
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function channelScopeDenied(allowed: Set<string>, a: Record<string, unknown>): boolean {
|
|
34
|
+
const account = str(a.account);
|
|
35
|
+
const station = account ? stationOfAccount(account) : undefined;
|
|
36
|
+
return station === undefined || stationByName(station) === undefined || callTargetDenied(allowed, station, { account });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function channelArgsError(a: Record<string, unknown>): ToolResult | undefined {
|
|
40
|
+
if (typeof a.account !== 'string' || !a.account.trim())
|
|
41
|
+
return errResult('list_channels requires `account` from list_accounts');
|
|
42
|
+
try {
|
|
43
|
+
channelOptions(a);
|
|
44
|
+
return undefined;
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return toErr('list_channels', e);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function dispatchListChannels(a: Record<string, unknown>): Promise<ToolResult> {
|
|
51
|
+
const account = str(a.account);
|
|
52
|
+
const name = stationOfAccount(account);
|
|
53
|
+
const station = name === undefined ? undefined : stationByName(name);
|
|
54
|
+
if (station === undefined) return errResult('list_channels requires a unique account from list_accounts');
|
|
55
|
+
if (!station.hasTrain || station.discoversChannels !== true) {
|
|
56
|
+
const list: ChannelList = {
|
|
57
|
+
channels: [],
|
|
58
|
+
capability: {
|
|
59
|
+
supported: false,
|
|
60
|
+
complete: false,
|
|
61
|
+
source: 'unsupported',
|
|
62
|
+
reason: `${station.name} does not support channel discovery`,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
return okJson({ account, station: station.name, ...list });
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const { limit, cursor } = channelOptions(a);
|
|
69
|
+
const args = {
|
|
70
|
+
account,
|
|
71
|
+
limit,
|
|
72
|
+
...(a.query === undefined ? {} : { query: str(a.query) }),
|
|
73
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
74
|
+
};
|
|
75
|
+
const { result } = await makeCtx(station.name).call('listChannels', args);
|
|
76
|
+
return okJson({ ...result as ChannelList, account, station: station.name });
|
|
77
|
+
} catch (e) {
|
|
78
|
+
return toErr('list_channels', e);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { str } from '@metro-labs/core/str';
|
|
1
2
|
import { readPreview, shownValue } from '../approvals/preview.js';
|
|
2
3
|
import { connectorToolOf } from '../connectors/gates.js';
|
|
4
|
+
import { stationByName } from '../stations/registry.js';
|
|
5
|
+
import { stationOfAccount } from './read-tool.js';
|
|
3
6
|
|
|
4
7
|
export interface PermissionParams {
|
|
5
8
|
request_id: string;
|
|
@@ -37,6 +40,14 @@ function channelOf(input: Record<string, unknown>): string | undefined {
|
|
|
37
40
|
return typeof input.station === 'string' && input.station !== '' ? input.station : undefined;
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
function discoveryChannel(input: Record<string, unknown>): string {
|
|
44
|
+
const account = str(input.account);
|
|
45
|
+
if (!account) return 'unknown account';
|
|
46
|
+
const name = stationOfAccount(account);
|
|
47
|
+
const station = name === undefined ? undefined : stationByName(name);
|
|
48
|
+
return `${station?.name ?? 'unknown station'} · ${shorten(account)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
function argText(value: unknown): string | undefined {
|
|
41
52
|
const plain = valueText(value);
|
|
42
53
|
if (plain !== undefined || value === null || typeof value !== 'object') return plain;
|
|
@@ -50,7 +61,7 @@ function fieldText(key: string, value: unknown): string | undefined {
|
|
|
50
61
|
|
|
51
62
|
function metroLines(tool: string, input: Record<string, unknown>): string[] {
|
|
52
63
|
const lines = [`Approval needed: ${tool}`];
|
|
53
|
-
const channel = channelOf(input);
|
|
64
|
+
const channel = tool === 'list_channels' ? discoveryChannel(input) : channelOf(input);
|
|
54
65
|
if (channel !== undefined) lines.push(`Channel: ${channel}`);
|
|
55
66
|
if (tool === 'send' && input.speech !== undefined) lines.push('Delivery: voice call only, no chat post');
|
|
56
67
|
const keys = [...LEAD, ...Object.keys(input).filter((k) => !LEAD.includes(k))];
|
|
@@ -25,8 +25,14 @@ function channelTargets(name: string, a: Record<string, unknown>): ChannelTarget
|
|
|
25
25
|
return line === undefined ? [] : fromLine(line, undefined);
|
|
26
26
|
}
|
|
27
27
|
const account = str(a.account);
|
|
28
|
+
if (name === 'list_channels') return accountTargets(name, { account });
|
|
28
29
|
const line = str(a.line);
|
|
29
30
|
if (line) return fromLine(line, name === 'list_members' ? undefined : account);
|
|
31
|
+
return accountTargets(name, a);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function accountTargets(name: string, a: Record<string, unknown>): ChannelTarget[] {
|
|
35
|
+
const account = str(a.account);
|
|
30
36
|
const station = stationForTool(name, a) ?? (account ? stationOfAccount(account) : undefined);
|
|
31
37
|
if (station === undefined) return [];
|
|
32
38
|
if (account) return [{ kind: 'channel', station, account }];
|
|
@@ -3,6 +3,7 @@ import { STATIONS } from '../stations/registry.js';
|
|
|
3
3
|
import { COMMON_TOOLS, LIST_ACCOUNTS_TOOL } from './tool-schemas.js';
|
|
4
4
|
import { SET_PROFILE_TOOL } from './profile-tool.js';
|
|
5
5
|
import { GET_PROFILE_TOOL } from './profile-lookup.js';
|
|
6
|
+
import { LIST_CHANNELS_TOOL } from './channel-tool.js';
|
|
6
7
|
import type { ToolDef } from './tool-def.js';
|
|
7
8
|
|
|
8
9
|
const defOf = (tool: StationTool): ToolDef => ({
|
|
@@ -17,6 +18,7 @@ export const TOOL_DEFS: ToolDef[] = [
|
|
|
17
18
|
...COMMON_TOOLS,
|
|
18
19
|
...STATIONS.flatMap((s) => s.tools.map(defOf)),
|
|
19
20
|
LIST_ACCOUNTS_TOOL,
|
|
21
|
+
LIST_CHANNELS_TOOL,
|
|
20
22
|
SET_PROFILE_TOOL,
|
|
21
23
|
GET_PROFILE_TOOL,
|
|
22
24
|
];
|
|
@@ -85,7 +87,8 @@ export const stationToolGroups = (): Record<string, GroupedTool[]> =>
|
|
|
85
87
|
export function channelToolsOf(station: Station): GroupedTool[] {
|
|
86
88
|
const names: string[] = [...station.messageVerbs];
|
|
87
89
|
if (station.hasTrain && station.hasAccounts) names.push('list_members');
|
|
88
|
-
|
|
90
|
+
if (station.hasTrain && station.discoversChannels === true) names.push('list_channels');
|
|
91
|
+
names.push(...Array.from(station.groupOps ?? [], (op) => GROUP_OP_TOOLS[op]));
|
|
89
92
|
if ((station.profileFields?.size ?? 0) > 0) names.push('set_profile');
|
|
90
93
|
if (station.readsProfiles === true) names.push('get_profile');
|
|
91
94
|
names.push(...station.tools.map((t) => t.name));
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from './request-identity.js';
|
|
38
38
|
import { str } from '@metro-labs/core/str';
|
|
39
39
|
import { linelessRead, stationOfAccount } from './read-tool.js';
|
|
40
|
+
import { channelArgsError, channelScopeDenied, dispatchListChannels } from './channel-tool.js';
|
|
40
41
|
|
|
41
42
|
const STATION_TOOLS = new Map<
|
|
42
43
|
string,
|
|
@@ -50,6 +51,7 @@ const CORE_DISPATCH: Record<
|
|
|
50
51
|
(a: Record<string, unknown>) => Promise<ToolResult>
|
|
51
52
|
> = {
|
|
52
53
|
list_members: dispatchListMembers,
|
|
54
|
+
list_channels: dispatchListChannels,
|
|
53
55
|
create_group: dispatchCreateGroup,
|
|
54
56
|
add_members: dispatchAddMembers,
|
|
55
57
|
remove_members: dispatchRemoveMembers,
|
|
@@ -91,6 +93,7 @@ export function scopeDenied(
|
|
|
91
93
|
args: Record<string, unknown>,
|
|
92
94
|
): boolean {
|
|
93
95
|
const allowed = allowedAgents(identity);
|
|
96
|
+
if (name === 'list_channels') return channelScopeDenied(allowed, args);
|
|
94
97
|
if (name === 'read' && !str(args.line)) {
|
|
95
98
|
const account = str(args.account);
|
|
96
99
|
if (!account) return false;
|
|
@@ -147,6 +150,10 @@ async function runTool(
|
|
|
147
150
|
): Promise<ToolResult> {
|
|
148
151
|
const name = req.params.name;
|
|
149
152
|
const a = req.params.arguments ?? {};
|
|
153
|
+
if (name === 'list_channels') {
|
|
154
|
+
const invalid = channelArgsError(a);
|
|
155
|
+
if (invalid) return invalid;
|
|
156
|
+
}
|
|
150
157
|
|
|
151
158
|
const identity = currentIdentity();
|
|
152
159
|
if (name !== 'list_accounts' && scopeDenied(identity, name, a))
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { makeStation, respond, type CallMsg, type StationHandler } from '@metro-labs/core/stations/station-runtime';
|
|
16
16
|
import { readProfile, setProfile } from './profile.js';
|
|
17
17
|
import { discordMembers } from './members.js';
|
|
18
|
+
import { listChannels } from './channels.js';
|
|
18
19
|
import {
|
|
19
20
|
groupAddHandler,
|
|
20
21
|
groupCreateHandler,
|
|
@@ -210,6 +211,7 @@ const HANDLERS: Record<string, StationHandler> = {
|
|
|
210
211
|
delete: remove,
|
|
211
212
|
read,
|
|
212
213
|
listMembers,
|
|
214
|
+
listChannels,
|
|
213
215
|
set_profile: setProfile,
|
|
214
216
|
profile: readProfile,
|
|
215
217
|
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChannelType,
|
|
3
|
+
type Client,
|
|
4
|
+
type Guild,
|
|
5
|
+
type GuildBasedChannel,
|
|
6
|
+
type GuildMember,
|
|
7
|
+
type PermissionsBitField,
|
|
8
|
+
} from 'discord.js';
|
|
9
|
+
|
|
10
|
+
export interface DiscordChannelMetadata {
|
|
11
|
+
id: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
type: ChannelType;
|
|
14
|
+
permissions?: Readonly<PermissionsBitField> | null;
|
|
15
|
+
joined?: boolean;
|
|
16
|
+
archived?: boolean | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DiscordGuildSource {
|
|
20
|
+
channels(): Promise<Iterable<DiscordChannelMetadata>>;
|
|
21
|
+
threads(): Promise<Iterable<DiscordChannelMetadata>>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DiscordChannelSource {
|
|
25
|
+
guilds(after: string | undefined, limit: number): Promise<string[]>;
|
|
26
|
+
guild(id: string): Promise<DiscordGuildSource>;
|
|
27
|
+
directs(): Iterable<DiscordChannelMetadata>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function* guildMetadata(
|
|
31
|
+
channels: Iterable<GuildBasedChannel | null>,
|
|
32
|
+
me: GuildMember,
|
|
33
|
+
): Iterable<DiscordChannelMetadata> {
|
|
34
|
+
for (const channel of channels) {
|
|
35
|
+
if (!channel) continue;
|
|
36
|
+
yield {
|
|
37
|
+
id: channel.id,
|
|
38
|
+
name: channel.name,
|
|
39
|
+
type: channel.type,
|
|
40
|
+
permissions: channel.permissionsFor(me),
|
|
41
|
+
...(channel.isThread() ? { joined: channel.joined, archived: channel.archived } : {}),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function guildSource(guild: Guild, me: GuildMember): DiscordGuildSource {
|
|
47
|
+
return {
|
|
48
|
+
async channels() {
|
|
49
|
+
return guildMetadata((await guild.channels.fetch()).values(), me);
|
|
50
|
+
},
|
|
51
|
+
async threads() {
|
|
52
|
+
const result = await guild.channels.fetchActiveThreads(false);
|
|
53
|
+
return guildMetadata(result.threads.values(), me);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function discordChannelSource(client: Client): DiscordChannelSource {
|
|
59
|
+
return {
|
|
60
|
+
async guilds(after, limit) {
|
|
61
|
+
return [...(await client.guilds.fetch({ after, limit })).keys()];
|
|
62
|
+
},
|
|
63
|
+
async guild(id) {
|
|
64
|
+
const guild = await client.guilds.fetch({ guild: id, force: true, withCounts: false });
|
|
65
|
+
const me = await guild.members.fetchMe({ force: true });
|
|
66
|
+
return guildSource(guild, me);
|
|
67
|
+
},
|
|
68
|
+
*directs() {
|
|
69
|
+
for (const channel of client.channels.cache.values()) {
|
|
70
|
+
if (channel.type !== ChannelType.DM && channel.type !== ChannelType.GroupDM) continue;
|
|
71
|
+
yield {
|
|
72
|
+
id: channel.id,
|
|
73
|
+
type: channel.type,
|
|
74
|
+
...('name' in channel && channel.name ? { name: channel.name } : {}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|