@stage-labs/metro 0.1.0-beta.231 → 0.1.0-beta.232

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.231",
3
+ "version": "0.1.0-beta.232",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.231",
3
+ "version": "0.1.0-beta.232",
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",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stage
3
- description: How to use Stage channels (XMTP groups, https://stage.box) through metro. Create a channel, rename it, add or remove members, set labels and assignees, read its info, and send, reply, react, unreact, delete and read messages. Works only when this agent has an XMTP account in metro.
3
+ description: How to use Stage channels (XMTP groups, https://stage.box) through metro. Create a channel, rename it, add or remove members, set labels and assignees, read its info, and send, reply, react, unreact, delete and read messages, and post frames (interactive ChatKit widget views). Works only when this agent has an XMTP account in metro.
4
4
  ---
5
5
 
6
6
  Stage (https://stage.box) is a messenger built on XMTP. A Stage channel is an XMTP group. You use it with the metro tools below (`mcp__metro__<name>`).
@@ -40,3 +40,20 @@ Move it from Backlog or To-do to In progress, then In review, then Done. Use Blo
40
40
  - History: `read {"line": "…", "limit": 20}`.
41
41
  - Poll: `ask {"line": "…", "question": "Ship today?", "options": ["Yes", "No"]}`.
42
42
  - File: `create_upload {"name": "report.pdf"}`, run the `curl` line it returns in a shell, then `send {"line": "…", "attachments": [{"upload": "<upload_id>"}]}`. Files that are not images are refused over about 190 KiB.
43
+
44
+ **Frames.** A frame is a small interactive view: a report, a choice, a form. Stage shows it in the chat as a card with a title and a description, and opens it full screen on a tap.
45
+ - Send: `send_frame {"line": "…", "title": "Deploy", "description": "Version 2.4 is ready", "widget": {…}}`. `title` and `description` are optional: they come from the first `Title` and text in the widget when missing.
46
+ - `widget` is OpenAI ChatKit widget JSON, with ChatKit's names and props (https://openai.github.io/chatkit-js/): a `Card`, `ListView` or `Basic` root, with `Row`, `Col`, `Box`, `Text`, `Title`, `Caption`, `Markdown`, `Badge`, `Icon`, `Image`, `Button`, `Divider`, `Spacer`, `Form`, `Input`, `Textarea`, `Select`, `DatePicker`, `Checkbox`, `RadioGroup` and `Table`. Images must be `https://`. Number spacing is in steps of 4px. At most 64K characters, 16 levels deep and 500 nodes. Stage shows a small notice for anything it does not know.
47
+ - Example:
48
+ ```json
49
+ {"type": "Card", "children": [
50
+ {"type": "Title", "value": "Deploy version 2.4?"},
51
+ {"type": "Text", "value": "3 fixes, no migration.", "color": "secondary"},
52
+ {"type": "Input", "name": "note", "placeholder": "Note (optional)"},
53
+ {"type": "Row", "children": [
54
+ {"type": "Button", "label": "Deploy", "onClickAction": {"type": "deploy", "payload": {"version": "2.4"}}},
55
+ {"type": "Button", "label": "Not now", "color": "secondary", "onClickAction": {"type": "deploy.cancel"}}
56
+ ]}
57
+ ]}
58
+ ```
59
+ - A tap comes back to you as a reply to the frame: `Frame action: deploy {"version":"2.4","note":"…"} (tapped "Deploy")`. The values of the fields (`Input`, `Select`, `Checkbox`, …) are added to the payload by their `name`. `Button.onClickAction`, `ListViewItem.onClickAction`, a `Card` `confirm`/`cancel` (`{"label", "action"}`) and a `Form` `onSubmitAction` send one. Answer it like any message.
@@ -160,7 +160,10 @@ const PRIOR_METRO: ReadonlySet<string> = new Set([
160
160
  '5ce3f76d610aae91adc922d0edc6c8be1f27aa2fec4a5e3a6d6231a04fb9310b',
161
161
  'b910597d24f31fa479b130a2545f94dd23684d802d99e36b637fc57c818a33be',
162
162
  ]);
163
- const PRIOR_STAGE: ReadonlySet<string> = new Set(['dd7d30474765de3865ddc87c1cf3220161ded09502027cdff18597e5df0aab43']);
163
+ const PRIOR_STAGE: ReadonlySet<string> = new Set([
164
+ 'dd7d30474765de3865ddc87c1cf3220161ded09502027cdff18597e5df0aab43',
165
+ '47439ecbe661ca70471a6839ab30a963714c296ad8c14ca23c6eebc3929038cc',
166
+ ]);
164
167
  const PRIOR_MEMORY: ReadonlySet<string> = new Set();
165
168
 
166
169
  interface ShippedSkill {
@@ -12,7 +12,8 @@ import {
12
12
  import { convOf, type Account } from './accounts.js';
13
13
  import { resolveMsgId } from './wire.js';
14
14
  import { emitOutbound } from './emit.js';
15
- import { PollCodec, buildPollContent, encodeDeleteMessage } from './codecs.js';
15
+ import { FrameCodec, PollCodec, buildPollContent, encodeDeleteMessage } from './codecs.js';
16
+ import { buildFrameContent } from './frames.js';
16
17
  import { convHandlers } from './actions-conv.js';
17
18
  import { messagingAliases } from '@metro-labs/core/stations/messaging-normalize';
18
19
  import { TrainError } from '@metro-labs/core/train-error';
@@ -48,6 +49,16 @@ async function ask(id: string, args: Args): Promise<void> {
48
49
  respond(id, { result: { messageId: sentId, pollId: mintedId } });
49
50
  }
50
51
 
52
+ async function sendFrame(id: string, args: Args): Promise<void> {
53
+ const { line } = args as { line: string };
54
+ const { acct, conv } = await convOf(line);
55
+ if (!conv) throw noConv(line);
56
+ const { frame, title } = buildFrameContent(args);
57
+ const sentId = await conv.send(new FrameCodec().encode(frame));
58
+ emitOutbound(acct.cfg.id, line, sentId, `Frame: ${title}`);
59
+ respond(id, { result: { messageId: sentId, title } });
60
+ }
61
+
51
62
  async function referenceInboxOf(conv: Conversation, xmtpMsgId: string): Promise<string> {
52
63
  const recent = await conv.messages({ limit: 200, direction: 1 });
53
64
  const inbox = recent.find((m) => m.id === xmtpMsgId)?.senderInboxId;
@@ -211,6 +222,7 @@ const handlers: Record<string, (id: string, args: Args) => Promise<void>> = {
211
222
  resolve_sender: resolveSenderAction,
212
223
  send,
213
224
  ask,
225
+ sendFrame,
214
226
  react,
215
227
  reply,
216
228
  delete: remove,
@@ -164,6 +164,40 @@ export const SignatureReferenceCodec =
164
164
  c.signature ? `[Signature] ${c.signature}` : '[Signature]',
165
165
  );
166
166
 
167
+ export const ContentTypeFrame: ContentTypeId = {
168
+ authorityId: 'stage.box',
169
+ typeId: 'frame',
170
+ versionMajor: 1,
171
+ versionMinor: 0,
172
+ };
173
+ export interface FrameContent {
174
+ title?: string;
175
+ description?: string;
176
+ widget: Record<string, unknown>;
177
+ }
178
+ export const frameFallback = (c: FrameContent): string => {
179
+ const head = c.title ? `Frame: ${c.title}` : 'Frame';
180
+ return c.description ? `${head}\n${c.description}` : head;
181
+ };
182
+ export const FrameCodec = makeJsonCodec<FrameContent>(ContentTypeFrame, frameFallback);
183
+
184
+ export const ContentTypeFrameAction: ContentTypeId = {
185
+ authorityId: 'stage.box',
186
+ typeId: 'frameAction',
187
+ versionMajor: 1,
188
+ versionMinor: 0,
189
+ };
190
+ export interface FrameActionContent {
191
+ frameId: string;
192
+ action: { type: string; payload?: Record<string, unknown> };
193
+ label?: string;
194
+ }
195
+ export const frameActionText = (c: FrameActionContent): string => {
196
+ const payload = c.action.payload === undefined ? '' : ` ${JSON.stringify(c.action.payload)}`;
197
+ return `Frame action: ${c.action.type}${payload}`;
198
+ };
199
+ const FrameActionCodec = makeJsonCodec<FrameActionContent>(ContentTypeFrameAction, frameActionText);
200
+
167
201
  const ContentTypeDeleteMessage: ContentTypeId = {
168
202
  authorityId: 'xmtp.org',
169
203
  typeId: 'deleteMessage',
@@ -195,4 +229,6 @@ export const CODECS = (): ContentCodec[] => [
195
229
  new PollCodec(),
196
230
  new SignatureRequestCodec(),
197
231
  new SignatureReferenceCodec(),
232
+ new FrameCodec(),
233
+ new FrameActionCodec(),
198
234
  ];
@@ -9,6 +9,8 @@ import {
9
9
  } from './attachments.js';
10
10
  import { reportAttachment } from '@metro-labs/core/stations/train-events';
11
11
  import { sentByMe } from './wire.js';
12
+ import { frameActionText, frameFallback, type FrameContent } from './codecs.js';
13
+ import { asFrameAction } from './frames.js';
12
14
 
13
15
  export interface EnvelopeCtx {
14
16
  accountId: string;
@@ -300,6 +302,32 @@ function refEnvelope(
300
302
  return undefined;
301
303
  }
302
304
 
305
+ function frameEnvelope(
306
+ base: Record<string, unknown>,
307
+ typeId: string,
308
+ c: object,
309
+ ): Record<string, unknown> | undefined {
310
+ if (typeId === 'frame') {
311
+ const f = c as Partial<FrameContent>;
312
+ const frame: FrameContent = {
313
+ ...(typeof f.title === 'string' ? { title: f.title } : {}),
314
+ ...(typeof f.description === 'string' ? { description: f.description } : {}),
315
+ widget: typeof f.widget === 'object' && f.widget !== null ? f.widget : {},
316
+ };
317
+ return { ...base, text: frameFallback(frame), payload: { contentType: typeId, frame: c } };
318
+ }
319
+ const a = typeId === 'frameAction' ? asFrameAction(c) : undefined;
320
+ if (!a) return undefined;
321
+ const label = a.label ? ` (tapped "${a.label}")` : '';
322
+ return {
323
+ ...base,
324
+ text: `${frameActionText(a)}${label}`,
325
+ event: { type: 'reply', replyTo: a.frameId },
326
+ ...(sentByMe(a.frameId) ? { reply_to_self: true } : {}),
327
+ payload: { contentType: typeId, frameAction: a, replyTo: a.frameId },
328
+ };
329
+ }
330
+
303
331
  export function typedEnvelope(
304
332
  base: Record<string, unknown>,
305
333
  typeId: string | undefined,
@@ -311,6 +339,7 @@ export function typedEnvelope(
311
339
  if (typeId === 'poll') return pollPayload(base, typeId, c);
312
340
  return (
313
341
  attachmentEnvelope(base, typeId ?? '', c, ctx) ??
314
- refEnvelope(base, typeId, c)
342
+ refEnvelope(base, typeId, c) ??
343
+ frameEnvelope(base, typeId ?? '', c)
315
344
  );
316
345
  }
@@ -0,0 +1,95 @@
1
+ import { TrainError } from '@metro-labs/core/train-error';
2
+ import type { FrameActionContent, FrameContent } from './codecs.js';
3
+
4
+ export const FRAME_MAX_CHARS = 64 * 1024;
5
+ const MAX_TITLE = 200;
6
+ const MAX_DESCRIPTION = 1000;
7
+ const SUMMARY_SCAN = 80;
8
+ const TEXT_TYPES = new Set(['Text', 'Caption', 'Markdown']);
9
+
10
+ type Node = Record<string, unknown>;
11
+
12
+ const isNode = (v: unknown): v is Node =>
13
+ typeof v === 'object' && v !== null && !Array.isArray(v);
14
+
15
+ const bad = (message: string): TrainError => new TrainError('INVALID_ARGS', message);
16
+
17
+ function plain(value: unknown): string {
18
+ if (typeof value !== 'string') return '';
19
+ return value
20
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
21
+ .replace(/^\s*(?:#{1,6}|>)\s*/gm, '')
22
+ .replace(/[*_`~]/g, '')
23
+ .replace(/\s+/g, ' ')
24
+ .trim();
25
+ }
26
+
27
+ function childrenOf(node: Node): Node[] {
28
+ const c = node.children;
29
+ if (Array.isArray(c)) return c.filter(isNode);
30
+ return isNode(c) ? [c] : [];
31
+ }
32
+
33
+ export function frameSummary(widget: Node): { title?: string; description?: string } {
34
+ const titles: string[] = [];
35
+ const texts: string[] = [];
36
+ const stack: Node[] = [widget];
37
+ for (let seen = 0; stack.length > 0 && seen < SUMMARY_SCAN; seen += 1) {
38
+ const node = stack.pop();
39
+ if (!node) break;
40
+ const value = plain(node.value);
41
+ if (node.type === 'Title' && value) titles.push(value);
42
+ if (typeof node.type === 'string' && TEXT_TYPES.has(node.type) && value) texts.push(value);
43
+ stack.push(...childrenOf(node).reverse());
44
+ }
45
+ const title = titles[0] ?? texts[0];
46
+ return { title, description: texts.find((t) => t !== title) };
47
+ }
48
+
49
+ function clip(value: unknown, max: number, name: string): string | undefined {
50
+ if (value === undefined) return undefined;
51
+ if (typeof value !== 'string') throw bad(`send_frame ${name} must be a string`);
52
+ const out = value.trim().slice(0, max);
53
+ return out === '' ? undefined : out;
54
+ }
55
+
56
+ function parseWidget(raw: unknown): Node {
57
+ let widget = raw;
58
+ if (typeof raw === 'string') {
59
+ try {
60
+ widget = JSON.parse(raw);
61
+ } catch {
62
+ throw bad('send_frame widget is not valid JSON');
63
+ }
64
+ }
65
+ if (!isNode(widget) || typeof widget.type !== 'string' || widget.type === '')
66
+ throw bad('send_frame widget must be a ChatKit widget object with a `type` (Card, ListView or Basic)');
67
+ const chars = JSON.stringify(widget).length;
68
+ if (chars > FRAME_MAX_CHARS)
69
+ throw bad(`send_frame widget is ${chars} characters; the limit is ${FRAME_MAX_CHARS}`);
70
+ return widget;
71
+ }
72
+
73
+ export function buildFrameContent(args: Record<string, unknown>): { frame: FrameContent; title: string } {
74
+ const widget = parseWidget(args.widget);
75
+ const derived = frameSummary(widget);
76
+ const title = clip(args.title, MAX_TITLE, 'title') ?? derived.title?.slice(0, MAX_TITLE);
77
+ const description = clip(args.description, MAX_DESCRIPTION, 'description') ?? derived.description?.slice(0, MAX_DESCRIPTION);
78
+ const frame: FrameContent = {
79
+ ...(title ? { title } : {}),
80
+ ...(description && description !== title ? { description } : {}),
81
+ widget,
82
+ };
83
+ return { frame, title: title ?? 'Frame' };
84
+ }
85
+
86
+ export function asFrameAction(c: object): FrameActionContent | undefined {
87
+ const v = c as Partial<FrameActionContent>;
88
+ if (typeof v.frameId !== 'string' || !isNode(v.action) || typeof v.action.type !== 'string') return undefined;
89
+ const payload = isNode(v.action.payload) ? v.action.payload : undefined;
90
+ return {
91
+ frameId: v.frameId,
92
+ action: payload ? { type: v.action.type, payload } : { type: v.action.type },
93
+ ...(typeof v.label === 'string' && v.label ? { label: v.label } : {}),
94
+ };
95
+ }
@@ -79,6 +79,46 @@ export const XMTP_TOOLS: StationTool[] = [
79
79
  return ctx.okJson(await ctx.call('ask', args));
80
80
  },
81
81
  },
82
+ {
83
+ name: 'send_frame',
84
+ group: 'write',
85
+ description:
86
+ 'Post a frame in a Stage conversation: a small interactive view described in OpenAI ChatKit ' +
87
+ 'widget JSON (the same nodes and props as ChatKit: a Card, ListView or Basic root with Row, Col, ' +
88
+ 'Box, Text, Title, Caption, Markdown, Badge, Icon, Image, Button, Divider, Spacer, Form, Input, ' +
89
+ 'Textarea, Select, DatePicker, Checkbox, RadioGroup, Table...). Stage shows it in the chat as a card ' +
90
+ '(title and description) that opens full screen. A tap on a Button onClickAction, a ListViewItem ' +
91
+ 'onClickAction, a Card confirm/cancel or a Form submit comes back to you as a reply to the frame: ' +
92
+ '"Frame action: <type> <payload JSON>", with the values of the form fields added to the payload by ' +
93
+ 'name. Images must be https. Args: line (required), widget (required, the ChatKit widget object, at ' +
94
+ 'most 64K characters of JSON), title? and description? (shown on the card; taken from the first ' +
95
+ 'Title and text in the widget when missing). xmtp-only. Returns the frame messageId.',
96
+ inputSchema: {
97
+ type: 'object',
98
+ properties: {
99
+ line: lineProp,
100
+ widget: {
101
+ type: 'object',
102
+ description:
103
+ 'ChatKit widget JSON, e.g. {"type":"Card","children":[{"type":"Title","value":"Deploy?"},' +
104
+ '{"type":"Button","label":"Ship it","onClickAction":{"type":"deploy","payload":{"env":"prod"}}}]}.',
105
+ additionalProperties: true,
106
+ },
107
+ title: { type: 'string', description: 'Card title in the chat (200 characters at most).' },
108
+ description: { type: 'string', description: 'Card description in the chat (1000 characters at most).' },
109
+ },
110
+ required: ['line', 'widget'],
111
+ },
112
+ async handle(a, ctx) {
113
+ const line = str(a.line);
114
+ if (!line) return ctx.err('send_frame requires `line`');
115
+ if (a.widget === undefined) return ctx.err('send_frame requires `widget`');
116
+ const args: Record<string, unknown> = { line, widget: a.widget };
117
+ if (a.title !== undefined) args.title = a.title;
118
+ if (a.description !== undefined) args.description = a.description;
119
+ return ctx.okJson(await ctx.call('sendFrame', args));
120
+ },
121
+ },
82
122
  {
83
123
  name: 'dm',
84
124
  group: 'write',
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.231"
2
+ "version": "0.1.0-beta.232"
3
3
  }