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

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.232",
3
+ "version": "0.1.0-beta.234",
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.232",
3
+ "version": "0.1.0-beta.234",
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",
@@ -42,11 +42,11 @@ Move it from Backlog or To-do to In progress, then In review, then Done. Use Blo
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
43
 
44
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.
45
+ - Send: `send {"line": "…", "frame": {"title": "Deploy", "description": "Version 2.4 is ready", "widget": {…}}}`. A frame goes with `send`, like a file: there is no separate tool. `title` and `description` are optional: they come from the first `Title` and text in the widget when missing. With `text` as well, the text goes first, and the `message_id` you get back is the frame's.
46
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:
47
+ - Example, the arguments of one `send`:
48
48
  ```json
49
- {"type": "Card", "children": [
49
+ {"line": "…", "frame": {"widget": {"type": "Card", "children": [
50
50
  {"type": "Title", "value": "Deploy version 2.4?"},
51
51
  {"type": "Text", "value": "3 fixes, no migration.", "color": "secondary"},
52
52
  {"type": "Input", "name": "note", "placeholder": "Note (optional)"},
@@ -54,6 +54,22 @@ Move it from Backlog or To-do to In progress, then In review, then Done. Use Blo
54
54
  {"type": "Button", "label": "Deploy", "onClickAction": {"type": "deploy", "payload": {"version": "2.4"}}},
55
55
  {"type": "Button", "label": "Not now", "color": "secondary", "onClickAction": {"type": "deploy.cancel"}}
56
56
  ]}
57
- ]}
57
+ ]}}}
58
58
  ```
