@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.180",
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.180",
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=_%.-]+$/.test(s);
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 (!atts.length) return { labels, messageId };
99
- const delivered = deliveredLabels(response);
100
- assertDelivered(station, delivered, atts);
101
- labels.push(...delivered);
102
- return { labels, messageId };
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
- return ok(withId(`sent: ${sent.labels.join(', ')}`, sent.messageId));
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. Each attachment names EXACTLY ONE ' +
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
- async function answer(id: string, args: Args, verb: 'reply' | 'replyAll'): Promise<void> {
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
- const replyTo = str(args.replyTo);
57
- const target = replyTo === '' ? await latestInConversation(acct, parsed.conversationId) : replyTo;
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 }));
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.180"
2
+ "version": "0.1.0-beta.181"
3
3
  }