@alfe.ai/openclaw-chat 0.0.18 → 0.0.20

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/plugin.d.cts CHANGED
@@ -225,6 +225,13 @@ interface PluginApi {
225
225
  config?: Record<string, unknown>;
226
226
  runtime?: PluginRuntime;
227
227
  registerChannel(channel: ReturnType<typeof createAlfeChannelPlugin>): void;
228
+ registerTool?(tool: {
229
+ name: string;
230
+ description: string;
231
+ label: string;
232
+ parameters: Record<string, unknown>;
233
+ execute: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
234
+ }): void;
228
235
  registerGatewayMethod?(name: string, handler: (...args: unknown[]) => Promise<unknown>): void;
229
236
  on(event: string, handler: (...args: unknown[]) => void | Promise<void>, options?: {
230
237
  priority?: number;
package/dist/plugin.d.ts CHANGED
@@ -225,6 +225,13 @@ interface PluginApi {
225
225
  config?: Record<string, unknown>;
226
226
  runtime?: PluginRuntime;
227
227
  registerChannel(channel: ReturnType<typeof createAlfeChannelPlugin>): void;
228
+ registerTool?(tool: {
229
+ name: string;
230
+ description: string;
231
+ label: string;
232
+ parameters: Record<string, unknown>;
233
+ execute: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
234
+ }): void;
228
235
  registerGatewayMethod?(name: string, handler: (...args: unknown[]) => Promise<unknown>): void;
229
236
  on(event: string, handler: (...args: unknown[]) => void | Promise<void>, options?: {
230
237
  priority?: number;
package/dist/plugin2.cjs CHANGED
@@ -1,10 +1,10 @@
1
1
  let node_module = require("node:module");
2
- let _alfe_ai_chat = require("@alfe.ai/chat");
3
- let _alfe_ai_config = require("@alfe.ai/config");
4
- let _alfe_ai_agent_api_client = require("@alfe.ai/agent-api-client");
5
2
  let node_fs_promises = require("node:fs/promises");
6
3
  let node_path = require("node:path");
7
4
  let node_os = require("node:os");
5
+ let _alfe_ai_chat = require("@alfe.ai/chat");
6
+ let _alfe_ai_config = require("@alfe.ai/config");
7
+ let _alfe_ai_agent_api_client = require("@alfe.ai/agent-api-client");
8
8
  let node_fs = require("node:fs");
9
9
  //#region src/alfe-channel.ts
10
10
  const CHANNEL_ID = "alfe";
@@ -151,7 +151,7 @@ function isAlfeSessionKey(key) {
151
151
  }
152
152
  /**
153
153
  * Extract the channel mode from a standardized session key or conversationId.
154
- * Returns the mode segment (e.g. 'sms', 'whatsapp', 'chat') or fallback.
154
+ * Returns the mode segment (e.g. 'sms', 'whatsapp', 'chat', 'a2a') or fallback.
155
155
  */
156
156
  function extractChannelMode(conversationId, fallback = "chat") {
157
157
  return /^alfe:(\w+):/.exec(conversationId)?.[1] ?? fallback;
@@ -181,7 +181,6 @@ function sessionPath(sessionId) {
181
181
  }
182
182
  async function cleanupOldSessions() {
183
183
  if (Date.now() - lastCleanupAt < CLEANUP_INTERVAL_MS) return;
184
- lastCleanupAt = Date.now();
185
184
  try {
186
185
  const jsonFiles = (await (0, node_fs_promises.readdir)(SESSIONS_DIR)).filter((f) => f.endsWith(".json"));
187
186
  if (jsonFiles.length <= MAX_SESSIONS) {
@@ -211,6 +210,7 @@ async function cleanupOldSessions() {
211
210
  remaining--;
212
211
  } catch {}
213
212
  }
213
+ lastCleanupAt = Date.now();
214
214
  } catch {}
215
215
  }
216
216
  async function getSession(sessionId) {
@@ -242,17 +242,24 @@ async function createSession(sessionId, agentId, channel, tenantId, userId) {
242
242
  cleanupOldSessions();
243
243
  return session;
244
244
  }
245
+ const writeLocks = /* @__PURE__ */ new Map();
245
246
  async function addMessage(sessionId, role, content, senderId, senderName) {
246
- const session = await getSession(sessionId);
247
- if (!session) return;
248
- session.messages.push({
249
- role,
250
- content,
251
- timestamp: Date.now(),
252
- ...senderId ? { senderId } : {},
253
- ...senderName ? { senderName } : {}
247
+ const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
248
+ const session = await getSession(sessionId);
249
+ if (!session) return;
250
+ session.messages.push({
251
+ role,
252
+ content,
253
+ timestamp: Date.now(),
254
+ ...senderId ? { senderId } : {},
255
+ ...senderName ? { senderName } : {}
256
+ });
257
+ await saveSession(session);
258
+ }).finally(() => {
259
+ if (writeLocks.get(sessionId) === next) writeLocks.delete(sessionId);
254
260
  });
255
- await saveSession(session);
261
+ writeLocks.set(sessionId, next);
262
+ await next;
256
263
  }
257
264
  async function listSessions(filters, limit = 50) {
258
265
  await ensureDir();
@@ -289,6 +296,112 @@ async function listSessions(filters, limit = 50) {
289
296
  return summaries.slice(0, limit);
290
297
  }
291
298
  //#endregion
299
+ //#region src/a2a-tools.ts
300
+ function ok(result) {
301
+ return { content: [{
302
+ type: "text",
303
+ text: JSON.stringify(result)
304
+ }] };
305
+ }
306
+ function errResult(message) {
307
+ return { content: [{
308
+ type: "text",
309
+ text: JSON.stringify({ error: message })
310
+ }] };
311
+ }
312
+ function defineTool(def) {
313
+ return {
314
+ name: def.name,
315
+ description: def.description,
316
+ label: def.name,
317
+ parameters: def.parameters,
318
+ execute: async (_toolCallId, params) => {
319
+ try {
320
+ return ok(await def.handler(params));
321
+ } catch (e) {
322
+ return errResult(e.message);
323
+ }
324
+ }
325
+ };
326
+ }
327
+ function buildA2ATools(chatClient, log) {
328
+ return [
329
+ defineTool({
330
+ name: "list_agents",
331
+ description: "List other agents in your organization that you can communicate with.",
332
+ parameters: {
333
+ type: "object",
334
+ properties: {},
335
+ required: []
336
+ },
337
+ handler: async () => {
338
+ log.info("list_agents tool called");
339
+ return await chatClient.sendRequest("a2a.list-agents", {});
340
+ }
341
+ }),
342
+ defineTool({
343
+ name: "message_agent",
344
+ description: "Send a message to another agent. Starts a new conversation thread, or continues an existing one. The conversation will bounce back and forth automatically until one agent calls end_conversation() or says [RESOLVED].",
345
+ parameters: {
346
+ type: "object",
347
+ properties: {
348
+ agent_id: {
349
+ type: "string",
350
+ description: "Target agent ID (use list_agents to find available agents)"
351
+ },
352
+ message: {
353
+ type: "string",
354
+ description: "Message to send to the agent"
355
+ },
356
+ thread_id: {
357
+ type: "string",
358
+ description: "Continue an existing conversation thread (omit to start a new one)"
359
+ },
360
+ max_depth: {
361
+ type: "number",
362
+ description: "Maximum number of back-and-forth exchanges (default: 10)"
363
+ }
364
+ },
365
+ required: ["agent_id", "message"]
366
+ },
367
+ handler: async (params) => {
368
+ const agentId = params.agent_id;
369
+ const message = params.message;
370
+ const threadId = params.thread_id;
371
+ const maxDepth = params.max_depth ?? 10;
372
+ if (!agentId || !message) throw new Error("agent_id and message are required");
373
+ log.info(`message_agent tool: sending to ${agentId}`);
374
+ return await chatClient.sendRequest("a2a.send", {
375
+ targetAgentId: agentId,
376
+ message,
377
+ threadId,
378
+ maxDepth
379
+ });
380
+ }
381
+ }),
382
+ defineTool({
383
+ name: "end_conversation",
384
+ description: "End the current agent-to-agent conversation. Call this when the discussion is resolved and no further exchanges are needed.",
385
+ parameters: {
386
+ type: "object",
387
+ properties: { summary: {
388
+ type: "string",
389
+ description: "Brief summary of what was discussed or resolved"
390
+ } },
391
+ required: []
392
+ },
393
+ handler: (params) => {
394
+ const summary = params.summary;
395
+ log.info(`end_conversation tool called${summary ? `: ${summary}` : ""}`);
396
+ return Promise.resolve({
397
+ status: "conversation_ended",
398
+ summary
399
+ });
400
+ }
401
+ })
402
+ ];
403
+ }
404
+ //#endregion
292
405
  //#region src/plugin.ts
293
406
  /**
294
407
  * @alfe.ai/openclaw-chat — OpenClaw chat channel plugin.
@@ -325,6 +438,45 @@ let pluginRuntime = null;
325
438
  let chatClient = null;
326
439
  let connectingPromise = null;
327
440
  let metricsClient = null;
441
+ const MAX_FILE_SIZE = 50 * 1024 * 1024;
442
+ const DOWNLOAD_TIMEOUT_MS = 3e4;
443
+ async function downloadAttachments(attachments, log) {
444
+ const attachDir = (0, node_path.join)((0, node_os.homedir)(), ".alfe", "attachments");
445
+ await (0, node_fs_promises.mkdir)(attachDir, { recursive: true });
446
+ const results = [];
447
+ for (const att of attachments) {
448
+ const filename = (att.filename ?? att.id).replace(/[/\\]/g, "_").replace(/\.\./g, "_").replace(/\0/g, "");
449
+ const localPath = (0, node_path.join)(attachDir, `${att.id}_${filename}`);
450
+ const controller = new AbortController();
451
+ const timeout = setTimeout(() => {
452
+ controller.abort();
453
+ }, DOWNLOAD_TIMEOUT_MS);
454
+ try {
455
+ const res = await fetch(att.url, { signal: controller.signal });
456
+ if (!res.ok) {
457
+ log.warn(`Failed to download attachment ${att.id}: ${String(res.status)}`);
458
+ continue;
459
+ }
460
+ const buffer = Buffer.from(await res.arrayBuffer());
461
+ if (buffer.length > MAX_FILE_SIZE) {
462
+ log.warn(`Attachment ${att.id} exceeds max size (${String(buffer.length)} bytes) — skipping`);
463
+ continue;
464
+ }
465
+ await (0, node_fs_promises.writeFile)(localPath, buffer);
466
+ results.push({
467
+ localPath,
468
+ filename,
469
+ mimeType: att.mimeType
470
+ });
471
+ log.info(`Downloaded attachment: ${localPath} (${String(buffer.length)} bytes)`);
472
+ } catch (err) {
473
+ log.error(`Failed to download attachment ${att.id}: ${err instanceof Error ? err.message : String(err)}`);
474
+ } finally {
475
+ clearTimeout(timeout);
476
+ }
477
+ }
478
+ return results;
479
+ }
328
480
  async function handleAgentRequest(request, log) {
329
481
  const runtime = pluginRuntime;
330
482
  if (!runtime) {
@@ -335,8 +487,9 @@ async function handleAgentRequest(request, log) {
335
487
  chatClient?.sendResponse(request.id, false, { message: "OpenClaw SDK not available — cannot dispatch" });
336
488
  return;
337
489
  }
338
- const { message, sessionKey: legacySessionKey, userId, conversationId, conversationType, tenantId, clientType, displayName } = request.params;
339
- if (!message) {
490
+ const { message, sessionKey: legacySessionKey, userId, conversationId, conversationType, tenantId, clientType, displayName, attachments: rawAttachments, a2a } = request.params;
491
+ const isA2A = !!a2a;
492
+ if (!message && !rawAttachments?.length) {
340
493
  chatClient?.sendResponse(request.id, false, { message: "Missing message" });
341
494
  return;
342
495
  }
@@ -360,11 +513,15 @@ async function handleAgentRequest(request, log) {
360
513
  });
361
514
  });
362
515
  try {
363
- const channelMode = extractChannelMode(conversationId ?? "", clientType ?? "chat");
364
- const channelLabel = channelMode === "sms" ? "SMS" : channelMode === "whatsapp" ? "WhatsApp" : "Alfe";
516
+ const downloadedFiles = rawAttachments?.length ? await downloadAttachments(rawAttachments, log) : [];
517
+ const bodyForAgent = downloadedFiles.length ? `${message || ""}\n\n[Attached files:\n${downloadedFiles.map((f) => `- ${f.filename}: ${f.localPath}`).join("\n")}]` : void 0;
518
+ const channelMode = isA2A ? "a2a" : extractChannelMode(conversationId ?? "", clientType ?? "chat");
519
+ const channelLabel = channelMode === "sms" ? "SMS" : channelMode === "whatsapp" ? "WhatsApp" : channelMode === "a2a" ? "Agent" : "Alfe";
365
520
  const shortConvId = conversationId?.slice(-8) ?? "";
366
521
  const userLabel = displayName ?? userId ?? senderId;
367
522
  const conversationLabel = conversationType === "group" ? shortConvId ? `[${channelLabel}] Group (${shortConvId})` : `[${channelLabel}] Group` : shortConvId ? `[${channelLabel}] ${userLabel} (${shortConvId})` : `[${channelLabel}] ${userLabel}`;
523
+ let a2aResponseBuffer = "";
524
+ let a2aResolved = false;
368
525
  resolvedOpenClawKey = (await dispatchInbound({
369
526
  cfg,
370
527
  runtime: { channel: runtime.channel },
@@ -382,7 +539,8 @@ async function handleAgentRequest(request, log) {
382
539
  senderAddress: `user:${senderId}`,
383
540
  recipientAddress: "agent",
384
541
  conversationLabel,
385
- rawBody: message,
542
+ rawBody: message || "",
543
+ bodyForAgent,
386
544
  messageId: request.id,
387
545
  timestamp: Date.now(),
388
546
  extraContext: {
@@ -390,22 +548,41 @@ async function handleAgentRequest(request, log) {
390
548
  ...clientType ? { ClientType: clientType } : {},
391
549
  ...conversationId ? { ConversationId: conversationId } : {},
392
550
  ...displayName ? { SenderName: displayName } : {},
393
- ChannelMode: channelMode
551
+ ChannelMode: channelMode,
552
+ ...isA2A ? {
553
+ CallerType: "agent",
554
+ CallerAgentId: a2a.sourceAgentId,
555
+ CallerAgentName: a2a.sourceAgentName,
556
+ InteractionDepth: String(a2a.depth),
557
+ A2ASystemPrompt: [
558
+ `This is an agent-to-agent conversation with ${a2a.sourceAgentName}.`,
559
+ "Rules:",
560
+ "- Only respond if you have new information, a question, or an action to coordinate.",
561
+ "- If the conversation is complete, call the end_conversation() tool OR end your message with [RESOLVED].",
562
+ "- Do NOT respond just to acknowledge — that creates infinite loops."
563
+ ].join("\n")
564
+ } : {}
394
565
  },
395
566
  deliver: async (payload) => {
396
567
  const responseText = payload.text ?? "";
568
+ const mediaUrls = [...payload.mediaUrl ? [payload.mediaUrl] : [], ...payload.mediaUrls ?? []];
397
569
  await addMessage(sessionId, "assistant", responseText);
570
+ if (isA2A) {
571
+ a2aResponseBuffer += responseText;
572
+ if (responseText.includes("\"status\":\"conversation_ended\"") || responseText.includes("\"status\": \"conversation_ended\"")) a2aResolved = true;
573
+ }
398
574
  chatClient?.notify("agent-message", {
399
575
  conversationId: conversationId ?? legacySessionKey,
400
576
  text: responseText,
401
- sessionKey: resolvedOpenClawKey ?? legacySessionKey
577
+ sessionKey: resolvedOpenClawKey ?? legacySessionKey,
578
+ ...mediaUrls.length ? { mediaUrls } : {}
402
579
  });
403
580
  if (metricsClient && userId) metricsClient.recordActivity({
404
581
  userId,
405
582
  channel: "alfe",
406
583
  role: "assistant"
407
584
  }).catch((err) => {
408
- log.debug(`Metrics report failed: ${err instanceof Error ? err.message : String(err)}`);
585
+ log.warn(`Metrics report failed: ${err instanceof Error ? err.message : String(err)}`);
409
586
  });
410
587
  },
411
588
  onRecordError: (err) => {
@@ -415,6 +592,12 @@ async function handleAgentRequest(request, log) {
415
592
  log.error(`Dispatch error (${info.kind}): ${err instanceof Error ? err.message : String(err)}`);
416
593
  }
417
594
  })).route.sessionKey;
595
+ if (isA2A && a2aResponseBuffer) chatClient?.notify("a2a-complete", {
596
+ conversationId: conversationId ?? legacySessionKey,
597
+ fullText: a2aResponseBuffer,
598
+ a2a,
599
+ resolved: a2aResolved
600
+ });
418
601
  chatClient?.sendResponse(request.id, true, { sessionKey: resolvedOpenClawKey });
419
602
  log.info(`Agent dispatch complete: sessionKey=${resolvedOpenClawKey}`);
420
603
  } catch (err) {
@@ -484,10 +667,15 @@ const plugin = {
484
667
  activate(api) {
485
668
  const log = api.logger;
486
669
  const alreadyActivated = globalThis.__alfeChatPluginActivated === true;
487
- globalThis.__alfeChatPluginActivated = true;
670
+ const isGatewayMode = !!api.runtime;
488
671
  const alfeChannel = createAlfeChannelPlugin();
489
672
  api.registerChannel(alfeChannel);
490
673
  log.info(`Registered channel: ${alfeChannel.id}`);
674
+ if (!isGatewayMode) {
675
+ log.debug("Management command context — skipping persistent resource init");
676
+ return;
677
+ }
678
+ globalThis.__alfeChatPluginActivated = true;
491
679
  if (!alreadyActivated) {
492
680
  log.info("Chat plugin registering...");
493
681
  resolveOpenClawSdk(log);
@@ -515,10 +703,17 @@ const plugin = {
515
703
  wsUrl: chatWsUrl,
516
704
  apiKey,
517
705
  onRequest: (request) => {
518
- if (request.method === "agent") handleAgentRequest(request, log);
519
- else if (request.method === "sessions.list") handleSessionsList(request, log);
520
- else if (request.method === "sessions.get") handleSessionsGet(request, log);
521
- else chatClient?.sendResponse(request.id, false, { message: `Unknown method: ${request.method}` });
706
+ const handle = async () => {
707
+ if (request.method === "agent") await handleAgentRequest(request, log);
708
+ else if (request.method === "sessions.list") await handleSessionsList(request, log);
709
+ else if (request.method === "sessions.get") await handleSessionsGet(request, log);
710
+ else chatClient?.sendResponse(request.id, false, { message: `Unknown method: ${request.method}` });
711
+ };
712
+ handle().catch((err) => {
713
+ const errMsg = err instanceof Error ? err.message : String(err);
714
+ log.error(`Request handler crashed: ${errMsg}`);
715
+ chatClient?.sendResponse(request.id, false, { message: "Internal error" });
716
+ });
522
717
  },
523
718
  onConnectionChange: (connected) => {
524
719
  log.info(`Chat service connection: ${connected ? "connected" : "disconnected"}`);
@@ -567,20 +762,30 @@ const plugin = {
567
762
  });
568
763
  log.info("Registered gateway RPC methods: sessions.list, sessions.get");
569
764
  }
765
+ if (!alreadyActivated && typeof api.registerTool === "function") connectingPromise?.then(() => {
766
+ if (chatClient && typeof api.registerTool === "function") {
767
+ const a2aTools = buildA2ATools(chatClient, log);
768
+ for (const tool of a2aTools) api.registerTool(tool);
769
+ log.info(`Registered ${String(a2aTools.length)} agent-to-agent tools`);
770
+ }
771
+ }).catch(() => {});
570
772
  if (!alreadyActivated) {
571
773
  api.on("session_start", async (...eventArgs) => {
774
+ if (!pluginRuntime) return;
572
775
  const key = eventArgs[0].sessionKey;
573
776
  if (!key || isAlfeSessionKey(key)) return;
574
777
  log.info(`Chat session starting: ${key}`);
575
778
  await createSession(key, "", "alfe");
576
779
  }, { priority: 50 });
577
780
  api.on("message_received", async (...eventArgs) => {
781
+ if (!pluginRuntime) return;
578
782
  const event = eventArgs[0];
579
783
  const ctx = eventArgs[1];
580
784
  if (!ctx.conversationId || ctx.channelId === "alfe") return;
581
785
  await addMessage(ctx.conversationId, "user", event.content, event.from);
582
786
  });
583
787
  api.on("session_end", (...eventArgs) => {
788
+ if (!pluginRuntime) return;
584
789
  const key = eventArgs[0].sessionKey;
585
790
  if (!key || !isAlfeSessionKey(key)) return;
586
791
  log.info(`Chat session ending: ${key}`);
package/dist/plugin2.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { createRequire } from "node:module";
2
- import { ChatServiceClient, resolveAlfeChat } from "@alfe.ai/chat";
3
- import { resolveConfig } from "@alfe.ai/config";
4
- import { AgentApiClient } from "@alfe.ai/agent-api-client";
5
2
  import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
6
3
  import { join } from "node:path";
7
4
  import { homedir } from "node:os";
5
+ import { ChatServiceClient, resolveAlfeChat } from "@alfe.ai/chat";
6
+ import { resolveConfig } from "@alfe.ai/config";
7
+ import { AgentApiClient } from "@alfe.ai/agent-api-client";
8
8
  import { existsSync } from "node:fs";
9
9
  //#region src/alfe-channel.ts
10
10
  const CHANNEL_ID = "alfe";
@@ -151,7 +151,7 @@ function isAlfeSessionKey(key) {
151
151
  }
152
152
  /**
153
153
  * Extract the channel mode from a standardized session key or conversationId.
154
- * Returns the mode segment (e.g. 'sms', 'whatsapp', 'chat') or fallback.
154
+ * Returns the mode segment (e.g. 'sms', 'whatsapp', 'chat', 'a2a') or fallback.
155
155
  */
156
156
  function extractChannelMode(conversationId, fallback = "chat") {
157
157
  return /^alfe:(\w+):/.exec(conversationId)?.[1] ?? fallback;
@@ -181,7 +181,6 @@ function sessionPath(sessionId) {
181
181
  }
182
182
  async function cleanupOldSessions() {
183
183
  if (Date.now() - lastCleanupAt < CLEANUP_INTERVAL_MS) return;
184
- lastCleanupAt = Date.now();
185
184
  try {
186
185
  const jsonFiles = (await readdir(SESSIONS_DIR)).filter((f) => f.endsWith(".json"));
187
186
  if (jsonFiles.length <= MAX_SESSIONS) {
@@ -211,6 +210,7 @@ async function cleanupOldSessions() {
211
210
  remaining--;
212
211
  } catch {}
213
212
  }
213
+ lastCleanupAt = Date.now();
214
214
  } catch {}
215
215
  }
216
216
  async function getSession(sessionId) {
@@ -242,17 +242,24 @@ async function createSession(sessionId, agentId, channel, tenantId, userId) {
242
242
  cleanupOldSessions();
243
243
  return session;
244
244
  }
245
+ const writeLocks = /* @__PURE__ */ new Map();
245
246
  async function addMessage(sessionId, role, content, senderId, senderName) {
246
- const session = await getSession(sessionId);
247
- if (!session) return;
248
- session.messages.push({
249
- role,
250
- content,
251
- timestamp: Date.now(),
252
- ...senderId ? { senderId } : {},
253
- ...senderName ? { senderName } : {}
247
+ const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
248
+ const session = await getSession(sessionId);
249
+ if (!session) return;
250
+ session.messages.push({
251
+ role,
252
+ content,
253
+ timestamp: Date.now(),
254
+ ...senderId ? { senderId } : {},
255
+ ...senderName ? { senderName } : {}
256
+ });
257
+ await saveSession(session);
258
+ }).finally(() => {
259
+ if (writeLocks.get(sessionId) === next) writeLocks.delete(sessionId);
254
260
  });
255
- await saveSession(session);
261
+ writeLocks.set(sessionId, next);
262
+ await next;
256
263
  }
257
264
  async function listSessions(filters, limit = 50) {
258
265
  await ensureDir();
@@ -289,6 +296,112 @@ async function listSessions(filters, limit = 50) {
289
296
  return summaries.slice(0, limit);
290
297
  }
291
298
  //#endregion
299
+ //#region src/a2a-tools.ts
300
+ function ok(result) {
301
+ return { content: [{
302
+ type: "text",
303
+ text: JSON.stringify(result)
304
+ }] };
305
+ }
306
+ function errResult(message) {
307
+ return { content: [{
308
+ type: "text",
309
+ text: JSON.stringify({ error: message })
310
+ }] };
311
+ }
312
+ function defineTool(def) {
313
+ return {
314
+ name: def.name,
315
+ description: def.description,
316
+ label: def.name,
317
+ parameters: def.parameters,
318
+ execute: async (_toolCallId, params) => {
319
+ try {
320
+ return ok(await def.handler(params));
321
+ } catch (e) {
322
+ return errResult(e.message);
323
+ }
324
+ }
325
+ };
326
+ }
327
+ function buildA2ATools(chatClient, log) {
328
+ return [
329
+ defineTool({
330
+ name: "list_agents",
331
+ description: "List other agents in your organization that you can communicate with.",
332
+ parameters: {
333
+ type: "object",
334
+ properties: {},
335
+ required: []
336
+ },
337
+ handler: async () => {
338
+ log.info("list_agents tool called");
339
+ return await chatClient.sendRequest("a2a.list-agents", {});
340
+ }
341
+ }),
342
+ defineTool({
343
+ name: "message_agent",
344
+ description: "Send a message to another agent. Starts a new conversation thread, or continues an existing one. The conversation will bounce back and forth automatically until one agent calls end_conversation() or says [RESOLVED].",
345
+ parameters: {
346
+ type: "object",
347
+ properties: {
348
+ agent_id: {
349
+ type: "string",
350
+ description: "Target agent ID (use list_agents to find available agents)"
351
+ },
352
+ message: {
353
+ type: "string",
354
+ description: "Message to send to the agent"
355
+ },
356
+ thread_id: {
357
+ type: "string",
358
+ description: "Continue an existing conversation thread (omit to start a new one)"
359
+ },
360
+ max_depth: {
361
+ type: "number",
362
+ description: "Maximum number of back-and-forth exchanges (default: 10)"
363
+ }
364
+ },
365
+ required: ["agent_id", "message"]
366
+ },
367
+ handler: async (params) => {
368
+ const agentId = params.agent_id;
369
+ const message = params.message;
370
+ const threadId = params.thread_id;
371
+ const maxDepth = params.max_depth ?? 10;
372
+ if (!agentId || !message) throw new Error("agent_id and message are required");
373
+ log.info(`message_agent tool: sending to ${agentId}`);
374
+ return await chatClient.sendRequest("a2a.send", {
375
+ targetAgentId: agentId,
376
+ message,
377
+ threadId,
378
+ maxDepth
379
+ });
380
+ }
381
+ }),
382
+ defineTool({
383
+ name: "end_conversation",
384
+ description: "End the current agent-to-agent conversation. Call this when the discussion is resolved and no further exchanges are needed.",
385
+ parameters: {
386
+ type: "object",
387
+ properties: { summary: {
388
+ type: "string",
389
+ description: "Brief summary of what was discussed or resolved"
390
+ } },
391
+ required: []
392
+ },
393
+ handler: (params) => {
394
+ const summary = params.summary;
395
+ log.info(`end_conversation tool called${summary ? `: ${summary}` : ""}`);
396
+ return Promise.resolve({
397
+ status: "conversation_ended",
398
+ summary
399
+ });
400
+ }
401
+ })
402
+ ];
403
+ }
404
+ //#endregion
292
405
  //#region src/plugin.ts
293
406
  /**
294
407
  * @alfe.ai/openclaw-chat — OpenClaw chat channel plugin.
@@ -325,6 +438,45 @@ let pluginRuntime = null;
325
438
  let chatClient = null;
326
439
  let connectingPromise = null;
327
440
  let metricsClient = null;
441
+ const MAX_FILE_SIZE = 50 * 1024 * 1024;
442
+ const DOWNLOAD_TIMEOUT_MS = 3e4;
443
+ async function downloadAttachments(attachments, log) {
444
+ const attachDir = join(homedir(), ".alfe", "attachments");
445
+ await mkdir(attachDir, { recursive: true });
446
+ const results = [];
447
+ for (const att of attachments) {
448
+ const filename = (att.filename ?? att.id).replace(/[/\\]/g, "_").replace(/\.\./g, "_").replace(/\0/g, "");
449
+ const localPath = join(attachDir, `${att.id}_${filename}`);
450
+ const controller = new AbortController();
451
+ const timeout = setTimeout(() => {
452
+ controller.abort();
453
+ }, DOWNLOAD_TIMEOUT_MS);
454
+ try {
455
+ const res = await fetch(att.url, { signal: controller.signal });
456
+ if (!res.ok) {
457
+ log.warn(`Failed to download attachment ${att.id}: ${String(res.status)}`);
458
+ continue;
459
+ }
460
+ const buffer = Buffer.from(await res.arrayBuffer());
461
+ if (buffer.length > MAX_FILE_SIZE) {
462
+ log.warn(`Attachment ${att.id} exceeds max size (${String(buffer.length)} bytes) — skipping`);
463
+ continue;
464
+ }
465
+ await writeFile(localPath, buffer);
466
+ results.push({
467
+ localPath,
468
+ filename,
469
+ mimeType: att.mimeType
470
+ });
471
+ log.info(`Downloaded attachment: ${localPath} (${String(buffer.length)} bytes)`);
472
+ } catch (err) {
473
+ log.error(`Failed to download attachment ${att.id}: ${err instanceof Error ? err.message : String(err)}`);
474
+ } finally {
475
+ clearTimeout(timeout);
476
+ }
477
+ }
478
+ return results;
479
+ }
328
480
  async function handleAgentRequest(request, log) {
329
481
  const runtime = pluginRuntime;
330
482
  if (!runtime) {
@@ -335,8 +487,9 @@ async function handleAgentRequest(request, log) {
335
487
  chatClient?.sendResponse(request.id, false, { message: "OpenClaw SDK not available — cannot dispatch" });
336
488
  return;
337
489
  }
338
- const { message, sessionKey: legacySessionKey, userId, conversationId, conversationType, tenantId, clientType, displayName } = request.params;
339
- if (!message) {
490
+ const { message, sessionKey: legacySessionKey, userId, conversationId, conversationType, tenantId, clientType, displayName, attachments: rawAttachments, a2a } = request.params;
491
+ const isA2A = !!a2a;
492
+ if (!message && !rawAttachments?.length) {
340
493
  chatClient?.sendResponse(request.id, false, { message: "Missing message" });
341
494
  return;
342
495
  }
@@ -360,11 +513,15 @@ async function handleAgentRequest(request, log) {
360
513
  });
361
514
  });
362
515
  try {
363
- const channelMode = extractChannelMode(conversationId ?? "", clientType ?? "chat");
364
- const channelLabel = channelMode === "sms" ? "SMS" : channelMode === "whatsapp" ? "WhatsApp" : "Alfe";
516
+ const downloadedFiles = rawAttachments?.length ? await downloadAttachments(rawAttachments, log) : [];
517
+ const bodyForAgent = downloadedFiles.length ? `${message || ""}\n\n[Attached files:\n${downloadedFiles.map((f) => `- ${f.filename}: ${f.localPath}`).join("\n")}]` : void 0;
518
+ const channelMode = isA2A ? "a2a" : extractChannelMode(conversationId ?? "", clientType ?? "chat");
519
+ const channelLabel = channelMode === "sms" ? "SMS" : channelMode === "whatsapp" ? "WhatsApp" : channelMode === "a2a" ? "Agent" : "Alfe";
365
520
  const shortConvId = conversationId?.slice(-8) ?? "";
366
521
  const userLabel = displayName ?? userId ?? senderId;
367
522
  const conversationLabel = conversationType === "group" ? shortConvId ? `[${channelLabel}] Group (${shortConvId})` : `[${channelLabel}] Group` : shortConvId ? `[${channelLabel}] ${userLabel} (${shortConvId})` : `[${channelLabel}] ${userLabel}`;
523
+ let a2aResponseBuffer = "";
524
+ let a2aResolved = false;
368
525
  resolvedOpenClawKey = (await dispatchInbound({
369
526
  cfg,
370
527
  runtime: { channel: runtime.channel },
@@ -382,7 +539,8 @@ async function handleAgentRequest(request, log) {
382
539
  senderAddress: `user:${senderId}`,
383
540
  recipientAddress: "agent",
384
541
  conversationLabel,
385
- rawBody: message,
542
+ rawBody: message || "",
543
+ bodyForAgent,
386
544
  messageId: request.id,
387
545
  timestamp: Date.now(),
388
546
  extraContext: {
@@ -390,22 +548,41 @@ async function handleAgentRequest(request, log) {
390
548
  ...clientType ? { ClientType: clientType } : {},
391
549
  ...conversationId ? { ConversationId: conversationId } : {},
392
550
  ...displayName ? { SenderName: displayName } : {},
393
- ChannelMode: channelMode
551
+ ChannelMode: channelMode,
552
+ ...isA2A ? {
553
+ CallerType: "agent",
554
+ CallerAgentId: a2a.sourceAgentId,
555
+ CallerAgentName: a2a.sourceAgentName,
556
+ InteractionDepth: String(a2a.depth),
557
+ A2ASystemPrompt: [
558
+ `This is an agent-to-agent conversation with ${a2a.sourceAgentName}.`,
559
+ "Rules:",
560
+ "- Only respond if you have new information, a question, or an action to coordinate.",
561
+ "- If the conversation is complete, call the end_conversation() tool OR end your message with [RESOLVED].",
562
+ "- Do NOT respond just to acknowledge — that creates infinite loops."
563
+ ].join("\n")
564
+ } : {}
394
565
  },
395
566
  deliver: async (payload) => {
396
567
  const responseText = payload.text ?? "";
568
+ const mediaUrls = [...payload.mediaUrl ? [payload.mediaUrl] : [], ...payload.mediaUrls ?? []];
397
569
  await addMessage(sessionId, "assistant", responseText);
570
+ if (isA2A) {
571
+ a2aResponseBuffer += responseText;
572
+ if (responseText.includes("\"status\":\"conversation_ended\"") || responseText.includes("\"status\": \"conversation_ended\"")) a2aResolved = true;
573
+ }
398
574
  chatClient?.notify("agent-message", {
399
575
  conversationId: conversationId ?? legacySessionKey,
400
576
  text: responseText,
401
- sessionKey: resolvedOpenClawKey ?? legacySessionKey
577
+ sessionKey: resolvedOpenClawKey ?? legacySessionKey,
578
+ ...mediaUrls.length ? { mediaUrls } : {}
402
579
  });
403
580
  if (metricsClient && userId) metricsClient.recordActivity({
404
581
  userId,
405
582
  channel: "alfe",
406
583
  role: "assistant"
407
584
  }).catch((err) => {
408
- log.debug(`Metrics report failed: ${err instanceof Error ? err.message : String(err)}`);
585
+ log.warn(`Metrics report failed: ${err instanceof Error ? err.message : String(err)}`);
409
586
  });
410
587
  },
411
588
  onRecordError: (err) => {
@@ -415,6 +592,12 @@ async function handleAgentRequest(request, log) {
415
592
  log.error(`Dispatch error (${info.kind}): ${err instanceof Error ? err.message : String(err)}`);
416
593
  }
417
594
  })).route.sessionKey;
595
+ if (isA2A && a2aResponseBuffer) chatClient?.notify("a2a-complete", {
596
+ conversationId: conversationId ?? legacySessionKey,
597
+ fullText: a2aResponseBuffer,
598
+ a2a,
599
+ resolved: a2aResolved
600
+ });
418
601
  chatClient?.sendResponse(request.id, true, { sessionKey: resolvedOpenClawKey });
419
602
  log.info(`Agent dispatch complete: sessionKey=${resolvedOpenClawKey}`);
420
603
  } catch (err) {
@@ -484,10 +667,15 @@ const plugin = {
484
667
  activate(api) {
485
668
  const log = api.logger;
486
669
  const alreadyActivated = globalThis.__alfeChatPluginActivated === true;
487
- globalThis.__alfeChatPluginActivated = true;
670
+ const isGatewayMode = !!api.runtime;
488
671
  const alfeChannel = createAlfeChannelPlugin();
489
672
  api.registerChannel(alfeChannel);
490
673
  log.info(`Registered channel: ${alfeChannel.id}`);
674
+ if (!isGatewayMode) {
675
+ log.debug("Management command context — skipping persistent resource init");
676
+ return;
677
+ }
678
+ globalThis.__alfeChatPluginActivated = true;
491
679
  if (!alreadyActivated) {
492
680
  log.info("Chat plugin registering...");
493
681
  resolveOpenClawSdk(log);
@@ -515,10 +703,17 @@ const plugin = {
515
703
  wsUrl: chatWsUrl,
516
704
  apiKey,
517
705
  onRequest: (request) => {
518
- if (request.method === "agent") handleAgentRequest(request, log);
519
- else if (request.method === "sessions.list") handleSessionsList(request, log);
520
- else if (request.method === "sessions.get") handleSessionsGet(request, log);
521
- else chatClient?.sendResponse(request.id, false, { message: `Unknown method: ${request.method}` });
706
+ const handle = async () => {
707
+ if (request.method === "agent") await handleAgentRequest(request, log);
708
+ else if (request.method === "sessions.list") await handleSessionsList(request, log);
709
+ else if (request.method === "sessions.get") await handleSessionsGet(request, log);
710
+ else chatClient?.sendResponse(request.id, false, { message: `Unknown method: ${request.method}` });
711
+ };
712
+ handle().catch((err) => {
713
+ const errMsg = err instanceof Error ? err.message : String(err);
714
+ log.error(`Request handler crashed: ${errMsg}`);
715
+ chatClient?.sendResponse(request.id, false, { message: "Internal error" });
716
+ });
522
717
  },
523
718
  onConnectionChange: (connected) => {
524
719
  log.info(`Chat service connection: ${connected ? "connected" : "disconnected"}`);
@@ -567,20 +762,30 @@ const plugin = {
567
762
  });
568
763
  log.info("Registered gateway RPC methods: sessions.list, sessions.get");
569
764
  }
765
+ if (!alreadyActivated && typeof api.registerTool === "function") connectingPromise?.then(() => {
766
+ if (chatClient && typeof api.registerTool === "function") {
767
+ const a2aTools = buildA2ATools(chatClient, log);
768
+ for (const tool of a2aTools) api.registerTool(tool);
769
+ log.info(`Registered ${String(a2aTools.length)} agent-to-agent tools`);
770
+ }
771
+ }).catch(() => {});
570
772
  if (!alreadyActivated) {
571
773
  api.on("session_start", async (...eventArgs) => {
774
+ if (!pluginRuntime) return;
572
775
  const key = eventArgs[0].sessionKey;
573
776
  if (!key || isAlfeSessionKey(key)) return;
574
777
  log.info(`Chat session starting: ${key}`);
575
778
  await createSession(key, "", "alfe");
576
779
  }, { priority: 50 });
577
780
  api.on("message_received", async (...eventArgs) => {
781
+ if (!pluginRuntime) return;
578
782
  const event = eventArgs[0];
579
783
  const ctx = eventArgs[1];
580
784
  if (!ctx.conversationId || ctx.channelId === "alfe") return;
581
785
  await addMessage(ctx.conversationId, "user", event.content, event.from);
582
786
  });
583
787
  api.on("session_end", (...eventArgs) => {
788
+ if (!pluginRuntime) return;
584
789
  const key = eventArgs[0].sessionKey;
585
790
  if (!key || !isAlfeSessionKey(key)) return;
586
791
  log.info(`Chat session ending: ${key}`);
@@ -1,6 +1,5 @@
1
1
  {
2
2
  "id": "@alfe.ai/openclaw-chat",
3
- "channels": ["alfe"],
4
3
  "configSchema": {
5
4
  "type": "object",
6
5
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/openclaw-chat",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "description": "OpenClaw chat plugin for Alfe — web widget and mobile app channels",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin.js",
@@ -28,7 +28,7 @@
28
28
  ],
29
29
  "dependencies": {
30
30
  "@alfe.ai/agent-api-client": "^0.0.7",
31
- "@alfe.ai/chat": "^0.0.6",
31
+ "@alfe.ai/chat": "^0.0.7",
32
32
  "@alfe.ai/config": "^0.0.7"
33
33
  },
34
34
  "peerDependencies": {