@atalk/sdk 0.1.0-alpha.1 → 0.1.0-alpha.11

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/dist/agent.js CHANGED
@@ -1,21 +1,79 @@
1
- import { randomUUID } from "node:crypto";
2
- import { serverFrameSchema, } from "@atalk/protocol";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, extname, resolve } from "node:path";
4
+ import { decodeAttachmentMessage, createChunkedAttachmentDescriptor, decryptAttachmentChunk, decodeDirectedMessage, decryptAttachment, attachmentPartDescriptors, encodeAgentActivity, encodeAttachmentMessage, encryptAttachment, encryptAttachmentChunk, joinEncryptedAttachmentParts, serverFrameSchema, splitEncryptedAttachment, } from "@atalk/protocol";
3
5
  import WebSocket from "ws";
4
- import { FileCredentialStore } from "./credential-store.js";
6
+ import { FileCredentialStore, } from "./credential-store.js";
5
7
  import { decryptTextNative, encryptTextNative, generateIdentityKeysNative } from "./native-core.js";
8
+ import { emptyRuntimeState, FileRuntimeStateStore, MemoryRuntimeStateStore, } from "./runtime-state-store.js";
9
+ import { WorkroomClient } from "./workrooms.js";
10
+ const MAX_PROCESSED_INCOMING = 10_000;
11
+ const DEFAULT_REFRESH_LEEWAY_MS = 5 * 60_000;
12
+ const FATAL_SESSION_CODES = new Set([
13
+ "AUTH_REQUIRED",
14
+ "INVALID_REFRESH_TOKEN",
15
+ "INVALID_SESSION",
16
+ "PEER_INACTIVE",
17
+ ]);
6
18
  export class Agent {
19
+ /** Durable, E2EE task/workroom API for this agent identity. */
20
+ workrooms;
7
21
  baseUrl;
8
22
  activationToken;
9
23
  credentialStore;
24
+ runtimeStateStore;
25
+ credentialRefresher;
26
+ usesDefaultCredentialRefresher;
27
+ refreshLeewayMs;
28
+ supervisionEnabled;
10
29
  credentials;
30
+ runtimeState = emptyRuntimeState();
31
+ stateMutation = Promise.resolve();
32
+ refreshPromise;
33
+ outboxDrain;
34
+ inboxDrain;
35
+ inboxRetryTimer;
36
+ inboxRetryAttempt = 0;
37
+ sentThisConnection = new Set();
11
38
  socket;
39
+ ready = false;
40
+ reconnectAttempt = 0;
12
41
  stopped = false;
13
42
  messageHandler;
14
43
  errorHandler;
44
+ supervisors = [];
45
+ counterparties = new Map();
46
+ processingIncoming = new Map();
15
47
  constructor(options) {
16
48
  this.activationToken = options.token;
17
49
  this.baseUrl = (options.baseUrl ?? "http://127.0.0.1:4001").replace(/\/$/u, "");
18
50
  this.credentialStore = options.credentialStore ?? new FileCredentialStore(options.token, options.credentialPath);
51
+ this.runtimeStateStore = options.runtimeStateStore
52
+ ?? (options.runtimeStatePath
53
+ ? new FileRuntimeStateStore(options.runtimeStatePath)
54
+ : this.credentialStore instanceof FileCredentialStore
55
+ ? new FileRuntimeStateStore(`${this.credentialStore.path}.runtime.json`)
56
+ : new MemoryRuntimeStateStore());
57
+ this.usesDefaultCredentialRefresher = !options.refreshCredentials;
58
+ this.credentialRefresher = options.refreshCredentials ?? refreshAtalkCredentials;
59
+ this.refreshLeewayMs = Math.max(0, options.refreshLeewayMs ?? DEFAULT_REFRESH_LEEWAY_MS);
60
+ this.supervisionEnabled = options.supervision ?? true;
61
+ this.workrooms = new WorkroomClient({
62
+ request: (path, init) => this.request(path, init),
63
+ credentials: () => this.requireCredentials(),
64
+ runtimeState: () => this.runtimeState,
65
+ mutateRuntimeState: (mutator) => this.mutateRuntimeState(mutator),
66
+ uploadPart: (scope, id, bytes, transfer) => this.uploadAttachment(scope, id, bytes, transfer),
67
+ deletePart: (id) => this.deleteAttachmentPart(id),
68
+ downloadAttachment: (descriptor) => this.downloadAttachment(descriptor),
69
+ downloadAttachmentTo: (descriptor, path, transfer) => this.downloadAttachmentTo(descriptor, path, transfer),
70
+ });
71
+ }
72
+ get connected() {
73
+ return this.ready && this.socket?.readyState === WebSocket.OPEN;
74
+ }
75
+ get peer() {
76
+ return this.credentials?.peer;
19
77
  }
20
78
  on(event, handler) {
21
79
  if (event === "message")
@@ -26,33 +84,143 @@ export class Agent {
26
84
  }
27
85
  async start() {
28
86
  this.stopped = false;
29
- this.credentials = (await this.credentialStore.load()) ?? (await this.activate());
30
- await this.connect();
87
+ this.inboxRetryAttempt = 0;
88
+ this.runtimeState = (await this.runtimeStateStore.load()) ?? emptyRuntimeState();
89
+ const persistedCredentials = await this.credentialStore.load();
90
+ this.credentials = persistedCredentials ?? (await this.activate());
91
+ this.counterparties.clear();
92
+ for (const [conversationId, peer] of Object.entries(this.runtimeState.counterparties)) {
93
+ this.counterparties.set(conversationId, peer);
94
+ }
95
+ try {
96
+ await this.prepareAndConnect();
97
+ }
98
+ catch (error) {
99
+ // A newly issued connection code is also the recovery path after an
100
+ // owner revoked this runtime's sessions. Reuse the locally persisted
101
+ // identity keys: silently generating a new pair would strand encrypted
102
+ // Task history and change the agent's cryptographic identity.
103
+ if (!persistedCredentials || !this.activationToken || !isSessionError(error))
104
+ throw error;
105
+ this.credentials = await this.activate(persistedCredentials.keys);
106
+ await this.prepareAndConnect();
107
+ }
31
108
  }
32
109
  async stop() {
33
110
  this.stopped = true;
111
+ this.ready = false;
112
+ this.reconnectAttempt = 0;
113
+ if (this.inboxRetryTimer)
114
+ clearTimeout(this.inboxRetryTimer);
115
+ this.inboxRetryTimer = undefined;
34
116
  this.socket?.close(1000, "Agent stopped");
117
+ await this.stateMutation;
35
118
  }
36
119
  async send(recipientHandle, text) {
37
- return this.sendEnvelope(recipientHandle, text, randomUUID());
120
+ return (await this.sendWithDetails(recipientHandle, text)).conversationId;
121
+ }
122
+ /** Start a conversation and return both transport identifiers. */
123
+ async sendWithDetails(recipientHandle, text) {
124
+ const conversationId = randomUUID();
125
+ const messageId = await this.sendEnvelope(recipientHandle, text, conversationId);
126
+ return { conversationId, messageId };
127
+ }
128
+ /** Send inside a known conversation and return the new message id. */
129
+ async sendInConversation(recipientHandle, text, conversationId) {
130
+ return this.sendEnvelope(recipientHandle, text, conversationId);
131
+ }
132
+ async sendAttachment(recipientHandle, input) {
133
+ return (await this.sendAttachmentWithDetails(recipientHandle, input)).conversationId;
134
+ }
135
+ async sendAttachmentWithDetails(recipientHandle, input) {
136
+ const conversationId = randomUUID();
137
+ const messageId = await this.sendAttachmentEnvelope(recipientHandle, input, conversationId);
138
+ return { conversationId, messageId };
139
+ }
140
+ async sendAttachmentFile(recipientHandle, input) {
141
+ return (await this.sendAttachmentFileWithDetails(recipientHandle, input)).conversationId;
142
+ }
143
+ async sendAttachmentFileWithDetails(recipientHandle, input) {
144
+ const conversationId = randomUUID();
145
+ const messageId = await this.sendAttachmentFileEnvelope(recipientHandle, input, conversationId);
146
+ return { conversationId, messageId };
38
147
  }
39
- async activate() {
40
- const keys = generateIdentityKeysNative();
148
+ async sendAttachmentInConversation(recipientHandle, input, conversationId) {
149
+ return this.sendAttachmentEnvelope(recipientHandle, input, conversationId);
150
+ }
151
+ async sendAttachmentFileInConversation(recipientHandle, input, conversationId) {
152
+ return this.sendAttachmentFileEnvelope(recipientHandle, input, conversationId);
153
+ }
154
+ /** Download an attachment descriptor retained by a durable bridge. */
155
+ async downloadAttachment(descriptor) {
156
+ const parts = [];
157
+ for (const part of attachmentPartDescriptors(descriptor)) {
158
+ const response = await this.authorizedFetch(`${this.baseUrl}/v1/attachments/${part.id}`);
159
+ if (!response.ok)
160
+ throw await responseError(response);
161
+ parts.push(new Uint8Array(await response.arrayBuffer()));
162
+ }
163
+ return decryptAttachment(joinEncryptedAttachmentParts(parts, descriptor), descriptor);
164
+ }
165
+ /** Stream-decrypt an attachment into an atomic local file without buffering the whole payload. */
166
+ async downloadAttachmentTo(descriptor, filePath, options) {
167
+ return this.downloadAttachmentToFile(descriptor, filePath, options);
168
+ }
169
+ /** Mark an incoming message as read when only its durable id is available. */
170
+ async markMessageRead(messageId) {
171
+ await this.completeIncoming(messageId, "READ");
172
+ this.sendFrame({ kind: "ACK", messageId, state: "READ" });
173
+ this.scheduleInboxRetry();
174
+ }
175
+ async activate(existingKeys) {
176
+ if (!this.activationToken) {
177
+ throw new Error("ACTIVATION_REQUIRED: Provide a one-time token because no persisted credentials were found");
178
+ }
179
+ const remembered = this.runtimeState.pendingActivation;
180
+ const pending = remembered && (!existingKeys || sameIdentityKeys(remembered.keys, existingKeys))
181
+ ? remembered
182
+ : {
183
+ requestId: randomUUID(),
184
+ keys: existingKeys ?? generateIdentityKeysNative(),
185
+ };
186
+ if (pending !== remembered) {
187
+ await this.mutateRuntimeState((state) => { state.pendingActivation = pending; });
188
+ }
41
189
  const response = await this.request("/v1/agents/activate", {
42
190
  method: "POST",
43
191
  body: JSON.stringify({
44
192
  activationToken: this.activationToken,
45
- signingPublicKey: keys.signingPublicKey,
46
- encryptionPublicKey: keys.encryptionPublicKey,
193
+ activationRequestId: pending.requestId,
194
+ signingPublicKey: pending.keys.signingPublicKey,
195
+ encryptionPublicKey: pending.keys.encryptionPublicKey,
47
196
  }),
48
197
  }, false);
49
- const credentials = { sessionToken: response.token, peer: response.peer, keys };
198
+ const access = response.accessToken ?? response.token;
199
+ const accessTokenExpiresAt = response.accessTokenExpiresAt ?? response.expiresAt;
200
+ const credentials = {
201
+ sessionToken: access,
202
+ accessToken: access,
203
+ ...(response.refreshToken ? { refreshToken: response.refreshToken } : {}),
204
+ ...(accessTokenExpiresAt ? { accessTokenExpiresAt } : {}),
205
+ peer: response.peer,
206
+ keys: pending.keys,
207
+ };
50
208
  await this.credentialStore.save(credentials);
209
+ await this.mutateRuntimeState((state) => { delete state.pendingActivation; });
51
210
  return credentials;
52
211
  }
212
+ async prepareAndConnect() {
213
+ await this.refreshCredentialsIfNeeded("EXPIRING");
214
+ if (this.supervisionEnabled) {
215
+ const result = await this.request("/v1/agent-runtime/supervisors");
216
+ this.supervisors = result.supervisors;
217
+ }
218
+ await this.connectWithRefresh();
219
+ }
53
220
  async connect() {
54
221
  const credentials = this.requireCredentials();
55
222
  const websocketUrl = `${this.baseUrl.replace(/^http/u, "ws")}/v1/ws`;
223
+ this.ready = false;
56
224
  await new Promise((resolve, reject) => {
57
225
  const socket = new WebSocket(websocketUrl);
58
226
  this.socket = socket;
@@ -63,15 +231,31 @@ export class Agent {
63
231
  reject(new Error("aTalk connection timed out"));
64
232
  }
65
233
  }, 10_000);
66
- socket.on("open", () => socket.send(JSON.stringify({ kind: "AUTH", token: credentials.sessionToken })));
234
+ socket.on("open", () => socket.send(JSON.stringify({ kind: "AUTH", token: accessToken(credentials) })));
67
235
  socket.on("message", (raw) => {
68
- void this.handleFrame(serverFrameSchema.parse(JSON.parse(raw.toString()))).then(() => {
69
- if (!ready && this.socket === socket) {
236
+ const frame = serverFrameSchema.parse(JSON.parse(raw.toString()));
237
+ void this.handleFrame(frame).then(() => {
238
+ if (frame.kind === "READY" && !ready && this.socket === socket) {
70
239
  ready = true;
240
+ this.ready = true;
241
+ this.reconnectAttempt = 0;
242
+ this.sentThisConnection.clear();
71
243
  clearTimeout(timeout);
72
244
  resolve();
245
+ void this.drainOutbox().catch((error) => this.emitError(error));
246
+ void this.drainInbox();
73
247
  }
74
- }).catch((error) => this.emitError(error));
248
+ }).catch((error) => {
249
+ if (!ready) {
250
+ clearTimeout(timeout);
251
+ reject(error);
252
+ socket.close();
253
+ }
254
+ else {
255
+ this.emitError(error);
256
+ this.scheduleInboxRetry();
257
+ }
258
+ });
75
259
  });
76
260
  socket.on("error", (error) => {
77
261
  clearTimeout(timeout);
@@ -80,48 +264,162 @@ export class Agent {
80
264
  else
81
265
  this.emitError(error);
82
266
  });
83
- socket.on("close", () => {
267
+ socket.on("close", (code) => {
84
268
  clearTimeout(timeout);
269
+ if (this.socket === socket)
270
+ this.ready = false;
271
+ if (!ready) {
272
+ const error = code === 4001 || code === 1008
273
+ ? new AgentProtocolError("INVALID_SESSION", "Agent credentials were rejected")
274
+ : new Error("aTalk connection closed before authentication");
275
+ reject(error);
276
+ return;
277
+ }
278
+ if (code === 4001 || code === 1008) {
279
+ void this.recoverRejectedSession();
280
+ return;
281
+ }
85
282
  if (!this.stopped && ready)
86
- setTimeout(() => void this.connect().catch((error) => this.emitError(error)), 1_000);
283
+ this.scheduleReconnect();
87
284
  });
88
285
  });
89
286
  }
287
+ scheduleReconnect() {
288
+ if (this.stopped)
289
+ return;
290
+ const delay = reconnectDelay(this.reconnectAttempt++);
291
+ setTimeout(() => {
292
+ if (this.stopped)
293
+ return;
294
+ void this.connectWithRefresh().catch((error) => {
295
+ if (isSessionError(error))
296
+ this.stopped = true;
297
+ this.emitError(error);
298
+ if (!this.stopped)
299
+ this.scheduleReconnect();
300
+ });
301
+ }, delay);
302
+ }
90
303
  async handleFrame(frame) {
91
- if (frame.kind === "ERROR")
92
- throw new Error(`${frame.code}: ${frame.message}`);
304
+ if (frame.kind === "ERROR") {
305
+ throw new AgentProtocolError(frame.code, frame.message);
306
+ }
93
307
  if (frame.kind === "RECEIPT") {
308
+ await this.removeFromOutbox(frame.messageId);
94
309
  this.sendFrame({ kind: "RECEIPT_ACK", messageId: frame.messageId, state: frame.state });
95
310
  return;
96
311
  }
312
+ if (frame.kind === "ACK_RECEIVED") {
313
+ await this.forgetIncoming(frame.messageId);
314
+ return;
315
+ }
97
316
  if (frame.kind !== "MESSAGE")
98
317
  return;
318
+ const messageId = frame.envelope.message_id;
319
+ const confirmed = this.runtimeState.processedIncoming[messageId];
320
+ if (confirmed) {
321
+ this.sendFrame({ kind: "ACK", messageId, state: confirmed });
322
+ this.scheduleInboxRetry();
323
+ return;
324
+ }
325
+ await this.rememberIncoming(frame.envelope);
326
+ const existing = this.processingIncoming.get(messageId);
327
+ if (existing) {
328
+ await existing;
329
+ return;
330
+ }
331
+ const processing = this.processIncomingMessage(frame);
332
+ this.processingIncoming.set(messageId, processing);
333
+ try {
334
+ await processing;
335
+ }
336
+ finally {
337
+ this.processingIncoming.delete(messageId);
338
+ }
339
+ }
340
+ async processIncomingMessage(frame) {
99
341
  const credentials = this.requireCredentials();
100
- const sender = await this.request(`/v1/peers/${frame.envelope.sender_peer_id}/keys`);
342
+ const sender = await this.request(`/v1/messages/${frame.envelope.message_id}/sender-keys`);
101
343
  const text = decryptTextNative({
102
344
  envelope: frame.envelope,
103
345
  senderSigningPublicKey: sender.signingPublicKey,
104
346
  senderEncryptionPublicKey: sender.encryptionPublicKey,
105
347
  recipientEncryptionSecretKey: credentials.keys.encryptionSecretKey,
106
348
  });
107
- this.sendFrame({ kind: "ACK", messageId: frame.envelope.message_id, state: "DELIVERED" });
108
- if (this.messageHandler) {
109
- await this.messageHandler({
110
- id: frame.envelope.message_id,
111
- conversationId: frame.envelope.conversation_id,
112
- text,
113
- sender,
114
- receivedAt: new Date(frame.envelope.timestamp),
115
- reply: async (replyText) => {
116
- await this.sendEnvelope(sender.handle, replyText, frame.envelope.conversation_id);
117
- },
349
+ const directedMessage = decodeDirectedMessage(text);
350
+ const content = directedMessage?.content ?? text;
351
+ const attachmentMessage = decodeAttachmentMessage(content);
352
+ const isSupervisor = this.supervisors.some((supervisor) => supervisor.id === sender.id);
353
+ const isMentioned = directedMessage?.mentions.some((mention) => mention.peerId === credentials.peer.id) ?? false;
354
+ const counterparty = isSupervisor ? this.counterparties.get(frame.envelope.conversation_id) : sender;
355
+ const routing = isSupervisor && !isMentioned
356
+ ? { mode: "RELAY", targetHandle: counterparty?.handle ?? "" }
357
+ : { mode: "REPLY", targetHandle: sender.handle };
358
+ if (!isSupervisor) {
359
+ this.counterparties.set(frame.envelope.conversation_id, sender);
360
+ await this.mutateRuntimeState((state) => {
361
+ state.counterparties[frame.envelope.conversation_id] = sender;
118
362
  });
363
+ await this.mirrorActivity("INCOMING", sender, text, frame.envelope.conversation_id, frame.envelope.message_id, frame.envelope.timestamp);
119
364
  }
365
+ if (!this.messageHandler)
366
+ throw new Error("MESSAGE_HANDLER_NOT_CONFIGURED: Register a message handler before start()");
367
+ let acknowledgedState = "DELIVERED";
368
+ let handlerComplete = false;
369
+ await this.messageHandler({
370
+ id: frame.envelope.message_id,
371
+ conversationId: frame.envelope.conversation_id,
372
+ text: attachmentMessage?.caption ?? (attachmentMessage ? "" : content),
373
+ ...(attachmentMessage ? { attachment: {
374
+ descriptor: attachmentMessage.attachment,
375
+ download: () => this.downloadAttachment(attachmentMessage.attachment),
376
+ downloadTo: (filePath, options) => this.downloadAttachmentToFile(attachmentMessage.attachment, filePath, options),
377
+ } } : {}),
378
+ sender,
379
+ receivedAt: new Date(frame.envelope.timestamp),
380
+ isSupervisor,
381
+ mentions: directedMessage?.mentions ?? [],
382
+ isMentioned,
383
+ reply: (replyText) => this.sendEnvelope(sender.handle, replyText, frame.envelope.conversation_id),
384
+ replyAttachment: (input) => this.sendAttachmentEnvelope(sender.handle, input, frame.envelope.conversation_id),
385
+ replyAttachmentFile: (input) => this.sendAttachmentFileEnvelope(sender.handle, input, frame.envelope.conversation_id),
386
+ relay: async (relayText) => {
387
+ if (!isSupervisor)
388
+ throw new Error("Only supervisor messages can be relayed");
389
+ if (!counterparty)
390
+ throw new Error("No active counterparty exists for this supervised conversation");
391
+ return this.sendEnvelope(counterparty.handle, relayText, frame.envelope.conversation_id);
392
+ },
393
+ relayAttachment: async (input) => {
394
+ if (!isSupervisor)
395
+ throw new Error("Only supervisor messages can be relayed");
396
+ if (!counterparty)
397
+ throw new Error("No active counterparty exists for this supervised conversation");
398
+ return this.sendAttachmentEnvelope(counterparty.handle, input, frame.envelope.conversation_id);
399
+ },
400
+ relayAttachmentFile: async (input) => {
401
+ if (!isSupervisor)
402
+ throw new Error("Only supervisor messages can be relayed");
403
+ if (!counterparty)
404
+ throw new Error("No active counterparty exists for this supervised conversation");
405
+ return this.sendAttachmentFileEnvelope(counterparty.handle, input, frame.envelope.conversation_id);
406
+ },
407
+ markRead: async () => {
408
+ if (!handlerComplete) {
409
+ acknowledgedState = "READ";
410
+ return;
411
+ }
412
+ await this.markMessageRead(frame.envelope.message_id);
413
+ },
414
+ routing,
415
+ });
416
+ handlerComplete = true;
417
+ await this.completeIncoming(frame.envelope.message_id, acknowledgedState);
418
+ this.sendFrame({ kind: "ACK", messageId: frame.envelope.message_id, state: acknowledgedState });
419
+ this.scheduleInboxRetry();
120
420
  }
121
421
  async sendEnvelope(recipientHandle, text, conversationId) {
122
422
  const credentials = this.requireCredentials();
123
- if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
124
- throw new Error("Agent is not connected");
125
423
  const { recipient } = await this.request("/v1/messages/authorize", {
126
424
  method: "POST",
127
425
  body: JSON.stringify({ recipientHandle }),
@@ -137,28 +435,509 @@ export class Agent {
137
435
  senderEncryptionSecretKey: credentials.keys.encryptionSecretKey,
138
436
  recipientEncryptionPublicKey: recipient.encryptionPublicKey,
139
437
  });
140
- this.sendFrame({ kind: "DELIVER", envelope });
438
+ const isSupervisor = this.supervisors.some((supervisor) => supervisor.id === recipient.id);
439
+ if (!isSupervisor) {
440
+ this.counterparties.set(conversationId, recipient);
441
+ await this.mutateRuntimeState((state) => {
442
+ state.counterparties[conversationId] = recipient;
443
+ });
444
+ }
445
+ await this.queueEnvelope(envelope);
446
+ if (!isSupervisor) {
447
+ await this.mirrorActivity("OUTGOING", recipient, text, conversationId, envelope.message_id, envelope.timestamp);
448
+ }
449
+ return envelope.message_id;
450
+ }
451
+ async sendAttachmentEnvelope(recipientHandle, input, conversationId) {
452
+ const credentials = this.requireCredentials();
453
+ const { recipient } = await this.request("/v1/messages/authorize", {
454
+ method: "POST",
455
+ body: JSON.stringify({ recipientHandle }),
456
+ });
457
+ const encrypted = splitEncryptedAttachment(encryptAttachment({
458
+ id: randomUUID(),
459
+ bytes: input.data,
460
+ name: input.name,
461
+ mimeType: input.mimeType ?? "application/octet-stream",
462
+ }), randomUUID);
463
+ for (const part of encrypted.parts) {
464
+ await this.uploadAttachment({ recipientPeerId: recipient.id }, part.id, part.ciphertext);
465
+ }
466
+ const caption = input.caption?.trim();
467
+ const plaintext = encodeAttachmentMessage({
468
+ attachment: encrypted.descriptor,
469
+ ...(caption ? { caption } : {}),
470
+ });
471
+ const envelope = encryptTextNative({
472
+ messageId: randomUUID(),
473
+ conversationId,
474
+ senderPeerId: credentials.peer.id,
475
+ recipientPeerId: recipient.id,
476
+ timestamp: new Date().toISOString(),
477
+ plaintext,
478
+ senderSigningSecretKey: credentials.keys.signingSecretKey,
479
+ senderEncryptionSecretKey: credentials.keys.encryptionSecretKey,
480
+ recipientEncryptionPublicKey: recipient.encryptionPublicKey,
481
+ });
482
+ const isSupervisor = this.supervisors.some((supervisor) => supervisor.id === recipient.id);
483
+ if (!isSupervisor) {
484
+ this.counterparties.set(conversationId, recipient);
485
+ await this.mutateRuntimeState((state) => {
486
+ state.counterparties[conversationId] = recipient;
487
+ });
488
+ }
489
+ await this.queueEnvelope(envelope);
490
+ if (!isSupervisor) {
491
+ await this.mirrorActivity("OUTGOING", recipient, plaintext, conversationId, envelope.message_id, envelope.timestamp);
492
+ }
493
+ return envelope.message_id;
494
+ }
495
+ async sendAttachmentFileEnvelope(recipientHandle, input, conversationId) {
496
+ const credentials = this.requireCredentials();
497
+ const path = resolve(input.path);
498
+ const metadata = await stat(path);
499
+ if (!metadata.isFile())
500
+ throw new Error("ATTACHMENT_NOT_A_FILE");
501
+ const { recipient } = await this.request("/v1/messages/authorize", {
502
+ method: "POST",
503
+ body: JSON.stringify({ recipientHandle }),
504
+ });
505
+ const descriptor = createChunkedAttachmentDescriptor({
506
+ id: randomUUID(),
507
+ size: metadata.size,
508
+ name: input.name?.trim() || basename(path),
509
+ mimeType: input.mimeType?.trim() || mimeTypeFromPath(path),
510
+ nextId: randomUUID,
511
+ });
512
+ if (descriptor.version !== 2)
513
+ throw new Error("ATTACHMENT_VERSION_UNSUPPORTED");
514
+ const file = await open(path, "r");
515
+ let transferred = 0;
516
+ try {
517
+ for (let index = 0; index < descriptor.chunks.length; index += 1) {
518
+ throwIfAborted(input.transfer?.signal);
519
+ const part = descriptor.chunks[index];
520
+ const plaintext = new Uint8Array(part.plaintextSize);
521
+ const { bytesRead } = await file.read(plaintext, 0, plaintext.byteLength, transferred);
522
+ if (bytesRead !== plaintext.byteLength)
523
+ throw new Error("ATTACHMENT_SIZE_MISMATCH");
524
+ await this.uploadAttachment({ recipientPeerId: recipient.id }, part.id, encryptAttachmentChunk(plaintext, descriptor, index), input.transfer);
525
+ transferred += bytesRead;
526
+ input.transfer?.onProgress?.({
527
+ phase: "UPLOADING",
528
+ bytesTransferred: transferred,
529
+ totalBytes: descriptor.size,
530
+ partIndex: index + 1,
531
+ partCount: descriptor.chunks.length,
532
+ });
533
+ }
534
+ }
535
+ catch (error) {
536
+ // Delete every id in the unpublished descriptor. This also removes a part
537
+ // whose POST reached the server but whose response was lost in transit.
538
+ await Promise.allSettled(descriptor.chunks.map((part) => this.deleteAttachmentPart(part.id)));
539
+ throw error;
540
+ }
541
+ finally {
542
+ await file.close();
543
+ }
544
+ const caption = input.caption?.trim();
545
+ const plaintext = encodeAttachmentMessage({ attachment: descriptor, ...(caption ? { caption } : {}) });
546
+ const envelope = encryptTextNative({
547
+ messageId: randomUUID(),
548
+ conversationId,
549
+ senderPeerId: credentials.peer.id,
550
+ recipientPeerId: recipient.id,
551
+ timestamp: new Date().toISOString(),
552
+ plaintext,
553
+ senderSigningSecretKey: credentials.keys.signingSecretKey,
554
+ senderEncryptionSecretKey: credentials.keys.encryptionSecretKey,
555
+ recipientEncryptionPublicKey: recipient.encryptionPublicKey,
556
+ });
557
+ const isSupervisor = this.supervisors.some((supervisor) => supervisor.id === recipient.id);
558
+ if (!isSupervisor) {
559
+ this.counterparties.set(conversationId, recipient);
560
+ await this.mutateRuntimeState((state) => { state.counterparties[conversationId] = recipient; });
561
+ }
562
+ await this.queueEnvelope(envelope);
563
+ if (!isSupervisor) {
564
+ await this.mirrorActivity("OUTGOING", recipient, plaintext, conversationId, envelope.message_id, envelope.timestamp);
565
+ }
141
566
  return envelope.message_id;
142
567
  }
568
+ async uploadAttachment(scope, attachmentId, ciphertext, options) {
569
+ throwIfAborted(options?.signal);
570
+ const existing = await this.authorizedFetch(`${this.baseUrl}/v1/attachments/${attachmentId}`, {
571
+ method: "HEAD", ...(options?.signal ? { signal: options.signal } : {}),
572
+ }).catch(() => undefined);
573
+ if (existing?.ok)
574
+ return;
575
+ const attempts = Math.max(1, Math.min(5, options?.maxAttempts ?? 3));
576
+ let lastError;
577
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
578
+ throwIfAborted(options?.signal);
579
+ let response;
580
+ try {
581
+ response = await this.authorizedFetch(`${this.baseUrl}/v1/attachments/${attachmentId}?${"recipientPeerId" in scope
582
+ ? `recipientPeerId=${encodeURIComponent(scope.recipientPeerId)}`
583
+ : `workroomId=${encodeURIComponent(scope.workroomId)}`}`, {
584
+ method: "POST",
585
+ headers: { "content-type": "application/octet-stream" },
586
+ body: exactArrayBuffer(ciphertext),
587
+ ...(options?.signal ? { signal: options.signal } : {}),
588
+ });
589
+ }
590
+ catch (error) {
591
+ if (options?.signal?.aborted)
592
+ throw abortError();
593
+ lastError = error;
594
+ if (attempt + 1 < attempts)
595
+ await abortableDelay(250 * (2 ** attempt), options?.signal);
596
+ continue;
597
+ }
598
+ if (response.ok)
599
+ return;
600
+ lastError = await responseError(response);
601
+ if (response.status < 500 && response.status !== 429)
602
+ throw lastError;
603
+ if (attempt + 1 < attempts)
604
+ await abortableDelay(250 * (2 ** attempt), options?.signal);
605
+ }
606
+ throw lastError instanceof Error ? lastError : new Error("ATTACHMENT_UPLOAD_FAILED");
607
+ }
608
+ async deleteAttachmentPart(id) {
609
+ await this.authorizedFetch(`${this.baseUrl}/v1/attachments/${id}`, { method: "DELETE" }).catch(() => undefined);
610
+ }
611
+ async downloadAttachmentToFile(descriptor, filePath, options) {
612
+ const target = resolve(filePath);
613
+ await mkdir(dirname(target), { recursive: true });
614
+ if (descriptor.version === 1) {
615
+ throwIfAborted(options?.signal);
616
+ await writeFile(target, await this.downloadAttachment(descriptor), { mode: 0o600 });
617
+ options?.onProgress?.({ phase: "DOWNLOADING", bytesTransferred: descriptor.size, totalBytes: descriptor.size, partIndex: 1, partCount: 1 });
618
+ return target;
619
+ }
620
+ const chunks = descriptor.chunks;
621
+ const temporary = `${target}.atalk-${randomUUID()}.part`;
622
+ const file = await open(temporary, "wx", 0o600);
623
+ let transferred = 0;
624
+ try {
625
+ for (let index = 0; index < chunks.length; index += 1) {
626
+ throwIfAborted(options?.signal);
627
+ const part = chunks[index];
628
+ const ciphertext = await this.downloadAttachmentPart(part.id, options);
629
+ const plaintext = decryptAttachmentChunk(ciphertext, descriptor, index);
630
+ await file.write(plaintext);
631
+ transferred += plaintext.byteLength;
632
+ options?.onProgress?.({
633
+ phase: "DOWNLOADING", bytesTransferred: transferred, totalBytes: descriptor.size,
634
+ partIndex: index + 1, partCount: chunks.length,
635
+ });
636
+ }
637
+ await file.sync();
638
+ await file.close();
639
+ await rename(temporary, target);
640
+ return target;
641
+ }
642
+ catch (error) {
643
+ await file.close().catch(() => undefined);
644
+ await rm(temporary, { force: true }).catch(() => undefined);
645
+ throw error;
646
+ }
647
+ }
648
+ async downloadAttachmentPart(id, options) {
649
+ const attempts = Math.max(1, Math.min(5, options?.maxAttempts ?? 3));
650
+ let lastError;
651
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
652
+ throwIfAborted(options?.signal);
653
+ let response;
654
+ try {
655
+ response = await this.authorizedFetch(`${this.baseUrl}/v1/attachments/${id}`, {
656
+ ...(options?.signal ? { signal: options.signal } : {}),
657
+ });
658
+ }
659
+ catch (error) {
660
+ if (options?.signal?.aborted)
661
+ throw abortError();
662
+ lastError = error;
663
+ if (attempt + 1 < attempts)
664
+ await abortableDelay(250 * (2 ** attempt), options?.signal);
665
+ continue;
666
+ }
667
+ if (response.ok)
668
+ return new Uint8Array(await response.arrayBuffer());
669
+ lastError = await responseError(response);
670
+ if (response.status < 500 && response.status !== 429)
671
+ throw lastError;
672
+ if (attempt + 1 < attempts)
673
+ await abortableDelay(250 * (2 ** attempt), options?.signal);
674
+ }
675
+ throw lastError instanceof Error ? lastError : new Error("ATTACHMENT_DOWNLOAD_FAILED");
676
+ }
677
+ async mirrorActivity(direction, counterparty, text, conversationId, sourceMessageId, observedAt) {
678
+ if (!this.supervisionEnabled || this.supervisors.length === 0)
679
+ return;
680
+ const credentials = this.requireCredentials();
681
+ const plaintext = encodeAgentActivity({
682
+ version: 1,
683
+ kind: "AGENT_ACTIVITY",
684
+ agentPeerId: credentials.peer.id,
685
+ agentHandle: credentials.peer.handle,
686
+ counterpartyPeerId: counterparty.id,
687
+ counterpartyHandle: counterparty.handle,
688
+ counterpartyDisplayName: counterparty.displayName,
689
+ direction,
690
+ sourceMessageId,
691
+ observedAt,
692
+ text,
693
+ });
694
+ for (const supervisor of this.supervisors) {
695
+ const envelope = encryptTextNative({
696
+ messageId: deterministicUuid(`${sourceMessageId}:${supervisor.id}:${direction}:activity`),
697
+ conversationId,
698
+ senderPeerId: credentials.peer.id,
699
+ recipientPeerId: supervisor.id,
700
+ timestamp: new Date().toISOString(),
701
+ plaintext,
702
+ senderSigningSecretKey: credentials.keys.signingSecretKey,
703
+ senderEncryptionSecretKey: credentials.keys.encryptionSecretKey,
704
+ recipientEncryptionPublicKey: supervisor.encryptionPublicKey,
705
+ });
706
+ await this.queueEnvelope(envelope);
707
+ }
708
+ }
709
+ async queueEnvelope(envelope) {
710
+ await this.mutateRuntimeState((state) => {
711
+ if (!state.outbox.some((item) => item.message_id === envelope.message_id))
712
+ state.outbox.push(envelope);
713
+ });
714
+ if (this.connected)
715
+ await this.drainOutbox();
716
+ }
717
+ async removeFromOutbox(messageId) {
718
+ if (!this.runtimeState.outbox.some((item) => item.message_id === messageId))
719
+ return;
720
+ await this.mutateRuntimeState((state) => {
721
+ state.outbox = state.outbox.filter((item) => item.message_id !== messageId);
722
+ });
723
+ this.sentThisConnection.delete(messageId);
724
+ }
725
+ async drainOutbox() {
726
+ if (this.outboxDrain)
727
+ return this.outboxDrain;
728
+ const drain = (async () => {
729
+ while (this.connected) {
730
+ const envelope = this.runtimeState.outbox.find((item) => !this.sentThisConnection.has(item.message_id));
731
+ if (!envelope)
732
+ return;
733
+ this.sendFrame({ kind: "DELIVER", envelope });
734
+ this.sentThisConnection.add(envelope.message_id);
735
+ }
736
+ })();
737
+ this.outboxDrain = drain;
738
+ try {
739
+ await drain;
740
+ }
741
+ finally {
742
+ if (this.outboxDrain === drain)
743
+ this.outboxDrain = undefined;
744
+ }
745
+ }
746
+ async rememberIncoming(envelope) {
747
+ if (this.runtimeState.inbox.some((item) => item.message_id === envelope.message_id))
748
+ return;
749
+ await this.mutateRuntimeState((runtime) => {
750
+ if (!runtime.inbox.some((item) => item.message_id === envelope.message_id))
751
+ runtime.inbox.push(envelope);
752
+ });
753
+ }
754
+ async completeIncoming(messageId, state) {
755
+ await this.mutateRuntimeState((runtime) => {
756
+ runtime.processedIncoming[messageId] = runtime.processedIncoming[messageId] === "READ" ? "READ" : state;
757
+ const ids = Object.keys(runtime.processedIncoming);
758
+ for (let index = 0; index < ids.length - MAX_PROCESSED_INCOMING; index += 1) {
759
+ delete runtime.processedIncoming[ids[index]];
760
+ }
761
+ });
762
+ }
763
+ async forgetIncoming(messageId) {
764
+ if (!this.runtimeState.inbox.some((item) => item.message_id === messageId))
765
+ return;
766
+ await this.mutateRuntimeState((runtime) => {
767
+ runtime.inbox = runtime.inbox.filter((item) => item.message_id !== messageId);
768
+ });
769
+ if (this.runtimeState.inbox.length === 0 && this.inboxRetryTimer) {
770
+ clearTimeout(this.inboxRetryTimer);
771
+ this.inboxRetryTimer = undefined;
772
+ this.inboxRetryAttempt = 0;
773
+ }
774
+ }
775
+ async drainInbox() {
776
+ if (this.inboxDrain)
777
+ return this.inboxDrain;
778
+ const drain = (async () => {
779
+ if (!this.connected) {
780
+ this.scheduleInboxRetry();
781
+ return;
782
+ }
783
+ for (const envelope of [...this.runtimeState.inbox]) {
784
+ try {
785
+ await this.handleFrame({ kind: "MESSAGE", envelope });
786
+ }
787
+ catch (error) {
788
+ this.emitError(error);
789
+ this.scheduleInboxRetry();
790
+ return;
791
+ }
792
+ }
793
+ if (this.runtimeState.inbox.length === 0)
794
+ this.inboxRetryAttempt = 0;
795
+ })();
796
+ this.inboxDrain = drain;
797
+ try {
798
+ await drain;
799
+ }
800
+ finally {
801
+ if (this.inboxDrain === drain)
802
+ this.inboxDrain = undefined;
803
+ }
804
+ }
805
+ scheduleInboxRetry() {
806
+ if (this.stopped || this.runtimeState.inbox.length === 0 || this.inboxRetryTimer)
807
+ return;
808
+ const delay = reconnectDelay(this.inboxRetryAttempt++);
809
+ this.inboxRetryTimer = setTimeout(() => {
810
+ this.inboxRetryTimer = undefined;
811
+ void this.drainInbox();
812
+ }, delay);
813
+ }
814
+ async mutateRuntimeState(mutator) {
815
+ const operation = this.stateMutation.then(async () => {
816
+ const next = structuredClone(this.runtimeState);
817
+ mutator(next);
818
+ await this.runtimeStateStore.save(next);
819
+ this.runtimeState = next;
820
+ });
821
+ this.stateMutation = operation.catch(() => undefined);
822
+ return operation;
823
+ }
143
824
  sendFrame(frame) {
144
825
  if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
145
826
  throw new Error("Agent is not connected");
146
827
  this.socket.send(JSON.stringify(frame));
147
828
  }
148
829
  async request(path, init = {}, authenticated = true) {
149
- const credentials = this.credentials;
150
- const response = await fetch(`${this.baseUrl}${path}`, {
830
+ const headers = { "content-type": "application/json", ...init.headers };
831
+ const response = authenticated
832
+ ? await this.authorizedFetch(`${this.baseUrl}${path}`, { ...init, headers })
833
+ : await fetch(`${this.baseUrl}${path}`, { ...init, headers });
834
+ const body = await response.json();
835
+ if (!response.ok)
836
+ throw new AgentProtocolError(body.error?.code ?? `HTTP_${response.status}`, body.error?.message ?? `HTTP ${response.status}`);
837
+ return body;
838
+ }
839
+ async authorizedFetch(url, init = {}, retry = true) {
840
+ await this.refreshCredentialsIfNeeded("EXPIRING");
841
+ const response = await fetch(url, {
151
842
  ...init,
152
843
  headers: {
153
- "content-type": "application/json",
154
- ...(authenticated && credentials ? { authorization: `Bearer ${credentials.sessionToken}` } : {}),
844
+ authorization: `Bearer ${accessToken(this.requireCredentials())}`,
155
845
  ...init.headers,
156
846
  },
157
847
  });
158
- const body = await response.json();
159
- if (!response.ok)
160
- throw new Error(body.error ? `${body.error.code}: ${body.error.message}` : `HTTP ${response.status}`);
161
- return body;
848
+ if (response.status === 401 && retry && await this.refreshCredentialsIfNeeded("UNAUTHORIZED", true)) {
849
+ return this.authorizedFetch(url, init, false);
850
+ }
851
+ return response;
852
+ }
853
+ async refreshCredentialsIfNeeded(reason, force = false) {
854
+ if (!this.credentialRefresher || !this.credentials)
855
+ return false;
856
+ if (reason === "EXPIRING" && !force) {
857
+ const expiresAt = this.credentials.accessTokenExpiresAt
858
+ ? Date.parse(this.credentials.accessTokenExpiresAt)
859
+ : Number.POSITIVE_INFINITY;
860
+ if (!Number.isFinite(expiresAt) || expiresAt > Date.now() + this.refreshLeewayMs)
861
+ return false;
862
+ }
863
+ if (this.refreshPromise)
864
+ return this.refreshPromise;
865
+ const refresh = (async () => {
866
+ let current = this.requireCredentials();
867
+ if (this.usesDefaultCredentialRefresher && current.refreshToken && !current.refreshRequestId) {
868
+ current = { ...current, refreshRequestId: randomUUID() };
869
+ // Persist intent before sending. If the successful response is lost, a
870
+ // restart repeats the same server-side operation instead of reusing the
871
+ // rotated token under a new idempotency key.
872
+ await this.credentialStore.save(current);
873
+ this.credentials = current;
874
+ }
875
+ let refreshed;
876
+ try {
877
+ refreshed = await this.credentialRefresher({ credentials: current, reason, baseUrl: this.baseUrl });
878
+ }
879
+ catch (error) {
880
+ if (this.usesDefaultCredentialRefresher && current.refreshRequestId && isSessionError(error)) {
881
+ const { refreshRequestId: _failedRefreshRequest, ...restored } = current;
882
+ await this.credentialStore.save(restored);
883
+ this.credentials = restored;
884
+ }
885
+ throw error;
886
+ }
887
+ if (!refreshed)
888
+ return false;
889
+ const { accessTokenExpiresAt: _previousExpiry, refreshRequestId: _completedRefreshRequest, ...currentWithoutExpiry } = current;
890
+ const next = {
891
+ ...currentWithoutExpiry,
892
+ sessionToken: refreshed.accessToken,
893
+ accessToken: refreshed.accessToken,
894
+ ...(refreshed.refreshToken ?? current.refreshToken
895
+ ? { refreshToken: refreshed.refreshToken ?? current.refreshToken }
896
+ : {}),
897
+ ...(refreshed.accessTokenExpiresAt ? { accessTokenExpiresAt: refreshed.accessTokenExpiresAt } : {}),
898
+ };
899
+ await this.credentialStore.save(next);
900
+ this.credentials = next;
901
+ return true;
902
+ })();
903
+ this.refreshPromise = refresh;
904
+ try {
905
+ return await refresh;
906
+ }
907
+ finally {
908
+ if (this.refreshPromise === refresh)
909
+ this.refreshPromise = undefined;
910
+ }
911
+ }
912
+ async connectWithRefresh() {
913
+ try {
914
+ await this.connect();
915
+ }
916
+ catch (error) {
917
+ if (isSessionError(error) && await this.refreshCredentialsIfNeeded("UNAUTHORIZED", true)) {
918
+ await this.connect();
919
+ return;
920
+ }
921
+ throw error;
922
+ }
923
+ }
924
+ async recoverRejectedSession() {
925
+ try {
926
+ if (!await this.refreshCredentialsIfNeeded("UNAUTHORIZED", true)) {
927
+ this.stopped = true;
928
+ this.emitError(new AgentProtocolError("INVALID_SESSION", "Agent credentials were rejected"));
929
+ return;
930
+ }
931
+ if (!this.stopped)
932
+ await this.connectWithRefresh();
933
+ }
934
+ catch (error) {
935
+ if (isSessionError(error))
936
+ this.stopped = true;
937
+ this.emitError(error);
938
+ if (!this.stopped)
939
+ this.scheduleReconnect();
940
+ }
162
941
  }
163
942
  requireCredentials() {
164
943
  if (!this.credentials)
@@ -173,4 +952,115 @@ export class Agent {
173
952
  queueMicrotask(() => { throw normalized; });
174
953
  }
175
954
  }
955
+ function exactArrayBuffer(bytes) {
956
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
957
+ }
958
+ function abortError() {
959
+ const error = new Error("Attachment transfer was cancelled");
960
+ error.name = "AbortError";
961
+ return error;
962
+ }
963
+ function throwIfAborted(signal) {
964
+ if (signal?.aborted)
965
+ throw abortError();
966
+ }
967
+ async function abortableDelay(milliseconds, signal) {
968
+ throwIfAborted(signal);
969
+ await new Promise((resolveDelay, rejectDelay) => {
970
+ const timer = setTimeout(resolveDelay, milliseconds);
971
+ signal?.addEventListener("abort", () => {
972
+ clearTimeout(timer);
973
+ rejectDelay(abortError());
974
+ }, { once: true });
975
+ });
976
+ }
977
+ class AgentProtocolError extends Error {
978
+ code;
979
+ constructor(code, message) {
980
+ super(`${code}: ${message}`);
981
+ this.code = code;
982
+ }
983
+ }
984
+ function accessToken(credentials) {
985
+ return credentials.accessToken ?? credentials.sessionToken;
986
+ }
987
+ async function refreshAtalkCredentials(context) {
988
+ const refreshToken = context.credentials.refreshToken;
989
+ if (!refreshToken)
990
+ return undefined;
991
+ const response = await fetch(`${context.baseUrl}/v1/agent-runtime/session/refresh`, {
992
+ method: "POST",
993
+ headers: { "content-type": "application/json" },
994
+ body: JSON.stringify({
995
+ refreshToken,
996
+ // Deterministic per credential, so a process restart retries the same
997
+ // idempotency operation if the first response was lost.
998
+ requestId: context.credentials.refreshRequestId
999
+ ?? deterministicUuid(`atalk-agent-refresh:${refreshToken}`),
1000
+ }),
1001
+ });
1002
+ if (!response.ok)
1003
+ throw await responseError(response);
1004
+ const body = await response.json();
1005
+ const nextAccessToken = body.accessToken ?? body.token;
1006
+ if (!nextAccessToken)
1007
+ throw new Error("INVALID_REFRESH_RESPONSE: aTalk did not return an access token");
1008
+ return {
1009
+ accessToken: nextAccessToken,
1010
+ ...(body.refreshToken ? { refreshToken: body.refreshToken } : {}),
1011
+ ...(body.accessTokenExpiresAt ?? body.expiresAt
1012
+ ? { accessTokenExpiresAt: body.accessTokenExpiresAt ?? body.expiresAt }
1013
+ : {}),
1014
+ };
1015
+ }
1016
+ function isSessionError(error) {
1017
+ return error instanceof AgentProtocolError && FATAL_SESSION_CODES.has(error.code);
1018
+ }
1019
+ function sameIdentityKeys(left, right) {
1020
+ return left.signingPublicKey === right.signingPublicKey
1021
+ && left.signingSecretKey === right.signingSecretKey
1022
+ && left.encryptionPublicKey === right.encryptionPublicKey
1023
+ && left.encryptionSecretKey === right.encryptionSecretKey;
1024
+ }
1025
+ function deterministicUuid(value) {
1026
+ const bytes = createHash("sha256").update(value).digest().subarray(0, 16);
1027
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
1028
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
1029
+ const hex = bytes.toString("hex");
1030
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
1031
+ }
1032
+ async function attachmentInputFromFile(input) {
1033
+ const path = resolve(input.path);
1034
+ return {
1035
+ data: new Uint8Array(await readFile(path)),
1036
+ name: input.name?.trim() || basename(path),
1037
+ mimeType: input.mimeType?.trim() || mimeTypeFromPath(path),
1038
+ ...(input.caption?.trim() ? { caption: input.caption.trim() } : {}),
1039
+ };
1040
+ }
1041
+ function mimeTypeFromPath(path) {
1042
+ const mimeTypes = {
1043
+ ".aac": "audio/aac", ".csv": "text/csv", ".gif": "image/gif", ".heic": "image/heic",
1044
+ ".jpeg": "image/jpeg", ".jpg": "image/jpeg", ".json": "application/json", ".m4a": "audio/mp4",
1045
+ ".mov": "video/quicktime", ".mp3": "audio/mpeg", ".mp4": "video/mp4", ".ogg": "audio/ogg",
1046
+ ".pdf": "application/pdf", ".png": "image/png", ".txt": "text/plain", ".wav": "audio/wav",
1047
+ ".webm": "video/webm", ".webp": "image/webp", ".zip": "application/zip",
1048
+ };
1049
+ return mimeTypes[extname(path).toLowerCase()] ?? "application/octet-stream";
1050
+ }
1051
+ async function responseError(response) {
1052
+ try {
1053
+ const body = await response.json();
1054
+ if (body.error)
1055
+ return new AgentProtocolError(body.error.code ?? `HTTP_${response.status}`, body.error.message ?? "request failed");
1056
+ }
1057
+ catch {
1058
+ // Keep the HTTP fallback for non-JSON proxy responses.
1059
+ }
1060
+ return new AgentProtocolError(`HTTP_${response.status}`, `HTTP ${response.status}`);
1061
+ }
1062
+ function reconnectDelay(attempt) {
1063
+ const exponential = Math.min(30_000, 500 * (2 ** Math.min(attempt, 6)));
1064
+ return exponential + Math.floor(Math.random() * Math.max(1, Math.floor(exponential * 0.2)));
1065
+ }
176
1066
  //# sourceMappingURL=agent.js.map