@stage-labs/metro 0.1.0-beta.226 → 0.1.0-beta.228

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.226",
3
+ "version": "0.1.0-beta.228",
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.226",
3
+ "version": "0.1.0-beta.228",
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
  import { createHash } from 'node:crypto';
2
2
  import { isRecord } from '@metro-labs/core/is-record';
3
- import { resultText } from './text.js';
3
+ import { inlineImage, partText, resultImages, resultParts, resultText, TOOL_ERROR, type InlineImage } from './text.js';
4
4
  import { stringOf } from '@metro-labs/http/api-http';
5
5
 
6
6
  const SIGNATURE_PREFIX = 'metro-codex:';
@@ -82,11 +82,25 @@ export function decodeSignature(signature: unknown): Item | null {
82
82
  }
83
83
  }
84
84
 
85
+ const imagePart = (image: InlineImage): Item => ({ type: 'input_image', image_url: `data:${image.mediaType};base64,${image.data}` });
86
+
85
87
  function userPart(block: Item): Item | null {
86
88
  if (block.type === 'text') return { type: 'input_text', text: stringOf(block.text) };
87
- if (block.type === 'image' && isRecord(block.source) && block.source.type === 'base64')
88
- return { type: 'input_image', image_url: `data:${stringOf(block.source.media_type)};base64,${stringOf(block.source.data)}` };
89
- return null;
89
+ const image = inlineImage(block);
90
+ return image === null ? null : imagePart(image);
91
+ }
92
+
93
+ function outputPart(part: Item): Item | null {
94
+ const image = inlineImage(part);
95
+ if (image !== null) return imagePart(image);
96
+ const text = partText(part);
97
+ return text === '' ? null : { type: 'input_text', text };
98
+ }
99
+
100
+ function toolOutput(block: Item): string | Item[] {
101
+ if (resultImages(block).length === 0) return resultText(block);
102
+ const parts = resultParts(block).map(outputPart).filter((part): part is Item => part !== null);
103
+ return block.is_error === true ? [{ type: 'input_text', text: TOOL_ERROR }, ...parts] : parts;
90
104
  }
91
105
 
