@stage-labs/metro 0.1.0-beta.233 → 0.1.0-beta.235
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 +16 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +11 -2
- package/runtime/node_modules/@metro-labs/xmtp/src/actions-conv.ts +21 -19
- package/runtime/node_modules/@metro-labs/xmtp/src/codecs.ts +12 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/delete-requests.ts +34 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/emit-payloads.ts +0 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/frames.ts +89 -10
- package/runtime/node_modules/@metro-labs/xmtp/src/index.ts +1 -0
- 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.235",
|
|
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.
|
|
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'],
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { IdentifierKind } from '@xmtp/node-sdk';
|
|
1
|
+
import { IdentifierKind, type DecodedMessage } from '@xmtp/node-sdk';
|
|
2
2
|
import { accountForCall, convOf, lineOf, parseLine } from './accounts.js';
|
|
3
3
|
import { respond } from '@metro-labs/core/stations/station-runtime';
|
|
4
4
|
import { resolveMsgId } from './wire.js';
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
groupRemoveMembers,
|
|
15
15
|
} from './group.js';
|
|
16
16
|
import { updateChannelMeta } from './actions-meta.js';
|
|
17
|
+
import { deletedByRequests, isDeleteRequest, superAdminCheck } from './delete-requests.js';
|
|
17
18
|
|
|
18
19
|
type Args = Record<string, unknown>;
|
|
19
20
|
type Handler = (id: string, args: Args) => Promise<void>;
|
|
@@ -42,6 +43,17 @@ function upTo<T extends { id: string }>(all: T[], before: string | undefined): T
|
|
|
42
43
|
return all.slice(0, at);
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
function textOf(m: DecodedMessage): string {
|
|
47
|
+
try {
|
|
48
|
+
const cc: unknown = m.content;
|
|
49
|
+
return typeof cc === 'string' ? cc : `[${m.contentType?.typeId ?? 'unknown'}]`;
|
|
50
|
+
} catch {
|
|
51
|
+
return `[${m.contentType?.typeId ?? 'unknown'}]`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const DELETED = { text: '[deletedMessage]', contentType: 'deletedMessage' };
|
|
56
|
+
|
|
45
57
|
async function read(id: string, args: Args): Promise<void> {
|
|
46
58
|
const { line, limit, before } = args as { line: string; limit?: number; before?: string };
|
|
47
59
|
const { conv } = await convOf(line);
|
|
@@ -50,28 +62,18 @@ async function read(id: string, args: Args): Promise<void> {
|
|
|
50
62
|
const lim = Math.min(Math.max(1, limit ?? 20), 200);
|
|
51
63
|
await conv.sync().catch(() => undefined);
|
|
52
64
|
const all = await conv.messages();
|
|
53
|
-
const
|
|
65
|
+
const deleted = deletedByRequests(all, superAdminCheck(conv));
|
|
66
|
+
const slice = upTo(all.filter((m) => !isDeleteRequest(m)), before).slice(-lim);
|
|
54
67
|
const parsed = parseLine(line);
|
|
55
68
|
if (!parsed)
|
|
56
69
|
throw new TrainError('NOT_FOUND', `could not parse line ${line}`);
|
|
57
70
|
const acctId = parsed.accountId;
|
|
58
|
-
const messages = slice.map((m) => {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
} catch {
|
|
65
|
-
text = `[${m.contentType?.typeId ?? 'unknown'}]`;
|
|
66
|
-
}
|
|
67
|
-
return {
|
|
68
|
-
id: m.id,
|
|
69
|
-
ts: new Date(Number(m.sentAtNs / 1_000_000n)).toISOString(),
|
|
70
|
-
from: `metro://xmtp/${acctId}/user/${m.senderInboxId}`,
|
|
71
|
-
text,
|
|
72
|
-
contentType: m.contentType?.typeId ?? 'unknown',
|
|
73
|
-
};
|
|
74
|
-
});
|
|
71
|
+
const messages = slice.map((m) => ({
|
|
72
|
+
id: m.id,
|
|
73
|
+
ts: new Date(Number(m.sentAtNs / 1_000_000n)).toISOString(),
|
|
74
|
+
from: `metro://xmtp/${acctId}/user/${m.senderInboxId}`,
|
|
75
|
+
...(deleted.has(m.id) ? DELETED : { text: textOf(m), contentType: m.contentType?.typeId ?? 'unknown' }),
|
|
76
|
+
}));
|
|
75
77
|
respond(id, { result: { line, count: messages.length, messages } });
|
|
76
78
|
}
|
|
77
79
|
|
|
@@ -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';
|
|
@@ -198,6 +200,14 @@ export const frameActionText = (c: FrameActionContent): string => {
|
|
|
198
200
|
};
|
|
199
201
|
const FrameActionCodec = makeJsonCodec<FrameActionContent>(ContentTypeFrameAction, frameActionText);
|
|
200
202
|
|
|
203
|
+
export const ContentTypeDeleteRequest: ContentTypeId = {
|
|
204
|
+
authorityId: 'stage.box',
|
|
205
|
+
typeId: 'deleteRequest',
|
|
206
|
+
versionMajor: 1,
|
|
207
|
+
versionMinor: 0,
|
|
208
|
+
};
|
|
209
|
+
const DeleteRequestCodec = makeJsonCodec<{ messageId: string }>(ContentTypeDeleteRequest, () => 'Message deleted');
|
|
210
|
+
|
|
201
211
|
const ContentTypeDeleteMessage: ContentTypeId = {
|
|
202
212
|
authorityId: 'xmtp.org',
|
|
203
213
|
typeId: 'deleteMessage',
|
|
@@ -231,4 +241,5 @@ export const CODECS = (): ContentCodec[] => [
|
|
|
231
241
|
new SignatureReferenceCodec(),
|
|
232
242
|
new FrameCodec(),
|
|
233
243
|
new FrameActionCodec(),
|
|
244
|
+
new DeleteRequestCodec(),
|
|
234
245
|
];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { DecodedMessage } from '@xmtp/node-sdk';
|
|
2
|
+
import { ContentTypeDeleteRequest } from './codecs.js';
|
|
3
|
+
|
|
4
|
+
type Listed = Pick<DecodedMessage, 'id' | 'senderInboxId' | 'contentType' | 'content'>;
|
|
5
|
+
|
|
6
|
+
export const isDeleteRequest = (m: Pick<Listed, 'contentType'>): boolean =>
|
|
7
|
+
m.contentType?.authorityId === ContentTypeDeleteRequest.authorityId &&
|
|
8
|
+
m.contentType.typeId === ContentTypeDeleteRequest.typeId;
|
|
9
|
+
|
|
10
|
+
function targetOf(m: Listed): string | undefined {
|
|
11
|
+
if (!isDeleteRequest(m)) return undefined;
|
|
12
|
+
const id = (m.content as { messageId?: unknown } | undefined)?.messageId;
|
|
13
|
+
return typeof id === 'string' && id ? id : undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function superAdminCheck(conv: unknown): (inboxId: string) => boolean {
|
|
17
|
+
const group = conv as { isSuperAdmin?: (inboxId: string) => boolean };
|
|
18
|
+
return (inboxId) => typeof group.isSuperAdmin === 'function' && group.isSuperAdmin(inboxId);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function deletedByRequests(
|
|
22
|
+
messages: Listed[],
|
|
23
|
+
isSuperAdmin: (inboxId: string) => boolean,
|
|
24
|
+
): Set<string> {
|
|
25
|
+
const senders = new Map(messages.map((m) => [m.id, m.senderInboxId]));
|
|
26
|
+
const deleted = new Set<string>();
|
|
27
|
+
for (const m of messages) {
|
|
28
|
+
const target = targetOf(m);
|
|
29
|
+
const sender = target === undefined ? undefined : senders.get(target);
|
|
30
|
+
if (target !== undefined && sender !== undefined && (sender === m.senderInboxId || isSuperAdmin(m.senderInboxId)))
|
|
31
|
+
deleted.add(target);
|
|
32
|
+
}
|
|
33
|
+
return deleted;
|
|
34
|
+
}
|
|
@@ -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
|
|
66
|
-
const
|
|
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(
|
|
69
|
-
|
|
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))
|
|
78
|
-
|
|
79
|
-
const
|
|
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
|
-
|
|
164
|
+
...body.content,
|
|
86
165
|
};
|
|
87
166
|
return { frame, title: title ?? 'Frame' };
|
|
88
167
|
}
|
package/runtime/runtime.json
CHANGED