@wacando/agent 2.1.0 → 2.3.0

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.
@@ -5,6 +5,7 @@ import {
5
5
  } from '#wacando/xmpp';
6
6
  import { ApiError } from '../onboarding/network.mjs';
7
7
  import { SafeError } from '../onboarding/storage.mjs';
8
+ import { fileApiMessages, fileBodyKind, fileErrors } from './files.mjs';
8
9
  import { attachRecovery, recoveryLifecycle, recoveryView } from './recovery.mjs';
9
10
 
10
11
  // Consent-aware conversations (lbframe/wacando docs/CONVERSATION_CONSENT.md):
@@ -44,6 +45,7 @@ export function apiFailure(error) {
44
45
  CONVERSATION_NOT_ACTIVE: 'Conversation is not active. Nothing was sent.',
45
46
  NOT_FOUND: consentErrors.notAccepted,
46
47
  FORBIDDEN: 'Refused: the other agent is not reachable for new requests, or this agent may not act here. Nothing was sent.',
48
+ ...fileApiMessages,
47
49
  };
48
50
  return new SafeError(known[error.code] ?? `Wacando API refused the request (${error.code ?? `HTTP ${error.status}`}).`);
49
51
  }
@@ -102,7 +104,7 @@ export function publicConversation(view, extra = {}) {
102
104
 
103
105
  /** Messaging for ONE connected identity. Created by AgentRuntime.conversations(). */
104
106
  export class ConversationClient {
105
- constructor({ runtime, agent, session }) {
107
+ constructor({ runtime, agent, session, files }) {
106
108
  this.runtime = runtime;
107
109
  this.agent = agent;
108
110
  this.session = session;
@@ -127,8 +129,17 @@ export class ConversationClient {
127
129
  this.recoveryStates = new Map(); // conversationId → recovery view
128
130
  this.recovery = this.attachRecovery(session);
129
131
  this.lifecyclesLoaded = this.loadLifecycles();
132
+ // lbframe/wacando-agent#10: encrypted files under owner approval.
133
+ this.files = files?.(this) ?? null;
134
+ // lbframe/wacando-agent#21: clean up ended transfers at every connection.
135
+ this.filesSwept = this.sweepFiles();
130
136
  }
131
137
 
138
+ /** Content-free counts of the local file records removed, or null (never throws). */
139
+ sweepFiles() { return this.files ? this.files.sweep().catch(() => null) : Promise.resolve(null); }
140
+
141
+ failure(error) { return apiFailure(error); }
142
+
132
143
  /**
133
144
  * Learn every conversation's state at start-up (and after a reconnection),
134
145
  * so recovery is not refused just because no tool call happened yet.
@@ -167,7 +178,7 @@ export class ConversationClient {
167
178
  return state ? { recovery: { ...state } } : {};
168
179
  }
169
180
 
170
- /** Own sent text, kept in memory only and bounded (Signal cannot self-decrypt). */
181
+ /** Own sent text (or a file summary), kept in memory only and bounded (Signal cannot self-decrypt). */
171
182
  remember(id, text) {
172
183
  this.sent.set(id, text);
173
184
  while (this.sent.size > MAX_ENTRIES) this.sent.delete(this.sent.keys().next().value);
@@ -180,6 +191,7 @@ export class ConversationClient {
180
191
  this.session = session;
181
192
  this.recovery = this.attachRecovery(session);
182
193
  this.lifecyclesLoaded = this.loadLifecycles();
194
+ this.filesSwept = this.sweepFiles();
183
195
  this.tracker = new MucOriginTracker(session.client, { trustedRelayJid: this.policyJid });
184
196
  this.rooms.clear();
185
197
  for (const resolve of this.submissions.values()) resolve({ state: 'refused', reason: 'connection-lost' });
@@ -220,14 +232,25 @@ export class ConversationClient {
220
232
  if (transcript.has(id)) return;
221
233
  const own = origin.senderBareJid === this.selfJid;
222
234
  let text = null;
235
+ let file;
223
236
  if (own) text = this.sent.get(id) ?? null;
224
237
  else if (!this.claimed.has(id)) {
225
238
  this.claimed.add(id);
226
239
  text = await this.session.provider.decryptStanza(stanza, origin).catch(() => null);
227
240
  }
241
+ if (text !== null && typeof text === 'object') { file = text.file; text = null; }
242
+ else if (fileBodyKind(text)) {
243
+ // A file descriptor is never shown as text: a valid share -> quarantined,
244
+ // anything else (invalid share, review descriptor in the room) -> refused.
245
+ const kind = fileBodyKind(text);
246
+ file = kind === 'review' ? { state: 'refused', reason: 'review_descriptor' }
247
+ : this.files && !own ? await this.files.intake(conversationId, id, text).catch(() => ({ state: 'refused', reason: 'storage_unavailable' }))
248
+ : { state: 'refused', reason: 'file_transfer_unavailable' };
249
+ text = null;
250
+ }
228
251
  if (this.held.has(id)) this.held.get(id).state = 'delivered';
229
252
  const entry = { id, conversationId, sender: own ? 'self' : 'peer', at: stamp ?? new Date().toISOString(), text,
230
- ...(text === null && !own ? { undecryptable: true } : {}), source };
253
+ ...(file ? { file } : {}), ...(text === null && !own && !file ? { undecryptable: true } : {}), source };
231
254
  transcript.set(id, entry);
232
255
  while (transcript.size > MAX_ENTRIES) transcript.delete(transcript.keys().next().value);
233
256
  if (!own) for (const waiter of [...this.waiters]) waiter(entry);
@@ -278,6 +301,7 @@ export class ConversationClient {
278
301
  * agent receives nothing before its owner accepts.
279
302
  */
280
303
  async propose(handle, text) {
304
+ if (fileBodyKind(text)) throw new SafeError(fileErrors.plainText);
281
305
  const peer = await this.resolve(handle);
282
306
  let conversation;
283
307
  try {
@@ -295,6 +319,7 @@ export class ConversationClient {
295
319
 
296
320
  /** Send in a conversation, respecting consent and the agent's mode. */
297
321
  async send(conversationId, text) {
322
+ if (fileBodyKind(text)) throw new SafeError(fileErrors.plainText);
298
323
  const view = await this.conversation(conversationId);
299
324
  if (view.state === 'requested') {
300
325
  if (view.role !== 'requester') throw new SafeError(consentErrors.waitingOther);
@@ -302,15 +327,33 @@ export class ConversationClient {
302
327
  return { conversation: publicConversation(view), message: await this.submit(view, text, 'first_message') };
303
328
  }
304
329
  if (view.state !== 'active') throw new SafeError(consentErrors.closed(view.state));
330
+ return this.deliver(view, text);
331
+ }
332
+
333
+ /** Deliver a body in an ACTIVE conversation: directly as a member, held by the policy when gated. */
334
+ async deliver(view, text, memo = text) {
305
335
  const mode = await this.enter(view);
306
- if (mode === 'gated') return { conversation: publicConversation(view, { mode }), message: await this.submit(view, text, 'service_message') };
336
+ if (mode === 'gated') return { conversation: publicConversation(view, { mode }), message: await this.submit(view, text, 'service_message', memo) };
307
337
  const stanza = await this.encrypt(view, text);
308
338
  const id = stanza.getAttr('id');
309
- this.remember(id, text);
339
+ this.remember(id, memo);
310
340
  await this.session.client.send(stanza);
311
341
  return { conversation: publicConversation(view, { mode }), message: { id, state: 'sent' } };
312
342
  }
313
343
 
344
+ fileTransfers() {
345
+ if (!this.files) throw new SafeError('File transfer is not available on this runtime.');
346
+ return this.files;
347
+ }
348
+ /** Encrypt a local file, ask the owner's approval, then deposit and share it (bounded wait). */
349
+ sendFile(conversationId, path, options) { return this.fileTransfers().send(conversationId, path, options); }
350
+ /** State of an outgoing (advanced as far as approved) or incoming file. */
351
+ fileStatus(fileId, options) { return this.fileTransfers().status(fileId, options); }
352
+ /** Fetch a released incoming file into the private quarantine directory. */
353
+ receiveFile(fileId, options) { return this.fileTransfers().receive(fileId, options); }
354
+ /** Local file transfers (no keys). */
355
+ listFiles(conversationId) { return this.fileTransfers().list(conversationId); }
356
+
314
357
  /**
315
358
  * Recipients, discovered fresh before EVERY encryption: owners observe
316
359
  * through per-installation device JIDs (lbframe/wacando #35/#108). There is
@@ -335,7 +378,7 @@ export class ConversationClient {
335
378
  }
336
379
 
337
380
  /** Encrypt for the room, then hand the ciphertext to the policy component. */
338
- async submit(view, text, kind) {
381
+ async submit(view, text, kind, memo = text) {
339
382
  const stanza = await this.encrypt(view, text);
340
383
  const id = stanza.getAttr('id');
341
384
  let timer;
@@ -343,7 +386,7 @@ export class ConversationClient {
343
386
  this.submissions.set(id, resolve);
344
387
  timer = setTimeout(() => reject(new SafeError('The policy component did not answer; the message may not be held. Retry later.')), SUBMISSION_TIMEOUT_MS);
345
388
  }).finally(() => { clearTimeout(timer); this.submissions.delete(id); });
346
- this.remember(id, text);
389
+ this.remember(id, memo);
347
390
  await this.session.client.send(toPolicySubmission(stanza, this.policyJid));
348
391
  const attrs = await status;
349
392
  if (attrs.state !== 'held') {