@alfe.ai/openclaw-chat 0.9.8 → 0.9.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.
package/dist/plugin2.cjs CHANGED
@@ -1,4 +1,5 @@
1
1
  let node_module = require("node:module");
2
+ let node_crypto = require("node:crypto");
2
3
  let node_fs_promises = require("node:fs/promises");
3
4
  let node_fs = require("node:fs");
4
5
  let node_child_process = require("node:child_process");
@@ -7,7 +8,7 @@ let node_url = require("node:url");
7
8
  let node_os = require("node:os");
8
9
  let _alfe_ai_chat = require("@alfe.ai/chat");
9
10
  let _alfe_ai_agent_api_client = require("@alfe.ai/agent-api-client");
10
- let node_crypto = require("node:crypto");
11
+ let _alfe_ai_openclaw_plugin_kit = require("@alfe.ai/openclaw-plugin-kit");
11
12
  let _alfe_ai_config = require("@alfe.ai/config");
12
13
  //#region src/outbound-media.ts
13
14
  /**
@@ -154,29 +155,40 @@ async function uploadLocalFile(localPath, log, deps) {
154
155
  const client = deps.getClient ? deps.getClient() : getUploadClient(log);
155
156
  if (!client) return null;
156
157
  let size;
158
+ let body;
157
159
  try {
158
- const st = await (0, node_fs_promises.stat)(localPath);
159
- if (!st.isFile()) {
160
- log.warn(`Outbound media ref is not a file — skipping: ${localPath}`);
161
- return null;
160
+ const handle = await (0, node_fs_promises.open)(localPath, node_fs.constants.O_RDONLY | node_fs.constants.O_NOFOLLOW);
161
+ try {
162
+ const before = await handle.stat();
163
+ if (!before.isFile()) {
164
+ log.warn(`Outbound media ref is not a file — skipping: ${localPath}`);
165
+ return null;
166
+ }
167
+ size = before.size;
168
+ if (size <= 0) {
169
+ log.warn(`Outbound media file is empty — skipping: ${localPath}`);
170
+ return null;
171
+ }
172
+ if (size > 26214400) {
173
+ log.warn(`Outbound media exceeds ${String(MAX_OUTBOUND_MEDIA_SIZE)} bytes (${String(size)}) — skipping: ${localPath}`);
174
+ return null;
175
+ }
176
+ body = Uint8Array.from(await handle.readFile());
177
+ const after = await handle.stat();
178
+ if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size || body.length !== before.size) {
179
+ log.warn(`Outbound media changed while being read — skipping: ${localPath}`);
180
+ return null;
181
+ }
182
+ } finally {
183
+ await handle.close();
162
184
  }
163
- size = st.size;
164
185
  } catch {
165
186
  log.warn(`Outbound media file not found — skipping: ${localPath}`);
166
187
  return null;
167
188
  }
168
- if (size <= 0) {
169
- log.warn(`Outbound media file is empty — skipping: ${localPath}`);
170
- return null;
171
- }
172
- if (size > 26214400) {
173
- log.warn(`Outbound media exceeds ${String(MAX_OUTBOUND_MEDIA_SIZE)} bytes (${String(size)}) — skipping: ${localPath}`);
174
- return null;
175
- }
176
189
  const filename = (0, node_path.basename)(localPath);
177
190
  const mimeType = mimeFromPath(localPath);
178
191
  try {
179
- const body = await (0, node_fs_promises.readFile)(localPath);
180
192
  const { attachments } = await client.presignAttachments([{
181
193
  filename,
182
194
  mimeType,
@@ -407,13 +419,17 @@ function isSafeComponentUrl(raw) {
407
419
  return false;
408
420
  }
409
421
  if (u.protocol !== "https:") return false;
422
+ if (u.username || u.password) return false;
410
423
  const host = u.hostname.toLowerCase();
411
424
  return host === "alfe.ai" || host.endsWith(".alfe.ai");
412
425
  }
413
426
  const MAX_COMPONENTS_PER_MESSAGE = 10;
414
- const MAX_COMPONENT_LABEL_CHARS = 120;
415
- const MAX_COMPONENT_VALUE_CHARS = 400;
427
+ const MAX_COMPONENT_LABEL_CHARS$1 = 120;
428
+ const MAX_COMPONENT_VALUE_CHARS$1 = 400;
416
429
  const MAX_OPTIONS_PER_COMPONENT = 25;
430
+ const MAX_COMPONENT_ID_CHARS$1 = 128;
431
+ const MAX_CONVERSATION_ID_CHARS = 512;
432
+ const MAX_USER_ID_CHARS = 256;
417
433
  /** Cap a maybe-string; returns '' for non-strings so callers can `if (!x)`. */
