@alfe.ai/openclaw-chat 0.9.9 → 0.9.11

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.js CHANGED
@@ -1,14 +1,14 @@
1
1
  import { createRequire } from "node:module";
2
- import { mkdir, open, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
3
- import { existsSync, realpathSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
4
+ import { constants, existsSync, realpathSync } from "node:fs";
4
5
  import { execFileSync } from "node:child_process";
5
6
  import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
6
7
  import { pathToFileURL } from "node:url";
7
8
  import { homedir } from "node:os";
8
9
  import { ChatServiceClient, resolveAlfeChat } from "@alfe.ai/chat";
9
10
  import { AgentApiClient, installToolErrorCapture } from "@alfe.ai/agent-api-client";
10
- import { defineTool, getActivationKey, resetActivation } from "@alfe.ai/openclaw-plugin-kit";
11
- import { randomUUID } from "node:crypto";
11
+ import { defineTool, getActivationKey, publicToolError, resetActivation } from "@alfe.ai/openclaw-plugin-kit";
12
12
  import { resolveConfig } from "@alfe.ai/config";
13
13
  //#region src/outbound-media.ts
14
14
  /**
@@ -155,29 +155,40 @@ async function uploadLocalFile(localPath, log, deps) {
155
155
  const client = deps.getClient ? deps.getClient() : getUploadClient(log);
156
156
  if (!client) return null;
157
157
  let size;
158
+ let body;
158
159
  try {
159
- const st = await stat(localPath);
160
- if (!st.isFile()) {
161
- log.warn(`Outbound media ref is not a file — skipping: ${localPath}`);
162
- return null;
160
+ const handle = await open(localPath, constants.O_RDONLY | 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();
163
184
  }
164
- size = st.size;
165
185
  } catch {
166
186
  log.warn(`Outbound media file not found — skipping: ${localPath}`);
167
187
  return null;
168
188
  }
169
- if (size <= 0) {
170
- log.warn(`Outbound media file is empty — skipping: ${localPath}`);
171
- return null;
172
- }
173
- if (size > 26214400) {
174
- log.warn(`Outbound media exceeds ${String(MAX_OUTBOUND_MEDIA_SIZE)} bytes (${String(size)}) — skipping: ${localPath}`);
175
- return null;
176
- }
177
189
  const filename = basename(localPath);
178
190
  const mimeType = mimeFromPath(localPath);
179
191
  try {
180
- const body = await readFile(localPath);
181
192
  const { attachments } = await client.presignAttachments([{
182
193
  filename,
183
194
  mimeType,
@@ -408,13 +419,17 @@ function isSafeComponentUrl(raw) {
408
419
  return false;
409
420
  }
410
421
  if (u.protocol !== "https:") return false;
422
+ if (u.username || u.password) return false;
411
423
  const host = u.hostname.toLowerCase();
412
424
  return host === "alfe.ai" || host.endsWith(".alfe.ai");
413
425
  }
414
426
  const MAX_COMPONENTS_PER_MESSAGE = 10;
415
- const MAX_COMPONENT_LABEL_CHARS = 120;
416
- const MAX_COMPONENT_VALUE_CHARS = 400;
427
+ const MAX_COMPONENT_LABEL_CHARS$1 = 120;
428
+ const MAX_COMPONENT_VALUE_CHARS$1 = 400;
417
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;
418
433
  /** Cap a maybe-string; returns '' for non-strings so callers can `if (!x)`. */
419
434
  function capStr(v, max) {
420
435
  return typeof v === "string" ? v.slice(0, max) : "";
@@ -431,8 +446,8 @@ function sanitizeOptions(raw) {
431
446
  if (out.length >= MAX_OPTIONS_PER_COMPONENT) break;
432
447
  if (!item || typeof item !== "object") continue;
433
448
  const o = item;
434
- const label = capStr(o.label, MAX_COMPONENT_LABEL_CHARS);
435
- 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);
436
451
  if (!label || !value) continue;
437
452
  out.push({
438
453
  label,
@@ -458,8 +473,8 @@ function sanitizeComponents(raw) {
458
473
  if (out.length >= MAX_COMPONENTS_PER_MESSAGE) break;
459
474
  if (!item || typeof item !== "object") continue;
460
475
  const c = item;
461
- const label = capStr(c.label, MAX_COMPONENT_LABEL_CHARS);
462
- let id = typeof c.id === "string" && c.id ? c.id : 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) : randomUUID();
463
478
  if (usedIds.has(id)) id = randomUUID();
464
479
  usedIds.add(id);
465
480
  const style = c.style === "primary" || c.style === "secondary" || c.style === "danger" ? c.style : void 0;
@@ -477,7 +492,7 @@ function sanitizeComponents(raw) {
477
492
  });
478
493
  } else if (c.type === "quick_reply") {
479
494
  if (!label) continue;
480
- const value = capStr(c.value, MAX_COMPONENT_VALUE_CHARS);
495
+ const value = capStr(c.value, MAX_COMPONENT_VALUE_CHARS$1);
481
496
  if (!value) continue;
482
497
  out.push({
483
498
  type: "quick_reply",
@@ -489,7 +504,7 @@ function sanitizeComponents(raw) {
489
504
  } else if (c.type === "select") {
490
505
  const options = sanitizeOptions(c.options);
491
506
  if (options.length === 0) continue;
492
- const placeholder = capStr(c.placeholder, MAX_COMPONENT_LABEL_CHARS);
507
+ const placeholder = capStr(c.placeholder, MAX_COMPONENT_LABEL_CHARS$1);
493
508
  out.push({
494
509
  type: "select",
495
510
  id,
@@ -500,7 +515,7 @@ function sanitizeComponents(raw) {
500
515
  } else if (c.type === "multi_select") {
501
516
  const options = sanitizeOptions(c.options);
502
517
  if (options.length === 0) continue;
503
- const submitLabel = capStr(c.submitLabel, MAX_COMPONENT_LABEL_CHARS);
518
+ const submitLabel = capStr(c.submitLabel, MAX_COMPONENT_LABEL_CHARS$1);
504
519
  out.push({
505
520
  type: "multi_select",
506
521
  id,
@@ -509,11 +524,11 @@ function sanitizeComponents(raw) {
509
524
  ...submitLabel ? { submitLabel } : {}
510
525
  });
511
526
  } else if (c.type === "confirm") {
512
- const confirmLabel = capStr(c.confirmLabel, MAX_COMPONENT_LABEL_CHARS);
513
- 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);
514
529
  if (!confirmLabel || !confirmValue) continue;
515
- const cancelLabel = capStr(c.cancelLabel, MAX_COMPONENT_LABEL_CHARS);
516
- 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);
517
532
  out.push({
518
533
  type: "confirm",
519
534
  id,
@@ -521,11 +536,11 @@ function sanitizeComponents(raw) {
521
536
  confirmLabel,
522
537
  confirmValue,
523
538
  ...cancelLabel ? { cancelLabel } : {},
524
- ...cancelValue ? { cancelValue } : {}
539
+ ...cancelLabel && cancelValue ? { cancelValue } : {}
525
540
  });
526
541
  } else if (c.type === "copy_button") {
527
542
  if (!label) continue;
528
- const value = capStr(c.value, MAX_COMPONENT_VALUE_CHARS);
543
+ const value = capStr(c.value, MAX_COMPONENT_VALUE_CHARS$1);
529
544
  if (!value) continue;
530
545
  out.push({
531
546
  type: "copy_button",
@@ -539,6 +554,23 @@ function sanitizeComponents(raw) {
539
554
  }
540
555
  const CHANNEL_ID = "alfe";
541
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
+ }
542
574
  async function sendViaChat(deps, ctx, mediaUrl, components) {
543
575
  const client = deps.getChatClient();
544
576
  if (!client) throw new Error("Chat service not connected — cannot deliver");
@@ -564,7 +596,8 @@ async function sendViaChat(deps, ctx, mediaUrl, components) {
564
596
  await deps.createSession(conversationId, "", "alfe", void 0, userId);
565
597
  }
566
598
  }
567
- 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);
568
601
  else await deps.addMessage(conversationId, "assistant", text);
569
602
  const messageId = randomUUID();
570
603
  client.notify("agent-message", {
@@ -702,17 +735,22 @@ function createAlfeChannelPlugin(deps) {
702
735
  error: /* @__PURE__ */ new Error("Missing target — use user:{userId} or conv:{conversationId}")
703
736
  };
704
737
  if (to.startsWith("conv:")) {
705
- if (!to.slice(5)) return {
738
+ const convId = to.slice(5);
739
+ if (!convId) return {
706
740
  ok: false,
707
741
  error: /* @__PURE__ */ new Error("Empty conversation ID")
708
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
+ };
709
747
  return {
710
748
  ok: true,
711
749
  to
712
750
  };
713
751
  }
714
752
  const userId = to.startsWith("user:") ? to.slice(5) : to;
715
- if (!userId || userId === "anon") return {
753
+ if (!userId || userId === "anon" || userId.length > MAX_USER_ID_CHARS || /\s/.test(userId)) return {
716
754
  ok: false,
717
755
  error: /* @__PURE__ */ new Error("Invalid target: userId is required")
718
756
  };
@@ -750,18 +788,27 @@ function createAlfeChannelPlugin(deps) {
750
788
  return params.accountId ?? DEFAULT_ACCOUNT_ID;
751
789
  },
752
790
  applyAccountConfig(params) {
753
- const cfg = { ...params.cfg };
754
- cfg.channels ??= {};
755
- cfg.channels.alfe ??= {};
756
- 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
+ };
757
804
  if (params.accountId === DEFAULT_ACCOUNT_ID) section.enabled = true;
758
- else {
759
- section.accounts ??= {};
760
- section.accounts[params.accountId] = {
761
- enabled: true,
762
- ...params.input
763
- };
764
- }
805
+ else section.accounts = {
806
+ ...section.accounts,
807
+ [params.accountId]: {
808
+ ...params.input,
809
+ enabled: true
810
+ }
811
+ };
765
812
  return cfg;
766
813
  }
767
814
  }
@@ -776,8 +823,7 @@ function createAlfeChannelPlugin(deps) {
776
823
  function isAlfeSessionKey(key) {
777
824
  if (key.startsWith("alfe:")) return true;
778
825
  if (key.includes(":alfe:")) return true;
779
- if (key.includes("chat-") || key.startsWith("sms-") || key.startsWith("wa-")) return true;
780
- return false;
826
+ return /^(?:agent:[^:]+:)?(?:chat-|sms-|wa-)/.test(key);
781
827
  }
782
828
  /**
783
829
  * Extract the channel mode from a standardized session key or conversationId.
@@ -787,6 +833,25 @@ function extractChannelMode(conversationId, fallback = "chat") {
787
833
  return /^alfe:(\w+):/.exec(conversationId)?.[1] ?? fallback;
788
834
  }
789
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
790
855
  //#region src/session-store.ts
791
856
  /**
792
857
  * Session Store — persists chat sessions to the local filesystem.
@@ -813,6 +878,94 @@ async function ensureDir() {
813
878
  function sessionPath(sessionId) {
814
879
  return join(SESSIONS_DIR, `${sessionId.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`);
815
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 = join(dirname(path), `.${basename(path)}.${String(process.pid)}.${randomUUID()}.tmp`);
958
+ try {
959
+ await writeFile(temporary, contents, {
960
+ encoding: "utf-8",
961
+ mode: 384
962
+ });
963
+ await rename(temporary, path);
964
+ } catch (error) {
965
+ await unlink(temporary).catch(() => void 0);
966
+ throw error;
967
+ }
968
+ }
816
969
  async function cleanupOldSessions() {
817
970
  if (Date.now() - lastCleanupAt < CLEANUP_INTERVAL_MS) return;
818
971
  try {
@@ -823,6 +976,7 @@ async function cleanupOldSessions() {
823
976
  const filePath = join(SESSIONS_DIR, file);
824
977
  if (now - (await stat(filePath)).mtimeMs > MAX_AGE_MS) await unlink(filePath);
825
978
  } catch {}
979
+ lastCleanupAt = Date.now();
826
980
  return;
827
981
  }
828
982
  const fileStats = [];
@@ -850,7 +1004,7 @@ async function cleanupOldSessions() {
850
1004
  async function getSession(sessionId) {
851
1005
  try {
852
1006
  const data = await readFile(sessionPath(sessionId), "utf-8");
853
- return JSON.parse(data);
1007
+ return asSessionData(JSON.parse(data), sessionId);
854
1008
  } catch {
855
1009
  return null;
856
1010
  }
@@ -858,7 +1012,7 @@ async function getSession(sessionId) {
858
1012
  async function saveSession(session) {
859
1013
  await ensureDir();
860
1014
  session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
861
- await writeFile(sessionPath(session.sessionId), JSON.stringify(session, null, 2), "utf-8");
1015
+ await writeTextAtomically(sessionPath(session.sessionId), JSON.stringify(session, null, 2));
862
1016
  }
863
1017
  async function createSession(sessionId, agentId, channel, tenantId, userId) {
864
1018
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -954,7 +1108,8 @@ async function listSessions(filters, limit = 50) {
954
1108
  const summaries = [];
955
1109
  for (const file of jsonFiles) try {
956
1110
  const data = await readFile(join(SESSIONS_DIR, file), "utf-8");
957
- const session = JSON.parse(data);
1111
+ const session = asSessionData(JSON.parse(data));
1112
+ if (!session) continue;
958
1113
  if (filters?.agentId && session.agentId !== filters.agentId) continue;
959
1114
  if (filters?.channel && session.channel !== filters.channel) continue;
960
1115
  if (filters?.tenantId && session.tenantId !== filters.tenantId) continue;
@@ -976,7 +1131,8 @@ async function listSessions(filters, limit = 50) {
976
1131
  const aTime = a.lastMessageAt ?? a.createdAt;
977
1132
  return (b.lastMessageAt ?? b.createdAt).localeCompare(aTime);
978
1133
  });
979
- 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);
980
1136
  }
981
1137
  //#endregion
982
1138
  //#region src/activity-serialize.ts
@@ -1337,6 +1493,7 @@ function stripThinkSpans(text) {
1337
1493
  const MAX_ENTRIES = 500;
1338
1494
  const MAX_THINKING_CHARS = 16e3;
1339
1495
  const MAX_TRANSCRIPT_BYTES = 4 * 1024 * 1024;
1496
+ const MAX_TRANSCRIPT_INDEX_BYTES = 4 * 1024 * 1024;
1340
1497
  /** Default OpenClaw store for the daemon's single agent ("main"). */
1341
1498
  function defaultStorePath() {
1342
1499
  return join(homedir(), ".openclaw", "agents", "main", "sessions", "sessions.json");
@@ -1357,8 +1514,8 @@ async function readTranscriptTail(path) {
1357
1514
  const { size } = await handle.stat();
1358
1515
  if (size <= MAX_TRANSCRIPT_BYTES) return await handle.readFile({ encoding: "utf-8" });
1359
1516
  const buf = Buffer.alloc(MAX_TRANSCRIPT_BYTES);
1360
- await handle.read(buf, 0, MAX_TRANSCRIPT_BYTES, size - MAX_TRANSCRIPT_BYTES);
1361
- 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");
1362
1519
  const firstNewline = text.indexOf("\n");
1363
1520
  return firstNewline === -1 ? "" : text.slice(firstNewline + 1);
1364
1521
  } finally {
@@ -1368,30 +1525,44 @@ async function readTranscriptTail(path) {
1368
1525
  return null;
1369
1526
  }
1370
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 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
+ }
1371
1543
  /**
1372
1544
  * Find the transcript files for a conversation: exact route matches first,
1373
1545
  * then an index scan for `:conv:{conversationId}` keys (retroactive path).
1374
1546
  * Returns absolute jsonl paths, deduped.
1375
1547
  */
1376
1548
  async function resolveTranscriptPaths(conversationId, routes) {
1377
- 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));
1378
1551
  if (storePaths.size === 0) storePaths.add(defaultStorePath());
1379
- const routeKeys = new Set((routes ?? []).map((r) => r.sessionKey));
1552
+ const routeKeys = new Set(validRoutes.map((r) => r.sessionKey));
1380
1553
  const convSegment = `:conv:${conversationId}`;
1381
1554
  const files = /* @__PURE__ */ new Set();
1382
1555
  for (const storePath of storePaths) {
1383
- let index;
1384
- try {
1385
- index = JSON.parse(await readFile(storePath, "utf-8"));
1386
- } catch {
1387
- continue;
1388
- }
1556
+ const parsed = await readTranscriptIndex(storePath);
1557
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
1389
1558
  const dir = dirname(storePath);
1390
- for (const [key, entry] of Object.entries(index)) {
1391
- 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;
1392
1563
  if (!(routeKeys.has(key) || key.endsWith(convSegment) || key.includes(`${convSegment}:`))) continue;
1393
- if (!/^[A-Za-z0-9._-]+$/.test(entry.sessionId)) continue;
1394
- files.add(join(dir, `${entry.sessionId}.jsonl`));
1564
+ if (!/^[A-Za-z0-9._-]+$/.test(sessionId)) continue;
1565
+ files.add(join(dir, `${sessionId}.jsonl`));
1395
1566
  }
1396
1567
  }
1397
1568
  return [...files];
@@ -1481,6 +1652,18 @@ async function collectTranscriptActivity(conversationId, routes) {
1481
1652
  }
1482
1653
  //#endregion
1483
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;
1484
1667
  let conversationEndRequested = false;
1485
1668
  let a2aTurnArmed = false;
1486
1669
  /**
@@ -1509,7 +1692,7 @@ function isA2AEndSignalled() {
1509
1692
  function buildA2ATools(getChatClient, log, present) {
1510
1693
  const requireClient = () => {
1511
1694
  const client = getChatClient();
1512
- if (!client) throw new Error("chat service not connected yet — try again in a moment");
1695
+ if (!client) throw publicToolError("chat service not connected yet — try again in a moment");
1513
1696
  return client;
1514
1697
  };
1515
1698
  const tools = [
@@ -1528,24 +1711,33 @@ function buildA2ATools(getChatClient, log, present) {
1528
1711
  }),
1529
1712
  defineTool({
1530
1713
  name: "message_agent",
1531
- 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.",
1532
1715
  parameters: {
1533
1716
  type: "object",
1534
1717
  properties: {
1535
1718
  agent_id: {
1536
1719
  type: "string",
1720
+ minLength: 1,
1721
+ maxLength: MAX_AGENT_ID_CHARS,
1537
1722
  description: "Target agent ID (use list_agents to find available agents)"
1538
1723
  },
1539
1724
  message: {
1540
1725
  type: "string",
1726
+ minLength: 1,
1727
+ maxLength: MAX_A2A_MESSAGE_CHARS,
1541
1728
  description: "Message to send to the agent"
1542
1729
  },
1543
1730
  thread_id: {
1544
1731
  type: "string",
1732
+ minLength: 1,
1733
+ maxLength: MAX_THREAD_ID_CHARS,
1734
+ pattern: "^[A-Za-z0-9_-]+$",
1545
1735
  description: "Continue an existing conversation thread (omit to start a new one)"
1546
1736
  },
1547
1737
  max_depth: {
1548
- type: "number",
1738
+ type: "integer",
1739
+ minimum: 1,
1740
+ maximum: MAX_A2A_DEPTH,
1549
1741
  description: "Maximum number of back-and-forth exchanges (default: 10)"
1550
1742
  }
1551
1743
  },
@@ -1556,7 +1748,7 @@ function buildA2ATools(getChatClient, log, present) {
1556
1748
  const message = params.message;
1557
1749
  const threadId = params.thread_id;
1558
1750
  const maxDepth = params.max_depth ?? 10;
1559
- 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 publicToolError("Invalid message_agent parameters");
1560
1752
  log.info(`message_agent tool: sending to ${agentId}`);
1561
1753
  return await requireClient().sendRequest("a2a.send", {
1562
1754
  targetAgentId: agentId,
@@ -1573,6 +1765,7 @@ function buildA2ATools(getChatClient, log, present) {
1573
1765
  type: "object",
1574
1766
  properties: { summary: {
1575
1767
  type: "string",
1768
+ maxLength: 1e3,
1576
1769
  description: "Brief summary of what was discussed or resolved"
1577
1770
  } },
1578
1771
  required: []
@@ -1596,14 +1789,20 @@ function buildA2ATools(getChatClient, log, present) {
1596
1789
  properties: {
1597
1790
  to: {
1598
1791
  type: "string",
1792
+ minLength: 6,
1793
+ maxLength: MAX_COMPONENT_TARGET_CHARS + 5,
1794
+ pattern: "^(conv|user):.+$",
1599
1795
  description: "Target conversation: \"conv:{conversationId}\" or \"user:{userId}\"."
1600
1796
  },
1601
1797
  text: {
1602
1798
  type: "string",
1799
+ maxLength: MAX_COMPONENT_TEXT_CHARS,
1603
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."
1604
1801
  },
1605
1802
  components: {
1606
1803
  type: "array",
1804
+ minItems: 1,
1805
+ maxItems: MAX_COMPONENTS,
1607
1806
  description: "Interactive components to attach (at least one, at most 10).",
1608
1807
  items: {
1609
1808
  type: "object",
@@ -1622,14 +1821,18 @@ function buildA2ATools(getChatClient, log, present) {
1622
1821
  },
1623
1822
  id: {
1624
1823
  type: "string",
1824
+ minLength: 1,
1825
+ maxLength: MAX_COMPONENT_ID_CHARS,
1625
1826
  description: "Stable id (optional — auto-generated when omitted)."
1626
1827
  },
1627
1828
  label: {
1628
1829
  type: "string",
1830
+ maxLength: MAX_COMPONENT_LABEL_CHARS,
1629
1831
  description: "Button/control text. Required for link_button, quick_reply, copy_button; optional heading for select / multi_select."
1630
1832
  },
1631
1833
  url: {
1632
1834
  type: "string",
1835
+ maxLength: MAX_COMPONENT_URL_CHARS,
1633
1836
  description: "link_button only: https URL on an Alfe-owned host to open when clicked."
1634
1837
  },
1635
1838
  target: {
@@ -1643,24 +1846,32 @@ function buildA2ATools(getChatClient, log, present) {
1643
1846
  },
1644
1847
  value: {
1645
1848
  type: "string",
1849
+ maxLength: MAX_COMPONENT_VALUE_CHARS,
1646
1850
  description: "quick_reply: the text submitted as the user's next message when tapped. copy_button: the value copied to the clipboard."
1647
1851
  },
1648
1852
  placeholder: {
1649
1853
  type: "string",
1854
+ maxLength: MAX_COMPONENT_LABEL_CHARS,
1650
1855
  description: "select only: the empty-state prompt shown before a choice is made."
1651
1856
  },
1652
1857
  options: {
1653
1858
  type: "array",
1859
+ minItems: 1,
1860
+ maxItems: MAX_COMPONENT_OPTIONS,
1654
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 \", \").",
1655
1862
  items: {
1656
1863
  type: "object",
1657
1864
  properties: {
1658
1865
  label: {
1659
1866
  type: "string",
1867
+ minLength: 1,
1868
+ maxLength: MAX_COMPONENT_LABEL_CHARS,
1660
1869
  description: "Option text shown to the user."
1661
1870
  },
1662
1871
  value: {
1663
1872
  type: "string",
1873
+ minLength: 1,
1874
+ maxLength: MAX_COMPONENT_VALUE_CHARS,
1664
1875
  description: "Value submitted when chosen."
1665
1876
  }
1666
1877
  },
@@ -1669,22 +1880,27 @@ function buildA2ATools(getChatClient, log, present) {
1669
1880
  },
1670
1881
  submitLabel: {
1671
1882
  type: "string",
1883
+ maxLength: MAX_COMPONENT_LABEL_CHARS,
1672
1884
  description: "multi_select only: submit button caption (default \"Submit\")."
1673
1885
  },
1674
1886
  confirmLabel: {
1675
1887
  type: "string",
1888
+ maxLength: MAX_COMPONENT_LABEL_CHARS,
1676
1889
  description: "confirm only: primary button text (e.g. \"Approve\")."
1677
1890
  },
1678
1891
  confirmValue: {
1679
1892
  type: "string",
1893
+ maxLength: MAX_COMPONENT_VALUE_CHARS,
1680
1894
  description: "confirm only: value submitted when the primary button is tapped."
1681
1895
  },
1682
1896
  cancelLabel: {
1683
1897
  type: "string",
1898
+ maxLength: MAX_COMPONENT_LABEL_CHARS,
1684
1899
  description: "confirm only: secondary button text (omit to show only the primary)."
1685
1900
  },
1686
1901
  cancelValue: {
1687
1902
  type: "string",
1903
+ maxLength: MAX_COMPONENT_VALUE_CHARS,
1688
1904
  description: "confirm only: value submitted when the secondary button is tapped."
1689
1905
  },
1690
1906
  style: {
@@ -1705,14 +1921,20 @@ function buildA2ATools(getChatClient, log, present) {
1705
1921
  },
1706
1922
  handler: async (params) => {
1707
1923
  const to = params.to;
1708
- 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 publicToolError("`to` must be \"conv:{conversationId}\" or \"user:{userId}\"");
1709
1925
  const text = params.text;
1926
+ if (text !== void 0 && (typeof text !== "string" || text.length > MAX_COMPONENT_TEXT_CHARS)) throw publicToolError("`text` exceeds the 32000-character limit");
1710
1927
  log.info("chat_present_components tool called");
1711
- return await present({
1712
- to,
1713
- text,
1714
- components: params.components
1715
- });
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 publicToolError(error.message);
1936
+ throw error;
1937
+ }
1716
1938
  }
1717
1939
  }));
1718
1940
  return tools;
@@ -1745,14 +1967,27 @@ const DEFAULT_EXACT_HOSTS = [
1745
1967
  "mmg.whatsapp.net"
1746
1968
  ];
1747
1969
  const DEFAULT_SUFFIX_HOSTS = [
1748
- ".s3.amazonaws.com",
1749
- ".amazonaws.com",
1750
1970
  ".twiliocdn.com",
1751
1971
  ".cdn.discordapp.com",
1752
1972
  ".discordapp.net",
1753
1973
  ".telegram.org",
1754
1974
  ".alfe.ai"
1755
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
+ }
1756
1991
  function parseExtraHosts(raw) {
1757
1992
  const exact = /* @__PURE__ */ new Set();
1758
1993
  const suffix = [];
@@ -1811,7 +2046,9 @@ function validateAttachmentUrl(input, opts = {}) {
1811
2046
  reason: "blocked_host"
1812
2047
  };
1813
2048
  const extra = parseExtraHosts(opts.extraHosts ?? process.env.ALFE_ATTACHMENT_ALLOWED_HOSTS);
1814
- 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 };
1815
2052
  if ([...DEFAULT_SUFFIX_HOSTS.map((h) => h.toLowerCase()), ...extra.suffix].some((rule) => host === rule.slice(1) || host.endsWith(rule))) return { ok: true };
1816
2053
  return {
1817
2054
  ok: false,
@@ -1819,6 +2056,40 @@ function validateAttachmentUrl(input, opts = {}) {
1819
2056
  };
1820
2057
  }
1821
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
1822
2093
  //#region src/plugin.ts
1823
2094
  /**
1824
2095
  * @alfe.ai/openclaw-chat — OpenClaw chat channel plugin.
@@ -2310,7 +2581,7 @@ function replayAttachments(atts) {
2310
2581
  ...typeof a.size === "number" ? { size: a.size } : {}
2311
2582
  }));
2312
2583
  }
2313
- const MAX_FILE_SIZE = 50 * 1024 * 1024;
2584
+ const MAX_INBOUND_ATTACHMENT_SIZE = 50 * 1024 * 1024;
2314
2585
  const DOWNLOAD_TIMEOUT_MS = 3e4;
2315
2586
  const MAX_REDIRECTS = 5;
2316
2587
  /**
@@ -2343,13 +2614,62 @@ async function fetchAttachmentWithValidation(url, signal) {
2343
2614
  }
2344
2615
  throw new Error("too_many_redirects");
2345
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}_${randomUUID()}_${safeName}`;
2662
+ }
2346
2663
  async function downloadAttachments(attachments, log) {
2347
2664
  const attachDir = join(homedir(), ".alfe", "attachments");
2348
- await mkdir(attachDir, { recursive: true });
2665
+ await mkdir(attachDir, {
2666
+ recursive: true,
2667
+ mode: 448
2668
+ });
2349
2669
  const results = [];
2350
- for (const att of attachments) {
2351
- const filename = (att.filename ?? att.id).replace(/[/\\]/g, "_").replace(/\.\./g, "_").replace(/\0/g, "");
2352
- const localPath = 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 = join(attachDir, buildAttachmentLocalFilename(att));
2353
2673
  const controller = new AbortController();
2354
2674
  const timeout = setTimeout(() => {
2355
2675
  controller.abort();
@@ -2360,12 +2680,11 @@ async function downloadAttachments(attachments, log) {
2360
2680
  log.warn(`Failed to download attachment ${att.id}: ${String(res.status)}`);
2361
2681
  continue;
2362
2682
  }
2363
- const buffer = Buffer.from(await res.arrayBuffer());
2364
- if (buffer.length > MAX_FILE_SIZE) {
2365
- log.warn(`Attachment ${att.id} exceeds max size (${String(buffer.length)} bytes) — skipping`);
2366
- continue;
2367
- }
2368
- await writeFile(localPath, buffer);
2683
+ const buffer = await readAttachmentBodyBounded(res);
2684
+ await writeFile(localPath, buffer, {
2685
+ flag: "wx",
2686
+ mode: 384
2687
+ });
2369
2688
  results.push({
2370
2689
  localPath,
2371
2690
  filename,
@@ -2373,6 +2692,7 @@ async function downloadAttachments(attachments, log) {
2373
2692
  });
2374
2693
  log.info(`Downloaded attachment: ${localPath} (${String(buffer.length)} bytes)`);
2375
2694
  } catch (err) {
2695
+ if ((err instanceof Error && "code" in err && typeof err.code === "string" ? err.code : void 0) !== "EEXIST") await unlink(localPath).catch(() => void 0);
2376
2696
  log.error(`Failed to download attachment ${att.id}: ${err instanceof Error ? err.message : String(err)}`);
2377
2697
  } finally {
2378
2698
  clearTimeout(timeout);
@@ -2512,6 +2832,20 @@ const VOICE_REPLY_SYSTEM_PROMPT = [
2512
2832
  */
2513
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");
2514
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
+ /**
2515
2849
  * Per-turn envelope context derived from the RPC `origin` flag, returned as a
2516
2850
  * `GroupSystemPrompt` block — the PROVEN runtime-rendered seam for per-turn
2517
2851
  * system-prompt guidance: dispatchInbound spreads `extraContext` into
@@ -2539,6 +2873,11 @@ function buildOriginEnvelopeContext(origin) {
2539
2873
  if (origin === "voice") return { GroupSystemPrompt: `${VOICE_REPLY_SYSTEM_PROMPT}\n\n${CROSS_AGENT_TOOLS_SYSTEM_PROMPT}` };
2540
2874
  return { GroupSystemPrompt: CROSS_AGENT_TOOLS_SYSTEM_PROMPT };
2541
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
+ }
2542
2881
  async function handleAgentRequest(request, log) {
2543
2882
  const runtime = pluginRuntime;
2544
2883
  if (!runtime) {
@@ -2549,7 +2888,7 @@ async function handleAgentRequest(request, log) {
2549
2888
  chatClient?.sendResponse(request.id, false, { message: "OpenClaw SDK not available — cannot dispatch" });
2550
2889
  return;
2551
2890
  }
2552
- 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;
2553
2892
  const isA2A = !!a2a;
2554
2893
  if (!message && !rawAttachments?.length) {
2555
2894
  chatClient?.sendResponse(request.id, false, { message: "Missing message" });
@@ -2707,16 +3046,16 @@ async function handleAgentRequest(request, log) {
2707
3046
  return;
2708
3047
  }
2709
3048
  });
3049
+ let localProxyIdentityActive = false;
2710
3050
  try {
2711
3051
  const downloadedFiles = rawAttachments?.length ? await downloadAttachments(rawAttachments, log) : [];
2712
3052
  const bodyForAgent = downloadedFiles.length ? `${message || ""}\n\n[Attached files:\n${downloadedFiles.map((f) => `- ${f.filename}: ${f.localPath}`).join("\n")}]` : void 0;
2713
- if (identityId) fetch("http://127.0.0.1:18193/__alfe/set-identity", {
2714
- method: "POST",
2715
- headers: { "Content-Type": "application/json" },
2716
- body: JSON.stringify({ identityId })
2717
- }).catch(() => {});
3053
+ const identitySynced = await syncLocalAiProxyIdentity(identityId);
3054
+ localProxyIdentityActive = identitySynced && identityId !== void 0;
3055
+ if (!identitySynced) log.debug("Local AI proxy identity attribution unavailable");
2718
3056
  const channelMode = isA2A ? "a2a" : extractChannelMode(conversationId ?? "", clientType ?? "chat");
2719
- const channelLabel = channelMode === "sms" ? "SMS" : channelMode === "whatsapp" ? "WhatsApp" : channelMode === "a2a" ? "Agent" : "Alfe";
3057
+ const inboundHookRouting = buildInboundHookRouting(identityProvider, channelMode, conversationId);
3058
+ const channelLabel = channelMode === "mobile" ? "Mobile" : channelMode === "sms" ? "SMS" : channelMode === "whatsapp" ? "WhatsApp" : channelMode === "a2a" ? "Agent" : "Alfe";
2720
3059
  const shortConvId = conversationId?.slice(-8) ?? "";
2721
3060
  const userLabel = displayName ?? userId ?? senderId;
2722
3061
  const conversationLabel = conversationType === "group" ? shortConvId ? `[${channelLabel}] Group (${shortConvId})` : `[${channelLabel}] Group` : shortConvId ? `[${channelLabel}] ${userLabel} (${shortConvId})` : `[${channelLabel}] ${userLabel}`;
@@ -2762,6 +3101,7 @@ async function handleAgentRequest(request, log) {
2762
3101
  bodyForAgent,
2763
3102
  messageId: request.id,
2764
3103
  timestamp: Date.now(),
3104
+ ...inboundHookRouting,
2765
3105
  extraContext: {
2766
3106
  ...tenantId ? { TenantId: tenantId } : {},
2767
3107
  ...clientType ? { ClientType: clientType } : {},
@@ -2769,22 +3109,13 @@ async function handleAgentRequest(request, log) {
2769
3109
  ...displayName ? { SenderName: displayName } : {},
2770
3110
  ...identityId ? { IdentityId: identityId } : {},
2771
3111
  ...userId ? { UserId: userId } : {},
2772
- ...senderPermissions?.length ? { SenderPermissions: senderPermissions } : {},
2773
3112
  ChannelMode: channelMode,
2774
- ...buildOriginEnvelopeContext(origin),
3113
+ ...buildTurnEnvelopeContext(origin, isA2A),
2775
3114
  ...isA2A ? {
2776
3115
  CallerType: "agent",
2777
3116
  CallerAgentId: a2a.sourceAgentId,
2778
3117
  CallerAgentName: a2a.sourceAgentName,
2779
- InteractionDepth: String(a2a.depth),
2780
- A2ASystemPrompt: [
2781
- `This is an agent-to-agent conversation with ${a2a.sourceAgentName}.`,
2782
- "Rules:",
2783
- "- Only respond if you have new information, a question, or an action to coordinate.",
2784
- "- 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).",
2785
- "- Do NOT write the word \"resolved\" in prose unless you mean to end — only the exact token [RESOLVED] ends the thread.",
2786
- "- Do NOT respond just to acknowledge — that creates infinite loops."
2787
- ].join("\n")
3118
+ InteractionDepth: String(a2a.depth)
2788
3119
  } : {}
2789
3120
  },
2790
3121
  deliver: async (payload) => {
@@ -2841,6 +3172,9 @@ async function handleAgentRequest(request, log) {
2841
3172
  activeRun = null;
2842
3173
  unsubscribe();
2843
3174
  clearAllToolUpdates();
3175
+ if (localProxyIdentityActive) {
3176
+ if (!await syncLocalAiProxyIdentity(void 0)) log.debug("Local AI proxy identity attribution clear failed");
3177
+ }
2844
3178
  try {
2845
3179
  await flushTurnActivity();
2846
3180
  } catch (err) {
@@ -2984,7 +3318,7 @@ const plugin = {
2984
3318
  chatWsUrl: pluginConfig.chatWsUrl
2985
3319
  });
2986
3320
  if (chatWsUrl && apiKey) {
2987
- log.info(`Connecting to chat service: ${chatWsUrl}`);
3321
+ log.info("Connecting to chat service relay");
2988
3322
  chatClient = new ChatServiceClient({
2989
3323
  wsUrl: chatWsUrl,
2990
3324
  apiKey,
@@ -3142,4 +3476,4 @@ const plugin = {
3142
3476
  }
3143
3477
  };
3144
3478
  //#endregion
3145
- export { createAlfeChannelPlugin as _, __setActiveRunForTest as a, buildOriginEnvelopeContext as c, decideChatServiceStart as d, joinAssistantText as f, stripLeakedToolSummary as g, rewriteAssistantText as h, __resetAgentTurnQueueForTest as i, buildToolActivity as l, resolveAbortTargetKeys as m, VOICE_REPLY_SYSTEM_PROMPT as n, admitAgentEvent as o, plugin as p, __agentTurnQueueForTest as r, buildA2ACompletePayload as s, CROSS_AGENT_TOOLS_SYSTEM_PROMPT as t, computeOpenClawSdkAnchors as u };
3479
+ export { createAlfeChannelPlugin as S, plugin as _, __agentTurnQueueForTest as a, rewriteAssistantText as b, admitAgentEvent as c, buildOriginEnvelopeContext as d, buildToolActivity as f, joinAssistantText as g, decideChatServiceStart as h, VOICE_REPLY_SYSTEM_PROMPT as i, buildA2ACompletePayload as l, computeOpenClawSdkAnchors as m, CROSS_AGENT_TOOLS_SYSTEM_PROMPT as n, __resetAgentTurnQueueForTest as o, buildTurnEnvelopeContext as p, MAX_INBOUND_ATTACHMENT_SIZE as r, __setActiveRunForTest as s, A2A_REPLY_SYSTEM_PROMPT as t, buildAttachmentLocalFilename as u, readAttachmentBodyBounded as v, stripLeakedToolSummary as x, resolveAbortTargetKeys as y };