59
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.
60
+ - Screens: send `screens` instead of `widget` to put several screens in one frame, e.g. a list and a page per item. Each screen is a widget, or `{"title", "widget"}` for its own title in Stage's top bar. `start` names the first screen and is required. The action `{"type": "frame.open", "payload": {"screen": "<id>"}}` opens a screen and `{"type": "frame.back"}` goes back. They stay in Stage: no message comes to you. Stage's back arrow goes to the previous screen, and to the chat from the first one. Every other action comes to you as usual, with `screen` (the screen it was tapped on) in its payload. At most 50 screens and 64K characters for all of them; `start` and every `frame.open` must name a screen. Example:
61
+ ```json
62
+ {"line": "…", "frame": {"title": "Open invoices", "start": "home", "screens": {
63
+ "home": {"type": "ListView", "children": [
64
+ {"type": "ListViewItem", "onClickAction": {"type": "frame.open", "payload": {"screen": "inv-1"}},
65
+ "children": [{"type": "Text", "value": "Acme, CHF 420"}]}
66
+ ]},
67
+ "inv-1": {"title": "Acme", "widget": {"type": "Card", "children": [
68
+ {"type": "Title", "value": "Invoice 1040, CHF 420"},
69
+ {"type": "Row", "children": [
70
+ {"type": "Button", "label": "Back", "color": "secondary", "onClickAction": {"type": "frame.back"}},
71
+ {"type": "Button", "label": "Pay", "onClickAction": {"type": "invoice.pay", "payload": {"id": 1040}}}
72
+ ]}
73
+ ]}}
74
+ }}}
75
+ ```
@@ -112,6 +112,7 @@ export interface Station {
112
112
  readsProfiles?: boolean;
113
113
  readFilters?: ReadonlySet<ReadFilter>;
114
114
  approvals?: boolean;
115
+ sendsFrames?: boolean;
115
116
  attachmentMode: AttachmentMode;
116
117
  forget?: (accountId: string) => void;
117
118
  forgetExcept?: (keptAccountIds: readonly string[]) => void;
@@ -163,6 +163,8 @@ const PRIOR_METRO: ReadonlySet<string> = new Set([
163
163
  const PRIOR_STAGE: ReadonlySet<string> = new Set([
164
164
  'dd7d30474765de3865ddc87c1cf3220161ded09502027cdff18597e5df0aab43',
165
165
  '47439ecbe661ca70471a6839ab30a963714c296ad8c14ca23c6eebc3929038cc',
166
+ 'e3b8f9bbdb205311bc643aaf91d9d903a91204fd5b55ca949b85cdfa6fd00136',
167
+ '08a30f5cd5cf8013ab70c1fa9e1109018c0337552296a8efe5c0cf543b284991',
166
168
  ]);
167
169
  const PRIOR_MEMORY: ReadonlySet<string> = new Set();
168
170
 
@@ -60,6 +60,21 @@ function assertDelivered(
60
60
  );
61
61
  }
62
62
 
63
+ const frameOf = (m: MessageArgs): Record<string, unknown> =>
64
+ m.a.frame === undefined ? {} : { frame: m.a.frame };
65
+
66
+ const sentLabels = (m: MessageArgs, text: string | undefined): string[] => [
67
+ ...(text ? ['text'] : []),
68
+ ...(m.a.frame === undefined ? [] : ['frame']),
69
+ ];
70
+
71
+ function nativeBody(m: MessageArgs, text: string | undefined, replyTo: string | undefined): Record<string, unknown> {
72
+ const body: Record<string, unknown> = { line: m.line, ...frameOf(m) };
73
+ if (text) body.text = text;
74
+ if (text && replyTo) body.replyTo = replyTo;
75
+ return body;
76
+ }
77
+
63
78
  async function sendNative(
64
79
  m: MessageArgs,
65
80
  text: string | undefined,
@@ -67,16 +82,9 @@ async function sendNative(
67
82
  atts: ResolvedAttachment[],
68
83
  ): Promise<Sent> {
69
84
  const { line, ctx, station } = m;
70
- const labels: string[] = [];
85
+ const labels = sentLabels(m, text);
71
86
  let messageId: string | undefined;
72
- if (text) {
73
- const response = await ctx.call(
74
- 'send',
75
- replyTo ? { line, text, replyTo } : { line, text },
76
- );
77
- messageId = messageIdOf(response);
78
- labels.push('text');
79
- }
87
+ if (labels.length) messageId = messageIdOf(await ctx.call('send', nativeBody(m, text, replyTo)));
80
88
  if (!atts.length) return { labels, messageId };
81
89
  const delivered = station.sendAttachments
82
90
  ? await station.sendAttachments(line, atts, ctx)
@@ -93,7 +101,7 @@ async function sendForwarded(
93
101
  atts: ResolvedAttachment[],
94
102
  ): Promise<Sent> {
95
103
  const { line, ctx, station } = m;
96
- const args: Record<string, unknown> = { line };
104
+ const args: Record<string, unknown> = { line, ...frameOf(m) };
97
105
  if (text) args.text = text;
98
106
  if (typeof m.a.subject === 'string' && m.a.subject.trim() !== '') args.subject = m.a.subject;
99
107
  if (replyTo) args.replyTo = replyTo;
@@ -101,8 +109,7 @@ async function sendForwarded(
101
109
  const response = await ctx.call('send', args);
102
110
  const messageId = messageIdOf(response);
103
111
  const thread = threadOf(response, line);
104
- const labels: string[] = [];
105
- if (text) labels.push('text');
112
+ const labels = sentLabels(m, text);
106
113
  if (atts.length) {
107
114
  const delivered = deliveredLabels(response);
108
115
  assertDelivered(station, delivered, atts);
@@ -116,6 +123,16 @@ const unsupported = (station: Station, atts: CanonicalAttachment[]): string =>
116
123
  .map((a) => kindOf(a.mime ?? '', a.path ?? a.url ?? a.name ?? ''))
117
124
  .join(', ')}); send a link in \`text\` instead`;
118
125
 
126
+ const NOTHING_TO_SEND = 'send requires `text`, `attachments` or `frame`';
127
+
128
+ function refusal(m: MessageArgs, text: string | undefined, requested: CanonicalAttachment[]): string | undefined {
129
+ if (!text && !requested.length && m.a.frame === undefined) return NOTHING_TO_SEND;
130
+ if (m.a.frame !== undefined && m.station.sendsFrames !== true)
131
+ return `${m.station.name} cannot send a frame; frames are Stage (XMTP) only`;
132
+ if (requested.length && m.station.attachmentMode === 'none') return unsupported(m.station, requested);
133
+ return undefined;
134
+ }
135
+
119
136
  function noteSent(m: MessageArgs, messageId: string | undefined): void {
120
137
  if (messageId !== undefined) m.onSent?.(messageId);
121
138
  }
@@ -124,10 +141,8 @@ async function handleSend(m: MessageArgs): Promise<ToolResult> {
124
141
  const text = m.a.text as string | undefined;
125
142
  const replyTo = m.a.reply_to as string | undefined;
126
143
  const requested = (m.a.attachments as CanonicalAttachment[] | undefined) ?? [];
127
- if (!text && !requested.length)
128
- return errResult('send requires `text` or `attachments`');
129
- if (requested.length && m.station.attachmentMode === 'none')
130
- return errResult(unsupported(m.station, requested));
144
+ const refused = refusal(m, text, requested);
145
+ if (refused !== undefined) return errResult(refused);
131
146
  const atts = await resolveAttachments(requested, {
132
147
  allowed: allowedAgents(currentIdentity()),
133
148
  });
@@ -138,8 +153,7 @@ async function handleSend(m: MessageArgs): Promise<ToolResult> {
138
153
  const sent = native
139
154
  ? await sendNative(m, text, replyTo, atts)
140
155
  : await sendForwarded(m, text, replyTo, atts);
141
- if (!sent.labels.length)
142
- return errResult('send requires `text` or `attachments`');
156
+ if (!sent.labels.length) return errResult(NOTHING_TO_SEND);
143
157
  noteSent(m, sent.messageId);
144
158
  const done = withId(`sent: ${sent.labels.join(', ')}`, sent.messageId);
145
159
  return ok(sent.thread === undefined ? done : `${done} — new thread line: ${sent.thread}`);
@@ -37,6 +37,17 @@ function channelOf(input: Record<string, unknown>): string | undefined {
37
37
  return typeof input.station === 'string' && input.station !== '' ? input.station : undefined;
38
38
  }
39
39
 
40
+ function argText(value: unknown): string | undefined {
41
+ const plain = valueText(value);
42
+ if (plain !== undefined || value === null || typeof value !== 'object') return plain;
43
+ return JSON.stringify(value);
44
+ }
45
+
46
+ function fieldText(key: string, value: unknown): string | undefined {
47
+ if (key === 'attachments' && Array.isArray(value)) return `${String(value.length)} file(s)`;
48
+ return key === 'frame' ? argText(value) : valueText(value);
49
+ }
50
+
40
51
  function metroLines(tool: string, input: Record<string, unknown>): string[] {
41
52
  const lines = [`Approval needed: ${tool}`];
42
53
  const channel = channelOf(input);
@@ -44,7 +55,7 @@ function metroLines(tool: string, input: Record<string, unknown>): string[] {
44
55
  const keys = [...LEAD, ...Object.keys(input).filter((k) => !LEAD.includes(k))];
45
56
  for (const key of keys) {
46
57
  if (PLACE.has(key)) continue;
47
- const text = key === 'attachments' && Array.isArray(input.attachments) ? `${String(input.attachments.length)} file(s)` : valueText(input[key]);
58
+ const text = fieldText(key, input[key]);
48
59
  if (text !== undefined) lines.push(`${key === 'text' ? 'Text' : key}: ${key === 'text' ? `"${shorten(text)}"` : shorten(text)}`);
49
60
  }
50
61
  return lines;
@@ -52,12 +63,6 @@ function metroLines(tool: string, input: Record<string, unknown>): string[] {
52
63
 
53
64
  const ARGS_MAX = 8;
54
65
 
55
- function argText(value: unknown): string | undefined {
56
- const plain = valueText(value);
57
- if (plain !== undefined || value === null || typeof value !== 'object') return plain;
58
- return JSON.stringify(value);
59
- }
60
-
61
66
  function connectorLines(name: string, tool: string, input: Record<string, unknown> | undefined): string[] {
62
67
  const lines = [`Approval needed: ${tool}`, `Connector: ${name}`];
63
68
  const args = Object.entries(input ?? {}).flatMap(([key, value]) => {
@@ -34,7 +34,7 @@ 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
- 'subject?, attachments?. The station is derived from the line. Outlook and Gmail: send to ' +
37
+ 'subject?, attachments?, frame?. The station is derived from the line. Outlook and Gmail: send to ' +
38
38
  'metro://<outlook|gmail>/<account>/<email address> to start a new email (subject defaults to the ' +
39
39
  'first line of text); the result names the new thread line. Each attachment names EXACTLY ONE ' +
40
40
  'source, and the choice matters: `upload` (an `upload_id` from `create_upload`, the ' +
@@ -42,7 +42,8 @@ export const COMMON_TOOLS: ToolDef[] = [
42
42
  '`data` (base64 inline -- TINY FILES ONLY, a few KB, because the base64 has to be ' +
43
43
  'written out verbatim in the call and longer than that it corrupts); `url` (the daemon ' +
44
44
  'fetches it, so the file must already be public); `path` (resolved on the DAEMON host, ' +
45
- 'which is not your machine). At least one of text/attachments is required. ' +
45
+ 'which is not your machine). Stage (XMTP) only: `frame` posts an interactive view. At least one ' +
46
+ 'of text/attachments/frame is required. ' +
46
47
  'The success line names each attachment the station actually delivered; a station that ' +
47
48
  'cannot carry a file errors instead of reporting success.',
48
49
  inputSchema: {
@@ -66,6 +67,37 @@ export const COMMON_TOOLS: ToolDef[] = [
66
67
  description: 'Optional files to attach.',
67
68
  items: attachmentItem,
68
69
  },
70
+ frame: {
71
+ type: 'object',
72
+ description:
73
+ 'Stage (XMTP) only: a frame, a small interactive view that Stage shows as a card (title and ' +
74
+ 'description) and opens full screen. `widget` is OpenAI ChatKit widget JSON (a Card, ListView ' +
75
+ 'or Basic root, 64K characters at most); a missing `title` or `description` is taken from the ' +
76
+ 'widget. Or give `screens` instead of `widget` for several screens in one frame. A tap comes back ' +
77
+ 'to you as a reply to the frame: "Frame action: <type> <payload ' +
78
+ "JSON>\". It is sent after the text, and the message_id returned is the frame's. The stage " +
79
+ 'skill lists the widget nodes, with an example.',
80
+ properties: {
81
+ widget: {
82
+ type: 'object',
83
+ description:
84
+ 'ChatKit widget JSON, e.g. {"type":"Card","children":[{"type":"Title","value":"Deploy?"},' +
85
+ '{"type":"Button","label":"Ship it","onClickAction":{"type":"deploy","payload":{"env":"prod"}}}]}.',
86
+ additionalProperties: true,
87
+ },
88
+ screens: {
89
+ type: 'object',
90
+ description:
91
+ 'Instead of `widget`: up to 50 screens by id, each a widget or {"title", "widget"}, 64K ' +
92
+ 'characters in all. An action {"type":"frame.open","payload":{"screen":"<id>"}} opens a ' +
93
+ 'screen and {"type":"frame.back"} goes back, in Stage, with no message to you.',
94
+ additionalProperties: true,
95
+ },
96
+ start: { type: 'string', description: 'Required with `screens`: the id of the first screen.' },
97
+ title: { type: 'string', description: 'Card title in the chat (200 characters at most).' },
98
+ description: { type: 'string', description: 'Card description in the chat (1000 characters at most).' },
99
+ },
100
+ },
69
101
  },
70
102
  required: ['line'],
71
103
  },
@@ -31,10 +31,18 @@ const badArgs = (message: string): TrainError =>
31
31
 
32
32
  async function send(id: string, args: Args): Promise<void> {
33
33
  const { line, text } = args as { line: string; text: string };
34
+ const built = args.frame === undefined ? undefined : buildFrameContent(args.frame);
34
35
  const { acct, conv } = await convOf(line);
35
36
  if (!conv) throw noConv(line);
36
- const messageId = await conv.sendText(text);
37
- emitOutbound(acct.cfg.id, line, messageId, text);
37
+ let messageId: string | undefined;
38
+ if (text || !built) {
39
+ messageId = await conv.sendText(text);
40
+ emitOutbound(acct.cfg.id, line, messageId, text);
41
+ }
42
+ if (built) {
43
+ messageId = await conv.send(new FrameCodec().encode(built.frame));
44
+ emitOutbound(acct.cfg.id, line, messageId, `Frame: ${built.title}`);
45
+ }
38
46
  respond(id, { result: { messageId } });
39
47
  }
40
48
 
@@ -49,16 +57,6 @@ async function ask(id: string, args: Args): Promise<void> {
49
57
  respond(id, { result: { messageId: sentId, pollId: mintedId } });
50
58
  }
51
59
 
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
-
62
60
  async function referenceInboxOf(conv: Conversation, xmtpMsgId: string): Promise<string> {
63
61
  const recent = await conv.messages({ limit: 200, direction: 1 });
64
62
  const inbox = recent.find((m) => m.id === xmtpMsgId)?.senderInboxId;
@@ -222,7 +220,6 @@ const handlers: Record<string, (id: string, args: Args) => Promise<void>> = {
222
220
  resolve_sender: resolveSenderAction,
223
221
  send,
224
222
  ask,
225
- sendFrame,
226
223
  react,
227
224
  reply,
228
225
  delete: remove,
@@ -173,7 +173,9 @@ export const ContentTypeFrame: ContentTypeId = {
173
173
  export interface FrameContent {
174
174
  title?: string;
175
175
  description?: string;
176
- widget: Record<string, unknown>;
176
+ widget?: Record<string, unknown>;
177
+ screens?: Record<string, unknown>;
178
+ start?: string;
177
179
  }
178
180
  export const frameFallback = (c: FrameContent): string => {
179
181
  const head = c.title ? `Frame: ${c.title}` : 'Frame';
@@ -312,7 +312,6 @@ function frameEnvelope(
312
312
  const frame: FrameContent = {
313
313
  ...(typeof f.title === 'string' ? { title: f.title } : {}),
314
314
  ...(typeof f.description === 'string' ? { description: f.description } : {}),
315
- widget: typeof f.widget === 'object' && f.widget !== null ? f.widget : {},
316
315
  };
317
316
  return { ...base, text: frameFallback(frame), payload: { contentType: typeId, frame: c } };
318
317
  }
@@ -2,6 +2,9 @@ import { TrainError } from '@metro-labs/core/train-error';
2
2
  import type { FrameActionContent, FrameContent } from './codecs.js';
3
3
 
4
4
  export const FRAME_MAX_CHARS = 64 * 1024;
5
+ export const FRAME_MAX_SCREENS = 50;
6
+ const MAX_SCREEN_ID = 120;
7
+ const FRAME_OPEN = 'frame.open';
5
8
  const MAX_TITLE = 200;
6
9
  const MAX_DESCRIPTION = 1000;
7
10
  const SUMMARY_SCAN = 80;
@@ -48,37 +51,117 @@ export function frameSummary(widget: Node): { title?: string; description?: stri
48
51
 
49
52
  function clip(value: unknown, max: number, name: string): string | undefined {
50
53
  if (value === undefined) return undefined;
51
- if (typeof value !== 'string') throw bad(`send_frame ${name} must be a string`);
54
+ if (typeof value !== 'string') throw bad(`frame ${name} must be a string`);
52
55
  const out = value.trim().slice(0, max);
53
56
  return out === '' ? undefined : out;
54
57
  }
55
58
 
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
- }
59
+ function parseJson(raw: unknown, name: string): unknown {
60
+ if (typeof raw !== 'string') return raw;
61
+ try {
62
+ return JSON.parse(raw);
63
+ } catch {
64
+ throw bad(`${name} is not valid JSON`);
64
65
  }
66
+ }
67
+
68
+ function checkSize(value: unknown, name: string): void {
69
+ const chars = JSON.stringify(value).length;
70
+ if (chars > FRAME_MAX_CHARS) throw bad(`${name} is ${chars} characters; the limit is ${FRAME_MAX_CHARS}`);
71
+ }
72
+
73
+ function parseWidget(raw: unknown, name = 'frame widget'): Node {
74
+ const widget = parseJson(raw, name);
65
75
  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}`);
76
+ throw bad(`${name} must be a ChatKit widget object with a \`type\` (Card, ListView or Basic)`);
77
+ checkSize(widget, name);
70
78
  return widget;
71
79
  }
72
80
 
73
- export function buildFrameContent(args: Record<string, unknown>): { frame: FrameContent; title: string } {
74
- const widget = parseWidget(args.widget);
75
- const derived = frameSummary(widget);
81
+ function parseScreen(id: string, raw: unknown): { screen: Node; widget: Node } {
82
+ const name = `frame screen "${id}"`;
83
+ const given = parseJson(raw, name);
84
+ if (!isNode(given) || given.type !== undefined || given.widget === undefined) {
85
+ const widget = parseWidget(given, name);
86
+ return { screen: widget, widget };
87
+ }
88
+ const widget = parseWidget(given.widget, `${name} widget`);
89
+ const title = clip(given.title, MAX_TITLE, `screen "${id}" title`);
90
+ return { screen: title ? { title, widget } : { widget }, widget };
91
+ }
92
+
93
+ function openTargets(root: unknown): string[] {
94
+ const targets: string[] = [];
95
+ const stack: unknown[] = [root];
96
+ while (stack.length > 0) {
97
+ const value = stack.pop();
98
+ if (Array.isArray(value)) stack.push(...(value as unknown[]));
99
+ else if (isNode(value)) {
100
+ if (value.type === FRAME_OPEN)
101
+ targets.push(isNode(value.payload) && typeof value.payload.screen === 'string' ? value.payload.screen : '');
102
+ stack.push(...Object.values(value));
103
+ }
104
+ }
105
+ return targets;
106
+ }
107
+
108
+ function screenIds(screens: Node): string[] {
109
+ const ids = Object.keys(screens);
110
+ if (ids.length === 0 || ids.length > FRAME_MAX_SCREENS)
111
+ throw bad(`frame screens must hold 1 to ${FRAME_MAX_SCREENS} screens, not ${ids.length}`);
112
+ const badId = ids.find((id) => id === '' || id.length > MAX_SCREEN_ID);
113
+ if (badId !== undefined) throw bad(`frame screen ids must be 1 to ${MAX_SCREEN_ID} characters`);
114
+ return ids;
115
+ }
116
+
117
+ function checkLinks(screens: Node, ids: string[], start: unknown): void {
118
+ if (typeof start !== 'string' || !ids.includes(start))
119
+ throw bad(`frame start is required with screens and must be one of their ids: ${ids.slice(0, 10).join(', ')}`);
120
+ const lost = openTargets(screens).find((target) => !ids.includes(target));
121
+ if (lost !== undefined)
122
+ throw bad(`frame.open goes to screen "${lost}", which is not in screens (${ids.slice(0, 10).join(', ')})`);
123
+ }
124
+
125
+ interface FrameBody {
126
+ content: Pick<FrameContent, 'widget' | 'screens' | 'start'>;
127
+ first: Node;
128
+ }
129
+
130
+ function parseScreens(raw: unknown, start: unknown): FrameBody {
131
+ const given = parseJson(raw, 'frame screens');
132
+ if (!isNode(given))
133
+ throw bad('frame screens must be an object of screen id to widget: {"home": {…}, "story": {"title": "…", "widget": {…}}}');
134
+ const ids = screenIds(given);
135
+ const parsed = ids.map((id) => [id, parseScreen(id, given[id])] as const);
136
+ const screens: Node = Object.fromEntries(parsed.map(([id, p]) => [id, p.screen]));
137
+ checkSize(screens, 'frame screens');
138
+ checkLinks(screens, ids, start);
139
+ const first = parsed.find(([id]) => id === start)?.[1];
140
+ if (!first || typeof start !== 'string') throw bad('frame start must name a screen');
141
+ return { content: { screens, start }, first: first.widget };
142
+ }
143
+
144
+ function frameBody(args: Node): FrameBody {
145
+ if (args.screens === undefined) {
146
+ const widget = parseWidget(args.widget);
147
+ return { content: { widget }, first: widget };
148
+ }
149
+ if (args.widget !== undefined) throw bad('frame takes `widget` or `screens`, not both');
150
+ return parseScreens(args.screens, args.start);
151
+ }
152
+
153
+ export function buildFrameContent(raw: unknown): { frame: FrameContent; title: string } {
154
+ const args = parseJson(raw, 'frame');
155
+ if (!isNode(args))
156
+ throw bad('frame must be an object: {widget, title?, description?} or {screens, start, title?, description?}');
157
+ const body = frameBody(args);
158
+ const derived = frameSummary(body.first);
76
159
  const title = clip(args.title, MAX_TITLE, 'title') ?? derived.title?.slice(0, MAX_TITLE);
77
160
  const description = clip(args.description, MAX_DESCRIPTION, 'description') ?? derived.description?.slice(0, MAX_DESCRIPTION);
78
161
  const frame: FrameContent = {
79
162
  ...(title ? { title } : {}),
80
163
  ...(description && description !== title ? { description } : {}),
81
- widget,
164
+ ...body.content,
82
165
  };
83
166
  return { frame, title: title ?? 'Frame' };
84
167
  }
@@ -13,6 +13,7 @@ export const xmtpStation: Station = {
13
13
  readsProfiles: true,
14
14
  resolvesSenders: true,
15
15
  claimsName: true,
16
+ sendsFrames: true,
16
17
  sendAttachments: xmtpSendAttachments,
17
18
  tools: XMTP_TOOLS,
18
19
  };
@@ -79,46 +79,6 @@ 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
- },
122
82
  {
123
83
  name: 'dm',
124
84
  group: 'write',
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.232"
2
+ "version": "0.1.0-beta.234"
3
3
  }