418
434
  function capStr(v, max) {
419
435
  return typeof v === "string" ? v.slice(0, max) : "";
@@ -430,8 +446,8 @@ function sanitizeOptions(raw) {
430
446
  if (out.length >= MAX_OPTIONS_PER_COMPONENT) break;
431
447
  if (!item || typeof item !== "object") continue;
432
448
  const o = item;
433
- const label = capStr(o.label, MAX_COMPONENT_LABEL_CHARS);
434
- const value = capStr(o.value, MAX_COMPONENT_VALUE_CHARS);
449
+ const label = capStr(o.label, MAX_COMPONENT_LABEL_CHARS$1);
450
+ const value = capStr(o.value, MAX_COMPONENT_VALUE_CHARS$1);
435
451
  if (!label || !value) continue;
436
452
  out.push({
437
453
  label,
@@ -457,8 +473,8 @@ function sanitizeComponents(raw) {
457
473
  if (out.length >= MAX_COMPONENTS_PER_MESSAGE) break;
458
474
  if (!item || typeof item !== "object") continue;
459
475
  const c = item;
460
- const label = capStr(c.label, MAX_COMPONENT_LABEL_CHARS);
461
- let id = typeof c.id === "string" && c.id ? c.id : (0, node_crypto.randomUUID)();
476
+ const label = capStr(c.label, MAX_COMPONENT_LABEL_CHARS$1);
477
+ let id = typeof c.id === "string" && c.id ? c.id.slice(0, MAX_COMPONENT_ID_CHARS$1) : (0, node_crypto.randomUUID)();
462
478
  if (usedIds.has(id)) id = (0, node_crypto.randomUUID)();
463
479
  usedIds.add(id);
464
480
  const style = c.style === "primary" || c.style === "secondary" || c.style === "danger" ? c.style : void 0;
@@ -476,7 +492,7 @@ function sanitizeComponents(raw) {
476
492
  });
477
493
  } else if (c.type === "quick_reply") {
478
494
  if (!label) continue;
479
- const value = capStr(c.value, MAX_COMPONENT_VALUE_CHARS);
495
+ const value = capStr(c.value, MAX_COMPONENT_VALUE_CHARS$1);
480
496
  if (!value) continue;
481
497
  out.push({
482
498
  type: "quick_reply",
@@ -488,7 +504,7 @@ function sanitizeComponents(raw) {
488
504
  } else if (c.type === "select") {
489
505
  const options = sanitizeOptions(c.options);
490
506
  if (options.length === 0) continue;
491
- const placeholder = capStr(c.placeholder, MAX_COMPONENT_LABEL_CHARS);
507
+ const placeholder = capStr(c.placeholder, MAX_COMPONENT_LABEL_CHARS$1);
492
508
  out.push({
493
509
  type: "select",
494
510
  id,
@@ -499,7 +515,7 @@ function sanitizeComponents(raw) {
499
515
  } else if (c.type === "multi_select") {
500
516
  const options = sanitizeOptions(c.options);
501
517
  if (options.length === 0) continue;
502
- const submitLabel = capStr(c.submitLabel, MAX_COMPONENT_LABEL_CHARS);
518
+ const submitLabel = capStr(c.submitLabel, MAX_COMPONENT_LABEL_CHARS$1);
503
519
  out.push({
504
520
  type: "multi_select",
505
521
  id,
@@ -508,11 +524,11 @@ function sanitizeComponents(raw) {
508
524
  ...submitLabel ? { submitLabel } : {}
509
525
  });
510
526
  } else if (c.type === "confirm") {
511
- const confirmLabel = capStr(c.confirmLabel, MAX_COMPONENT_LABEL_CHARS);
512
- const confirmValue = capStr(c.confirmValue, MAX_COMPONENT_VALUE_CHARS);
527
+ const confirmLabel = capStr(c.confirmLabel, MAX_COMPONENT_LABEL_CHARS$1);
528
+ const confirmValue = capStr(c.confirmValue, MAX_COMPONENT_VALUE_CHARS$1);
513
529
  if (!confirmLabel || !confirmValue) continue;
514
- const cancelLabel = capStr(c.cancelLabel, MAX_COMPONENT_LABEL_CHARS);
515
- const cancelValue = capStr(c.cancelValue, MAX_COMPONENT_VALUE_CHARS);
530
+ const cancelLabel = capStr(c.cancelLabel, MAX_COMPONENT_LABEL_CHARS$1);
531
+ const cancelValue = capStr(c.cancelValue, MAX_COMPONENT_VALUE_CHARS$1);
516
532
  out.push({
517
533
  type: "confirm",
518
534
  id,
@@ -520,11 +536,11 @@ function sanitizeComponents(raw) {
520
536
  confirmLabel,
521
537
  confirmValue,
522
538
  ...cancelLabel ? { cancelLabel } : {},
523
- ...cancelValue ? { cancelValue } : {}
539
+ ...cancelLabel && cancelValue ? { cancelValue } : {}
524
540
  });
525
541
  } else if (c.type === "copy_button") {
526
542
  if (!label) continue;
527
- const value = capStr(c.value, MAX_COMPONENT_VALUE_CHARS);
543
+ const value = capStr(c.value, MAX_COMPONENT_VALUE_CHARS$1);
528
544
  if (!value) continue;
529
545
  out.push({
530
546
  type: "copy_button",
@@ -538,6 +554,23 @@ function sanitizeComponents(raw) {
538
554
  }
539
555
  const CHANNEL_ID = "alfe";
540
556
  const DEFAULT_ACCOUNT_ID = "default";
557
+ /** Persist only bucket-backed media; passthrough URLs have no stable id. */
558
+ function mediaToStoredAttachments(media) {
559
+ const stored = [];
560
+ for (const item of media) {
561
+ if (!item.attachmentId) continue;
562
+ const mimeType = item.mimeType ?? "application/octet-stream";
563
+ const type = mimeType.startsWith("image/") ? "image" : mimeType.startsWith("video/") ? "video" : mimeType.startsWith("audio/") ? "audio" : mimeType === "application/pdf" ? "document" : "file";
564
+ stored.push({
565
+ attachmentId: item.attachmentId,
566
+ type,
567
+ mimeType,
568
+ filename: item.filename ?? "file",
569
+ ...typeof item.size === "number" && item.size > 0 ? { size: item.size } : {}
570
+ });
571
+ }
572
+ return stored.length ? stored : void 0;
573
+ }
541
574
  async function sendViaChat(deps, ctx, mediaUrl, components) {
542
575
  const client = deps.getChatClient();
543
576
  if (!client) throw new Error("Chat service not connected — cannot deliver");
@@ -563,7 +596,8 @@ async function sendViaChat(deps, ctx, mediaUrl, components) {
563
596
  await deps.createSession(conversationId, "", "alfe", void 0, userId);
564
597
  }
565
598
  }
566
- if (components?.length) await deps.addMessage(conversationId, "assistant", text, void 0, void 0, components);
599
+ const storedMedia = mediaToStoredAttachments(media.attachments);
600
+ if (components?.length || storedMedia?.length) await deps.addMessage(conversationId, "assistant", text, void 0, void 0, components, storedMedia);
567
601
  else await deps.addMessage(conversationId, "assistant", text);
568
602
  const messageId = (0, node_crypto.randomUUID)();
569
603
  client.notify("agent-message", {
@@ -701,17 +735,22 @@ function createAlfeChannelPlugin(deps) {
701
735
  error: /* @__PURE__ */ new Error("Missing target — use user:{userId} or conv:{conversationId}")
702
736
  };
703
737
  if (to.startsWith("conv:")) {
704
- if (!to.slice(5)) return {
738
+ const convId = to.slice(5);
739
+ if (!convId) return {
705
740
  ok: false,
706
741
  error: /* @__PURE__ */ new Error("Empty conversation ID")
707
742
  };
743
+ if (convId.length > MAX_CONVERSATION_ID_CHARS || /\s/.test(convId)) return {
744
+ ok: false,
745
+ error: /* @__PURE__ */ new Error("Invalid conversation ID")
746
+ };
708
747
  return {
709
748
  ok: true,
710
749
  to
711
750
  };
712
751
  }
713
752
  const userId = to.startsWith("user:") ? to.slice(5) : to;
714
- if (!userId || userId === "anon") return {
753
+ if (!userId || userId === "anon" || userId.length > MAX_USER_ID_CHARS || /\s/.test(userId)) return {
715
754
  ok: false,
716
755
  error: /* @__PURE__ */ new Error("Invalid target: userId is required")
717
756
  };
@@ -749,18 +788,27 @@ function createAlfeChannelPlugin(deps) {
749
788
  return params.accountId ?? DEFAULT_ACCOUNT_ID;
750
789
  },
751
790
  applyAccountConfig(params) {
752
- const cfg = { ...params.cfg };
753
- cfg.channels ??= {};
754
- cfg.channels.alfe ??= {};
755
- const section = cfg.channels.alfe;
791
+ const previousChannels = params.cfg.channels ?? {};
792
+ const previousSection = previousChannels.alfe ?? {};
793
+ const section = {
794
+ ...previousSection,
795
+ ...previousSection.accounts ? { accounts: { ...previousSection.accounts } } : {}
796
+ };
797
+ const cfg = {
798
+ ...params.cfg,
799
+ channels: {
800
+ ...previousChannels,
801
+ alfe: section
802
+ }
803
+ };
756
804
  if (params.accountId === DEFAULT_ACCOUNT_ID) section.enabled = true;
757
- else {
758
- section.accounts ??= {};
759
- section.accounts[params.accountId] = {
760
- enabled: true,
761
- ...params.input
762
- };
763
- }
805
+ else section.accounts = {
806
+ ...section.accounts,
807
+ [params.accountId]: {
808
+ ...params.input,
809
+ enabled: true
810
+ }
811
+ };
764
812
  return cfg;
765
813
  }
766
814
  }
@@ -775,8 +823,7 @@ function createAlfeChannelPlugin(deps) {
775
823
  function isAlfeSessionKey(key) {
776
824
  if (key.startsWith("alfe:")) return true;
777
825
  if (key.includes(":alfe:")) return true;
778
- if (key.includes("chat-") || key.startsWith("sms-") || key.startsWith("wa-")) return true;
779
- return false;
826
+ return /^(?:agent:[^:]+:)?(?:chat-|sms-|wa-)/.test(key);
780
827
  }
781
828
  /**
782
829
  * Extract the channel mode from a standardized session key or conversationId.
@@ -786,6 +833,25 @@ function extractChannelMode(conversationId, fallback = "chat") {
786
833
  return /^alfe:(\w+):/.exec(conversationId)?.[1] ?? fallback;
787
834
  }
788
835
  //#endregion
836
+ //#region src/inbound-hook-routing.ts
837
+ /**
838
+ * Preserve Alfe's authoritative identity route on OpenClaw's canonical inbound
839
+ * fields. Arbitrary `extraContext` values are not copied into plugin message
840
+ * hooks, while these fields become `metadata.provider`, `ctx.channelId`, and
841
+ * `ctx.conversationId` respectively.
842
+ */
843
+ function buildInboundHookRouting(identityProvider, channelMode, conversationId) {
844
+ const explicitProvider = identityProvider?.trim();
845
+ const modeProvider = channelMode.trim();
846
+ const provider = explicitProvider && explicitProvider.length > 0 ? explicitProvider : modeProvider.length > 0 ? modeProvider : "chat";
847
+ return {
848
+ provider,
849
+ surface: "alfe",
850
+ originatingChannel: provider,
851
+ ...conversationId ? { originatingTo: conversationId } : {}
852
+ };
853
+ }
854
+ //#endregion
789
855
  //#region src/session-store.ts
790
856
  /**
791
857
  * Session Store — persists chat sessions to the local filesystem.
@@ -812,6 +878,94 @@ async function ensureDir() {
812
878
  function sessionPath(sessionId) {
813
879
  return (0, node_path.join)(SESSIONS_DIR, `${sessionId.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`);
814
880
  }
881
+ function isRecord(value) {
882
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
883
+ }
884
+ function isOptionalString(value) {
885
+ return value === void 0 || typeof value === "string";
886
+ }
887
+ function isOptionalBoolean(value) {
888
+ return value === void 0 || typeof value === "boolean";
889
+ }
890
+ function isStoredSelectOption(value) {
891
+ return isRecord(value) && typeof value.label === "string" && typeof value.value === "string";
892
+ }
893
+ function isStoredMessageComponent(value) {
894
+ if (!isRecord(value) || typeof value.type !== "string" || typeof value.id !== "string") return false;
895
+ if (!(value.style === void 0 || typeof value.style === "string" && [
896
+ "primary",
897
+ "secondary",
898
+ "danger"
899
+ ].includes(value.style))) return false;
900
+ switch (value.type) {
901
+ case "link_button": return typeof value.label === "string" && typeof value.url === "string" && (value.target === void 0 || typeof value.target === "string" && [
902
+ "same-tab",
903
+ "new-tab",
904
+ "popup"
905
+ ].includes(value.target));
906
+ case "quick_reply": return typeof value.label === "string" && typeof value.value === "string";
907
+ case "select": return isOptionalString(value.label) && isOptionalString(value.placeholder) && Array.isArray(value.options) && value.options.every(isStoredSelectOption);
908
+ case "multi_select": return isOptionalString(value.label) && isOptionalString(value.submitLabel) && Array.isArray(value.options) && value.options.every(isStoredSelectOption);
909
+ case "confirm": return isOptionalString(value.label) && typeof value.confirmLabel === "string" && typeof value.confirmValue === "string" && isOptionalString(value.cancelLabel) && isOptionalString(value.cancelValue);
910
+ case "copy_button": return typeof value.label === "string" && typeof value.value === "string";
911
+ default: return false;
912
+ }
913
+ }
914
+ function isStoredAttachment(value) {
915
+ return isRecord(value) && typeof value.attachmentId === "string" && typeof value.type === "string" && [
916
+ "image",
917
+ "video",
918
+ "audio",
919
+ "document",
920
+ "file"
921
+ ].includes(value.type) && typeof value.mimeType === "string" && typeof value.filename === "string" && (value.size === void 0 || typeof value.size === "number" && Number.isFinite(value.size) && value.size >= 0);
922
+ }
923
+ function isChatActivityRecord(value) {
924
+ if (!isRecord(value) || typeof value.ts !== "number" || !Number.isFinite(value.ts)) return false;
925
+ if (value.kind === "thinking") return typeof value.text === "string";
926
+ if (value.kind !== "tool") return false;
927
+ if (typeof value.toolCallId !== "string" || typeof value.name !== "string" || typeof value.status !== "string" || ![
928
+ "done",
929
+ "failed",
930
+ "interrupted"
931
+ ].includes(value.status) || !isOptionalString(value.summary) || !isOptionalString(value.argsText) || !isOptionalString(value.resultText) || !isOptionalBoolean(value.isError) || value.durationMs !== void 0 && (typeof value.durationMs !== "number" || !Number.isFinite(value.durationMs) || value.durationMs < 0)) return false;
932
+ if (value.truncated === void 0) return true;
933
+ return isRecord(value.truncated) && isOptionalBoolean(value.truncated.args) && isOptionalBoolean(value.truncated.progress) && isOptionalBoolean(value.truncated.result);
934
+ }
935
+ function isStoredRoute(value) {
936
+ return isRecord(value) && typeof value.sessionKey === "string" && typeof value.storePath === "string";
937
+ }
938
+ /** Parse the local persistence boundary without trusting a JSON cast. */
939
+ function asSessionData(value, expectedSessionId) {
940
+ if (!isRecord(value)) return null;
941
+ const record = value;
942
+ if (typeof record.sessionId !== "string" || expectedSessionId !== void 0 && record.sessionId !== expectedSessionId || typeof record.agentId !== "string" || typeof record.channel !== "string" || !isOptionalString(record.tenantId) || !isOptionalString(record.userId) || typeof record.createdAt !== "string" || typeof record.updatedAt !== "string" || !Array.isArray(record.messages)) return null;
943
+ if (!record.messages.every((message) => {
944
+ if (!message || typeof message !== "object" || Array.isArray(message)) return false;
945
+ const item = message;
946
+ return (item.role === "user" || item.role === "assistant") && typeof item.content === "string" && typeof item.timestamp === "number" && Number.isFinite(item.timestamp) && isOptionalString(item.senderId) && isOptionalString(item.senderName) && (item.components === void 0 || Array.isArray(item.components) && item.components.every(isStoredMessageComponent)) && (item.attachments === void 0 || Array.isArray(item.attachments) && item.attachments.every(isStoredAttachment));
947
+ })) return null;
948
+ if (record.activity !== void 0 && (!Array.isArray(record.activity) || !record.activity.every(isChatActivityRecord))) return null;
949
+ if (record.routes !== void 0 && (!Array.isArray(record.routes) || !record.routes.every(isStoredRoute))) return null;
950
+ return record;
951
+ }
952
+ /**
953
+ * Replace a session file atomically with owner-only permissions. Temporary
954
+ * names never end in `.json`, so list/backfill scanners ignore crash debris.
955
+ */
956
+ async function writeTextAtomically(path, contents) {
957
+ const temporary = (0, node_path.join)((0, node_path.dirname)(path), `.${(0, node_path.basename)(path)}.${String(process.pid)}.${(0, node_crypto.randomUUID)()}.tmp`);
958
+ try {
959
+ await (0, node_fs_promises.writeFile)(temporary, contents, {
960
+ encoding: "utf-8",
961
+ mode: 384
962
+ });
963
+ await (0, node_fs_promises.rename)(temporary, path);
964
+ } catch (error) {
965
+ await (0, node_fs_promises.unlink)(temporary).catch(() => void 0);
966
+ throw error;
967
+ }
968
+ }
815
969
  async function cleanupOldSessions() {
816
970
  if (Date.now() - lastCleanupAt < CLEANUP_INTERVAL_MS) return;
817
971
  try {
@@ -822,6 +976,7 @@ async function cleanupOldSessions() {
822
976
  const filePath = (0, node_path.join)(SESSIONS_DIR, file);
823
977
  if (now - (await (0, node_fs_promises.stat)(filePath)).mtimeMs > MAX_AGE_MS) await (0, node_fs_promises.unlink)(filePath);
824
978
  } catch {}
979
+ lastCleanupAt = Date.now();
825
980
  return;
826
981
  }
827
982
  const fileStats = [];
@@ -849,7 +1004,7 @@ async function cleanupOldSessions() {
849
1004
  async function getSession(sessionId) {
850
1005
  try {
851
1006
  const data = await (0, node_fs_promises.readFile)(sessionPath(sessionId), "utf-8");
852
- return JSON.parse(data);
1007
+ return asSessionData(JSON.parse(data), sessionId);
853
1008
  } catch {
854
1009
  return null;
855
1010
  }
@@ -857,7 +1012,7 @@ async function getSession(sessionId) {
857
1012
  async function saveSession(session) {
858
1013
  await ensureDir();
859
1014
  session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
860
- await (0, node_fs_promises.writeFile)(sessionPath(session.sessionId), JSON.stringify(session, null, 2), "utf-8");
1015
+ await writeTextAtomically(sessionPath(session.sessionId), JSON.stringify(session, null, 2));
861
1016
  }
862
1017
  async function createSession(sessionId, agentId, channel, tenantId, userId) {
863
1018
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -953,7 +1108,8 @@ async function listSessions(filters, limit = 50) {
953
1108
  const summaries = [];
954
1109
  for (const file of jsonFiles) try {
955
1110
  const data = await (0, node_fs_promises.readFile)((0, node_path.join)(SESSIONS_DIR, file), "utf-8");
956
- const session = JSON.parse(data);
1111
+ const session = asSessionData(JSON.parse(data));
1112
+ if (!session) continue;
957
1113
  if (filters?.agentId && session.agentId !== filters.agentId) continue;
958
1114
  if (filters?.channel && session.channel !== filters.channel) continue;
959
1115
  if (filters?.tenantId && session.tenantId !== filters.tenantId) continue;
@@ -975,7 +1131,8 @@ async function listSessions(filters, limit = 50) {
975
1131
  const aTime = a.lastMessageAt ?? a.createdAt;
976
1132
  return (b.lastMessageAt ?? b.createdAt).localeCompare(aTime);
977
1133
  });
978
- return summaries.slice(0, limit);
1134
+ const boundedLimit = Number.isFinite(limit) ? Math.min(100, Math.max(1, Math.trunc(limit))) : 50;
1135
+ return summaries.slice(0, boundedLimit);
979
1136
  }
980
1137
  //#endregion
981
1138
  //#region src/activity-serialize.ts
@@ -1336,6 +1493,7 @@ function stripThinkSpans(text) {
1336
1493
  const MAX_ENTRIES = 500;
1337
1494
  const MAX_THINKING_CHARS = 16e3;
1338
1495
  const MAX_TRANSCRIPT_BYTES = 4 * 1024 * 1024;
1496
+ const MAX_TRANSCRIPT_INDEX_BYTES = 4 * 1024 * 1024;
1339
1497
  /** Default OpenClaw store for the daemon's single agent ("main"). */
1340
1498
  function defaultStorePath() {
1341
1499
  return (0, node_path.join)((0, node_os.homedir)(), ".openclaw", "agents", "main", "sessions", "sessions.json");
@@ -1356,8 +1514,8 @@ async function readTranscriptTail(path) {
1356
1514
  const { size } = await handle.stat();
1357
1515
  if (size <= MAX_TRANSCRIPT_BYTES) return await handle.readFile({ encoding: "utf-8" });
1358
1516
  const buf = Buffer.alloc(MAX_TRANSCRIPT_BYTES);
1359
- await handle.read(buf, 0, MAX_TRANSCRIPT_BYTES, size - MAX_TRANSCRIPT_BYTES);
1360
- const text = buf.toString("utf-8");
1517
+ const { bytesRead } = await handle.read(buf, 0, MAX_TRANSCRIPT_BYTES, size - MAX_TRANSCRIPT_BYTES);
1518
+ const text = buf.subarray(0, bytesRead).toString("utf-8");
1361
1519
  const firstNewline = text.indexOf("\n");
1362
1520
  return firstNewline === -1 ? "" : text.slice(firstNewline + 1);
1363
1521
  } finally {
@@ -1367,30 +1525,44 @@ async function readTranscriptTail(path) {
1367
1525
  return null;
1368
1526
  }
1369
1527
  }
1528
+ /** Read a complete JSON index only when it stays inside the local memory cap. */
1529
+ async function readTranscriptIndex(path) {
1530
+ try {
1531
+ const handle = await (0, node_fs_promises.open)(path, "r");
1532
+ try {
1533
+ const { size } = await handle.stat();
1534
+ if (size < 1 || size > MAX_TRANSCRIPT_INDEX_BYTES) return null;
1535
+ return JSON.parse(await handle.readFile({ encoding: "utf-8" }));
1536
+ } finally {
1537
+ await handle.close();
1538
+ }
1539
+ } catch {
1540
+ return null;
1541
+ }
1542
+ }
1370
1543
  /**
1371
1544
  * Find the transcript files for a conversation: exact route matches first,
1372
1545
  * then an index scan for `:conv:{conversationId}` keys (retroactive path).
1373
1546
  * Returns absolute jsonl paths, deduped.
1374
1547
  */
1375
1548
  async function resolveTranscriptPaths(conversationId, routes) {
1376
- const storePaths = new Set((routes ?? []).map((r) => r.storePath));
1549
+ const validRoutes = (routes ?? []).filter((route) => Boolean(route) && typeof route === "object" && typeof route.sessionKey === "string" && route.sessionKey.length > 0 && typeof route.storePath === "string" && route.storePath.length > 0 && route.storePath.length <= 4096);
1550
+ const storePaths = new Set(validRoutes.map((r) => r.storePath));
1377
1551
  if (storePaths.size === 0) storePaths.add(defaultStorePath());
1378
- const routeKeys = new Set((routes ?? []).map((r) => r.sessionKey));
1552
+ const routeKeys = new Set(validRoutes.map((r) => r.sessionKey));
1379
1553
  const convSegment = `:conv:${conversationId}`;
1380
1554
  const files = /* @__PURE__ */ new Set();
1381
1555
  for (const storePath of storePaths) {
1382
- let index;
1383
- try {
1384
- index = JSON.parse(await (0, node_fs_promises.readFile)(storePath, "utf-8"));
1385
- } catch {
1386
- continue;
1387
- }
1556
+ const parsed = await readTranscriptIndex(storePath);
1557
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
1388
1558
  const dir = (0, node_path.dirname)(storePath);
1389
- for (const [key, entry] of Object.entries(index)) {
1390
- if (!entry.sessionId) continue;
1559
+ for (const [key, value] of Object.entries(parsed)) {
1560
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
1561
+ const sessionId = value.sessionId;
1562
+ if (typeof sessionId !== "string" || !sessionId) continue;
1391
1563
  if (!(routeKeys.has(key) || key.endsWith(convSegment) || key.includes(`${convSegment}:`))) continue;
1392
- if (!/^[A-Za-z0-9._-]+$/.test(entry.sessionId)) continue;
1393
- files.add((0, node_path.join)(dir, `${entry.sessionId}.jsonl`));
1564
+ if (!/^[A-Za-z0-9._-]+$/.test(sessionId)) continue;
1565
+ files.add((0, node_path.join)(dir, `${sessionId}.jsonl`));
1394
1566
  }
1395
1567
  }
1396
1568
  return [...files];
@@ -1480,6 +1652,18 @@ async function collectTranscriptActivity(conversationId, routes) {
1480
1652
  }
1481
1653
  //#endregion
1482
1654
  //#region src/a2a-tools.ts
1655
+ const MAX_AGENT_ID_CHARS = 256;
1656
+ const MAX_A2A_MESSAGE_CHARS = 32e3;
1657
+ const MAX_THREAD_ID_CHARS = 128;
1658
+ const MAX_A2A_DEPTH = 20;
1659
+ const MAX_COMPONENT_TARGET_CHARS = 512;
1660
+ const MAX_COMPONENT_TEXT_CHARS = 32e3;
1661
+ const MAX_COMPONENT_ID_CHARS = 128;
1662
+ const MAX_COMPONENT_LABEL_CHARS = 120;
1663
+ const MAX_COMPONENT_VALUE_CHARS = 400;
1664
+ const MAX_COMPONENT_URL_CHARS = 2048;
1665
+ const MAX_COMPONENTS = 10;
1666
+ const MAX_COMPONENT_OPTIONS = 25;
1483
1667
  let conversationEndRequested = false;
1484
1668
  let a2aTurnArmed = false;
1485
1669
  /**
@@ -1505,41 +1689,14 @@ function disarmA2AEndSignal() {
1505
1689
  function isA2AEndSignalled() {
1506
1690
  return conversationEndRequested;
1507
1691
  }
1508
- function ok(result) {
1509
- return { content: [{
1510
- type: "text",
1511
- text: JSON.stringify(result)
1512
- }] };
1513
- }
1514
- function errResult(message) {
1515
- return { content: [{
1516
- type: "text",
1517
- text: JSON.stringify({ error: message })
1518
- }] };
1519
- }
1520
- function defineTool(def) {
1521
- return {
1522
- name: def.name,
1523
- description: def.description,
1524
- label: def.name,
1525
- parameters: def.parameters,
1526
- execute: async (_toolCallId, params) => {
1527
- try {
1528
- return ok(await def.handler(params));
1529
- } catch (e) {
1530
- return errResult(e.message);
1531
- }
1532
- }
1533
- };
1534
- }
1535
1692
  function buildA2ATools(getChatClient, log, present) {
1536
1693
  const requireClient = () => {
1537
1694
  const client = getChatClient();
1538
- if (!client) throw new Error("chat service not connected yet — try again in a moment");
1695
+ if (!client) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)("chat service not connected yet — try again in a moment");
1539
1696
  return client;
1540
1697
  };
1541
1698
  const tools = [
1542
- defineTool({
1699
+ (0, _alfe_ai_openclaw_plugin_kit.defineTool)({
1543
1700
  name: "list_agents",
1544
1701
  description: "List other agents in your organization that you can communicate with.",
1545
1702
  parameters: {
@@ -1552,26 +1709,35 @@ function buildA2ATools(getChatClient, log, present) {
1552
1709
  return await requireClient().sendRequest("a2a.list-agents", {});
1553
1710
  }
1554
1711
  }),
1555
- defineTool({
1712
+ (0, _alfe_ai_openclaw_plugin_kit.defineTool)({
1556
1713
  name: "message_agent",
1557
- 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].",
1714
+ 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 an agent calls end_conversation() or the server depth limit is reached.",
1558
1715
  parameters: {
1559
1716
  type: "object",
1560
1717
  properties: {
1561
1718
  agent_id: {
1562
1719
  type: "string",
1720
+ minLength: 1,
1721
+ maxLength: MAX_AGENT_ID_CHARS,
1563
1722
  description: "Target agent ID (use list_agents to find available agents)"
1564
1723
  },
1565
1724
  message: {
1566
1725
  type: "string",
1726
+ minLength: 1,
1727
+ maxLength: MAX_A2A_MESSAGE_CHARS,
1567
1728
  description: "Message to send to the agent"
1568
1729
  },
1569
1730
  thread_id: {
1570
1731
  type: "string",
1732
+ minLength: 1,
1733
+ maxLength: MAX_THREAD_ID_CHARS,
1734
+ pattern: "^[A-Za-z0-9_-]+$",
1571
1735
  description: "Continue an existing conversation thread (omit to start a new one)"
1572
1736
  },
1573
1737
  max_depth: {
1574
- type: "number",
1738
+ type: "integer",
1739
+ minimum: 1,
1740
+ maximum: MAX_A2A_DEPTH,
1575
1741
  description: "Maximum number of back-and-forth exchanges (default: 10)"
1576
1742
  }
1577
1743
  },
@@ -1582,7 +1748,7 @@ function buildA2ATools(getChatClient, log, present) {
1582
1748
  const message = params.message;
1583
1749
  const threadId = params.thread_id;
1584
1750
  const maxDepth = params.max_depth ?? 10;
1585
- if (!agentId || !message) throw new Error("agent_id and message are required");
1751
+ if (typeof agentId !== "string" || agentId.length < 1 || agentId.length > MAX_AGENT_ID_CHARS || typeof message !== "string" || message.length < 1 || message.length > MAX_A2A_MESSAGE_CHARS || threadId !== void 0 && (typeof threadId !== "string" || threadId.length < 1 || threadId.length > MAX_THREAD_ID_CHARS || !/^[A-Za-z0-9_-]+$/.test(threadId)) || !Number.isInteger(maxDepth) || maxDepth < 1 || maxDepth > MAX_A2A_DEPTH) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)("Invalid message_agent parameters");
1586
1752
  log.info(`message_agent tool: sending to ${agentId}`);
1587
1753
  return await requireClient().sendRequest("a2a.send", {
1588
1754
  targetAgentId: agentId,
@@ -1592,13 +1758,14 @@ function buildA2ATools(getChatClient, log, present) {
1592
1758
  });
1593
1759
  }
1594
1760
  }),
1595
- defineTool({
1761
+ (0, _alfe_ai_openclaw_plugin_kit.defineTool)({
1596
1762
  name: "end_conversation",
1597
1763
  description: "End the current agent-to-agent conversation. Call this when the discussion is resolved and no further exchanges are needed.",
1598
1764
  parameters: {
1599
1765
  type: "object",
1600
1766
  properties: { summary: {
1601
1767
  type: "string",
1768
+ maxLength: 1e3,
1602
1769
  description: "Brief summary of what was discussed or resolved"
1603
1770
  } },
1604
1771
  required: []
@@ -1614,7 +1781,7 @@ function buildA2ATools(getChatClient, log, present) {
1614
1781
  }
1615
1782
  })
1616
1783
  ];
1617
- if (present) tools.push(defineTool({
1784
+ if (present) tools.push((0, _alfe_ai_openclaw_plugin_kit.defineTool)({
1618
1785
  name: "chat_present_components",
1619
1786
  description: "Attach interactive UI components to a chat message so the user can act with a tap instead of typing. PREFER components whenever you offer the user a discrete set of choices — a quick_reply or select reads far better than \"reply 1, 2, or 3\". Use:\n • quick_reply — one-tap canned replies for a short set of options.\n • select — a single choice from a longer list (dropdown).\n • multi_select — let the user pick MANY options, then submit.\n • confirm — an approval / yes-no decision (primary + secondary button).\n • link_button — an actionable LINK (open a dashboard, a browser-takeover deep link). URL must be https on an Alfe-owned host.\n • copy_button — let the user copy a value (token, id, snippet) to their clipboard.\nChoosing/submitting an interactive component sends the chosen value back AS THE USER'S NEXT MESSAGE, so the conversation continues normally — you will see it as ordinary user input. Don't button-spam: components are for real interactions, not decoration, and each message allows at most 10. Components degrade to plain text on clients that don't render them, so keep the essentials in `text` too.\nExamples:\n Approval: { to:\"conv:abc\", text:\"Deploy to production?\", components:[ { type:\"confirm\", id:\"c1\", confirmLabel:\"Deploy\", confirmValue:\"yes, deploy\", cancelLabel:\"Cancel\", cancelValue:\"no, hold off\" } ] }\n Pick one: { to:\"user:u_123\", text:\"Which environment?\", components:[ { type:\"select\", id:\"s1\", placeholder:\"Choose an environment\", options:[ { label:\"Dev\", value:\"dev\" }, { label:\"Staging\", value:\"staging\" }, { label:\"Prod\", value:\"prod\" } ] } ] }\n Link: { to:\"conv:abc\", text:\"I need you to finish the login step.\", components:[ { type:\"link_button\", id:\"b1\", label:\"Open browser session\", url:\"https://app.alfe.ai/agents/x?tab=browser\", target:\"new-tab\", style:\"primary\" } ] }",
1620
1787
  parameters: {
@@ -1622,14 +1789,20 @@ function buildA2ATools(getChatClient, log, present) {
1622
1789
  properties: {
1623
1790
  to: {
1624
1791
  type: "string",
1792
+ minLength: 6,
1793
+ maxLength: MAX_COMPONENT_TARGET_CHARS + 5,
1794
+ pattern: "^(conv|user):.+$",
1625
1795
  description: "Target conversation: \"conv:{conversationId}\" or \"user:{userId}\"."
1626
1796
  },
1627
1797
  text: {
1628
1798
  type: "string",
1799
+ maxLength: MAX_COMPONENT_TEXT_CHARS,
1629
1800
  description: "Optional message text rendered above the components. Keep the key info here too — it is the fallback on clients that cannot render components."
1630
1801
  },
1631
1802
  components: {
1632
1803
  type: "array",
1804
+ minItems: 1,
1805
+ maxItems: MAX_COMPONENTS,
1633
1806
  description: "Interactive components to attach (at least one, at most 10).",
1634
1807
  items: {
1635
1808
  type: "object",
@@ -1648,14 +1821,18 @@ function buildA2ATools(getChatClient, log, present) {
1648
1821
  },
1649
1822
  id: {
1650
1823
  type: "string",
1824
+ minLength: 1,
1825
+ maxLength: MAX_COMPONENT_ID_CHARS,
1651
1826
  description: "Stable id (optional — auto-generated when omitted)."
1652
1827
  },
1653
1828
  label: {
1654
1829
  type: "string",
1830
+ maxLength: MAX_COMPONENT_LABEL_CHARS,
1655
1831
  description: "Button/control text. Required for link_button, quick_reply, copy_button; optional heading for select / multi_select."
1656
1832
  },
1657
1833
  url: {
1658
1834
  type: "string",
1835
+ maxLength: MAX_COMPONENT_URL_CHARS,
1659
1836
  description: "link_button only: https URL on an Alfe-owned host to open when clicked."
1660
1837
  },
1661
1838
  target: {
@@ -1669,24 +1846,32 @@ function buildA2ATools(getChatClient, log, present) {
1669
1846
  },
1670
1847
  value: {
1671
1848
  type: "string",
1849
+ maxLength: MAX_COMPONENT_VALUE_CHARS,
1672
1850
  description: "quick_reply: the text submitted as the user's next message when tapped. copy_button: the value copied to the clipboard."
1673
1851
  },
1674
1852
  placeholder: {
1675
1853
  type: "string",
1854
+ maxLength: MAX_COMPONENT_LABEL_CHARS,
1676
1855
  description: "select only: the empty-state prompt shown before a choice is made."
1677
1856
  },
1678
1857
  options: {
1679
1858
  type: "array",
1859
+ minItems: 1,
1860
+ maxItems: MAX_COMPONENT_OPTIONS,
1680
1861
  description: "select / multi_select only: the choices (max 25). Each is { label, value }; `value` is what gets submitted (multi_select joins checked values with \", \").",
1681
1862
  items: {
1682
1863
  type: "object",
1683
1864
  properties: {
1684
1865
  label: {
1685
1866
  type: "string",
1867
+ minLength: 1,
1868
+ maxLength: MAX_COMPONENT_LABEL_CHARS,
1686
1869
  description: "Option text shown to the user."
1687
1870
  },
1688
1871
  value: {
1689
1872
  type: "string",
1873
+ minLength: 1,
1874
+ maxLength: MAX_COMPONENT_VALUE_CHARS,
1690
1875
  description: "Value submitted when chosen."
1691
1876
  }
1692
1877
  },
@@ -1695,22 +1880,27 @@ function buildA2ATools(getChatClient, log, present) {
1695
1880
  },
1696
1881
  submitLabel: {
1697
1882
  type: "string",
1883
+ maxLength: MAX_COMPONENT_LABEL_CHARS,
1698
1884
  description: "multi_select only: submit button caption (default \"Submit\")."
1699
1885
  },
1700
1886
  confirmLabel: {
1701
1887
  type: "string",
1888
+ maxLength: MAX_COMPONENT_LABEL_CHARS,
1702
1889
  description: "confirm only: primary button text (e.g. \"Approve\")."
1703
1890
  },
1704
1891
  confirmValue: {
1705
1892
  type: "string",
1893
+ maxLength: MAX_COMPONENT_VALUE_CHARS,
1706
1894
  description: "confirm only: value submitted when the primary button is tapped."
1707
1895
  },
1708
1896
  cancelLabel: {
1709
1897
  type: "string",
1898
+ maxLength: MAX_COMPONENT_LABEL_CHARS,
1710
1899
  description: "confirm only: secondary button text (omit to show only the primary)."
1711
1900
  },
1712
1901
  cancelValue: {
1713
1902
  type: "string",
1903
+ maxLength: MAX_COMPONENT_VALUE_CHARS,
1714
1904
  description: "confirm only: value submitted when the secondary button is tapped."
1715
1905
  },
1716
1906
  style: {
@@ -1731,14 +1921,20 @@ function buildA2ATools(getChatClient, log, present) {
1731
1921
  },
1732
1922
  handler: async (params) => {
1733
1923
  const to = params.to;
1734
- if (!to || !to.startsWith("conv:") && !to.startsWith("user:")) throw new Error("`to` must be \"conv:{conversationId}\" or \"user:{userId}\"");
1924
+ if (!to || !to.startsWith("conv:") && !to.startsWith("user:") || to.length > MAX_COMPONENT_TARGET_CHARS + 5 || to.slice(to.indexOf(":") + 1).length < 1) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)("`to` must be \"conv:{conversationId}\" or \"user:{userId}\"");
1735
1925
  const text = params.text;
1926
+ if (text !== void 0 && (typeof text !== "string" || text.length > MAX_COMPONENT_TEXT_CHARS)) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)("`text` exceeds the 32000-character limit");
1736
1927
  log.info("chat_present_components tool called");
1737
- return await present({
1738
- to,
1739
- text,
1740
- components: params.components
1741
- });
1928
+ try {
1929
+ return await present({
1930
+ to,
1931
+ text,
1932
+ components: params.components
1933
+ });
1934
+ } catch (error) {
1935
+ if (error instanceof Error && error.message === "At least one valid component is required") throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)(error.message);
1936
+ throw error;
1937
+ }
1742
1938
  }
1743
1939
  }));
1744
1940
  return tools;
@@ -1771,14 +1967,27 @@ const DEFAULT_EXACT_HOSTS = [
1771
1967
  "mmg.whatsapp.net"
1772
1968
  ];
1773
1969
  const DEFAULT_SUFFIX_HOSTS = [
1774
- ".s3.amazonaws.com",
1775
- ".amazonaws.com",
1776
1970
  ".twiliocdn.com",
1777
1971
  ".cdn.discordapp.com",
1778
1972
  ".discordapp.net",
1779
1973
  ".telegram.org",
1780
1974
  ".alfe.ai"
1781
1975
  ];
1976
+ const AWS_REGION = /^(?:[a-z]{2}(?:-gov)?|us-iso[a-z]?|eusc(?:-[a-z]{2})?)-[a-z0-9-]+-\d$/;
1977
+ /** Keep byte-for-byte policy parity with services/chat/src/lib/media-url. */
1978
+ function isAmazonS3Host(host) {
1979
+ if (host === "s3.amazonaws.com" || host.endsWith(".s3.amazonaws.com")) return true;
1980
+ if (!host.endsWith(".amazonaws.com")) return false;
1981
+ const labels = host.slice(0, -14).split(".");
1982
+ return labels.some((service, index) => {
1983
+ const tail = labels.slice(index + 1);
1984
+ if (/^s3-(?:[a-z]{2}(?:-gov)?|us-iso[a-z]?|eusc(?:-[a-z]{2})?)-[a-z0-9-]+-\d$/.test(service)) return tail.length === 0;
1985
+ if (service === "s3-accelerate") return tail.length === 0 || tail.length === 1 && tail[0] === "dualstack";
1986
+ if (service !== "s3" && service !== "s3-fips" && service !== "s3-accesspoint" && service !== "s3-object-lambda" && service !== "s3-outposts") return false;
1987
+ if (tail.length === 1) return AWS_REGION.test(tail[0] ?? "");
1988
+ return tail.length === 2 && tail[0] === "dualstack" && AWS_REGION.test(tail[1] ?? "");
1989
+ });
1990
+ }
1782
1991
  function parseExtraHosts(raw) {
1783
1992
  const exact = /* @__PURE__ */ new Set();
1784
1993
  const suffix = [];
@@ -1837,7 +2046,9 @@ function validateAttachmentUrl(input, opts = {}) {
1837
2046
  reason: "blocked_host"
1838
2047
  };
1839
2048
  const extra = parseExtraHosts(opts.extraHosts ?? process.env.ALFE_ATTACHMENT_ALLOWED_HOSTS);
1840
- if (new Set([...DEFAULT_EXACT_HOSTS.map((h) => h.toLowerCase()), ...extra.exact]).has(host)) return { ok: true };
2049
+ const exact = new Set([...DEFAULT_EXACT_HOSTS.map((h) => h.toLowerCase()), ...extra.exact]);
2050
+ if (isAmazonS3Host(host)) return { ok: true };
2051
+ if (exact.has(host)) return { ok: true };
1841
2052
  if ([...DEFAULT_SUFFIX_HOSTS.map((h) => h.toLowerCase()), ...extra.suffix].some((rule) => host === rule.slice(1) || host.endsWith(rule))) return { ok: true };
1842
2053
  return {
1843
2054
  ok: false,
@@ -1845,6 +2056,40 @@ function validateAttachmentUrl(input, opts = {}) {
1845
2056
  };
1846
2057
  }
1847
2058
  //#endregion
2059
+ //#region src/local-ai-proxy-identity.ts
2060
+ const LOCAL_AI_PROXY_IDENTITY_URL = "http://127.0.0.1:18193/__alfe/set-identity";
2061
+ const LOCAL_AI_PROXY_CONTROL_TIMEOUT_MS = 1e3;
2062
+ const MAX_IDENTITY_ID_CHARS = 256;
2063
+ /**
2064
+ * Best-effort loopback identity handshake for the local AI proxy.
2065
+ *
2066
+ * A turn awaits this before model dispatch so attribution cannot lose a race
2067
+ * with the first LLM request. Failure never rejects the chat turn.
2068
+ */
2069
+ async function syncLocalAiProxyIdentity(identityId, fetchFn = fetch) {
2070
+ if (identityId !== void 0 && !isValidIdentityId(identityId)) return false;
2071
+ try {
2072
+ const response = await fetchFn(LOCAL_AI_PROXY_IDENTITY_URL, {
2073
+ method: "POST",
2074
+ headers: { "Content-Type": "application/json" },
2075
+ body: JSON.stringify({ identityId: identityId ?? null }),
2076
+ signal: AbortSignal.timeout(LOCAL_AI_PROXY_CONTROL_TIMEOUT_MS)
2077
+ });
2078
+ try {
2079
+ await response.body?.cancel();
2080
+ } catch {}
2081
+ return response.ok;
2082
+ } catch {
2083
+ return false;
2084
+ }
2085
+ }
2086
+ function isValidIdentityId(value) {
2087
+ return value.length > 0 && value.length <= MAX_IDENTITY_ID_CHARS && !Array.from(value).some((character) => {
2088
+ const codePoint = character.codePointAt(0) ?? 0;
2089
+ return codePoint < 32 || codePoint === 127;
2090
+ });
2091
+ }
2092
+ //#endregion
1848
2093
  //#region src/plugin.ts
1849
2094
  /**
1850
2095
  * @alfe.ai/openclaw-chat — OpenClaw chat channel plugin.
@@ -2233,6 +2478,7 @@ async function resolveOpenClawSdk(log) {
2233
2478
  } catch {}
2234
2479
  log.warn(`OpenClaw SDK not resolvable — chat dispatch will not work. argv1=${process.argv[1] ?? "<none>"} execPath=${process.execPath} PATH=${pathEnv ? "set" : "EMPTY"} anchorsTried=[${anchors.join(", ")}]`);
2235
2480
  }
2481
+ const CHAT_ACTIVATION_KEY = (0, _alfe_ai_openclaw_plugin_kit.getActivationKey)("chat");
2236
2482
  let pluginRuntime = null;
2237
2483
  let chatClient = null;
2238
2484
  let connectingPromise = null;
@@ -2335,7 +2581,7 @@ function replayAttachments(atts) {
2335
2581
  ...typeof a.size === "number" ? { size: a.size } : {}
2336
2582
  }));
2337
2583
  }
2338
- const MAX_FILE_SIZE = 50 * 1024 * 1024;
2584
+ const MAX_INBOUND_ATTACHMENT_SIZE = 50 * 1024 * 1024;
2339
2585
  const DOWNLOAD_TIMEOUT_MS = 3e4;
2340
2586
  const MAX_REDIRECTS = 5;
2341
2587
  /**
@@ -2368,13 +2614,62 @@ async function fetchAttachmentWithValidation(url, signal) {
2368
2614
  }
2369
2615
  throw new Error("too_many_redirects");
2370
2616
  }
2617
+ /**
2618
+ * Read a response body without ever retaining more than the attachment cap.
2619
+ * `arrayBuffer()` cannot enforce a cap until after the entire response has
2620
+ * already been allocated, so both declared and streamed sizes are checked.
2621
+ */
2622
+ async function readAttachmentBodyBounded(response, maxBytes = MAX_INBOUND_ATTACHMENT_SIZE) {
2623
+ const declaredRaw = response.headers.get("content-length");
2624
+ if (declaredRaw !== null) {
2625
+ const declared = Number(declaredRaw);
2626
+ if (Number.isFinite(declared) && declared > maxBytes) throw new Error("attachment_too_large");
2627
+ }
2628
+ if (!response.body) return new Uint8Array();
2629
+ const reader = response.body.getReader();
2630
+ const chunks = [];
2631
+ let total = 0;
2632
+ try {
2633
+ for (;;) {
2634
+ const { done, value } = await reader.read();
2635
+ if (done) break;
2636
+ total += value.byteLength;
2637
+ if (total > maxBytes) {
2638
+ await reader.cancel("attachment_too_large").catch(() => void 0);
2639
+ throw new Error("attachment_too_large");
2640
+ }
2641
+ chunks.push(value);
2642
+ }
2643
+ } finally {
2644
+ reader.releaseLock();
2645
+ }
2646
+ const body = new Uint8Array(total);
2647
+ let offset = 0;
2648
+ for (const chunk of chunks) {
2649
+ body.set(chunk, offset);
2650
+ offset += chunk.byteLength;
2651
+ }
2652
+ return body;
2653
+ }
2654
+ /** Build one safe, collision-resistant filename inside the attachment dir. */
2655
+ function buildAttachmentLocalFilename(att) {
2656
+ const safeId = att.id.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 256) || "attachment";
2657
+ const safeName = Array.from(att.filename ?? "file", (character) => {
2658
+ const code = character.codePointAt(0) ?? 0;
2659
+ return code <= 31 || code === 127 ? "_" : character;
2660
+ }).join("").replace(/[\\/]/g, "_").replace(/\.\./g, "_").slice(0, 512) || "file";
2661
+ return `${safeId}_${(0, node_crypto.randomUUID)()}_${safeName}`;
2662
+ }
2371
2663
  async function downloadAttachments(attachments, log) {
2372
2664
  const attachDir = (0, node_path.join)((0, node_os.homedir)(), ".alfe", "attachments");
2373
- await (0, node_fs_promises.mkdir)(attachDir, { recursive: true });
2665
+ await (0, node_fs_promises.mkdir)(attachDir, {
2666
+ recursive: true,
2667
+ mode: 448
2668
+ });
2374
2669
  const results = [];
2375
- for (const att of attachments) {
2376
- const filename = (att.filename ?? att.id).replace(/[/\\]/g, "_").replace(/\.\./g, "_").replace(/\0/g, "");
2377
- const localPath = (0, node_path.join)(attachDir, `${att.id}_${filename}`);
2670
+ for (const att of attachments.slice(0, 10)) {
2671
+ const filename = (att.filename ?? "file").slice(0, 512) || "file";
2672
+ const localPath = (0, node_path.join)(attachDir, buildAttachmentLocalFilename(att));
2378
2673
  const controller = new AbortController();
2379
2674
  const timeout = setTimeout(() => {
2380
2675
  controller.abort();
@@ -2385,12 +2680,11 @@ async function downloadAttachments(attachments, log) {
2385
2680
  log.warn(`Failed to download attachment ${att.id}: ${String(res.status)}`);
2386
2681
  continue;
2387
2682
  }
2388
- const buffer = Buffer.from(await res.arrayBuffer());
2389
- if (buffer.length > MAX_FILE_SIZE) {
2390
- log.warn(`Attachment ${att.id} exceeds max size (${String(buffer.length)} bytes) — skipping`);
2391
- continue;
2392
- }
2393
- await (0, node_fs_promises.writeFile)(localPath, buffer);
2683
+ const buffer = await readAttachmentBodyBounded(res);
2684
+ await (0, node_fs_promises.writeFile)(localPath, buffer, {
2685
+ flag: "wx",
2686
+ mode: 384
2687
+ });
2394
2688
  results.push({
2395
2689
  localPath,
2396
2690
  filename,
@@ -2398,6 +2692,7 @@ async function downloadAttachments(attachments, log) {
2398
2692
  });
2399
2693
  log.info(`Downloaded attachment: ${localPath} (${String(buffer.length)} bytes)`);
2400
2694
  } catch (err) {
2695
+ if ((err instanceof Error && "code" in err && typeof err.code === "string" ? err.code : void 0) !== "EEXIST") await (0, node_fs_promises.unlink)(localPath).catch(() => void 0);
2401
2696
  log.error(`Failed to download attachment ${att.id}: ${err instanceof Error ? err.message : String(err)}`);
2402
2697
  } finally {
2403
2698
  clearTimeout(timeout);
@@ -2537,6 +2832,20 @@ const VOICE_REPLY_SYSTEM_PROMPT = [
2537
2832
  */
2538
2833
  const CROSS_AGENT_TOOLS_SYSTEM_PROMPT = ["Cross-agent messaging: to contact another Alfe agent (IDs like agt_…), use list_agents to discover and message_agent to send.", "The runtime's sessions_send / sessions_spawn tools do NOT know Alfe agent IDs and will fail with \"agent not found\" — never use them to reach another agent."].join("\n");
2539
2834
  /**
2835
+ * Rules for an inbound agent-to-agent turn. This must ride the runtime-rendered
2836
+ * `GroupSystemPrompt` field; arbitrary extra-context keys are not shown to the
2837
+ * model. Keep the instruction structural: `end_conversation` is the plugin's
2838
+ * authoritative turn-local completion signal. The relay still accepts the
2839
+ * legacy trailing `[RESOLVED]` sentinel for older plugin versions.
2840
+ */
2841
+ const A2A_REPLY_SYSTEM_PROMPT = [
2842
+ "This is an agent-to-agent conversation with another Alfe agent.",
2843
+ "Rules:",
2844
+ "- Only respond if you have new information, a question, or an action to coordinate.",
2845
+ "- When the discussion is complete, call the end_conversation tool.",
2846
+ "- Do not respond just to acknowledge receipt; that creates unproductive reply loops."
2847
+ ].join("\n");
2848
+ /**
2540
2849
  * Per-turn envelope context derived from the RPC `origin` flag, returned as a
2541
2850
  * `GroupSystemPrompt` block — the PROVEN runtime-rendered seam for per-turn
2542
2851
  * system-prompt guidance: dispatchInbound spreads `extraContext` into
@@ -2564,6 +2873,11 @@ function buildOriginEnvelopeContext(origin) {
2564
2873
  if (origin === "voice") return { GroupSystemPrompt: `${VOICE_REPLY_SYSTEM_PROMPT}\n\n${CROSS_AGENT_TOOLS_SYSTEM_PROMPT}` };
2565
2874
  return { GroupSystemPrompt: CROSS_AGENT_TOOLS_SYSTEM_PROMPT };
2566
2875
  }
2876
+ /** Build the single live per-turn prompt block without competing object keys. */
2877
+ function buildTurnEnvelopeContext(origin, isA2A) {
2878
+ const originPrompt = String(buildOriginEnvelopeContext(origin).GroupSystemPrompt);
2879
+ return { GroupSystemPrompt: isA2A ? `${originPrompt}\n\n${A2A_REPLY_SYSTEM_PROMPT}` : originPrompt };
2880
+ }
2567
2881
  async function handleAgentRequest(request, log) {
2568
2882
  const runtime = pluginRuntime;
2569
2883
  if (!runtime) {
@@ -2574,7 +2888,7 @@ async function handleAgentRequest(request, log) {
2574
2888
  chatClient?.sendResponse(request.id, false, { message: "OpenClaw SDK not available — cannot dispatch" });
2575
2889
  return;
2576
2890
  }
2577
- const { message, sessionKey: legacySessionKey, userId, conversationId, conversationType, tenantId, clientType, origin, displayName, identityId, senderPermissions, attachments: rawAttachments, a2a, chatMessageId } = request.params;
2891
+ const { message, sessionKey: legacySessionKey, userId, conversationId, conversationType, tenantId, clientType, origin, displayName, identityId, identityProvider, attachments: rawAttachments, a2a, chatMessageId } = request.params;
2578
2892
  const isA2A = !!a2a;
2579
2893
  if (!message && !rawAttachments?.length) {
2580
2894
  chatClient?.sendResponse(request.id, false, { message: "Missing message" });
@@ -2732,15 +3046,15 @@ async function handleAgentRequest(request, log) {
2732
3046
  return;
2733
3047
  }
2734
3048
  });
3049
+ let localProxyIdentityActive = false;
2735
3050
  try {
2736
3051
  const downloadedFiles = rawAttachments?.length ? await downloadAttachments(rawAttachments, log) : [];
2737
3052
  const bodyForAgent = downloadedFiles.length ? `${message || ""}\n\n[Attached files:\n${downloadedFiles.map((f) => `- ${f.filename}: ${f.localPath}`).join("\n")}]` : void 0;
2738
- if (identityId) fetch("http://127.0.0.1:18193/__alfe/set-identity", {
2739
- method: "POST",
2740
- headers: { "Content-Type": "application/json" },
2741
- body: JSON.stringify({ identityId })
2742
- }).catch(() => {});
3053
+ const identitySynced = await syncLocalAiProxyIdentity(identityId);
3054
+ localProxyIdentityActive = identitySynced && identityId !== void 0;
3055
+ if (!identitySynced) log.debug("Local AI proxy identity attribution unavailable");
2743
3056
  const channelMode = isA2A ? "a2a" : extractChannelMode(conversationId ?? "", clientType ?? "chat");
3057
+ const inboundHookRouting = buildInboundHookRouting(identityProvider, channelMode, conversationId);
2744
3058
  const channelLabel = channelMode === "sms" ? "SMS" : channelMode === "whatsapp" ? "WhatsApp" : channelMode === "a2a" ? "Agent" : "Alfe";
2745
3059
  const shortConvId = conversationId?.slice(-8) ?? "";
2746
3060
  const userLabel = displayName ?? userId ?? senderId;
@@ -2787,6 +3101,7 @@ async function handleAgentRequest(request, log) {
2787
3101
  bodyForAgent,
2788
3102
  messageId: request.id,
2789
3103
  timestamp: Date.now(),
3104
+ ...inboundHookRouting,
2790
3105
  extraContext: {
2791
3106
  ...tenantId ? { TenantId: tenantId } : {},
2792
3107
  ...clientType ? { ClientType: clientType } : {},
@@ -2794,22 +3109,13 @@ async function handleAgentRequest(request, log) {
2794
3109
  ...displayName ? { SenderName: displayName } : {},
2795
3110
  ...identityId ? { IdentityId: identityId } : {},
2796
3111
  ...userId ? { UserId: userId } : {},
2797
- ...senderPermissions?.length ? { SenderPermissions: senderPermissions } : {},
2798
3112
  ChannelMode: channelMode,
2799
- ...buildOriginEnvelopeContext(origin),
3113
+ ...buildTurnEnvelopeContext(origin, isA2A),
2800
3114
  ...isA2A ? {
2801
3115
  CallerType: "agent",
2802
3116
  CallerAgentId: a2a.sourceAgentId,
2803
3117
  CallerAgentName: a2a.sourceAgentName,
2804
- InteractionDepth: String(a2a.depth),
2805
- A2ASystemPrompt: [
2806
- `This is an agent-to-agent conversation with ${a2a.sourceAgentName}.`,
2807
- "Rules:",
2808
- "- Only respond if you have new information, a question, or an action to coordinate.",
2809
- "- When the discussion is complete, call the end_conversation() tool AND end your final message with the literal token [RESOLVED] (uppercase, in square brackets, as the last thing in the message).",
2810
- "- Do NOT write the word \"resolved\" in prose unless you mean to end — only the exact token [RESOLVED] ends the thread.",
2811
- "- Do NOT respond just to acknowledge — that creates infinite loops."
2812
- ].join("\n")
3118
+ InteractionDepth: String(a2a.depth)
2813
3119
  } : {}
2814
3120
  },
2815
3121
  deliver: async (payload) => {
@@ -2866,6 +3172,9 @@ async function handleAgentRequest(request, log) {
2866
3172
  activeRun = null;
2867
3173
  unsubscribe();
2868
3174
  clearAllToolUpdates();
3175
+ if (localProxyIdentityActive) {
3176
+ if (!await syncLocalAiProxyIdentity(void 0)) log.debug("Local AI proxy identity attribution clear failed");
3177
+ }
2869
3178
  try {
2870
3179
  await flushTurnActivity();
2871
3180
  } catch (err) {
@@ -3009,7 +3318,7 @@ const plugin = {
3009
3318
  chatWsUrl: pluginConfig.chatWsUrl
3010
3319
  });
3011
3320
  if (chatWsUrl && apiKey) {
3012
- log.info(`Connecting to chat service: ${chatWsUrl}`);
3321
+ log.info("Connecting to chat service relay");
3013
3322
  chatClient = new _alfe_ai_chat.ChatServiceClient({
3014
3323
  wsUrl: chatWsUrl,
3015
3324
  apiKey,
@@ -3056,7 +3365,6 @@ const plugin = {
3056
3365
  connectingPromise = thisConnect;
3057
3366
  };
3058
3367
  const stopChatService = async () => {
3059
- globalThis.__alfeChatPluginActivated = false;
3060
3368
  if (connectingPromise) {
3061
3369
  await connectingPromise.catch((err) => {
3062
3370
  log.debug(`Connection attempt failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -3070,6 +3378,7 @@ const plugin = {
3070
3378
  }
3071
3379
  pluginRuntime = null;
3072
3380
  dispatchInbound = null;
3381
+ (0, _alfe_ai_openclaw_plugin_kit.resetActivation)(CHAT_ACTIVATION_KEY);
3073
3382
  log.info("Chat plugin deactivated");
3074
3383
  };
3075
3384
  const gw = globalThis;
@@ -3147,7 +3456,6 @@ const plugin = {
3147
3456
  log.info("Chat plugin registered");
3148
3457
  },
3149
3458
  async deactivate(api) {
3150
- globalThis.__alfeChatPluginActivated = false;
3151
3459
  const log = api.logger;
3152
3460
  log.info("Chat plugin deactivating...");
3153
3461
  if (connectingPromise) {
@@ -3163,16 +3471,29 @@ const plugin = {
3163
3471
  }
3164
3472
  pluginRuntime = null;
3165
3473
  dispatchInbound = null;
3474
+ (0, _alfe_ai_openclaw_plugin_kit.resetActivation)(CHAT_ACTIVATION_KEY);
3166
3475
  log.info("Chat plugin deactivated");
3167
3476
  }
3168
3477
  };
3169
3478
  //#endregion
3479
+ Object.defineProperty(exports, "A2A_REPLY_SYSTEM_PROMPT", {
3480
+ enumerable: true,
3481
+ get: function() {
3482
+ return A2A_REPLY_SYSTEM_PROMPT;
3483
+ }
3484
+ });
3170
3485
  Object.defineProperty(exports, "CROSS_AGENT_TOOLS_SYSTEM_PROMPT", {
3171
3486
  enumerable: true,
3172
3487
  get: function() {
3173
3488
  return CROSS_AGENT_TOOLS_SYSTEM_PROMPT;
3174
3489
  }
3175
3490
  });
3491
+ Object.defineProperty(exports, "MAX_INBOUND_ATTACHMENT_SIZE", {
3492
+ enumerable: true,
3493
+ get: function() {
3494
+ return MAX_INBOUND_ATTACHMENT_SIZE;
3495
+ }
3496
+ });
3176
3497
  Object.defineProperty(exports, "VOICE_REPLY_SYSTEM_PROMPT", {
3177
3498
  enumerable: true,
3178
3499
  get: function() {
@@ -3209,6 +3530,12 @@ Object.defineProperty(exports, "buildA2ACompletePayload", {
3209
3530
  return buildA2ACompletePayload;
3210
3531
  }
3211
3532
  });
3533
+ Object.defineProperty(exports, "buildAttachmentLocalFilename", {
3534
+ enumerable: true,
3535
+ get: function() {
3536
+ return buildAttachmentLocalFilename;
3537
+ }
3538
+ });
3212
3539
  Object.defineProperty(exports, "buildOriginEnvelopeContext", {
3213
3540
  enumerable: true,
3214
3541
  get: function() {
@@ -3221,6 +3548,12 @@ Object.defineProperty(exports, "buildToolActivity", {
3221
3548
  return buildToolActivity;
3222
3549
  }
3223
3550
  });
3551
+ Object.defineProperty(exports, "buildTurnEnvelopeContext", {
3552
+ enumerable: true,
3553
+ get: function() {
3554
+ return buildTurnEnvelopeContext;
3555
+ }
3556
+ });
3224
3557
  Object.defineProperty(exports, "computeOpenClawSdkAnchors", {
3225
3558
  enumerable: true,
3226
3559
  get: function() {
@@ -3251,6 +3584,12 @@ Object.defineProperty(exports, "plugin", {
3251
3584
  return plugin;
3252
3585
  }
3253
3586
  });
3587
+ Object.defineProperty(exports, "readAttachmentBodyBounded", {
3588
+ enumerable: true,
3589
+ get: function() {
3590
+ return readAttachmentBodyBounded;
3591
+ }
3592
+ });
3254
3593
  Object.defineProperty(exports, "resolveAbortTargetKeys", {
3255
3594
  enumerable: true,
3256
3595
  get: function() {