@stage-labs/metro 0.1.0-beta.282 → 0.1.0-beta.284

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.282",
3
+ "version": "0.1.0-beta.284",
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.282",
3
+ "version": "0.1.0-beta.284",
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",
@@ -11,6 +11,7 @@ import { replyMeta } from './addressed.js';
11
11
  import {
12
12
  capSet,
13
13
  displayNameMeta,
14
+ missingAttachmentNames,
14
15
  senderMeta,
15
16
  tsMeta,
16
17
  takeMediaCtx,
@@ -56,6 +57,7 @@ interface InboundDeps {
56
57
  }
57
58
 
58
59
  const ATTACH_TIMEOUT_MS = 15_000;
60
+ const LATE_ATTACH_WINDOW_MS = 15 * 60_000;
59
61
  const DEDUPE_TTL_MS = 30_000;
60
62
  const DEDUPE_MAX = 2_000;
61
63
  const ALLOWED_LINES_MAX = 2_000;
@@ -149,27 +151,24 @@ export class InboundRelay {
149
151
  private async flushPendingFallback(id: string): Promise<void> {
150
152
  const e = this.pendingAttachments.get(id);
151
153
  if (!e) return;
152
- this.pendingAttachments.delete(id);
153
- const missing = e.attachments.filter((_, i) => !e.saved.has(i));
154
- if (!missing.length) return;
155
- const names = missing
156
- .map((a) => a.name ?? a.kind ?? 'attachment')
157
- .join(', ');
154
+ const missing = missingAttachmentNames(e);
155
+ if (!missing.length) {
156
+ this.pendingAttachments.delete(id);
157
+ return;
158
+ }
159
+ e.timer = setTimeout(() => { this.pendingAttachments.delete(id); }, LATE_ATTACH_WINDOW_MS);
160
+ const ctx = takeMediaCtx(e);
158
161
  await this.notify('notifications/claude/channel', {
159
162
  content:
160
- (e.text ? `${e.text}\n` : '') +
161
- `[attachment(s) still downloading: ${names}; they follow in a separate note]`,
163
+ (ctx.text ? `${ctx.text}\n` : '') +
164
+ `[attachment(s) still downloading: ${missing.join(', ')}; they follow in a separate note]`,
162
165
  meta: {
163
- line: e.line,
164
- from: e.from,
165
- station: e.station,
166
- ...tsMeta(e.ts),
167
- message_id: e.messageId,
168
- line_name: e.lineName,
169
- from_name: e.fromName,
170
- ...displayNameMeta(e.fromDisplayName),
171
- ...e.reply,
172
- ...e.callMeta?.(),
166
+ line: ctx.line,
167
+ from: ctx.from,
168
+ station: ctx.station,
169
+ ...senderMeta(ctx),
170
+ ...ctx.reply,
171
+ ...ctx.callMeta?.(),
173
172
  },
174
173
  });
175
174
  }
@@ -62,6 +62,12 @@ export const senderMeta = (c: MediaCtx): Record<string, string> => ({
62
62
  ...displayNameMeta(c.fromDisplayName),
63
63
  });
64
64
 
65
+ export function missingAttachmentNames(buf: PendingMsg): string[] {
66
+ return buf.attachments
67
+ .filter((_, i) => !buf.saved.has(i))
68
+ .map((a) => a.name ?? a.kind ?? 'attachment');
69
+ }
70
+
65
71
  export function takeMediaCtx(buf: PendingMsg): MediaCtx {
66
72
  const ctx: MediaCtx = {
67
73
  line: buf.line,
@@ -12,14 +12,21 @@ import { errMsg } from '@metro-labs/core/log';
12
12
 
13
13
  export type { SavedAttachment };
14
14
 
15
- const READ_RETRY_DELAYS_MS: readonly number[] = [400, 800];
16
- export const REMOTE_FETCH_ATTEMPTS = READ_RETRY_DELAYS_MS.length + 1;
17
- export const INBOUND_RETRY_DELAYS_MS: readonly number[] = [
18
- 2_000, 4_000, 8_000, 15_000, 30_000, 30_000,
19
- 60_000, 60_000, 60_000, 60_000, 60_000, 60_000,
20
- ];
21
- const REMOTE_FETCH_TIMEOUT_MS = 120_000;
22
- const SWARM_FALLBACK_GATEWAY = 'https://download.gateway.ethswarm.org/bzz/';
15
+ interface FetchSchedule {
16
+ delaysMs: readonly number[];
17
+ requestTimeoutMs: number;
18
+ }
19
+
20
+ const READ_SCHEDULE: FetchSchedule = { delaysMs: [400, 800], requestTimeoutMs: 18_000 };
21
+ export const INBOUND_FETCH_SCHEDULE: FetchSchedule = {
22
+ delaysMs: [
23
+ 2_000, 4_000, 8_000, 15_000, 30_000, 30_000,
24
+ 60_000, 60_000, 60_000, 60_000, 60_000, 60_000,
25
+ ],
26
+ requestTimeoutMs: 120_000,
27
+ };
28
+ export const REMOTE_FETCH_ATTEMPTS = READ_SCHEDULE.delaysMs.length + 1;
29
+ const SWARM_GATEWAY = 'https://download.gateway.ethswarm.org/bzz/';
23
30
  const SWARM_BZZ_URL = /^https:\/\/[^/]+\/bzz\/([0-9a-f]{64}(?:[0-9a-f]{64})?)\/?$/i;
24
31
 
25
32
  const sleep = (ms: number): Promise<void> =>
@@ -81,17 +88,16 @@ interface DecodedAttachment {
81
88
  data: Uint8Array;
82
89
  }
83
90
 
84
- function remoteUrls(url: string): string[] {
91
+ function remoteUrl(url: string): string {
85
92
  const ref = SWARM_BZZ_URL.exec(url)?.[1];
86
- const fallback = ref === undefined ? url : `${SWARM_FALLBACK_GATEWAY}${ref}/`;
87
- return fallback === url ? [url] : [url, fallback];
93
+ return ref === undefined ? url : `${SWARM_GATEWAY}${ref.toLowerCase()}/`;
88
94
  }
89
95
 
90
- function withTimeout<T>(work: Promise<T>, ms: number): Promise<T> {
96
+ function withTimeout<T>(work: Promise<T>, ms: number, url: string): Promise<T> {
91
97
  let timer: ReturnType<typeof setTimeout> | undefined;
92
98
  const expired = new Promise<never>((_resolve, reject) => {
93
99
  timer = setTimeout(() => {
94
- reject(new Error(`no answer within ${ms / 1000}s`));
100
+ reject(new Error(`no answer from ${url} within ${ms / 1000}s`));
95
101
  }, ms);
96
102
  });
97
103
  return Promise.race([work, expired]).finally(() => {
@@ -99,36 +105,33 @@ function withTimeout<T>(work: Promise<T>, ms: number): Promise<T> {
99
105
  });
100
106
  }
101
107
 
102
- function loadFrom(r: RemoteEntry, url: string): Promise<DecodedAttachment> {
108
+ function loadFrom(r: RemoteEntry, url: string, timeoutMs: number): Promise<DecodedAttachment> {
103
109
  return withTimeout(
104
110
  RemoteAttachmentCodec.load<DecodedAttachment>(
105
111
  { ...toRemoteDescriptor(r), url },
106
112
  loadRegistry,
107
113
  ),
108
- REMOTE_FETCH_TIMEOUT_MS,
114
+ timeoutMs,
115
+ url,
109
116
  );
110
117
  }
111
118
 
112
119
  async function loadRemote(
113
120
  r: RemoteEntry,
114
- retryDelaysMs: readonly number[],
121
+ schedule: FetchSchedule,
115
122
  ): Promise<DecodedAttachment> {
116
- const urls = remoteUrls(r.url);
123
+ const url = remoteUrl(r.url);
117
124
  let last = '';
118
- for (const delay of [0, ...retryDelaysMs]) {
125
+ for (const delay of [0, ...schedule.delaysMs]) {
119
126
  if (delay > 0) await sleep(delay);
120
- const errors: string[] = [];
121
- for (const url of urls) {
122
- try {
123
- return await loadFrom(r, url);
124
- } catch (err) {
125
- errors.push(errMsg(err));
126
- }
127
+ try {
128
+ return await loadFrom(r, url, schedule.requestTimeoutMs);
129
+ } catch (err) {
130
+ last = errMsg(err);
127
131
  }
128
- last = errors.join('; ');
129
132
  }
130
133
  throw new Error(
131
- `xmtp remote attachment fetch failed after ${retryDelaysMs.length + 1} attempts: ${last}`,
134
+ `xmtp remote attachment fetch failed after ${schedule.delaysMs.length + 1} attempts: ${last}`,
132
135
  );
133
136
  }
134
137
 
@@ -136,10 +139,10 @@ export async function saveRemoteAttachment(
136
139
  r: RemoteEntry,
137
140
  messageId: string,
138
141
  index = 0,
139
- retryDelaysMs: readonly number[] = READ_RETRY_DELAYS_MS,
142
+ schedule: FetchSchedule = READ_SCHEDULE,
140
143
  ): Promise<SavedAttachment> {
141
144
  if (r.contentLength) assertAttachmentSize(r.contentLength);
142
- const decoded = await loadRemote(r, retryDelaysMs);
145
+ const decoded = await loadRemote(r, schedule);
143
146
  return saveBufferToCache(decoded.data, messageId, index, {
144
147
  mime: decoded.mimeType,
145
148
  name: decoded.filename ?? r.filename,
@@ -3,7 +3,7 @@ import type { Reply } from '@xmtp/content-type-reply';
3
3
  import { errMsg } from '@metro-labs/core/log';
4
4
  import { transcribeAndEmit } from './transcribe.js';
5
5
  import {
6
- INBOUND_RETRY_DELAYS_MS,
6
+ INBOUND_FETCH_SCHEDULE,
7
7
  saveInlineAttachment,
8
8
  saveRemoteAttachment,
9
9
  type RemoteEntry,
@@ -208,7 +208,7 @@ function remoteStaticPayload(
208
208
  ctx: EnvelopeCtx,
209
209
  ): Record<string, unknown> {
210
210
  const kind = IMG_RE.test(c.url) ? 'image' : 'file';
211
- reportAttachment(saveRemoteAttachment(c, ctx.msgId, 0, INBOUND_RETRY_DELAYS_MS), { station: 'xmtp', account: ctx.accountId, line: ctx.line, forId: ctx.baseId, index: 0 });
211
+ reportAttachment(saveRemoteAttachment(c, ctx.msgId, 0, INBOUND_FETCH_SCHEDULE), { station: 'xmtp', account: ctx.accountId, line: ctx.line, forId: ctx.baseId, index: 0 });
212
212
  return {
213
213
  ...base,
214
214
  text: `[${kind}: ${c.filename ?? c.url}]`,
@@ -224,7 +224,7 @@ function multiRemoteEnvelope(
224
224
  ): Record<string, unknown> {
225
225
  const m = c as { attachments?: RemoteEntry[] };
226
226
  (Array.isArray(m.attachments) ? m.attachments : []).forEach((r, i) => {
227
- reportAttachment(saveRemoteAttachment(r, ctx.msgId, i, INBOUND_RETRY_DELAYS_MS), { station: 'xmtp', account: ctx.accountId, line: ctx.line, forId: ctx.baseId, index: i });
227
+ reportAttachment(saveRemoteAttachment(r, ctx.msgId, i, INBOUND_FETCH_SCHEDULE), { station: 'xmtp', account: ctx.accountId, line: ctx.line, forId: ctx.baseId, index: i });
228
228
  });
229
229
  return multiRemotePayload(base, typeId, c);
230
230
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.282"
2
+ "version": "0.1.0-beta.284"
3
3
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.282",
2
+ "version": "0.1.0-beta.284",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",