@stage-labs/metro 0.1.0-beta.247 → 0.1.0-beta.249
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 +17 -2
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-tools.ts +14 -9
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +20 -3
- package/runtime/node_modules/@metro-labs/xmtp/src/actions-meta.ts +2 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +15 -5
- package/runtime/node_modules/@metro-labs/xmtp/src/codecs.ts +18 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/conv-helpers.ts +2 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/frames.ts +3 -3
- package/runtime/node_modules/@metro-labs/xmtp/src/labels.ts +30 -23
- package/runtime/node_modules/@metro-labs/xmtp/src/tools.ts +9 -4
- package/runtime/node_modules/@metro-labs/xmtp/src/wallet.ts +28 -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.249",
|
|
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, and post frames (interactive ChatKit widget views). 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) and wallet cards (payment and signature requests, receipts). 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>`).
|
|
@@ -18,9 +18,10 @@ Stage (https://stage.box) is a messenger built on XMTP. A Stage channel is an XM
|
|
|
18
18
|
- Direct chat: `dm {"address": "0x…"}` opens a 1:1 chat and returns its line.
|
|
19
19
|
- With several XMTP accounts, add `"account": "<id>"` to `create_group` and `dm`.
|
|
20
20
|
|
|
21
|
-
**Labels and
|
|
21
|
+
**Labels, assignees and category.** They live in the channel's metadata, and Stage shows them on the channel.
|
|
22
22
|
- Labels: `set_channel_metadata {"line": "…", "metadata": {"labels": ["🚧 In progress", "Metro"]}}`. At most 16 labels of 24 characters each.
|
|
23
23
|
- Assignees: `set_channel_metadata {"line": "…", "metadata": {"assigned": ["0x…"]}}`. Only 0x addresses of current members.
|
|
24
|
+
- Category: `set_channel_metadata {"line": "…", "metadata": {"category": "Clients"}}`. One per channel, 24 characters at most; `""` clears it. `group_info` returns it as `category`.
|
|
24
25
|
- Each key you send replaces that whole list. Keys you leave out stay as they are. To add one label or one assignee, read `group_info` first and send the old list plus the new one. `[]` clears a list. Read `group_info` again to check it saved.
|
|
25
26
|
|
|
26
27
|
**Task status labels.** When a channel is a task, keep exactly one of these status labels, plus any project labels:
|
|
@@ -73,3 +74,17 @@ Move it from Backlog or To-do to In progress, then In review, then Done. Use Blo
|
|
|
73
74
|
]}}
|
|
74
75
|
}}}
|
|
75
76
|
```
|
|
77
|
+
|
|
78
|
+
**Wallet cards.** The payment and signature cards Stage's own Request payment and Request signature send. Sending one moves nothing and signs nothing: the person taps Pay or Sign and confirms in Stage. Send one only when you were asked to.
|
|
79
|
+
- Send: `send {"line": "…", "wallet": {"type": "<type>", "content": {…}}}`. With `text` or a `frame` as well, the card goes last, and the `message_id` you get back is the card's.
|
|
80
|
+
- Payment request, `walletSendCalls`, a card with a Pay button. Stage pays a request with one call; a Stage wallet pays on Base only.
|
|
81
|
+
```json
|
|
82
|
+
{"type": "walletSendCalls", "content": {"version": "1.0", "chainId": "0x2105", "from": "0x<your address>", "calls": [
|
|
83
|
+
{"to": "0x<who gets paid>", "value": "0x38d7ea4c68000", "metadata": {"description": "Dinner", "transactionType": "transfer",
|
|
84
|
+
"currency": "ETH", "amount": 0.001, "decimals": 18, "toAddress": "0x<who gets paid>"}}
|
|
85
|
+
]}}
|
|
86
|
+
```
|
|
87
|
+
`chainId` is a hex string (`0x2105` is Base) and `value` is in wei, hex. For a token, `to` is the token contract, `data` its `transfer(address,uint256)` call data, `value` is left out, and `metadata` has `currency`, `amount` and `toAddress` (who gets paid).
|
|
88
|
+
- Receipt, `transactionReference`: `{"networkId": 8453, "reference": "0x<tx hash>", "metadata": {"currency": "USDC", "amount": 5}}`. Stage shows "Payment sent · 5 USDC" (or "Transaction sent" without an amount) and a link to the explorer. Only post a real transaction hash.
|
|
89
|
+
- Signature request, `signatureRequest`, a card with a Sign button: `{"id": "<any id>", "kind": "personal", "message": "The text to sign", "description": "Why"}`, or `"kind": "eip712"` with `"eip712": {"domain", "types", "primaryType", "message"}` instead of `message`.
|
|
90
|
+
- Signature, `signatureReference`: `{"requestId": "<message_id of the request>", "signature": "0x…", "signer": "0x…"}`. Stage shows it as Signed.
|
|
@@ -166,6 +166,7 @@ const PRIOR_STAGE: ReadonlySet<string> = new Set([
|
|
|
166
166
|
'e3b8f9bbdb205311bc643aaf91d9d903a91204fd5b55ca949b85cdfa6fd00136',
|
|
167
167
|
'08a30f5cd5cf8013ab70c1fa9e1109018c0337552296a8efe5c0cf543b284991',
|
|
168
168
|
'f87e9494ea6151d140b742c1b88b4f435ae653559be641fddc2160cd24dd4e1b',
|
|
169
|
+
'cb4307b016855c1a09d48bc8b3924a3080bd78b205963df3d6f20401d0486df9',
|
|
169
170
|
]);
|
|
170
171
|
const PRIOR_MEMORY: ReadonlySet<string> = new Set(['08795399635c8cdec2b50cb4e336f89ae880f3997ae52560ef647a9cdc8f1b08']);
|
|
171
172
|
|
|
@@ -60,16 +60,20 @@ function assertDelivered(
|
|
|
60
60
|
);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
const
|
|
64
|
-
|
|
63
|
+
const STAGE_PARTS = ['frame', 'wallet'] as const;
|
|
64
|
+
|
|
65
|
+
const stagePartsIn = (m: MessageArgs): string[] => STAGE_PARTS.filter((key) => m.a[key] !== undefined);
|
|
66
|
+
|
|
67
|
+
const stagePartArgs = (m: MessageArgs): Record<string, unknown> =>
|
|
68
|
+
Object.fromEntries(stagePartsIn(m).map((key) => [key, m.a[key]]));
|
|
65
69
|
|
|
66
70
|
const sentLabels = (m: MessageArgs, text: string | undefined): string[] => [
|
|
67
71
|
...(text ? ['text'] : []),
|
|
68
|
-
...(m
|
|
72
|
+
...stagePartsIn(m),
|
|
69
73
|
];
|
|
70
74
|
|
|
71
75
|
function nativeBody(m: MessageArgs, text: string | undefined, replyTo: string | undefined): Record<string, unknown> {
|
|
72
|
-
const body: Record<string, unknown> = { line: m.line, ...
|
|
76
|
+
const body: Record<string, unknown> = { line: m.line, ...stagePartArgs(m) };
|
|
73
77
|
if (text) body.text = text;
|
|
74
78
|
if (text && replyTo) body.replyTo = replyTo;
|
|
75
79
|
return body;
|
|
@@ -101,7 +105,7 @@ async function sendForwarded(
|
|
|
101
105
|
atts: ResolvedAttachment[],
|
|
102
106
|
): Promise<Sent> {
|
|
103
107
|
const { line, ctx, station } = m;
|
|
104
|
-
const args: Record<string, unknown> = { line, ...
|
|
108
|
+
const args: Record<string, unknown> = { line, ...stagePartArgs(m) };
|
|
105
109
|
if (text) args.text = text;
|
|
106
110
|
if (typeof m.a.subject === 'string' && m.a.subject.trim() !== '') args.subject = m.a.subject;
|
|
107
111
|
if (replyTo) args.replyTo = replyTo;
|
|
@@ -123,12 +127,13 @@ const unsupported = (station: Station, atts: CanonicalAttachment[]): string =>
|
|
|
123
127
|
.map((a) => kindOf(a.mime ?? '', a.path ?? a.url ?? a.name ?? ''))
|
|
124
128
|
.join(', ')}); send a link in \`text\` instead`;
|
|
125
129
|
|
|
126
|
-
const NOTHING_TO_SEND = 'send requires `text`, `attachments` or `
|
|
130
|
+
const NOTHING_TO_SEND = 'send requires `text`, `attachments`, `frame` or `wallet`';
|
|
127
131
|
|
|
128
132
|
function refusal(m: MessageArgs, text: string | undefined, requested: CanonicalAttachment[]): string | undefined {
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
|
|
133
|
+
const stageOnly = stagePartsIn(m);
|
|
134
|
+
if (!text && !requested.length && !stageOnly.length) return NOTHING_TO_SEND;
|
|
135
|
+
if (stageOnly.length && m.station.sendsFrames !== true)
|
|
136
|
+
return `${m.station.name} cannot send \`${stageOnly.join('` or `')}\`; that is Stage (XMTP) only`;
|
|
132
137
|
if (requested.length && m.station.attachmentMode === 'none') return unsupported(m.station, requested);
|
|
133
138
|
return undefined;
|
|
134
139
|
}
|
|
@@ -45,7 +45,7 @@ function argText(value: unknown): string | undefined {
|
|
|
45
45
|
|
|
46
46
|
function fieldText(key: string, value: unknown): string | undefined {
|
|
47
47
|
if (key === 'attachments' && Array.isArray(value)) return `${String(value.length)} file(s)`;
|
|
48
|
-
return key === 'frame' ? argText(value) : valueText(value);
|
|
48
|
+
return key === 'frame' || key === 'wallet' ? argText(value) : valueText(value);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function metroLines(tool: string, input: Record<string, unknown>): string[] {
|
|
@@ -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?, frame?. The station is derived from the line. Outlook and Gmail: send to ' +
|
|
37
|
+
'subject?, attachments?, frame?, wallet?. 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,8 +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). Stage (XMTP) only: `frame` posts an interactive view
|
|
46
|
-
'of text/attachments/frame is required. ' +
|
|
45
|
+
'which is not your machine). Stage (XMTP) only: `frame` posts an interactive view, `wallet` a payment ' +
|
|
46
|
+
'or signature card. At least one of text/attachments/frame/wallet is required. ' +
|
|
47
47
|
'The success line names each attachment the station actually delivered; a station that ' +
|
|
48
48
|
'cannot carry a file errors instead of reporting success.',
|
|
49
49
|
inputSchema: {
|
|
@@ -99,6 +99,23 @@ export const COMMON_TOOLS: ToolDef[] = [
|
|
|
99
99
|
description: { type: 'string', description: 'Card description in the chat (1000 characters at most).' },
|
|
100
100
|
},
|
|
101
101
|
},
|
|
102
|
+
wallet: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
description:
|
|
105
|
+
'Stage (XMTP) only: a wallet card. `type` walletSendCalls is a payment request with a Pay button, ' +
|
|
106
|
+
'transactionReference a payment receipt with an explorer link, signatureRequest a Sign button, ' +
|
|
107
|
+
'signatureReference a signed proof. `content` is that type\'s JSON as Stage reads it; the stage skill ' +
|
|
108
|
+
'has an example of each. Sending it moves nothing: the person taps Pay or Sign and confirms in Stage. ' +
|
|
109
|
+
"It is sent after the text and any frame, and the message_id returned is the card's.",
|
|
110
|
+
properties: {
|
|
111
|
+
type: {
|
|
112
|
+
type: 'string',
|
|
113
|
+
enum: ['walletSendCalls', 'transactionReference', 'signatureRequest', 'signatureReference'],
|
|
114
|
+
},
|
|
115
|
+
content: { type: 'object', description: 'The JSON of that content type.', additionalProperties: true },
|
|
116
|
+
},
|
|
117
|
+
required: ['type', 'content'],
|
|
118
|
+
},
|
|
102
119
|
},
|
|
103
120
|
required: ['line'],
|
|
104
121
|
},
|
|
@@ -105,12 +105,14 @@ async function applyChannelMeta(
|
|
|
105
105
|
|
|
106
106
|
function metaFields(merged: Record<string, unknown>): {
|
|
107
107
|
labels: string[];
|
|
108
|
+
category: string | undefined;
|
|
108
109
|
github: string | undefined;
|
|
109
110
|
preview: string | undefined;
|
|
110
111
|
assigned: unknown;
|
|
111
112
|
} {
|
|
112
113
|
return {
|
|
113
114
|
labels: Array.isArray(merged.labels) ? (merged.labels as string[]) : [],
|
|
115
|
+
category: typeof merged.category === 'string' ? merged.category : undefined,
|
|
114
116
|
github: typeof merged.github === 'string' ? merged.github : undefined,
|
|
115
117
|
preview: typeof merged.preview === 'string' ? merged.preview : undefined,
|
|
116
118
|
assigned: Object.hasOwn(merged, 'assigned') ? merged.assigned : [],
|
|
@@ -9,11 +9,13 @@ import {
|
|
|
9
9
|
AttachmentCodec,
|
|
10
10
|
type Attachment,
|
|
11
11
|
} from '@xmtp/content-type-remote-attachment';
|
|
12
|
+
import type { EncodedContent } from '@xmtp/content-type-primitives';
|
|
12
13
|
import { convOf, type Account } from './accounts.js';
|
|
13
14
|
import { resolveMsgId } from './wire.js';
|
|
14
15
|
import { emitOutbound } from './emit.js';
|
|
15
16
|
import { CallSignalCodec, FrameCodec, PollCodec, buildPollContent, encodeDeleteMessage } from './codecs.js';
|
|
16
17
|
import { buildFrameContent } from './frames.js';
|
|
18
|
+
import { buildWalletContent } from './wallet.js';
|
|
17
19
|
import { callLeftovers } from './call-leftovers.js';
|
|
18
20
|
import { convHandlers } from './actions-conv.js';
|
|
19
21
|
import { messagingAliases } from '@metro-labs/core/stations/messaging-normalize';
|
|
@@ -30,19 +32,27 @@ const noConv = (line: string): TrainError =>
|
|
|
30
32
|
const badArgs = (message: string): TrainError =>
|
|
31
33
|
new TrainError('INVALID_ARGS', message);
|
|
32
34
|
|
|
35
|
+
function framePart(raw: unknown): { encoded: EncodedContent; summary: string } {
|
|
36
|
+
const built = buildFrameContent(raw);
|
|
37
|
+
return { encoded: new FrameCodec().encode(built.frame), summary: `Frame: ${built.title}` };
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
async function send(id: string, args: Args): Promise<void> {
|
|
34
41
|
const { line, text } = args as { line: string; text: string };
|
|
35
|
-
const
|
|
42
|
+
const parts = [
|
|
43
|
+
...(args.frame === undefined ? [] : [framePart(args.frame)]),
|
|
44
|
+
...(args.wallet === undefined ? [] : [buildWalletContent(args.wallet)]),
|
|
45
|
+
];
|
|
36
46
|
const { acct, conv } = await convOf(line);
|
|
37
47
|
if (!conv) throw noConv(line);
|
|
38
48
|
let messageId: string | undefined;
|
|
39
|
-
if (text ||
|
|
49
|
+
if (text || parts.length === 0) {
|
|
40
50
|
messageId = await conv.sendText(text);
|
|
41
51
|
emitOutbound(acct.cfg.id, line, messageId, text);
|
|
42
52
|
}
|
|
43
|
-
|
|
44
|
-
messageId = await conv.send(
|
|
45
|
-
emitOutbound(acct.cfg.id, line, messageId,
|
|
53
|
+
for (const part of parts) {
|
|
54
|
+
messageId = await conv.send(part.encoded);
|
|
55
|
+
emitOutbound(acct.cfg.id, line, messageId, part.summary);
|
|
46
56
|
}
|
|
47
57
|
respond(id, { result: { messageId } });
|
|
48
58
|
}
|
|
@@ -166,6 +166,24 @@ export const SignatureReferenceCodec =
|
|
|
166
166
|
c.signature ? `[Signature] ${c.signature}` : '[Signature]',
|
|
167
167
|
);
|
|
168
168
|
|
|
169
|
+
const xmtpOrg = (typeId: string): ContentTypeId => ({
|
|
170
|
+
authorityId: 'xmtp.org',
|
|
171
|
+
typeId,
|
|
172
|
+
versionMajor: 1,
|
|
173
|
+
versionMinor: 0,
|
|
174
|
+
});
|
|
175
|
+
export const WalletSendCallsCodec = makeJsonCodec<{ calls?: { metadata?: { description?: string } }[] }>(
|
|
176
|
+
xmtpOrg('walletSendCalls'),
|
|
177
|
+
(c) => {
|
|
178
|
+
const desc = c.calls?.[0]?.metadata?.description;
|
|
179
|
+
return desc ? `[Transaction request] ${desc}` : '[Transaction request]';
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
export const TransactionReferenceCodec = makeJsonCodec<{ reference?: string }>(
|
|
183
|
+
xmtpOrg('transactionReference'),
|
|
184
|
+
(c) => (c.reference ? `[Transaction] ${c.reference}` : '[Transaction]'),
|
|
185
|
+
);
|
|
186
|
+
|
|
169
187
|
export const ContentTypeFrame: ContentTypeId = {
|
|
170
188
|
authorityId: 'stage.box',
|
|
171
189
|
typeId: 'frame',
|
|
@@ -89,7 +89,7 @@ export async function buildGroupInfo(
|
|
|
89
89
|
const gn = (conv as unknown as { name?: string | (() => Promise<string>) })
|
|
90
90
|
.name;
|
|
91
91
|
const resolvedName = typeof gn === 'function' ? await gn() : (gn ?? '');
|
|
92
|
-
const { labels, github, preview } = readAppData(rawAppData);
|
|
92
|
+
const { labels, category, github, preview } = readAppData(rawAppData);
|
|
93
93
|
return {
|
|
94
94
|
line,
|
|
95
95
|
id: conv.id,
|
|
@@ -98,6 +98,7 @@ export async function buildGroupInfo(
|
|
|
98
98
|
name: resolvedName ?? '',
|
|
99
99
|
memberCount: inboxIds.length,
|
|
100
100
|
labels,
|
|
101
|
+
category,
|
|
101
102
|
github,
|
|
102
103
|
preview,
|
|
103
104
|
assigned: Object.hasOwn(appData, 'assigned') ? appData.assigned : [],
|
|
@@ -12,10 +12,10 @@ const TEXT_TYPES = new Set(['Text', 'Caption', 'Markdown']);
|
|
|
12
12
|
|
|
13
13
|
type Node = Record<string, unknown>;
|
|
14
14
|
|
|
15
|
-
const isNode = (v: unknown): v is Node =>
|
|
15
|
+
export const isNode = (v: unknown): v is Node =>
|
|
16
16
|
typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
17
17
|
|
|
18
|
-
const bad = (message: string): TrainError => new TrainError('INVALID_ARGS', message);
|
|
18
|
+
export const bad = (message: string): TrainError => new TrainError('INVALID_ARGS', message);
|
|
19
19
|
|
|
20
20
|
function plain(value: unknown): string {
|
|
21
21
|
if (typeof value !== 'string') return '';
|
|
@@ -56,7 +56,7 @@ function clip(value: unknown, max: number, name: string): string | undefined {
|
|
|
56
56
|
return out === '' ? undefined : out;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
function parseJson(raw: unknown, name: string): unknown {
|
|
59
|
+
export function parseJson(raw: unknown, name: string): unknown {
|
|
60
60
|
if (typeof raw !== 'string') return raw;
|
|
61
61
|
try {
|
|
62
62
|
return JSON.parse(raw);
|
|
@@ -14,13 +14,17 @@ const MAX_LABELS = 16;
|
|
|
14
14
|
const MAX_LABEL_LEN = 24;
|
|
15
15
|
const MAX_APP_DATA_BYTES = 8192;
|
|
16
16
|
|
|
17
|
+
function cleanLabel(raw: string): string {
|
|
18
|
+
return raw.trim().replace(/\s+/g, ' ').slice(0, MAX_LABEL_LEN);
|
|
19
|
+
}
|
|
20
|
+
|
|
17
21
|
function cleanLabels(raw: unknown): string[] {
|
|
18
22
|
if (!Array.isArray(raw)) return [];
|
|
19
23
|
const out: string[] = [];
|
|
20
24
|
const seen = new Set<string>();
|
|
21
25
|
for (const item of raw) {
|
|
22
26
|
if (typeof item !== 'string') continue;
|
|
23
|
-
const label = item
|
|
27
|
+
const label = cleanLabel(item);
|
|
24
28
|
const key = label.toLowerCase();
|
|
25
29
|
if (!label || seen.has(key)) continue;
|
|
26
30
|
seen.add(key);
|
|
@@ -70,8 +74,13 @@ function trimmedString(v: unknown): string | undefined {
|
|
|
70
74
|
return typeof v === 'string' && v.trim() ? v.trim() : undefined;
|
|
71
75
|
}
|
|
72
76
|
|
|
77
|
+
function cleanCategory(v: unknown): string | undefined {
|
|
78
|
+
return typeof v === 'string' ? cleanLabel(v) || undefined : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
export function readAppData(appData: string | undefined): {
|
|
74
82
|
labels: string[];
|
|
83
|
+
category?: string;
|
|
75
84
|
github?: string;
|
|
76
85
|
preview?: string;
|
|
77
86
|
} {
|
|
@@ -82,6 +91,7 @@ export function readAppData(appData: string | undefined): {
|
|
|
82
91
|
const rec = p as Record<string, unknown>;
|
|
83
92
|
return {
|
|
84
93
|
labels: cleanLabels(rec.labels),
|
|
94
|
+
category: cleanCategory(rec.category),
|
|
85
95
|
github: trimmedString(rec.github),
|
|
86
96
|
preview: trimmedString(rec.preview),
|
|
87
97
|
};
|
|
@@ -97,33 +107,30 @@ function checkedLabels(value: unknown): string[] {
|
|
|
97
107
|
return cleanLabels(value);
|
|
98
108
|
}
|
|
99
109
|
|
|
110
|
+
function checkedCategory(value: unknown): string | undefined {
|
|
111
|
+
if (value !== undefined && value !== null && typeof value !== 'string') {
|
|
112
|
+
throw new TrainError('INVALID_ARGS', 'category must be a string or null');
|
|
113
|
+
}
|
|
114
|
+
return cleanCategory(value);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const KNOWN_FIELDS = new Map<string, (value: unknown) => unknown>([
|
|
118
|
+
['labels', checkedLabels],
|
|
119
|
+
['assigned', normalizeAssigned],
|
|
120
|
+
['github', (value) => normalizeGithubUrl(value) || undefined],
|
|
121
|
+
['preview', (value) => normalizePreviewUrl(value) || undefined],
|
|
122
|
+
['category', checkedCategory],
|
|
123
|
+
]);
|
|
124
|
+
|
|
100
125
|
function applyMergeKey(
|
|
101
126
|
merged: Record<string, unknown>,
|
|
102
127
|
k: string,
|
|
103
128
|
v: unknown,
|
|
104
129
|
): void {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
if (k === 'assigned') {
|
|
110
|
-
merged.assigned = normalizeAssigned(v);
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
if (k === 'github') {
|
|
114
|
-
const g = normalizeGithubUrl(v);
|
|
115
|
-
if (g) merged.github = g;
|
|
116
|
-
else delete merged.github;
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
if (k === 'preview') {
|
|
120
|
-
const p = normalizePreviewUrl(v);
|
|
121
|
-
if (p) merged.preview = p;
|
|
122
|
-
else delete merged.preview;
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
if (v === undefined || v === null) Reflect.deleteProperty(merged, k);
|
|
126
|
-
else Object.defineProperty(merged, k, { value: v, enumerable: true, configurable: true, writable: true });
|
|
130
|
+
const normalize = KNOWN_FIELDS.get(k);
|
|
131
|
+
const value = normalize ? normalize(v) : v;
|
|
132
|
+
if (value === undefined || value === null) Reflect.deleteProperty(merged, k);
|
|
133
|
+
else Object.defineProperty(merged, k, { value, enumerable: true, configurable: true, writable: true });
|
|
127
134
|
}
|
|
128
135
|
|
|
129
136
|
export function mergeAppData(
|
|
@@ -114,7 +114,7 @@ export const XMTP_TOOLS: StationTool[] = [
|
|
|
114
114
|
group: 'read',
|
|
115
115
|
description:
|
|
116
116
|
"Read an XMTP channel's current metadata + membership. Args: line (required). Returns " +
|
|
117
|
-
'{line, id, account, version (dm|group), name, memberCount, labels, github, preview, ' +
|
|
117
|
+
'{line, id, account, version (dm|group), name, memberCount, labels, category, github, preview, ' +
|
|
118
118
|
'assigned, appData (stored JSON object), rawAppData (stored JSON string), ' +
|
|
119
119
|
'members:[{inboxId, address}]}. Syncs first and fails if metadata cannot be read. ' +
|
|
120
120
|
'xmtp-only (daemon `groupInfo`). Use before ' +
|
|
@@ -171,10 +171,11 @@ export const XMTP_TOOLS: StationTool[] = [
|
|
|
171
171
|
"Update an existing channel's metadata. Args: line (required, the metro:// line), and " +
|
|
172
172
|
'any of metadata? (JSON object), github? (url), preview? (url), name? (string). ' +
|
|
173
173
|
'metadata is a shallow appData patch: omitted keys are preserved, supplied nested ' +
|
|
174
|
-
'values replace that key, and null deletes custom keys. v is reserved. Put labels ' +
|
|
175
|
-
'and
|
|
174
|
+
'values replace that key, and null deletes custom keys. v is reserved. Put labels, ' +
|
|
175
|
+
'assigned and category inside metadata. assigned replaces the array with normalized lowercase ' +
|
|
176
176
|
'Ethereum addresses of current members. Read group_info and union existing IDs to ' +
|
|
177
|
-
'add assignees.
|
|
177
|
+
'add assignees. category is one string per channel (24 characters at most); "" or null ' +
|
|
178
|
+
'clears it. labels remains a legacy top-level alias. Duplicate top-level/metadata ' +
|
|
178
179
|
'label/link aliases are refused. All fields use one updateChannelMeta call. xmtp-only. ' +
|
|
179
180
|
'Returns updated metadata. Reread group_info to verify persistence.',
|
|
180
181
|
inputSchema: {
|
|
@@ -191,6 +192,10 @@ export const XMTP_TOOLS: StationTool[] = [
|
|
|
191
192
|
description: 'Replacement assignee list. Current member Ethereum addresses only. [] clears.',
|
|
192
193
|
items: { type: 'string', pattern: '^0x[0-9a-fA-F]{40}$' },
|
|
193
194
|
},
|
|
195
|
+
category: {
|
|
196
|
+
type: 'string',
|
|
197
|
+
description: 'The one category of the channel, 24 characters at most. "" clears it.',
|
|
198
|
+
},
|
|
194
199
|
github: { type: 'string' },
|
|
195
200
|
preview: { type: 'string' },
|
|
196
201
|
},
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { EncodedContent } from '@xmtp/content-type-primitives';
|
|
2
|
+
import {
|
|
3
|
+
SignatureReferenceCodec,
|
|
4
|
+
SignatureRequestCodec,
|
|
5
|
+
TransactionReferenceCodec,
|
|
6
|
+
WalletSendCallsCodec,
|
|
7
|
+
} from './codecs.js';
|
|
8
|
+
import { bad, isNode, parseJson } from './frames.js';
|
|
9
|
+
|
|
10
|
+
const WALLET_TYPES: Record<string, { codec: { encode(c: Record<string, unknown>): EncodedContent }; needs: string[] }> = {
|
|
11
|
+
walletSendCalls: { codec: new WalletSendCallsCodec(), needs: ['version', 'chainId', 'from', 'calls'] },
|
|
12
|
+
transactionReference: { codec: new TransactionReferenceCodec(), needs: ['networkId', 'reference'] },
|
|
13
|
+
signatureRequest: { codec: new SignatureRequestCodec(), needs: ['id', 'kind'] },
|
|
14
|
+
signatureReference: { codec: new SignatureReferenceCodec(), needs: ['requestId', 'signature', 'signer'] },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function buildWalletContent(raw: unknown): { encoded: EncodedContent; summary: string } {
|
|
18
|
+
const args = parseJson(raw, 'wallet');
|
|
19
|
+
const type = isNode(args) && typeof args.type === 'string' ? args.type : '';
|
|
20
|
+
const spec = Object.hasOwn(WALLET_TYPES, type) ? WALLET_TYPES[type] : undefined;
|
|
21
|
+
if (!isNode(args) || !spec) throw bad(`wallet type must be one of ${Object.keys(WALLET_TYPES).join(', ')}`);
|
|
22
|
+
const content = parseJson(args.content, 'wallet content');
|
|
23
|
+
if (!isNode(content)) throw bad(`wallet content must be the ${type} JSON object`);
|
|
24
|
+
const missing = spec.needs.filter((key) => content[key] === undefined);
|
|
25
|
+
if (missing.length > 0) throw bad(`${type} content needs ${missing.join(', ')}`);
|
|
26
|
+
const encoded = spec.codec.encode(content);
|
|
27
|
+
return { encoded, summary: encoded.fallback ?? type };
|
|
28
|
+
}
|
package/runtime/runtime.json
CHANGED