@zengxingyuan/aamp-acp-bridge 0.1.28-dev.10

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.
@@ -0,0 +1,963 @@
1
+ import { AampClient, } from 'aamp-sdk';
2
+ import { AcpxClient, } from './acpx-client.js';
3
+ import { buildPrompt, parseResponse } from './prompt-builder.js';
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, mkdtempSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { basename, dirname, join } from 'node:path';
7
+ import { resolveCredentialsFile } from './storage.js';
8
+ import { addSenderPolicy, consumePairingCode, loadSenderPolicies, resolvePairingFile, resolveSenderPoliciesFile, rulesMatch, validatePairingCode, } from './pairing.js';
9
+ const TEXT_DELTA_FLUSH_MS = 5_000;
10
+ const TEXT_DELTA_FLUSH_CHARS = 120;
11
+ const TEXT_DELTA_BOUNDARY_CHARS = 32;
12
+ function matchSenderPolicy(task, senderPolicies) {
13
+ if (!senderPolicies?.length)
14
+ return { allowed: false, reason: 'no configured senderPolicies' };
15
+ const sender = task.from.toLowerCase();
16
+ const policy = senderPolicies.find((item) => item.sender.trim().toLowerCase() === sender);
17
+ if (!policy) {
18
+ return { allowed: false, reason: `sender ${task.from} is not allowed by senderPolicies` };
19
+ }
20
+ const rules = policy.dispatchContextRules;
21
+ if (!rules || Object.keys(rules).length === 0) {
22
+ return { allowed: true };
23
+ }
24
+ const context = task.dispatchContext ?? {};
25
+ const effectiveRules = Object.entries(rules)
26
+ .map(([key, allowedValues]) => [
27
+ key,
28
+ (allowedValues ?? []).map((value) => value.trim()).filter(Boolean),
29
+ ])
30
+ .filter(([, allowedValues]) => allowedValues.length > 0);
31
+ if (effectiveRules.length === 0) {
32
+ return { allowed: true };
33
+ }
34
+ for (const [key, allowedValues] of effectiveRules) {
35
+ const contextValue = context[key];
36
+ if (!contextValue) {
37
+ return { allowed: false, reason: `dispatchContext missing required key "${key}"` };
38
+ }
39
+ if (!allowedValues.includes(contextValue)) {
40
+ return { allowed: false, reason: `dispatchContext ${key}=${contextValue} is not allowed` };
41
+ }
42
+ }
43
+ return { allowed: true };
44
+ }
45
+ function matchPairedSenderPolicy(task, senderPolicies) {
46
+ if (senderPolicies.length === 0)
47
+ return { allowed: false, reason: 'no paired sender policies configured' };
48
+ const sender = task.from.toLowerCase();
49
+ const policy = senderPolicies.find((item) => item.sender.trim().toLowerCase() === sender);
50
+ if (!policy) {
51
+ return { allowed: false, reason: `sender ${task.from} is not paired` };
52
+ }
53
+ if (!rulesMatch(policy.dispatchContextRules, task.dispatchContext)) {
54
+ return { allowed: false, reason: `dispatchContext does not match paired sender policy for ${task.from}` };
55
+ }
56
+ return { allowed: true };
57
+ }
58
+ function matchCombinedSenderPolicy(task, configuredPolicies, pairedPolicies) {
59
+ const hasConfiguredPolicies = Boolean(configuredPolicies?.length);
60
+ const hasPairedPolicies = pairedPolicies.length > 0;
61
+ if (!hasConfiguredPolicies && !hasPairedPolicies) {
62
+ return { allowed: false, reason: 'no sender policy configured' };
63
+ }
64
+ const configuredDecision = hasConfiguredPolicies
65
+ ? matchSenderPolicy(task, configuredPolicies)
66
+ : { allowed: false, reason: undefined };
67
+ if (configuredDecision.allowed)
68
+ return configuredDecision;
69
+ const pairedDecision = hasPairedPolicies
70
+ ? matchPairedSenderPolicy(task, pairedPolicies)
71
+ : { allowed: false, reason: undefined };
72
+ if (pairedDecision.allowed)
73
+ return pairedDecision;
74
+ return configuredDecision.reason ? configuredDecision : pairedDecision;
75
+ }
76
+ export function formatDebugPromptLog(options) {
77
+ return [
78
+ `[${options.agentName}] ACP prompt debug task=${options.taskId} session=${options.sessionName}`,
79
+ '--- BEGIN ACP PROMPT ---',
80
+ options.prompt,
81
+ '--- END ACP PROMPT ---',
82
+ ].join('\n');
83
+ }
84
+ function buildPhaseStatusLabel(channel) {
85
+ return channel === 'thought'
86
+ ? 'ACP agent is thinking'
87
+ : 'ACP agent is composing the reply';
88
+ }
89
+ function buildToolProgressLabel(update) {
90
+ const target = update.title?.trim()
91
+ || update.locations?.[0]?.path
92
+ || update.kind?.trim()
93
+ || 'tool';
94
+ switch (update.status) {
95
+ case 'completed':
96
+ return `Tool completed: ${target}`;
97
+ case 'failed':
98
+ return `Tool failed: ${target}`;
99
+ case 'pending':
100
+ return `Tool pending: ${target}`;
101
+ case 'in_progress':
102
+ default:
103
+ return `Tool running: ${target}`;
104
+ }
105
+ }
106
+ function formatPlanUpdate(entries) {
107
+ const lines = entries.map((entry) => {
108
+ const prefix = entry.status ? `[${entry.status}] ` : '';
109
+ return `- ${prefix}${entry.content}`;
110
+ });
111
+ return `[plan]\n${lines.join('\n')}`;
112
+ }
113
+ function renderTextChunk(chunk, state) {
114
+ if (!chunk.text)
115
+ return '';
116
+ const sameChannel = state.currentChannel === chunk.channel;
117
+ const sameMessage = sameChannel && (chunk.messageId && state.currentMessageId
118
+ ? chunk.messageId === state.currentMessageId
119
+ : !chunk.messageId && !state.currentMessageId);
120
+ if (sameMessage) {
121
+ return chunk.text;
122
+ }
123
+ const prefix = state.hasContent ? '\n\n' : '';
124
+ state.currentChannel = chunk.channel;
125
+ state.currentMessageId = chunk.messageId;
126
+ state.hasContent = true;
127
+ if (chunk.channel === 'thought') {
128
+ return `${prefix}[thinking] ${chunk.text}`;
129
+ }
130
+ return `${prefix}${chunk.text}`;
131
+ }
132
+ function threadAlreadyTerminal(events) {
133
+ return (events ?? []).some((event) => event.intent === 'task.result' || event.intent === 'task.cancel');
134
+ }
135
+ function threadAlreadyPairResponded(events) {
136
+ return (events ?? []).some((event) => event.intent === 'pair.respond');
137
+ }
138
+ function isThreadNotFoundError(err) {
139
+ const message = err instanceof Error ? err.message : String(err);
140
+ return message.includes('Thread history fetch failed: 404')
141
+ || message.includes('"Task not found"');
142
+ }
143
+ function isClosedStreamAppendError(err) {
144
+ const message = err instanceof Error ? err.message : String(err);
145
+ return message.includes('AAMP stream append failed: 409')
146
+ && message.includes('Task stream is already closed');
147
+ }
148
+ function isStreamServiceUnavailableError(err) {
149
+ const message = err instanceof Error ? err.message : String(err);
150
+ return message.includes('AAMP stream create failed: 503')
151
+ || message.includes('Stream service unavailable');
152
+ }
153
+ function firstDispatchContextValue(context, keys) {
154
+ if (!context)
155
+ return undefined;
156
+ for (const key of keys) {
157
+ const value = context[key]?.trim();
158
+ if (value)
159
+ return value;
160
+ }
161
+ return undefined;
162
+ }
163
+ function sanitizeAttachmentFilename(value, path) {
164
+ const fallback = basename(path).replace(/[\r\n]/g, ' ').trim();
165
+ const fromValue = value?.replace(/[\r\n]/g, ' ').trim();
166
+ if (!fromValue)
167
+ return fallback;
168
+ return basename(fromValue) || fallback;
169
+ }
170
+ function sanitizeContentType(value) {
171
+ const normalized = value?.replace(/[\r\n]/g, '').trim();
172
+ return normalized || 'application/octet-stream';
173
+ }
174
+ function endsAtTextBoundary(value) {
175
+ return /(?:\n|[。!?!?..]\s*)$/.test(value);
176
+ }
177
+ function sanitizeIncomingAttachmentFilename(value, index) {
178
+ const fallback = `attachment-${index + 1}`;
179
+ const base = basename(value?.replace(/[\r\n]/g, ' ').trim() || fallback)
180
+ .replace(/[^\w .()@+-]/g, '_')
181
+ .replace(/\s+/g, ' ')
182
+ .trim();
183
+ return base || fallback;
184
+ }
185
+ function sanitizePathToken(value) {
186
+ return value.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 64) || 'task';
187
+ }
188
+ function describeIncomingAttachment(attachment) {
189
+ const parts = [
190
+ attachment.filename,
191
+ attachment.contentType,
192
+ Number.isFinite(attachment.size) ? `${attachment.size} bytes` : '',
193
+ ].filter(Boolean);
194
+ return parts.join(', ');
195
+ }
196
+ function mergeAttachmentRefs(files, attachmentRefs) {
197
+ const byKey = new Map();
198
+ for (const file of files) {
199
+ byKey.set(file, { path: file });
200
+ }
201
+ for (const attachment of attachmentRefs ?? []) {
202
+ byKey.set(attachment.path, attachment);
203
+ }
204
+ return [...byKey.values()];
205
+ }
206
+ function isAttachmentStructuredField(field) {
207
+ return /(attachment|file)/i.test(field.fieldTypeKey ?? '');
208
+ }
209
+ function fillStructuredResultAttachmentFilenames(structuredResult, attachments) {
210
+ if (!structuredResult?.length || !attachments.length)
211
+ return structuredResult;
212
+ const filenames = attachments.map((attachment) => attachment.filename);
213
+ return structuredResult.map((field) => {
214
+ if (!isAttachmentStructuredField(field) || field.attachmentFilenames?.length)
215
+ return field;
216
+ return {
217
+ ...field,
218
+ attachmentFilenames: filenames,
219
+ };
220
+ });
221
+ }
222
+ /**
223
+ * Bridges a single ACP agent to the AAMP network.
224
+ * Manages AAMP identity, ACP session, and task routing.
225
+ */
226
+ export class AgentBridge {
227
+ agentConfig;
228
+ aampHost;
229
+ rejectUnauthorized;
230
+ client = null;
231
+ acpx;
232
+ identity = null;
233
+ sessionName;
234
+ activeTaskCount = 0;
235
+ pollingFallback = false;
236
+ transportMode = 'connecting';
237
+ cancelledTaskIds = new Set();
238
+ senderPolicies = [];
239
+ activeTaskIds = new Set();
240
+ isHistoricalReconcile = false;
241
+ debugPrompt = false;
242
+ constructor(agentConfig, aampHost, rejectUnauthorized) {
243
+ this.agentConfig = agentConfig;
244
+ this.aampHost = aampHost;
245
+ this.rejectUnauthorized = rejectUnauthorized;
246
+ this.acpx = new AcpxClient();
247
+ this.sessionName = `aamp-${agentConfig.name}`;
248
+ }
249
+ get name() { return this.agentConfig.name; }
250
+ get email() { return this.identity?.email ?? '(not registered)'; }
251
+ get isConnected() { return this.client?.isConnected() ?? false; }
252
+ get isUsingPollingFallback() { return this.pollingFallback || (this.client?.isUsingPollingFallback() ?? false); }
253
+ get isBusy() { return this.activeTaskCount > 0; }
254
+ sanitizeSessionSuffix(value) {
255
+ return value
256
+ .trim()
257
+ .toLowerCase()
258
+ .replace(/[^a-z0-9:_-]+/g, '-')
259
+ .replace(/-+/g, '-')
260
+ .replace(/^-|-$/g, '')
261
+ .slice(0, 96);
262
+ }
263
+ resolveTaskSessionName(task) {
264
+ const stickyValue = task.sessionKey?.trim();
265
+ if (!stickyValue)
266
+ return this.sessionName;
267
+ const suffix = this.sanitizeSessionSuffix(stickyValue);
268
+ return suffix ? `${this.sessionName}-${suffix}` : this.sessionName;
269
+ }
270
+ getConfiguredCardText() {
271
+ const inline = this.agentConfig.cardText?.trim();
272
+ if (inline)
273
+ return inline;
274
+ const file = this.agentConfig.cardFile?.trim();
275
+ if (!file)
276
+ return undefined;
277
+ const fromFile = readFileSync(file, 'utf-8').trim();
278
+ return fromFile || undefined;
279
+ }
280
+ async syncDirectoryProfile(options = {}) {
281
+ if (!this.client)
282
+ return;
283
+ const summary = this.agentConfig.summary?.trim() || this.agentConfig.description?.trim();
284
+ const cardText = this.getConfiguredCardText();
285
+ if (!summary && !cardText)
286
+ return;
287
+ await this.client.updateDirectoryProfile({
288
+ ...(summary ? { summary } : {}),
289
+ ...(cardText ? { cardText } : {}),
290
+ });
291
+ if (!options.quiet) {
292
+ console.log(`[${this.name}] Directory profile synced${cardText ? ' (card text registered)' : ''}`);
293
+ }
294
+ }
295
+ /**
296
+ * Start the bridge: resolve identity → connect AAMP → ensure ACP session.
297
+ */
298
+ async start(options = {}) {
299
+ let quietStartup = options.quiet === true;
300
+ this.debugPrompt = options.debug === true;
301
+ // 1. Resolve AAMP identity
302
+ this.identity = await this.resolveIdentity();
303
+ if (!quietStartup) {
304
+ console.log(`[${this.name}] AAMP identity: ${this.identity.email}`);
305
+ }
306
+ // 2. Create AAMP client
307
+ this.client = AampClient.fromMailboxIdentity({
308
+ email: this.identity.email,
309
+ smtpPassword: this.identity.smtpPassword,
310
+ baseUrl: this.aampHost,
311
+ rejectUnauthorized: this.rejectUnauthorized,
312
+ });
313
+ const client = this.client;
314
+ this.senderPolicies = loadSenderPolicies(resolveSenderPoliciesFile(this.agentConfig.senderPoliciesFile, this.agentConfig.name));
315
+ // 3. Wire up task handler
316
+ client.on('task.dispatch', (task) => {
317
+ const historical = this.isHistoricalReconcile;
318
+ return this.handleTask(task, { historical }).catch((err) => {
319
+ console.error(`[${this.name}] Task ${task.taskId} failed: ${err.message}`);
320
+ });
321
+ });
322
+ client.on('task.cancel', (task) => {
323
+ this.handleCancel(task);
324
+ });
325
+ client.on('pair.request', (request) => {
326
+ const historical = this.isHistoricalReconcile;
327
+ void this.handlePairRequest(request, { historical }).catch((err) => {
328
+ console.warn(`[${this.name}] Failed to handle pair.request: ${err.message}`);
329
+ });
330
+ });
331
+ client.on('connected', () => {
332
+ const usingPollingFallback = client.isUsingPollingFallback();
333
+ this.pollingFallback = usingPollingFallback;
334
+ if (usingPollingFallback) {
335
+ if (this.transportMode !== 'polling') {
336
+ if (!quietStartup) {
337
+ console.warn(`[${this.name}] AAMP connected (polling fallback active)`);
338
+ }
339
+ }
340
+ this.transportMode = 'polling';
341
+ }
342
+ else {
343
+ const previousMode = this.transportMode;
344
+ this.transportMode = 'websocket';
345
+ if (quietStartup) {
346
+ return;
347
+ }
348
+ if (previousMode === 'polling') {
349
+ console.log(`[${this.name}] AAMP WebSocket restored`);
350
+ }
351
+ else {
352
+ console.log(`[${this.name}] AAMP connected`);
353
+ }
354
+ }
355
+ });
356
+ client.on('disconnected', (reason) => {
357
+ const usingPollingFallback = client.isUsingPollingFallback();
358
+ this.pollingFallback = usingPollingFallback;
359
+ if (usingPollingFallback) {
360
+ if (this.transportMode !== 'polling') {
361
+ if (!quietStartup) {
362
+ console.warn(`[${this.name}] AAMP WebSocket unavailable, using polling fallback: ${reason}`);
363
+ }
364
+ }
365
+ this.transportMode = 'polling';
366
+ }
367
+ else {
368
+ this.transportMode = 'disconnected';
369
+ console.warn(`[${this.name}] AAMP disconnected: ${reason}`);
370
+ }
371
+ });
372
+ client.on('error', (err) => {
373
+ if (err.message.includes('falling back to polling')) {
374
+ this.pollingFallback = true;
375
+ if (this.transportMode !== 'polling') {
376
+ if (!quietStartup) {
377
+ console.warn(`[${this.name}] ${err.message}`);
378
+ }
379
+ this.transportMode = 'polling';
380
+ }
381
+ return;
382
+ }
383
+ if (this.transportMode === 'polling' && (err.message.includes('JMAP WebSocket handshake failed')
384
+ || err.message.includes('Failed to get JMAP session')
385
+ || err.message.includes('Polling fallback failed'))) {
386
+ return;
387
+ }
388
+ console.error(`[${this.name}] AAMP error: ${err.message}`);
389
+ });
390
+ // 4. Connect to AAMP
391
+ await client.connect();
392
+ this.isHistoricalReconcile = true;
393
+ const reconciled = await client.reconcileRecentEmails(50, { includeHistorical: true })
394
+ .catch((err) => {
395
+ if (!quietStartup) {
396
+ console.warn(`[${this.name}] Recent email reconcile failed: ${err.message}`);
397
+ }
398
+ return 0;
399
+ })
400
+ .finally(() => {
401
+ this.isHistoricalReconcile = false;
402
+ });
403
+ if (!quietStartup) {
404
+ console.log(`[${this.name}] Reconciled ${reconciled} recent email(s)`);
405
+ }
406
+ await this.syncDirectoryProfile({ quiet: quietStartup }).catch((err) => {
407
+ if (!quietStartup) {
408
+ console.warn(`[${this.name}] Directory profile sync failed: ${err.message}`);
409
+ }
410
+ });
411
+ // 5. Ensure ACP session
412
+ try {
413
+ await this.acpx.ensureSession(this.agentConfig.acpCommand, this.sessionName);
414
+ if (!quietStartup) {
415
+ console.log(`[${this.name}] ACP session ready: ${this.sessionName}`);
416
+ }
417
+ }
418
+ catch (err) {
419
+ if (!quietStartup) {
420
+ console.warn(`[${this.name}] ACP session setup deferred: ${err.message}`);
421
+ }
422
+ }
423
+ quietStartup = false;
424
+ }
425
+ /**
426
+ * Stop the bridge.
427
+ */
428
+ stop() {
429
+ this.client?.disconnect();
430
+ this.client = null;
431
+ }
432
+ normalizeEmail(email) {
433
+ return email.trim().toLowerCase();
434
+ }
435
+ /**
436
+ * Handle an incoming AAMP task by forwarding to the ACP agent.
437
+ */
438
+ async handleTask(task, options = {}) {
439
+ if (!this.client)
440
+ return;
441
+ const shouldLogTask = !options.historical;
442
+ if (shouldLogTask) {
443
+ console.log(`[${this.name}] <- task.dispatch ${task.taskId} "${task.title}" from=${task.from}`);
444
+ }
445
+ if (task.expiresAt && new Date(task.expiresAt).getTime() <= Date.now()) {
446
+ console.warn(`[${this.name}] Skipping expired task ${task.taskId}`);
447
+ return;
448
+ }
449
+ if (this.cancelledTaskIds.has(task.taskId)) {
450
+ console.warn(`[${this.name}] Ignoring cancelled task ${task.taskId}`);
451
+ return;
452
+ }
453
+ if (this.activeTaskIds.has(task.taskId)) {
454
+ console.warn(`[${this.name}] Ignoring duplicate active task ${task.taskId}`);
455
+ return;
456
+ }
457
+ const hydratedTask = await this.client.hydrateTaskDispatch(task).catch((err) => {
458
+ if (!options.historical) {
459
+ console.warn(`[${this.name}] Failed to load thread history for ${task.taskId}: ${err.message}`);
460
+ }
461
+ if (options.historical)
462
+ return null;
463
+ return {
464
+ ...task,
465
+ threadHistory: [],
466
+ threadContextText: '',
467
+ };
468
+ });
469
+ if (!hydratedTask) {
470
+ return;
471
+ }
472
+ if (threadAlreadyTerminal(hydratedTask.threadHistory)) {
473
+ if (shouldLogTask) {
474
+ console.log(`[${this.name}] Skipping task ${task.taskId} because the thread already reached a terminal state`);
475
+ }
476
+ return;
477
+ }
478
+ const senderDecision = matchCombinedSenderPolicy(task, this.agentConfig.senderPolicies, this.senderPolicies);
479
+ if (!senderDecision.allowed) {
480
+ if (options.historical)
481
+ return;
482
+ console.warn(`[${this.name}] Rejecting task ${task.taskId}: ${senderDecision.reason ?? 'sender policy rejected the task'}`);
483
+ await this.client.sendResult({
484
+ to: task.from,
485
+ taskId: task.taskId,
486
+ status: 'rejected',
487
+ output: '',
488
+ errorMsg: `Unauthorized sender policy: ${senderDecision.reason ?? 'task does not match senderPolicies.'}`,
489
+ inReplyTo: task.messageId,
490
+ });
491
+ return;
492
+ }
493
+ this.activeTaskIds.add(task.taskId);
494
+ this.activeTaskCount += 1;
495
+ const taskSessionName = this.resolveTaskSessionName(hydratedTask);
496
+ let activeStream = null;
497
+ const pendingStreamWrites = new Set();
498
+ let streamClosed = false;
499
+ const streamTextState = { hasContent: false };
500
+ let currentPhase = null;
501
+ let pendingTextDelta = null;
502
+ const queueStreamAppend = (type, payload) => {
503
+ if (!this.client || !activeStream || streamClosed)
504
+ return;
505
+ const streamId = activeStream.streamId;
506
+ let write;
507
+ write = this.client.appendStreamEvent({
508
+ streamId,
509
+ type,
510
+ payload,
511
+ })
512
+ .then(() => undefined)
513
+ .catch((err) => {
514
+ if (isClosedStreamAppendError(err)) {
515
+ streamClosed = true;
516
+ return;
517
+ }
518
+ console.warn(`[${this.name}] Failed to append ${type} stream event for ${task.taskId}: ${err.message}`);
519
+ })
520
+ .finally(() => {
521
+ pendingStreamWrites.delete(write);
522
+ });
523
+ pendingStreamWrites.add(write);
524
+ };
525
+ const clearPendingTextDeltaTimer = () => {
526
+ if (pendingTextDelta?.timer) {
527
+ clearTimeout(pendingTextDelta.timer);
528
+ pendingTextDelta.timer = undefined;
529
+ }
530
+ };
531
+ const flushPendingTextDelta = () => {
532
+ if (!pendingTextDelta?.text) {
533
+ clearPendingTextDeltaTimer();
534
+ pendingTextDelta = null;
535
+ return;
536
+ }
537
+ const payload = {
538
+ text: pendingTextDelta.text,
539
+ ...(typeof pendingTextDelta.channel === 'string' ? { channel: pendingTextDelta.channel } : {}),
540
+ ...(typeof pendingTextDelta.messageId === 'string' ? { messageId: pendingTextDelta.messageId } : {}),
541
+ };
542
+ clearPendingTextDeltaTimer();
543
+ pendingTextDelta = null;
544
+ queueStreamAppend('text.delta', payload);
545
+ };
546
+ const schedulePendingTextDeltaFlush = () => {
547
+ if (!pendingTextDelta || pendingTextDelta.timer)
548
+ return;
549
+ pendingTextDelta.timer = setTimeout(() => {
550
+ flushPendingTextDelta();
551
+ }, TEXT_DELTA_FLUSH_MS);
552
+ };
553
+ const queueTextDelta = (payload) => {
554
+ const text = typeof payload.text === 'string' ? payload.text : '';
555
+ if (!text)
556
+ return;
557
+ const channel = payload.channel;
558
+ const messageId = payload.messageId;
559
+ const canMerge = pendingTextDelta
560
+ && pendingTextDelta.channel === channel
561
+ && pendingTextDelta.messageId === messageId;
562
+ if (!canMerge) {
563
+ flushPendingTextDelta();
564
+ pendingTextDelta = {
565
+ text: '',
566
+ channel,
567
+ messageId,
568
+ };
569
+ }
570
+ pendingTextDelta.text += text;
571
+ const pendingText = pendingTextDelta.text;
572
+ if (pendingText.length >= TEXT_DELTA_FLUSH_CHARS
573
+ || (pendingText.length >= TEXT_DELTA_BOUNDARY_CHARS
574
+ && endsAtTextBoundary(pendingText))) {
575
+ flushPendingTextDelta();
576
+ return;
577
+ }
578
+ schedulePendingTextDeltaFlush();
579
+ };
580
+ const flushStreamWrites = async () => {
581
+ flushPendingTextDelta();
582
+ while (pendingStreamWrites.size > 0) {
583
+ await Promise.allSettled([...pendingStreamWrites]);
584
+ }
585
+ };
586
+ const appendStreamEvent = async (type, payload) => {
587
+ if (!this.client || !activeStream || streamClosed)
588
+ return;
589
+ flushPendingTextDelta();
590
+ await flushStreamWrites();
591
+ try {
592
+ await this.client.appendStreamEvent({
593
+ streamId: activeStream.streamId,
594
+ type,
595
+ payload,
596
+ });
597
+ }
598
+ catch (err) {
599
+ if (isClosedStreamAppendError(err)) {
600
+ streamClosed = true;
601
+ return;
602
+ }
603
+ throw err;
604
+ }
605
+ };
606
+ const closeStream = async (payload) => {
607
+ if (!this.client || !activeStream || streamClosed)
608
+ return;
609
+ await flushStreamWrites();
610
+ await this.client.closeStream({
611
+ streamId: activeStream.streamId,
612
+ payload,
613
+ });
614
+ streamClosed = true;
615
+ };
616
+ const queuePhaseStatus = (channel) => {
617
+ if (currentPhase === channel)
618
+ return;
619
+ flushPendingTextDelta();
620
+ currentPhase = channel;
621
+ queueStreamAppend('status', {
622
+ state: 'running',
623
+ label: buildPhaseStatusLabel(channel),
624
+ });
625
+ };
626
+ try {
627
+ try {
628
+ activeStream = await this.client.createStream({
629
+ taskId: task.taskId,
630
+ peerEmail: task.from,
631
+ });
632
+ await this.client.sendStreamOpened({
633
+ to: task.from,
634
+ taskId: task.taskId,
635
+ streamId: activeStream.streamId,
636
+ inReplyTo: task.messageId,
637
+ });
638
+ await appendStreamEvent('status', { state: 'running', label: 'ACP task started' });
639
+ }
640
+ catch (err) {
641
+ if (!isStreamServiceUnavailableError(err))
642
+ throw err;
643
+ activeStream = null;
644
+ streamClosed = true;
645
+ console.warn(`[${this.name}] AAMP stream unavailable for ${task.taskId}; continuing without realtime stream: ${err.message}`);
646
+ }
647
+ const attachmentPromptLines = await this.materializeIncomingAttachments(hydratedTask);
648
+ const promptTask = attachmentPromptLines.length > 0
649
+ ? {
650
+ ...hydratedTask,
651
+ bodyText: [
652
+ hydratedTask.bodyText,
653
+ '',
654
+ 'Downloaded attachments:',
655
+ ...attachmentPromptLines,
656
+ '',
657
+ 'Use these local file paths when the user asks about attached images or files.',
658
+ ].filter((line) => line != null).join('\n'),
659
+ }
660
+ : hydratedTask;
661
+ const prompt = buildPrompt(promptTask, hydratedTask.threadContextText, this.name);
662
+ if (this.debugPrompt) {
663
+ console.log(formatDebugPromptLog({
664
+ agentName: this.name,
665
+ taskId: task.taskId,
666
+ sessionName: taskSessionName,
667
+ prompt,
668
+ }));
669
+ }
670
+ await this.acpx.ensureSession(this.agentConfig.acpCommand, taskSessionName);
671
+ await appendStreamEvent('progress', { value: 0.2, label: 'Prompt sent to ACP agent' });
672
+ const result = await this.acpx.prompt(this.agentConfig.acpCommand, taskSessionName, prompt, {
673
+ onTextChunk: (chunk) => {
674
+ queuePhaseStatus(chunk.channel);
675
+ const rendered = renderTextChunk(chunk, streamTextState);
676
+ if (!rendered)
677
+ return;
678
+ queueTextDelta({
679
+ text: rendered,
680
+ channel: chunk.channel,
681
+ ...(chunk.messageId ? { messageId: chunk.messageId } : {}),
682
+ });
683
+ },
684
+ onToolUpdate: (update) => {
685
+ flushPendingTextDelta();
686
+ queueStreamAppend('progress', {
687
+ label: buildToolProgressLabel(update),
688
+ ...(update.title ? { title: update.title } : {}),
689
+ ...(update.status ? { status: update.status } : {}),
690
+ ...(update.kind ? { kind: update.kind } : {}),
691
+ ...(update.toolCallId ? { toolCallId: update.toolCallId } : {}),
692
+ ...(update.locations?.length ? { locations: update.locations } : {}),
693
+ });
694
+ },
695
+ onPlanUpdate: (entries) => {
696
+ queuePhaseStatus('thought');
697
+ queueTextDelta({
698
+ text: `${streamTextState.hasContent ? '\n\n' : ''}${formatPlanUpdate(entries)}`,
699
+ channel: 'thought',
700
+ });
701
+ streamTextState.hasContent = true;
702
+ streamTextState.currentChannel = 'thought';
703
+ streamTextState.currentMessageId = undefined;
704
+ },
705
+ });
706
+ if (this.cancelledTaskIds.has(task.taskId)) {
707
+ console.warn(`[${this.name}] Dropping task ${task.taskId} result because the task was cancelled`);
708
+ return;
709
+ }
710
+ await flushStreamWrites();
711
+ await appendStreamEvent('progress', { value: 0.8, label: 'ACP response received' });
712
+ const parsed = parseResponse(result.output);
713
+ if (!parsed.isHelp
714
+ && !parsed.output
715
+ && parsed.files.length === 0
716
+ && !parsed.structuredResult?.length
717
+ && !parsed.attachments?.length) {
718
+ throw new Error('ACP agent completed without a final response');
719
+ }
720
+ if (parsed.isHelp) {
721
+ // Agent needs help
722
+ if (!result.streamedAssistantText && parsed.question) {
723
+ queuePhaseStatus('assistant');
724
+ queueTextDelta({
725
+ text: renderTextChunk({ channel: 'assistant', text: parsed.question }, streamTextState),
726
+ channel: 'assistant',
727
+ });
728
+ await flushStreamWrites();
729
+ }
730
+ await appendStreamEvent('status', { state: 'help_needed', label: parsed.question ?? 'Agent requested clarification' });
731
+ await closeStream({ reason: 'task.help_needed' });
732
+ await this.client.sendHelp({
733
+ to: task.from,
734
+ taskId: task.taskId,
735
+ question: parsed.question ?? 'Agent needs more information',
736
+ blockedReason: 'ACP agent requested clarification',
737
+ suggestedOptions: [],
738
+ inReplyTo: task.messageId,
739
+ });
740
+ console.log(`[${this.name}] -> task.help_needed ${task.taskId}`);
741
+ }
742
+ else {
743
+ // Collect file attachments referenced by the agent
744
+ const attachments = [];
745
+ for (const attachmentRef of mergeAttachmentRefs(parsed.files, parsed.attachments)) {
746
+ const filepath = attachmentRef.path;
747
+ if (existsSync(filepath)) {
748
+ try {
749
+ attachments.push({
750
+ filename: sanitizeAttachmentFilename(attachmentRef.filename, filepath),
751
+ contentType: sanitizeContentType(attachmentRef.contentType),
752
+ content: readFileSync(filepath),
753
+ });
754
+ console.log(`[${this.name}] Attaching file: ${filepath}`);
755
+ }
756
+ catch (err) {
757
+ console.warn(`[${this.name}] Failed to read file ${filepath}: ${err.message}`);
758
+ }
759
+ }
760
+ else {
761
+ console.warn(`[${this.name}] Attachment file not found: ${filepath}`);
762
+ }
763
+ }
764
+ const structuredResult = fillStructuredResultAttachmentFilenames(parsed.structuredResult, attachments);
765
+ // Task completed
766
+ if (parsed.output && !result.streamedAssistantText) {
767
+ queuePhaseStatus('assistant');
768
+ queueTextDelta({
769
+ text: renderTextChunk({ channel: 'assistant', text: parsed.output }, streamTextState),
770
+ channel: 'assistant',
771
+ });
772
+ await flushStreamWrites();
773
+ }
774
+ await closeStream({ reason: 'task.result', status: 'completed' });
775
+ await this.client.sendResult({
776
+ to: task.from,
777
+ taskId: task.taskId,
778
+ status: 'completed',
779
+ output: parsed.output,
780
+ structuredResult,
781
+ inReplyTo: task.messageId,
782
+ attachments: attachments.length > 0 ? attachments : undefined,
783
+ });
784
+ console.log(`[${this.name}] -> task.result ${task.taskId} completed${structuredResult?.length ? ` (${structuredResult.length} structured field(s))` : ''}${attachments.length ? ` (${attachments.length} attachment(s))` : ''}`);
785
+ }
786
+ }
787
+ catch (err) {
788
+ const errorMsg = err.message;
789
+ console.error(`[${this.name}] Task ${task.taskId} error: ${errorMsg}`);
790
+ try {
791
+ await flushStreamWrites();
792
+ if (activeStream) {
793
+ await closeStream({ reason: 'task.result', status: 'rejected', error: errorMsg });
794
+ }
795
+ await this.client.sendResult({
796
+ to: task.from,
797
+ taskId: task.taskId,
798
+ status: 'rejected',
799
+ output: '',
800
+ errorMsg: `ACP agent error: ${errorMsg}`,
801
+ inReplyTo: task.messageId,
802
+ });
803
+ }
804
+ catch { /* best effort */ }
805
+ }
806
+ finally {
807
+ this.activeTaskCount = Math.max(0, this.activeTaskCount - 1);
808
+ this.activeTaskIds.delete(task.taskId);
809
+ }
810
+ }
811
+ handleCancel(task) {
812
+ this.cancelledTaskIds.add(task.taskId);
813
+ console.warn(`[${this.name}] <- task.cancel ${task.taskId} from=${task.from}`);
814
+ }
815
+ async materializeIncomingAttachments(task) {
816
+ const attachments = task.attachments ?? [];
817
+ if (!attachments.length || !this.client)
818
+ return [];
819
+ const attachmentDir = mkdtempSync(join(tmpdir(), `aamp-acp-${sanitizePathToken(task.taskId)}-`));
820
+ const usedNames = new Set();
821
+ const lines = [];
822
+ for (const [index, attachment] of attachments.entries()) {
823
+ const baseName = sanitizeIncomingAttachmentFilename(attachment.filename, index);
824
+ const filename = usedNames.has(baseName) ? `${index + 1}-${baseName}` : baseName;
825
+ usedNames.add(filename);
826
+ const filePath = join(attachmentDir, filename);
827
+ try {
828
+ const content = await this.client.downloadBlob(attachment.blobId, attachment.filename);
829
+ writeFileSync(filePath, content);
830
+ lines.push(`- ${describeIncomingAttachment(attachment)}: ${filePath}`);
831
+ }
832
+ catch (err) {
833
+ const message = err instanceof Error ? err.message : String(err);
834
+ lines.push(`- ${describeIncomingAttachment(attachment)}: download failed: ${message}`);
835
+ }
836
+ }
837
+ return lines;
838
+ }
839
+ async sendPairResponse(request, success, reason) {
840
+ if (!this.client)
841
+ return false;
842
+ let lastError;
843
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
844
+ try {
845
+ await this.client.sendPairRespond({
846
+ to: request.from,
847
+ taskId: request.taskId,
848
+ success,
849
+ reason,
850
+ inReplyTo: request.messageId,
851
+ });
852
+ return true;
853
+ }
854
+ catch (err) {
855
+ lastError = err;
856
+ if (attempt < 3) {
857
+ await new Promise((resolve) => setTimeout(resolve, attempt * 1_000));
858
+ }
859
+ }
860
+ }
861
+ console.warn(`[${this.name}] Failed to send pair.respond to ${request.from}: ${lastError?.message ?? String(lastError)}`);
862
+ return false;
863
+ }
864
+ async handlePairRequest(request, options = {}) {
865
+ if (!this.identity || !this.client)
866
+ return;
867
+ const shouldLogRequest = !options.historical;
868
+ if (shouldLogRequest) {
869
+ console.log(`[${this.name}] <- pair.request ${request.taskId} from=${request.from}`);
870
+ }
871
+ const requestTo = this.normalizeEmail(request.to);
872
+ if (requestTo && requestTo !== this.normalizeEmail(this.identity.email)) {
873
+ console.warn(`[${this.name}] Ignoring pair.request ${request.taskId}: addressed to ${request.to}`);
874
+ return;
875
+ }
876
+ const history = await this.client.getThreadHistory(request.taskId).catch((err) => {
877
+ if (isThreadNotFoundError(err) && options.historical) {
878
+ return null;
879
+ }
880
+ if (!isThreadNotFoundError(err)) {
881
+ console.warn(`[${this.name}] Failed to load pair thread ${request.taskId}: ${err.message}`);
882
+ }
883
+ return { taskId: request.taskId, events: [] };
884
+ });
885
+ if (!history)
886
+ return;
887
+ const priorEvents = history.events.filter((event) => event.messageId !== request.messageId);
888
+ if (threadAlreadyPairResponded(priorEvents)) {
889
+ if (shouldLogRequest) {
890
+ console.log(`[${this.name}] Skipping pair.request ${request.taskId} because it already has pair.respond`);
891
+ }
892
+ return;
893
+ }
894
+ const pairingFile = resolvePairingFile(this.agentConfig.pairingFile, this.agentConfig.name);
895
+ const senderPoliciesFile = resolveSenderPoliciesFile(this.agentConfig.senderPoliciesFile, this.agentConfig.name);
896
+ const pairParams = {
897
+ file: pairingFile,
898
+ mailbox: this.identity.email,
899
+ pairCode: request.pairCode,
900
+ };
901
+ const validPairing = validatePairingCode(pairParams);
902
+ if (!validPairing) {
903
+ const reason = 'invalid or expired pair code';
904
+ if (options.historical) {
905
+ return;
906
+ }
907
+ console.warn(`[${this.name}] Rejected pair.request from ${request.from}: ${reason}`);
908
+ await this.sendPairResponse(request, false, reason);
909
+ return;
910
+ }
911
+ this.senderPolicies = addSenderPolicy(senderPoliciesFile, {
912
+ sender: this.normalizeEmail(request.from),
913
+ dispatchContextRules: request.dispatchContextRules ?? {},
914
+ pairedAt: new Date().toISOString(),
915
+ });
916
+ console.log(`[${this.name}] Paired sender ${request.from}; policy saved to ${senderPoliciesFile}`);
917
+ if (await this.sendPairResponse(request, true)) {
918
+ consumePairingCode(pairParams);
919
+ }
920
+ else {
921
+ console.warn(`[${this.name}] Pairing code left active so ${request.from} can retry before it expires`);
922
+ }
923
+ }
924
+ /**
925
+ * Resolve AAMP identity: load from credentials file or register new.
926
+ */
927
+ async resolveIdentity() {
928
+ const credFile = resolveCredentialsFile(this.agentConfig.credentialsFile, this.agentConfig.name);
929
+ // Try loading existing credentials
930
+ if (existsSync(credFile)) {
931
+ try {
932
+ const data = JSON.parse(readFileSync(credFile, 'utf-8'));
933
+ if (data.email && data.mailboxToken && data.smtpPassword) {
934
+ return {
935
+ email: data.email,
936
+ mailboxToken: data.mailboxToken,
937
+ smtpPassword: data.smtpPassword,
938
+ };
939
+ }
940
+ }
941
+ catch { /* re-register */ }
942
+ }
943
+ // Self-register
944
+ const slug = this.agentConfig.slug ?? `${this.agentConfig.name}-bridge`;
945
+ const description = this.agentConfig.description ?? `${this.agentConfig.name} via ACP bridge`;
946
+ const creds = await AampClient.registerMailbox({
947
+ aampHost: this.aampHost,
948
+ slug,
949
+ description,
950
+ });
951
+ const identity = {
952
+ email: creds.email,
953
+ mailboxToken: creds.mailboxToken,
954
+ smtpPassword: creds.smtpPassword,
955
+ };
956
+ // Persist credentials
957
+ mkdirSync(dirname(credFile), { recursive: true });
958
+ writeFileSync(credFile, JSON.stringify(identity, null, 2));
959
+ console.log(`[${this.name}] Registered: ${identity.email} (credentials saved to ${credFile})`);
960
+ return identity;
961
+ }
962
+ }
963
+ //# sourceMappingURL=agent-bridge.js.map