@stage-labs/metro 0.1.0-beta.248 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.248",
3
+ "version": "0.1.0-beta.249",
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.248",
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",
@@ -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 assignees.** They live in the channel's metadata, and Stage shows them on the channel.
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:
@@ -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 : [],
@@ -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 : [],
@@ -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.trim().replace(/\s+/g, ' ').slice(0, MAX_LABEL_LEN);
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
- if (k === 'labels') {
106
- merged.labels = checkedLabels(v);
107
- return;
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 assigned inside metadata. assigned replaces the array with normalized lowercase ' +
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. labels remains a legacy top-level alias. Duplicate top-level/metadata ' +
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
  },
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.248"
2
+ "version": "0.1.0-beta.249"
3
3
  }