@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 +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/STAGE.md +20 -4
- package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-tools.ts +32 -18
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +12 -7
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +34 -2
- package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +10 -13
- package/runtime/node_modules/@metro-labs/xmtp/src/codecs.ts +3 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/emit-payloads.ts +0 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/frames.ts +100 -17
- package/runtime/node_modules/@metro-labs/xmtp/src/station.ts +1 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/tools.ts +0 -40
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
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: `
|
|
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
|
|
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
|
|
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
|
-
|
|
128
|
-
|
|
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
|
|
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).
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
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(`
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
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(
|
|
67
|
-
|
|
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
|
-
|
|
74
|
-
const
|
|
75
|
-
const
|
|
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
|
-
|
|
164
|
+
...body.content,
|
|
82
165
|
};
|
|
83
166
|
return { frame, title: title ?? 'Frame' };
|
|
84
167
|
}
|
|
@@ -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',
|
package/runtime/runtime.json
CHANGED