92
106
  function userItems(content: unknown): Item[] {
@@ -101,7 +115,7 @@ function userItems(content: unknown): Item[] {
101
115
  for (const block of content.filter(isRecord)) {
102
116
  if (block.type === 'tool_result') {
103
117
  flush();
104
- out.push({ type: 'function_call_output', call_id: stringOf(block.tool_use_id), output: resultText(block) });
118
+ out.push({ type: 'function_call_output', call_id: stringOf(block.tool_use_id), output: toolOutput(block) });
105
119
  continue;
106
120
  }
107
121
  const part = userPart(block);
@@ -3,7 +3,7 @@ import { isRecord } from '@metro-labs/core/is-record';
3
3
  import { ToolNames } from './codex-translate.js';
4
4
  import { CLIENT_NAME, requestId, SYSTEM_PREFIX } from './gemini-client.js';
5
5
  import { cappedEffort, effortToApply } from './effort.js';
6
- import { resultText } from './text.js';
6
+ import { inlineImage, resultImages, resultText, type InlineImage } from './text.js';
7
7
  import { stringOf } from '@metro-labs/http/api-http';
8
8
 
9
9
  type Item = Record<string, unknown>;
@@ -63,11 +63,22 @@ export function systemText(system: unknown): string {
63
63
  .trim();
64
64
  }
65
65
 
66
+ const imagePart = (image: InlineImage): Item => ({ inlineData: { mimeType: image.mediaType, data: image.data } });
67
+
68
+ const GEMINI_IMAGES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/heic', 'image/heif']);
69
+ const geminiSees = (mediaType: string): boolean => GEMINI_IMAGES.has(mediaType);
70
+
71
+ function toolParts(block: Item, id: string, name: string): Item[] {
72
+ const images = resultImages(block, geminiSees);
73
+ const text = resultText(block, geminiSees);
74
+ const result = text === '' && images.length > 0 ? `Binary content provided (${String(images.length)} item(s)).` : text;
75
+ return [{ functionResponse: { id, name, response: { result } } }, ...images.map(imagePart)];
76
+ }
77
+
66
78
  function userPart(block: Item): Item | null {
67
79
  if (block.type === 'text') return { text: stringOf(block.text) };
68
- if (block.type === 'image' && isRecord(block.source) && block.source.type === 'base64')
69
- return { inlineData: { mimeType: stringOf(block.source.media_type), data: stringOf(block.source.data) } };
70
- return null;
80
+ const image = inlineImage(block);
81
+ return image === null ? null : imagePart(image);
71
82
  }
72
83
 
73
84
  function userParts(content: unknown, calls: Map<string, string>): Item[] {
@@ -77,7 +88,7 @@ function userParts(content: unknown, calls: Map<string, string>): Item[] {
77
88
  for (const block of content.filter(isRecord)) {
78
89
  if (block.type === 'tool_result') {
79
90
  const id = stringOf(block.tool_use_id);
80
- out.push({ functionResponse: { id, name: calls.get(id) ?? 'tool', response: { result: resultText(block) } } });
91
+ out.push(...toolParts(block, id, calls.get(id) ?? 'tool'));
81
92
  continue;
82
93
  }
83
94
  const part = userPart(block);
@@ -2,13 +2,34 @@ import { isRecord } from '@metro-labs/core/is-record';
2
2
  import { stringOf } from '@metro-labs/http/api-http';
3
3
 
4
4
  const IMAGE_NOTE = '[an image was attached here; this model cannot see it]';
5
+ export const TOOL_ERROR = '[tool error]';
5
6
 
6
7
  export const nonEmpty = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
7
8
 
8
- const partText = (part: Record<string, unknown>): string => (part.type === 'text' ? stringOf(part.text) : part.type === 'image' ? IMAGE_NOTE : '');
9
+ export interface InlineImage {
10
+ mediaType: string;
11
+ data: string;
12
+ }
13
+
14
+ type Part = Record<string, unknown>;
15
+ type Accepts = (mediaType: string) => boolean;
16
+ const anyImage: Accepts = () => true;
17
+
18
+ export function inlineImage(block: Part, accepts = anyImage): InlineImage | null {
19
+ if (block.type !== 'image' || !isRecord(block.source) || block.source.type !== 'base64') return null;
20
+ const image = { mediaType: stringOf(block.source.media_type), data: stringOf(block.source.data) };
21
+ return image.mediaType !== '' && image.data !== '' && accepts(image.mediaType) ? image : null;
22
+ }
9
23
 
10
- export function resultText(block: Record<string, unknown>): string {
24
+ export const resultParts = (block: Part): Part[] => (Array.isArray(block.content) ? block.content.filter(isRecord) : []);
25
+
26
+ export const partText = (part: Part, accepts = anyImage): string =>
27
+ part.type === 'text' ? stringOf(part.text) : part.type === 'image' && inlineImage(part, accepts) === null ? IMAGE_NOTE : '';
28
+
29
+ export function resultText(block: Part, accepts = anyImage): string {
11
30
  const content = block.content;
12
- const text = typeof content === 'string' ? content : Array.isArray(content) ? content.filter(isRecord).map(partText).filter((t) => t !== '').join('\n') : '';
13
- return block.is_error === true ? `[tool error] ${text}` : text;
31
+ const text = typeof content === 'string' ? content : resultParts(block).map((part) => partText(part, accepts)).filter((t) => t !== '').join('\n');
32
+ return block.is_error === true ? `${TOOL_ERROR} ${text}` : text;
14
33
  }
34
+
35
+ export const resultImages = (block: Part, accepts = anyImage): InlineImage[] => resultParts(block).flatMap((part) => inlineImage(part, accepts) ?? []);
@@ -1,13 +1,18 @@
1
- import { ReactionAction, ReactionSchema, type Conversation } from '@xmtp/node-sdk';
1
+ import {
2
+ GroupMessageKind,
3
+ ReactionAction,
4
+ ReactionSchema,
5
+ type Conversation,
6
+ } from '@xmtp/node-sdk';
2
7
  import type { Reply } from '@xmtp/node-bindings';
3
8
  import {
4
9
  AttachmentCodec,
5
10
  type Attachment,
6
11
  } from '@xmtp/content-type-remote-attachment';
7
- import { convOf } from './accounts.js';
12
+ import { convOf, type Account } from './accounts.js';
8
13
  import { resolveMsgId } from './wire.js';
9
14
  import { emitOutbound } from './emit.js';
10
- import { PollCodec, buildPollContent } from './codecs.js';
15
+ import { PollCodec, buildPollContent, encodeDeleteMessage } from './codecs.js';
11
16
  import { convHandlers } from './actions-conv.js';
12
17
  import { messagingAliases } from '@metro-labs/core/stations/messaging-normalize';
13
18
  import { TrainError } from '@metro-labs/core/train-error';
@@ -106,6 +111,28 @@ async function reply(id: string, args: Args): Promise<void> {
106
111
  respond(id, { result: { messageId: sentId } });
107
112
  }
108
113
 
114
+ const UNDELETABLE = new Set(['deleteMessage', 'deletedMessage']);
115
+
116
+ function assertDeletable(acct: Account, conv: Conversation, messageId: string): void {
117
+ const msg = acct.client.conversations.getMessageById(messageId);
118
+ if (msg?.conversationId !== conv.id)
119
+ throw new TrainError('NOT_FOUND', `message ${messageId} is not in this conversation`);
120
+ if (msg.senderInboxId !== acct.inboxId)
121
+ throw badArgs(`message ${messageId} was not sent by this account; only your own messages can be deleted`);
122
+ if (msg.kind !== GroupMessageKind.Application || UNDELETABLE.has(msg.contentType?.typeId ?? ''))
123
+ throw badArgs(`message ${messageId} cannot be deleted`);
124
+ }
125
+
126
+ async function remove(id: string, args: Args): Promise<void> {
127
+ const { line, messageId } = args as { line: string; messageId: string };
128
+ const { acct, conv } = await convOf(line);
129
+ if (!conv) throw noConv(line);
130
+ const target = resolveMsgId(messageId);
131
+ assertDeletable(acct, conv, target);
132
+ const sentId = await conv.send(encodeDeleteMessage(target), { shouldPush: false });
133
+ respond(id, { result: { messageId: sentId } });
134
+ }
135
+
109
136
  async function sendAttachment(id: string, args: Args): Promise<void> {
110
137
  const { line, name, mime, dataB64 } = args as {
111
138
  line: string;
@@ -186,6 +213,7 @@ const handlers: Record<string, (id: string, args: Args) => Promise<void>> = {
186
213
  ask,
187
214
  react,
188
215
  reply,
216
+ delete: remove,
189
217
  sendAttachment,
190
218
  sendImage,
191
219
  ...convHandlers,
@@ -164,6 +164,33 @@ export const SignatureReferenceCodec =
164
164
  c.signature ? `[Signature] ${c.signature}` : '[Signature]',
165
165
  );
166
166
 
167
+ const ContentTypeDeleteMessage: ContentTypeId = {
168
+ authorityId: 'xmtp.org',
169
+ typeId: 'deleteMessage',
170
+ versionMajor: 1,
171
+ versionMinor: 0,
172
+ };
173
+
174
+ const varint = (value: number): number[] => {
175
+ const out: number[] = [];
176
+ let rest = value;
177
+ while (rest > 0x7f) {
178
+ out.push((rest & 0x7f) | 0x80);
179
+ rest = Math.floor(rest / 0x80);
180
+ }
181
+ out.push(rest);
182
+ return out;
183
+ };
184
+
185
+ export function encodeDeleteMessage(messageId: string): EncodedContent {
186
+ const id = new TextEncoder().encode(messageId);
187
+ return {
188
+ type: ContentTypeDeleteMessage,
189
+ parameters: {},
190
+ content: new Uint8Array([0x0a, ...varint(id.length), ...id]),
191
+ };
192
+ }
193
+
167
194
  export const CODECS = (): ContentCodec[] => [
168
195
  new PollCodec(),
169
196
  new SignatureRequestCodec(),
@@ -6,7 +6,7 @@ export const xmtpStation: Station = {
6
6
  name: 'xmtp',
7
7
  hasAccounts: true,
8
8
  hasTrain: true,
9
- messageVerbs: new Set<Verb>(['send', 'reply', 'react', 'unreact', 'read']),
9
+ messageVerbs: new Set<Verb>(['send', 'reply', 'react', 'unreact', 'delete', 'read']),
10
10
  groupOps: new Set<GroupOp>(['create_group', 'add_members', 'remove_members']),
11
11
  attachmentMode: 'native',
12
12
  profileFields: new Set<ProfileField>(PROFILE_FIELDS),
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.226"
2
+ "version": "0.1.0-beta.228"
3
3
  }