@stage-labs/metro 0.1.0-beta.233 → 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.233",
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.233",
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",
@@ -57,3 +57,19 @@ Move it from Backlog or To-do to In progress, then In review, then Done. Use Blo
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
+ ```
@@ -164,6 +164,7 @@ const PRIOR_STAGE: ReadonlySet<string> = new Set([
164
164
  'dd7d30474765de3865ddc87c1cf3220161ded09502027cdff18597e5df0aab43',
165
165
  '47439ecbe661ca70471a6839ab30a963714c296ad8c14ca23c6eebc3929038cc',
166
166
  'e3b8f9bbdb205311bc643aaf91d9d903a91204fd5b55ca949b85cdfa6fd00136',
167
+ '08a30f5cd5cf8013ab70c1fa9e1109018c0337552296a8efe5c0cf543b284991',
167
168
  ]);
168
169
  const PRIOR_MEMORY: ReadonlySet<string> = new Set();
169
170
 
@@ -73,7 +73,8 @@ export const COMMON_TOOLS: ToolDef[] = [
73
73
  'Stage (XMTP) only: a frame, a small interactive view that Stage shows as a card (title and ' +
74
74
  'description) and opens full screen. `widget` is OpenAI ChatKit widget JSON (a Card, ListView ' +
75
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 ' +
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 ' +
77
78
  "JSON>\". It is sent after the text, and the message_id returned is the frame's. The stage " +
78
79
  'skill lists the widget nodes, with an example.',
79
80
  properties: {
@@ -84,10 +85,18 @@ export const COMMON_TOOLS: ToolDef[] = [
84
85
  '{"type":"Button","label":"Ship it","onClickAction":{"type":"deploy","payload":{"env":"prod"}}}]}.',
85
86
  additionalProperties: true,
86
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.' },
87
97
  title: { type: 'string', description: 'Card title in the chat (200 characters at most).' },
88
98
  description: { type: 'string', description: 'Card description in the chat (1000 characters at most).' },
89
99
  },
90
- required: ['widget'],
91
100
  },
92
101
  },
93
102
  required: ['line'],
@@ -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;
@@ -62,27 +65,103 @@ function parseJson(raw: unknown, name: string): unknown {
62
65
  }
63
66
  }
64
67
 
65
- function parseWidget(raw: unknown): Node {
66
- const widget = parseJson(raw, 'frame widget');
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);
67
75
  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}`);
76
+ throw bad(`${name} must be a ChatKit widget object with a \`type\` (Card, ListView or Basic)`);
77
+ checkSize(widget, name);
72
78
  return widget;
73
79
  }
74
80
 
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
+
75
153
  export function buildFrameContent(raw: unknown): { frame: FrameContent; title: string } {
76
154
  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);
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);
80
159
  const title = clip(args.title, MAX_TITLE, 'title') ?? derived.title?.slice(0, MAX_TITLE);
81
160
  const description = clip(args.description, MAX_DESCRIPTION, 'description') ?? derived.description?.slice(0, MAX_DESCRIPTION);
82
161
  const frame: FrameContent = {
83
162
  ...(title ? { title } : {}),
84
163
  ...(description && description !== title ? { description } : {}),
85
- widget,
164
+ ...body.content,
86
165
  };
87
166
  return { frame, title: title ?? 'Frame' };
88
167
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.233"
2
+ "version": "0.1.0-beta.234"
3
3
  }