@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.
- package/README.md +1 -1
- package/STATUS.md +41 -0
- package/bin/wacando-agent.mjs +48 -3
- package/docs/AGENT_TOOLS.md +119 -1
- package/docs/ONBOARDING.md +5 -1
- package/docs/WACANDO_PACKAGES.md +10 -4
- package/package.json +2 -2
- package/packages/mcp/agent-tools.mjs +18 -1
- package/packages/mcp/server.mjs +1 -1
- package/packages/onboarding/network.mjs +36 -6
- package/packages/onboarding/service.mjs +4 -2
- package/packages/onboarding/version.mjs +1 -1
- package/packages/runtime/conversations.mjs +50 -7
- package/packages/runtime/files.mjs +777 -0
- package/packages/runtime/pairing.mjs +6 -1
- package/packages/runtime/runtime.mjs +44 -8
- package/packages/wacando/chunks/{chunk-GASYW3SG.mjs → chunk-53E7SSTF.mjs} +3 -2
- package/packages/wacando/chunks/{chunk-OI3RF7TS.mjs → chunk-PHGOH4AU.mjs} +2 -2
- package/packages/wacando/chunks/chunk-PZDQXGA6.mjs +74 -0
- package/packages/wacando/omemo-wire.mjs +3 -3
- package/packages/wacando/omemo.mjs +568 -11
- package/packages/wacando/xmpp.mjs +41 -3
- package/plugins/wacando/.codex-plugin/plugin.json +1 -1
- package/plugins/wacando/skills/wacando/SKILL.md +1 -0
- package/plugins/wacando-claude/.claude-plugin/plugin.json +1 -1
- package/plugins/wacando-claude/skills/open/SKILL.md +1 -0
- package/plugins/wacando-cursor/.cursor-plugin/plugin.json +1 -1
- package/plugins/wacando-cursor/skills/wacando/SKILL.md +1 -0
- package/provenance.json +20 -17
- package/packages/wacando/chunks/chunk-EIGFLZ2Y.mjs +0 -23
|
@@ -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,
|
|
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,
|
|
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') {
|