@stage-labs/metro 0.1.0-beta.180 → 0.1.0-beta.181
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/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/src/lines.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-tools.ts +16 -6
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +7 -1
- package/runtime/node_modules/@metro-labs/outlook/src/outbound.ts +53 -4
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.181",
|
|
4
4
|
"description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.181",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -27,7 +27,7 @@ function parseAccountScoped(
|
|
|
27
27
|
const isSnowflake = (s: string): boolean => /^\d+$/.test(s);
|
|
28
28
|
const isThreemaResource = (s: string): boolean =>
|
|
29
29
|
/^(?:[A-Z0-9]{8}|\*[A-Z0-9]{7})(?:-[0-9a-f]{16})?$/i.test(s);
|
|
30
|
-
const isOutlookResource = (s: string): boolean => /^[A-Za-z0-9=_
|
|
30
|
+
const isOutlookResource = (s: string): boolean => /^[A-Za-z0-9=_%.@+-]+$/.test(s);
|
|
31
31
|
const isSignedInt = (s: string): boolean => /^-?\d+$/.test(s);
|
|
32
32
|
|
|
33
33
|
function splitTelegramAccount(path: string[]): { accountId: string; rest: string[] } {
|
|
@@ -32,8 +32,14 @@ const withId = (text: string, messageId: string | undefined): string =>
|
|
|
32
32
|
interface Sent {
|
|
33
33
|
labels: string[];
|
|
34
34
|
messageId?: string;
|
|
35
|
+
thread?: string;
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
const threadOf = (response: { result: unknown }, asked: string): string | undefined => {
|
|
39
|
+
const line = (response.result as { line?: unknown } | null)?.line;
|
|
40
|
+
return typeof line === 'string' && line !== '' && line !== asked ? line : undefined;
|
|
41
|
+
};
|
|
42
|
+
|
|
37
43
|
const deliveredLabels = (response: { result: unknown }): string[] => {
|
|
38
44
|
const list = (response.result as { attachments?: unknown } | null)
|
|
39
45
|
?.attachments;
|
|
@@ -89,17 +95,20 @@ async function sendForwarded(
|
|
|
89
95
|
const { line, ctx, station } = m;
|
|
90
96
|
const args: Record<string, unknown> = { line };
|
|
91
97
|
if (text) args.text = text;
|
|
98
|
+
if (typeof m.a.subject === 'string' && m.a.subject.trim() !== '') args.subject = m.a.subject;
|
|
92
99
|
if (replyTo) args.replyTo = replyTo;
|
|
93
100
|
if (atts.length) args.attachments = atts.map(toCanonical);
|
|
94
101
|
const response = await ctx.call('send', args);
|
|
95
102
|
const messageId = messageIdOf(response);
|
|
103
|
+
const thread = threadOf(response, line);
|
|
96
104
|
const labels: string[] = [];
|
|
97
105
|
if (text) labels.push('text');
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
106
|
+
if (atts.length) {
|
|
107
|
+
const delivered = deliveredLabels(response);
|
|
108
|
+
assertDelivered(station, delivered, atts);
|
|
109
|
+
labels.push(...delivered);
|
|
110
|
+
}
|
|
111
|
+
return thread === undefined ? { labels, messageId } : { labels, messageId, thread };
|
|
103
112
|
}
|
|
104
113
|
|
|
105
114
|
const unsupported = (station: Station, atts: CanonicalAttachment[]): string =>
|
|
@@ -132,7 +141,8 @@ async function handleSend(m: MessageArgs): Promise<ToolResult> {
|
|
|
132
141
|
if (!sent.labels.length)
|
|
133
142
|
return errResult('send requires `text` or `attachments`');
|
|
134
143
|
noteSent(m, sent.messageId);
|
|
135
|
-
|
|
144
|
+
const done = withId(`sent: ${sent.labels.join(', ')}`, sent.messageId);
|
|
145
|
+
return ok(sent.thread === undefined ? done : `${done} — new thread line: ${sent.thread}`);
|
|
136
146
|
} finally {
|
|
137
147
|
await cleanupAttachments(atts);
|
|
138
148
|
}
|
|
@@ -34,7 +34,9 @@ export const COMMON_TOOLS: ToolDef[] = [
|
|
|
34
34
|
group: 'write',
|
|
35
35
|
description:
|
|
36
36
|
'Send a message (and/or media) to a Metro conversation. Args: line, text?, reply_to?, ' +
|
|
37
|
-
'attachments?. The station is derived from the line.
|
|
37
|
+
'subject?, attachments?. The station is derived from the line. Outlook: send to ' +
|
|
38
|
+
'metro://outlook/<account>/<email address> to start a new email (subject defaults to the ' +
|
|
39
|
+
'first line of text); the result names the new thread line. Each attachment names EXACTLY ONE ' +
|
|
38
40
|
'source, and the choice matters: `upload` (an `upload_id` from `create_upload`, the ' +
|
|
39
41
|
'route for any real file on your own machine, confidential or not, up to 64 MiB); ' +
|
|
40
42
|
'`data` (base64 inline -- TINY FILES ONLY, a few KB, because the base64 has to be ' +
|
|
@@ -55,6 +57,10 @@ export const COMMON_TOOLS: ToolDef[] = [
|
|
|
55
57
|
type: 'string',
|
|
56
58
|
description: 'Optional message_id to quote/reply to.',
|
|
57
59
|
},
|
|
60
|
+
subject: {
|
|
61
|
+
type: 'string',
|
|
62
|
+
description: 'Outlook only: the subject of a new email.',
|
|
63
|
+
},
|
|
58
64
|
attachments: {
|
|
59
65
|
type: 'array',
|
|
60
66
|
description: 'Optional files to attach.',
|
|
@@ -2,7 +2,7 @@ import { respond } from '@metro-labs/core/stations/station-runtime';
|
|
|
2
2
|
import { TrainError } from '@metro-labs/core/train-error';
|
|
3
3
|
import { accountOf, type Account } from './accounts.js';
|
|
4
4
|
import { assertSendable, attachFile, filesOf, type OutgoingFile } from './attachments.js';
|
|
5
|
-
import { conversationOfLine } from './format.js';
|
|
5
|
+
import { conversationOfLine, lineOf } from './format.js';
|
|
6
6
|
import { graph, graphJson, jsonInit, messagePath, odataQuote, queryOf } from './graph.js';
|
|
7
7
|
import { escapeHtml } from './html.js';
|
|
8
8
|
|
|
@@ -43,7 +43,46 @@ async function withFiles(acct: Account, target: string, verb: 'reply' | 'replyAl
|
|
|
43
43
|
return labels;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
const ADDRESS_RE = /^[^@\s/]+@[^@\s/]+\.[^@\s/]+$/;
|
|
47
|
+
const SUBJECT_MAX = 255;
|
|
48
|
+
|
|
49
|
+
export const isAddress = (target: string): boolean => target.includes('@');
|
|
50
|
+
|
|
51
|
+
export function subjectOf(subject: string, text: string): string {
|
|
52
|
+
const chosen = subject !== '' ? subject : (text.split('\n').find((l) => l.trim() !== '') ?? '').trim();
|
|
53
|
+
if (chosen === '') throw new TrainError('outlook_subject_required', 'a new email needs a subject or some text', { retryable: false });
|
|
54
|
+
return chosen.length > SUBJECT_MAX ? `${chosen.slice(0, SUBJECT_MAX - 3)}...` : chosen;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function compose(id: string, acct: Account, to: string, args: Args, text: string, files: OutgoingFile[]): Promise<void> {
|
|
58
|
+
if (!ADDRESS_RE.test(to)) throw new TrainError('outlook_bad_address', `not an email address: ${to}`, { retryable: false });
|
|
59
|
+
const draft = await graphJson<{ id?: string; conversationId?: string }>(acct, '/me/messages', jsonInit('POST', {
|
|
60
|
+
subject: subjectOf(str(args.subject), text),
|
|
61
|
+
body: { contentType: 'Text', content: text },
|
|
62
|
+
toRecipients: [{ emailAddress: { address: to } }],
|
|
63
|
+
}));
|
|
64
|
+
if (draft.id === undefined) throw new TrainError('outlook_graph_error', 'Outlook did not open a draft', { retryable: true });
|
|
65
|
+
const labels: string[] = [];
|
|
66
|
+
try {
|
|
67
|
+
for (const file of files) labels.push(await attachFile(acct, draft.id, file));
|
|
68
|
+
await graph(acct, `${messagePath(draft.id)}/send`, { method: 'POST' });
|
|
69
|
+
} catch (err) {
|
|
70
|
+
await discard(acct, draft.id);
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
const line = draft.conversationId === undefined ? undefined : lineOf(acct.id, draft.conversationId);
|
|
74
|
+
respond(id, { result: { account: acct.id, messageId: draft.id, ...(line === undefined ? {} : { line }), ...(labels.length > 0 ? { attachments: labels } : {}) } });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface Outgoing {
|
|
78
|
+
target: string;
|
|
79
|
+
acct: Account;
|
|
80
|
+
files: OutgoingFile[];
|
|
81
|
+
text: string;
|
|
82
|
+
replyTo: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function outgoing(args: Args): Promise<Outgoing> {
|
|
47
86
|
const line = str(args.line);
|
|
48
87
|
const parsed = conversationOfLine(line);
|
|
49
88
|
if (parsed === null) throw new TrainError('outlook_bad_line', `not an outlook line: ${line}`, { retryable: false });
|
|
@@ -53,8 +92,18 @@ async function answer(id: string, args: Args, verb: 'reply' | 'replyAll'): Promi
|
|
|
53
92
|
if (text.trim() === '' && files.length === 0)
|
|
54
93
|
throw new TrainError('outlook_text_required', 'give some text or a file to send', { retryable: false });
|
|
55
94
|
await assertSendable(files);
|
|
56
|
-
|
|
57
|
-
|
|
95
|
+
return { target: parsed.conversationId, acct, files, text, replyTo: str(args.replyTo) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function answer(id: string, args: Args, verb: 'reply' | 'replyAll'): Promise<void> {
|
|
99
|
+
const { target: conversation, acct, files, text, replyTo } = await outgoing(args);
|
|
100
|
+
if (isAddress(conversation)) {
|
|
101
|
+
if (verb === 'reply' || replyTo !== '')
|
|
102
|
+
throw new TrainError('outlook_reply_needs_thread', 'reply on the thread\'s own line; an address line starts a new email', { retryable: false });
|
|
103
|
+
await compose(id, acct, conversation.toLowerCase(), args, text, files);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const target = replyTo === '' ? await latestInConversation(acct, conversation) : replyTo;
|
|
58
107
|
const comment = escapeHtml(text);
|
|
59
108
|
let labels: string[] = [];
|
|
60
109
|
if (files.length === 0) await graph(acct, `${messagePath(target)}/${verb}`, jsonInit('POST', { comment }));
|
package/runtime/runtime.json
CHANGED