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

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.233",
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.233",
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 {"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
+ - `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, the arguments of one `send`:
48
+ ```json
49
+ {"line": "…", "frame": {"widget": {"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.
@@ -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;
@@ -160,7 +160,11 @@ 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
+ 'e3b8f9bbdb205311bc643aaf91d9d903a91204fd5b55ca949b85cdfa6fd00136',
167
+ ]);
164
168
  const PRIOR_MEMORY: ReadonlySet<string> = new Set();
165
169
 
166
170
  interface ShippedSkill {
@@ -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,28 @@ 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. A tap comes back to you as a reply to the frame: "Frame action: <type> <payload ' +
77
+ "JSON>\". It is sent after the text, and the message_id returned is the frame's. The stage " +
78
+ 'skill lists the widget nodes, with an example.',
79
+ properties: {
80
+ widget: {
81
+ type: 'object',
82
+ description:
83
+ 'ChatKit widget JSON, e.g. {"type":"Card","children":[{"type":"Title","value":"Deploy?"},' +
84
+ '{"type":"Button","label":"Ship it","onClickAction":{"type":"deploy","payload":{"env":"prod"}}}]}.',
85
+ additionalProperties: true,
86
+ },
87
+ title: { type: 'string', description: 'Card title in the chat (200 characters at most).' },
88
+ description: { type: 'string', description: 'Card description in the chat (1000 characters at most).' },
89
+ },
90
+ required: ['widget'],
91
+ },
69
92
  },
70
93
  required: ['line'],
71
94
  },
@@ -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';
@@ -30,10 +31,18 @@ const badArgs = (message: string): TrainError =>
30
31
 
31
32
  async function send(id: string, args: Args): Promise<void> {
32
33
  const { line, text } = args as { line: string; text: string };
34
+ const built = args.frame === undefined ? undefined : buildFrameContent(args.frame);
33
35
  const { acct, conv } = await convOf(line);
34
36
  if (!conv) throw noConv(line);
35
- const messageId = await conv.sendText(text);
36
- 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
+ }
37
46
  respond(id, { result: { messageId } });
38
47
  }
39
48
 
@@ -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,99 @@
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(`frame ${name} must be a string`);
52
+ const out = value.trim().slice(0, max);
53
+ return out === '' ? undefined : out;
54
+ }
55
+
56
+ function parseJson(raw: unknown, name: string): unknown {
57
+ if (typeof raw !== 'string') return raw;
58
+ try {
59
+ return JSON.parse(raw);
60
+ } catch {
61
+ throw bad(`${name} is not valid JSON`);
62
+ }
63
+ }
64
+
65
+ function parseWidget(raw: unknown): Node {
66
+ const widget = parseJson(raw, 'frame widget');
67
+ if (!isNode(widget) || typeof widget.type !== 'string' || widget.type === '')
68
+ throw bad('frame widget must be a ChatKit widget object with a `type` (Card, ListView or Basic)');
69
+ const chars = JSON.stringify(widget).length;
70
+ if (chars > FRAME_MAX_CHARS)
71
+ throw bad(`frame widget is ${chars} characters; the limit is ${FRAME_MAX_CHARS}`);
72
+ return widget;
73
+ }
74
+
75
+ export function buildFrameContent(raw: unknown): { frame: FrameContent; title: string } {
76
+ const args = parseJson(raw, 'frame');
77
+ if (!isNode(args)) throw bad('frame must be an object: {widget, title?, description?}');
78
+ const widget = parseWidget(args.widget);
79
+ const derived = frameSummary(widget);
80
+ const title = clip(args.title, MAX_TITLE, 'title') ?? derived.title?.slice(0, MAX_TITLE);
81
+ const description = clip(args.description, MAX_DESCRIPTION, 'description') ?? derived.description?.slice(0, MAX_DESCRIPTION);
82
+ const frame: FrameContent = {
83
+ ...(title ? { title } : {}),
84
+ ...(description && description !== title ? { description } : {}),
85
+ widget,
86
+ };
87
+ return { frame, title: title ?? 'Frame' };
88
+ }
89
+
90
+ export function asFrameAction(c: object): FrameActionContent | undefined {
91
+ const v = c as Partial<FrameActionContent>;
92
+ if (typeof v.frameId !== 'string' || !isNode(v.action) || typeof v.action.type !== 'string') return undefined;
93
+ const payload = isNode(v.action.payload) ? v.action.payload : undefined;
94
+ return {
95
+ frameId: v.frameId,
96
+ action: payload ? { type: v.action.type, payload } : { type: v.action.type },
97
+ ...(typeof v.label === 'string' && v.label ? { label: v.label } : {}),
98
+ };
99
+ }
@@ -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
  };
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.231"
2
+ "version": "0.1.0-beta.233"
3
3
  }