@alfe.ai/openclaw-chat 0.5.1 → 0.6.1

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
@@ -250,6 +250,10 @@ function extractChannelMode(conversationId, fallback = "chat") {
250
250
  * Each session file contains metadata and the full message history.
251
251
  * Sessions are written on every message to ensure durability.
252
252
  * Old sessions are cleaned up automatically (30-day TTL, 1000 max).
253
+ *
254
+ * NOTE: the storage path and SessionData shape are a read contract —
255
+ * packages/openclaw-memory-cloud/src/session-backfill.ts reads these files
256
+ * directly (the memory plugin can't depend on this package). Keep in sync.
253
257
  */
254
258
  const SESSIONS_DIR = (0, node_path.join)((0, node_os.homedir)(), ".alfe", "sessions", "chat");
255
259
  const MAX_SESSIONS = 1e3;
@@ -369,6 +373,26 @@ async function appendActivity(sessionId, entries) {
369
373
  writeLocks.set(sessionId, next);
370
374
  await next;
371
375
  }
376
+ const MAX_ROUTES_PER_SESSION = 8;
377
+ /**
378
+ * Record the OpenClaw route a turn dispatched through (idempotent upsert
379
+ * by sessionKey). Called once per turn from the dispatch success path.
380
+ */
381
+ async function recordRoute(sessionId, route) {
382
+ const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
383
+ const session = await getSession(sessionId);
384
+ if (!session) return;
385
+ const routes = session.routes ?? [];
386
+ if (routes.some((r) => r.sessionKey === route.sessionKey)) return;
387
+ if (routes.length >= MAX_ROUTES_PER_SESSION) return;
388
+ session.routes = [...routes, route];
389
+ await saveSession(session);
390
+ }).finally(() => {
391
+ if (writeLocks.get(sessionId) === next) writeLocks.delete(sessionId);
392
+ });
393
+ writeLocks.set(sessionId, next);
394
+ await next;
395
+ }
372
396
  async function listSessions(filters, limit = 50) {
373
397
  await ensureDir();
374
398
  let files;
@@ -406,244 +430,6 @@ async function listSessions(filters, limit = 50) {
406
430
  return summaries.slice(0, limit);
407
431
  }
408
432
  //#endregion
409
- //#region src/a2a-tools.ts
410
- let conversationEndRequested = false;
411
- let a2aTurnArmed = false;
412
- /**
413
- * Clear the end signal AND arm the gate. Call immediately before an A2A
414
- * dispatch. Arming is what permits `end_conversation` to set the flag for the
415
- * duration of this turn.
416
- */
417
- function resetA2AEndSignal() {
418
- conversationEndRequested = false;
419
- a2aTurnArmed = true;
420
- }
421
- /**
422
- * Clear the end signal AND disarm the gate. Call after an A2A dispatch (in a
423
- * `finally`, so it also runs on error). Disarming closes the gate so a later
424
- * NON-A2A turn invoking `end_conversation` can't set a flag the next A2A turn
425
- * would read.
426
- */
427
- function disarmA2AEndSignal() {
428
- conversationEndRequested = false;
429
- a2aTurnArmed = false;
430
- }
431
- /** True if `end_conversation` was invoked since the last reset. */
432
- function isA2AEndSignalled() {
433
- return conversationEndRequested;
434
- }
435
- function ok(result) {
436
- return { content: [{
437
- type: "text",
438
- text: JSON.stringify(result)
439
- }] };
440
- }
441
- function errResult(message) {
442
- return { content: [{
443
- type: "text",
444
- text: JSON.stringify({ error: message })
445
- }] };
446
- }
447
- function defineTool(def) {
448
- return {
449
- name: def.name,
450
- description: def.description,
451
- label: def.name,
452
- parameters: def.parameters,
453
- execute: async (_toolCallId, params) => {
454
- try {
455
- return ok(await def.handler(params));
456
- } catch (e) {
457
- return errResult(e.message);
458
- }
459
- }
460
- };
461
- }
462
- function buildA2ATools(getChatClient, log) {
463
- const requireClient = () => {
464
- const client = getChatClient();
465
- if (!client) throw new Error("chat service not connected yet — try again in a moment");
466
- return client;
467
- };
468
- return [
469
- defineTool({
470
- name: "list_agents",
471
- description: "List other agents in your organization that you can communicate with.",
472
- parameters: {
473
- type: "object",
474
- properties: {},
475
- required: []
476
- },
477
- handler: async () => {
478
- log.info("list_agents tool called");
479
- return await requireClient().sendRequest("a2a.list-agents", {});
480
- }
481
- }),
482
- defineTool({
483
- name: "message_agent",
484
- 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].",
485
- parameters: {
486
- type: "object",
487
- properties: {
488
- agent_id: {
489
- type: "string",
490
- description: "Target agent ID (use list_agents to find available agents)"
491
- },
492
- message: {
493
- type: "string",
494
- description: "Message to send to the agent"
495
- },
496
- thread_id: {
497
- type: "string",
498
- description: "Continue an existing conversation thread (omit to start a new one)"
499
- },
500
- max_depth: {
501
- type: "number",
502
- description: "Maximum number of back-and-forth exchanges (default: 10)"
503
- }
504
- },
505
- required: ["agent_id", "message"]
506
- },
507
- handler: async (params) => {
508
- const agentId = params.agent_id;
509
- const message = params.message;
510
- const threadId = params.thread_id;
511
- const maxDepth = params.max_depth ?? 10;
512
- if (!agentId || !message) throw new Error("agent_id and message are required");
513
- log.info(`message_agent tool: sending to ${agentId}`);
514
- return await requireClient().sendRequest("a2a.send", {
515
- targetAgentId: agentId,
516
- message,
517
- threadId,
518
- maxDepth
519
- });
520
- }
521
- }),
522
- defineTool({
523
- name: "end_conversation",
524
- description: "End the current agent-to-agent conversation. Call this when the discussion is resolved and no further exchanges are needed.",
525
- parameters: {
526
- type: "object",
527
- properties: { summary: {
528
- type: "string",
529
- description: "Brief summary of what was discussed or resolved"
530
- } },
531
- required: []
532
- },
533
- handler: (params) => {
534
- const summary = params.summary;
535
- log.info(`end_conversation tool called${summary ? `: ${summary}` : ""}`);
536
- if (a2aTurnArmed) conversationEndRequested = true;
537
- return Promise.resolve({
538
- status: "conversation_ended",
539
- summary
540
- });
541
- }
542
- })
543
- ];
544
- }
545
- //#endregion
546
- //#region src/attachment-url.ts
547
- /**
548
- * Attachment URL validation for the openclaw-chat plugin.
549
- *
550
- * This package runs inside user-installed OpenClaw agents and has no
551
- * dependency on the internal @alfe/api-core SSRF validator, so the
552
- * plumbing is inlined here. The rules are deliberately conservative:
553
- *
554
- * - `https:` only.
555
- * - Userinfo (`https://u:p@host/...`) is rejected.
556
- * - IP-literal hosts (v4 or bracketed v6) are rejected outright — only
557
- * DNS names for known provider CDNs are acceptable.
558
- * - The host must match a curated allowlist of Alfe-issued / channel-
559
- * provider CDNs. Anything else is dropped before we fetch it.
560
- *
561
- * Operators running an agent in a private network that needs to dereference
562
- * additional hosts can extend the list via the
563
- * `ALFE_ATTACHMENT_ALLOWED_HOSTS` env var (comma-separated, entries prefixed
564
- * with `.` match suffixes).
565
- */
566
- const DEFAULT_EXACT_HOSTS = [
567
- "s3.amazonaws.com",
568
- "api.twilio.com",
569
- "graph.microsoft.com",
570
- "mmg.whatsapp.net"
571
- ];
572
- const DEFAULT_SUFFIX_HOSTS = [
573
- ".s3.amazonaws.com",
574
- ".amazonaws.com",
575
- ".twiliocdn.com",
576
- ".cdn.discordapp.com",
577
- ".discordapp.net",
578
- ".telegram.org",
579
- ".alfe.ai"
580
- ];
581
- function parseExtraHosts(raw) {
582
- const exact = /* @__PURE__ */ new Set();
583
- const suffix = [];
584
- if (!raw) return {
585
- exact,
586
- suffix
587
- };
588
- for (const part of raw.split(",")) {
589
- const trimmed = part.trim().toLowerCase();
590
- if (!trimmed) continue;
591
- if (trimmed.startsWith(".")) suffix.push(trimmed);
592
- else exact.add(trimmed);
593
- }
594
- return {
595
- exact,
596
- suffix
597
- };
598
- }
599
- function isIpLiteral(host) {
600
- if (host.startsWith("[") && host.endsWith("]")) return true;
601
- return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
602
- }
603
- function validateAttachmentUrl(input, opts = {}) {
604
- if (!input || input.trim() === "") return {
605
- ok: false,
606
- reason: "empty"
607
- };
608
- let url;
609
- try {
610
- url = new URL(input);
611
- } catch {
612
- return {
613
- ok: false,
614
- reason: "invalid_url"
615
- };
616
- }
617
- if (url.protocol !== "https:") return {
618
- ok: false,
619
- reason: "not_https"
620
- };
621
- if (url.username !== "" || url.password !== "") return {
622
- ok: false,
623
- reason: "userinfo_not_allowed"
624
- };
625
- const host = url.hostname.toLowerCase();
626
- if (!host) return {
627
- ok: false,
628
- reason: "empty_host"
629
- };
630
- if (isIpLiteral(host)) return {
631
- ok: false,
632
- reason: "ip_literal"
633
- };
634
- if (host === "localhost" || host === "metadata" || host === "metadata.google.internal") return {
635
- ok: false,
636
- reason: "blocked_host"
637
- };
638
- const extra = parseExtraHosts(opts.extraHosts ?? process.env.ALFE_ATTACHMENT_ALLOWED_HOSTS);
639
- if (new Set([...DEFAULT_EXACT_HOSTS.map((h) => h.toLowerCase()), ...extra.exact]).has(host)) return { ok: true };
640
- if ([...DEFAULT_SUFFIX_HOSTS.map((h) => h.toLowerCase()), ...extra.suffix].some((rule) => host === rule.slice(1) || host.endsWith(rule))) return { ok: true };
641
- return {
642
- ok: false,
643
- reason: "host_not_in_allowlist"
644
- };
645
- }
646
- //#endregion
647
433
  //#region src/activity-serialize.ts
648
434
  /**
649
435
  * Console-grade tool-activity serialization (Slice 3).
@@ -873,99 +659,514 @@ function extractResultText(result) {
873
659
  return parts.join("\n");
874
660
  }
875
661
  //#endregion
876
- //#region src/think-tags.ts
662
+ //#region src/think-tags.ts
663
+ /**
664
+ * Inline `<think>` span handling for assistant text.
665
+ *
666
+ * Reasoning models routed through the AI proxy (DeepSeek `deepseek-*`,
667
+ * Zhipu `glm-*`, …) emit their reasoning INLINE as `<think>…</think>` inside
668
+ * the normal content stream instead of a structured thinking channel. The
669
+ * openclaw-chat plugin (0.4.0+) diverts those spans into thinking activity
670
+ * frames at the source; this relay-side copy is the DEFENSIVE layer for
671
+ * agents still running older plugin versions — leaked reasoning is dropped
672
+ * here, never synthesized into activity (that's the plugin's job).
673
+ *
674
+ * KEEP IN SYNC (byte-identical): this module exists at BOTH
675
+ * `services/chat/src/lib/think-tags.ts` (relay, defensive layer)
676
+ * `packages/openclaw-chat/src/think-tags.ts` (plugin, at-source divert)
677
+ * The published plugin cannot import service internals, so the module is
678
+ * duplicated, same as the ChatActivity shape.
679
+ *
680
+ * Known accepted limitation: a literal `<think>` in legitimate prose or a
681
+ * code fence is treated as reasoning and stripped/diverted.
682
+ */
683
+ const OPEN_TAG = "<think>";
684
+ const CLOSE_TAG = "</think>";
685
+ /**
686
+ * Split CUMULATIVE assistant text into the user-visible part and the
687
+ * reasoning part.
688
+ *
689
+ * Invariant (load-bearing): for successive cumulative snapshots that grow by
690
+ * appending, `visible` is monotonically append-only — `visible(n)` is always
691
+ * a prefix of `visible(n + 1)`. The delta relays slice by a `lastSentLength`
692
+ * offset into this string; if `visible` ever shrank or rewrote earlier
693
+ * characters, clients would receive corrupted deltas. This holds because the
694
+ * scan is deterministic left-to-right and a trailing PARTIAL tag (any strict
695
+ * prefix of the next expected tag, e.g. `"<thi"` or `"</th"`) is HELD BACK
696
+ * from both outputs until later text disambiguates it.
697
+ */
698
+ function splitThinkTags(cumulative) {
699
+ let visible = "";
700
+ let thinking = "";
701
+ let inThink = false;
702
+ let i = 0;
703
+ const n = cumulative.length;
704
+ while (i < n) {
705
+ const tag = inThink ? CLOSE_TAG : OPEN_TAG;
706
+ const idx = cumulative.indexOf(tag, i);
707
+ if (idx !== -1) {
708
+ const chunk = cumulative.slice(i, idx);
709
+ if (inThink) thinking += chunk;
710
+ else visible += chunk;
711
+ inThink = !inThink;
712
+ i = idx + tag.length;
713
+ continue;
714
+ }
715
+ let rest = cumulative.slice(i);
716
+ const held = trailingPartialTagLength(rest, tag);
717
+ if (held > 0) rest = rest.slice(0, rest.length - held);
718
+ if (inThink) thinking += rest;
719
+ else visible += rest;
720
+ break;
721
+ }
722
+ return {
723
+ visible,
724
+ thinking
725
+ };
726
+ }
727
+ /** Length of the longest strict prefix of `tag` that is a suffix of `text`. */
728
+ function trailingPartialTagLength(text, tag) {
729
+ const max = Math.min(text.length, tag.length - 1);
730
+ for (let len = max; len > 0; len--) if (text.endsWith(tag.slice(0, len))) return len;
731
+ return 0;
732
+ }
733
+ /**
734
+ * Strip `<think>…</think>` spans (and an unclosed trailing `<think>…`) from
735
+ * FINAL text, then tidy the whitespace the spans leave behind.
736
+ *
737
+ * Idempotent — `stripThinkSpans(stripThinkSpans(x)) === stripThinkSpans(x)`.
738
+ * New plugin + new relay both strip, so the second pass must be a no-op; the
739
+ * fixpoint loop also covers pathological inputs where removing one span
740
+ * makes the surrounding fragments form a new one.
741
+ *
742
+ * NOT for sliced/streaming paths — the trim + blank-line collapse breaks
743
+ * prefix alignment with `lastSentLength`; those paths use
744
+ * `splitThinkTags(...).visible` instead.
745
+ */
746
+ function stripThinkSpans(text) {
747
+ let out = text;
748
+ let prev;
749
+ do {
750
+ prev = out;
751
+ out = out.replace(/<think>[\s\S]*?<\/think>/g, "");
752
+ } while (out !== prev);
753
+ out = out.replace(/<think>[\s\S]*$/, "");
754
+ return out.replace(/\n{3,}/g, "\n\n").trim();
755
+ }
756
+ //#endregion
757
+ //#region src/transcript-activity.ts
758
+ /**
759
+ * Transcript-backed activity history — read tool calls + thinking for a
760
+ * conversation from OPENCLAW'S OWN session transcripts.
761
+ *
762
+ * Why: every chat turn dispatches through OpenClaw's standard pipeline,
763
+ * which records a full per-session JSONL transcript (assistant `thinking`
764
+ * and `toolCall` content parts, separate `toolResult` messages, exact
765
+ * timestamps) under `~/.openclaw/agents/{agentId}/sessions/`. That store is
766
+ * the console's history source and has FULL fidelity — including turns
767
+ * that ran before this plugin started persisting its own activity records.
768
+ * `sessions.get` therefore prefers the transcript; the plugin's own
769
+ * `SessionData.activity` records remain the FALLBACK for transcripts that
770
+ * are missing (deleted / rotated / compacted).
771
+ *
772
+ * Layout (verified against openclaw 2026.5.x, matches the plugin-sdk's
773
+ * `resolveSessionTranscriptPathInDir`):
774
+ * {storeDir}/sessions.json — index: sessionKey → {sessionId}
775
+ * {storeDir}/{sessionId}.jsonl — the transcript
776
+ * where `storePath` (as returned by dispatchInboundDirectDmWithRuntime)
777
+ * points at sessions.json.
778
+ *
779
+ * Conversation → transcript mapping: routes recorded at dispatch time
780
+ * (exact sessionKey + storePath) cover every conversation shape going
781
+ * forward. The additional `:conv:{conversationId}` index scan is the
782
+ * RETROACTIVE path for conversations that predate route recording — it
783
+ * only applies to DIRECT web-chat conversations (their peer id embeds the
784
+ * `:conv:` segment); group and single-threaded SMS/WhatsApp keys carry no
785
+ * such segment and rely entirely on recorded routes. All matched sessions
786
+ * (a direct conversation can span one per sender) merge by timestamp.
787
+ */
788
+ const MAX_ENTRIES = 500;
789
+ const MAX_THINKING_CHARS = 16e3;
790
+ const MAX_TRANSCRIPT_BYTES = 4 * 1024 * 1024;
791
+ /** Default OpenClaw store for the daemon's single agent ("main"). */
792
+ function defaultStorePath() {
793
+ return (0, node_path.join)((0, node_os.homedir)(), ".openclaw", "agents", "main", "sessions", "sessions.json");
794
+ }
795
+ function parseTs(iso) {
796
+ const ts = iso ? Date.parse(iso) : NaN;
797
+ return Number.isFinite(ts) ? ts : 0;
798
+ }
799
+ /**
800
+ * Read at most the trailing MAX_TRANSCRIPT_BYTES of a transcript. When the
801
+ * cap truncates, the first (partial) line is dropped so parsing starts on a
802
+ * record boundary. Returns null when the file is unreadable.
803
+ */
804
+ async function readTranscriptTail(path) {
805
+ try {
806
+ const handle = await (0, node_fs_promises.open)(path, "r");
807
+ try {
808
+ const { size } = await handle.stat();
809
+ if (size <= MAX_TRANSCRIPT_BYTES) return await handle.readFile({ encoding: "utf-8" });
810
+ const buf = Buffer.alloc(MAX_TRANSCRIPT_BYTES);
811
+ await handle.read(buf, 0, MAX_TRANSCRIPT_BYTES, size - MAX_TRANSCRIPT_BYTES);
812
+ const text = buf.toString("utf-8");
813
+ const firstNewline = text.indexOf("\n");
814
+ return firstNewline === -1 ? "" : text.slice(firstNewline + 1);
815
+ } finally {
816
+ await handle.close();
817
+ }
818
+ } catch {
819
+ return null;
820
+ }
821
+ }
822
+ /**
823
+ * Find the transcript files for a conversation: exact route matches first,
824
+ * then an index scan for `:conv:{conversationId}` keys (retroactive path).
825
+ * Returns absolute jsonl paths, deduped.
826
+ */
827
+ async function resolveTranscriptPaths(conversationId, routes) {
828
+ const storePaths = new Set((routes ?? []).map((r) => r.storePath));
829
+ if (storePaths.size === 0) storePaths.add(defaultStorePath());
830
+ const routeKeys = new Set((routes ?? []).map((r) => r.sessionKey));
831
+ const convSegment = `:conv:${conversationId}`;
832
+ const files = /* @__PURE__ */ new Set();
833
+ for (const storePath of storePaths) {
834
+ let index;
835
+ try {
836
+ index = JSON.parse(await (0, node_fs_promises.readFile)(storePath, "utf-8"));
837
+ } catch {
838
+ continue;
839
+ }
840
+ const dir = (0, node_path.dirname)(storePath);
841
+ for (const [key, entry] of Object.entries(index)) {
842
+ if (!entry.sessionId) continue;
843
+ if (!(routeKeys.has(key) || key.endsWith(convSegment) || key.includes(`${convSegment}:`))) continue;
844
+ if (!/^[A-Za-z0-9._-]+$/.test(entry.sessionId)) continue;
845
+ files.add((0, node_path.join)(dir, `${entry.sessionId}.jsonl`));
846
+ }
847
+ }
848
+ return [...files];
849
+ }
850
+ /**
851
+ * Parse one transcript file into terminal activity records.
852
+ *
853
+ * - assistant `thinking` parts (and `<think>`-tagged text for inline
854
+ * reasoning models) aggregate into ONE thinking record per assistant
855
+ * message;
856
+ * - `toolCall` parts open a tool record; the matching `toolResult` message
857
+ * completes it (status/result). Tools with no result stay `interrupted`.
858
+ */
859
+ async function parseTranscriptActivity(path) {
860
+ const raw = await readTranscriptTail(path);
861
+ if (raw === null) return [];
862
+ const records = [];
863
+ const openTools = /* @__PURE__ */ new Map();
864
+ for (const line of raw.split("\n")) {
865
+ if (!line.trim()) continue;
866
+ let entry;
867
+ try {
868
+ entry = JSON.parse(line);
869
+ } catch {
870
+ continue;
871
+ }
872
+ if (entry.type !== "message" || !entry.message) continue;
873
+ const ts = parseTs(entry.timestamp);
874
+ const msg = entry.message;
875
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
876
+ let thinkingText = "";
877
+ const tools = [];
878
+ for (const part of msg.content) if (part.type === "thinking" && typeof part.thinking === "string") thinkingText += part.thinking;
879
+ else if (part.type === "text" && typeof part.text === "string") thinkingText += splitThinkTags(part.text).thinking;
880
+ else if (part.type === "toolCall" && typeof part.id === "string" && typeof part.name === "string") {
881
+ const args = serializeArgs(part.arguments);
882
+ const tool = {
883
+ kind: "tool",
884
+ toolCallId: part.id,
885
+ name: part.name,
886
+ status: "interrupted",
887
+ ...args ? { argsText: args.text } : {},
888
+ ...args?.truncated ? { truncated: { args: true } } : {},
889
+ ts
890
+ };
891
+ openTools.set(part.id, tool);
892
+ tools.push(tool);
893
+ }
894
+ if (thinkingText.trim()) records.push({
895
+ kind: "thinking",
896
+ text: thinkingText.length > MAX_THINKING_CHARS ? thinkingText.slice(0, MAX_THINKING_CHARS) : thinkingText,
897
+ ts
898
+ });
899
+ records.push(...tools);
900
+ continue;
901
+ }
902
+ if (msg.role === "toolResult" && typeof msg.toolCallId === "string") {
903
+ const tool = openTools.get(msg.toolCallId);
904
+ if (!tool) continue;
905
+ tool.status = msg.isError === true ? "failed" : "done";
906
+ if (msg.isError === true) tool.isError = true;
907
+ const result = serializeResult({ content: msg.content });
908
+ if (result) {
909
+ tool.resultText = result.text;
910
+ if (result.truncated) tool.truncated = {
911
+ ...tool.truncated ?? {},
912
+ result: true
913
+ };
914
+ }
915
+ openTools.delete(msg.toolCallId);
916
+ }
917
+ }
918
+ return records;
919
+ }
920
+ /**
921
+ * Full conversation activity from OpenClaw's transcripts: resolve, parse,
922
+ * merge (a group conversation can span several per-sender sessions), sort
923
+ * by timestamp, keep the most recent MAX_ENTRIES. Empty array when no
924
+ * transcript could be found — the caller falls back to the plugin's own
925
+ * persisted activity records.
926
+ */
927
+ async function collectTranscriptActivity(conversationId, routes) {
928
+ const paths = await resolveTranscriptPaths(conversationId, routes);
929
+ if (paths.length === 0) return [];
930
+ const merged = (await Promise.all(paths.map((p) => parseTranscriptActivity(p)))).flat().sort((a, b) => a.ts - b.ts);
931
+ return merged.length > MAX_ENTRIES ? merged.slice(merged.length - MAX_ENTRIES) : merged;
932
+ }
933
+ //#endregion
934
+ //#region src/a2a-tools.ts
935
+ let conversationEndRequested = false;
936
+ let a2aTurnArmed = false;
937
+ /**
938
+ * Clear the end signal AND arm the gate. Call immediately before an A2A
939
+ * dispatch. Arming is what permits `end_conversation` to set the flag for the
940
+ * duration of this turn.
941
+ */
942
+ function resetA2AEndSignal() {
943
+ conversationEndRequested = false;
944
+ a2aTurnArmed = true;
945
+ }
946
+ /**
947
+ * Clear the end signal AND disarm the gate. Call after an A2A dispatch (in a
948
+ * `finally`, so it also runs on error). Disarming closes the gate so a later
949
+ * NON-A2A turn invoking `end_conversation` can't set a flag the next A2A turn
950
+ * would read.
951
+ */
952
+ function disarmA2AEndSignal() {
953
+ conversationEndRequested = false;
954
+ a2aTurnArmed = false;
955
+ }
956
+ /** True if `end_conversation` was invoked since the last reset. */
957
+ function isA2AEndSignalled() {
958
+ return conversationEndRequested;
959
+ }
960
+ function ok(result) {
961
+ return { content: [{
962
+ type: "text",
963
+ text: JSON.stringify(result)
964
+ }] };
965
+ }
966
+ function errResult(message) {
967
+ return { content: [{
968
+ type: "text",
969
+ text: JSON.stringify({ error: message })
970
+ }] };
971
+ }
972
+ function defineTool(def) {
973
+ return {
974
+ name: def.name,
975
+ description: def.description,
976
+ label: def.name,
977
+ parameters: def.parameters,
978
+ execute: async (_toolCallId, params) => {
979
+ try {
980
+ return ok(await def.handler(params));
981
+ } catch (e) {
982
+ return errResult(e.message);
983
+ }
984
+ }
985
+ };
986
+ }
987
+ function buildA2ATools(getChatClient, log) {
988
+ const requireClient = () => {
989
+ const client = getChatClient();
990
+ if (!client) throw new Error("chat service not connected yet — try again in a moment");
991
+ return client;
992
+ };
993
+ return [
994
+ defineTool({
995
+ name: "list_agents",
996
+ description: "List other agents in your organization that you can communicate with.",
997
+ parameters: {
998
+ type: "object",
999
+ properties: {},
1000
+ required: []
1001
+ },
1002
+ handler: async () => {
1003
+ log.info("list_agents tool called");
1004
+ return await requireClient().sendRequest("a2a.list-agents", {});
1005
+ }
1006
+ }),
1007
+ defineTool({
1008
+ name: "message_agent",
1009
+ 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].",
1010
+ parameters: {
1011
+ type: "object",
1012
+ properties: {
1013
+ agent_id: {
1014
+ type: "string",
1015
+ description: "Target agent ID (use list_agents to find available agents)"
1016
+ },
1017
+ message: {
1018
+ type: "string",
1019
+ description: "Message to send to the agent"
1020
+ },
1021
+ thread_id: {
1022
+ type: "string",
1023
+ description: "Continue an existing conversation thread (omit to start a new one)"
1024
+ },
1025
+ max_depth: {
1026
+ type: "number",
1027
+ description: "Maximum number of back-and-forth exchanges (default: 10)"
1028
+ }
1029
+ },
1030
+ required: ["agent_id", "message"]
1031
+ },
1032
+ handler: async (params) => {
1033
+ const agentId = params.agent_id;
1034
+ const message = params.message;
1035
+ const threadId = params.thread_id;
1036
+ const maxDepth = params.max_depth ?? 10;
1037
+ if (!agentId || !message) throw new Error("agent_id and message are required");
1038
+ log.info(`message_agent tool: sending to ${agentId}`);
1039
+ return await requireClient().sendRequest("a2a.send", {
1040
+ targetAgentId: agentId,
1041
+ message,
1042
+ threadId,
1043
+ maxDepth
1044
+ });
1045
+ }
1046
+ }),
1047
+ defineTool({
1048
+ name: "end_conversation",
1049
+ description: "End the current agent-to-agent conversation. Call this when the discussion is resolved and no further exchanges are needed.",
1050
+ parameters: {
1051
+ type: "object",
1052
+ properties: { summary: {
1053
+ type: "string",
1054
+ description: "Brief summary of what was discussed or resolved"
1055
+ } },
1056
+ required: []
1057
+ },
1058
+ handler: (params) => {
1059
+ const summary = params.summary;
1060
+ log.info(`end_conversation tool called${summary ? `: ${summary}` : ""}`);
1061
+ if (a2aTurnArmed) conversationEndRequested = true;
1062
+ return Promise.resolve({
1063
+ status: "conversation_ended",
1064
+ summary
1065
+ });
1066
+ }
1067
+ })
1068
+ ];
1069
+ }
1070
+ //#endregion
1071
+ //#region src/attachment-url.ts
877
1072
  /**
878
- * Inline `<think>` span handling for assistant text.
879
- *
880
- * Reasoning models routed through the AI proxy (DeepSeek `deepseek-*`,
881
- * Zhipu `glm-*`, …) emit their reasoning INLINE as `<think>…</think>` inside
882
- * the normal content stream instead of a structured thinking channel. The
883
- * openclaw-chat plugin (0.4.0+) diverts those spans into thinking activity
884
- * frames at the source; this relay-side copy is the DEFENSIVE layer for
885
- * agents still running older plugin versions — leaked reasoning is dropped
886
- * here, never synthesized into activity (that's the plugin's job).
1073
+ * Attachment URL validation for the openclaw-chat plugin.
887
1074
  *
888
- * KEEP IN SYNC (byte-identical): this module exists at BOTH
889
- * `services/chat/src/lib/think-tags.ts` (relay, defensive layer)
890
- * `packages/openclaw-chat/src/think-tags.ts` (plugin, at-source divert)
891
- * The published plugin cannot import service internals, so the module is
892
- * duplicated, same as the ChatActivity shape.
1075
+ * This package runs inside user-installed OpenClaw agents and has no
1076
+ * dependency on the internal @alfe/api-core SSRF validator, so the
1077
+ * plumbing is inlined here. The rules are deliberately conservative:
893
1078
  *
894
- * Known accepted limitation: a literal `<think>` in legitimate prose or a
895
- * code fence is treated as reasoning and stripped/diverted.
896
- */
897
- const OPEN_TAG = "<think>";
898
- const CLOSE_TAG = "</think>";
899
- /**
900
- * Split CUMULATIVE assistant text into the user-visible part and the
901
- * reasoning part.
1079
+ * - `https:` only.
1080
+ * - Userinfo (`https://u:p@host/...`) is rejected.
1081
+ * - IP-literal hosts (v4 or bracketed v6) are rejected outright — only
1082
+ * DNS names for known provider CDNs are acceptable.
1083
+ * - The host must match a curated allowlist of Alfe-issued / channel-
1084
+ * provider CDNs. Anything else is dropped before we fetch it.
902
1085
  *
903
- * Invariant (load-bearing): for successive cumulative snapshots that grow by
904
- * appending, `visible` is monotonically append-only `visible(n)` is always
905
- * a prefix of `visible(n + 1)`. The delta relays slice by a `lastSentLength`
906
- * offset into this string; if `visible` ever shrank or rewrote earlier
907
- * characters, clients would receive corrupted deltas. This holds because the
908
- * scan is deterministic left-to-right and a trailing PARTIAL tag (any strict
909
- * prefix of the next expected tag, e.g. `"<thi"` or `"</th"`) is HELD BACK
910
- * from both outputs until later text disambiguates it.
1086
+ * Operators running an agent in a private network that needs to dereference
1087
+ * additional hosts can extend the list via the
1088
+ * `ALFE_ATTACHMENT_ALLOWED_HOSTS` env var (comma-separated, entries prefixed
1089
+ * with `.` match suffixes).
911
1090
  */
912
- function splitThinkTags(cumulative) {
913
- let visible = "";
914
- let thinking = "";
915
- let inThink = false;
916
- let i = 0;
917
- const n = cumulative.length;
918
- while (i < n) {
919
- const tag = inThink ? CLOSE_TAG : OPEN_TAG;
920
- const idx = cumulative.indexOf(tag, i);
921
- if (idx !== -1) {
922
- const chunk = cumulative.slice(i, idx);
923
- if (inThink) thinking += chunk;
924
- else visible += chunk;
925
- inThink = !inThink;
926
- i = idx + tag.length;
927
- continue;
928
- }
929
- let rest = cumulative.slice(i);
930
- const held = trailingPartialTagLength(rest, tag);
931
- if (held > 0) rest = rest.slice(0, rest.length - held);
932
- if (inThink) thinking += rest;
933
- else visible += rest;
934
- break;
1091
+ const DEFAULT_EXACT_HOSTS = [
1092
+ "s3.amazonaws.com",
1093
+ "api.twilio.com",
1094
+ "graph.microsoft.com",
1095
+ "mmg.whatsapp.net"
1096
+ ];
1097
+ const DEFAULT_SUFFIX_HOSTS = [
1098
+ ".s3.amazonaws.com",
1099
+ ".amazonaws.com",
1100
+ ".twiliocdn.com",
1101
+ ".cdn.discordapp.com",
1102
+ ".discordapp.net",
1103
+ ".telegram.org",
1104
+ ".alfe.ai"
1105
+ ];
1106
+ function parseExtraHosts(raw) {
1107
+ const exact = /* @__PURE__ */ new Set();
1108
+ const suffix = [];
1109
+ if (!raw) return {
1110
+ exact,
1111
+ suffix
1112
+ };
1113
+ for (const part of raw.split(",")) {
1114
+ const trimmed = part.trim().toLowerCase();
1115
+ if (!trimmed) continue;
1116
+ if (trimmed.startsWith(".")) suffix.push(trimmed);
1117
+ else exact.add(trimmed);
935
1118
  }
936
1119
  return {
937
- visible,
938
- thinking
1120
+ exact,
1121
+ suffix
939
1122
  };
940
1123
  }
941
- /** Length of the longest strict prefix of `tag` that is a suffix of `text`. */
942
- function trailingPartialTagLength(text, tag) {
943
- const max = Math.min(text.length, tag.length - 1);
944
- for (let len = max; len > 0; len--) if (text.endsWith(tag.slice(0, len))) return len;
945
- return 0;
1124
+ function isIpLiteral(host) {
1125
+ if (host.startsWith("[") && host.endsWith("]")) return true;
1126
+ return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
946
1127
  }
947
- /**
948
- * Strip `<think>…</think>` spans (and an unclosed trailing `<think>…`) from
949
- * FINAL text, then tidy the whitespace the spans leave behind.
950
- *
951
- * Idempotent — `stripThinkSpans(stripThinkSpans(x)) === stripThinkSpans(x)`.
952
- * New plugin + new relay both strip, so the second pass must be a no-op; the
953
- * fixpoint loop also covers pathological inputs where removing one span
954
- * makes the surrounding fragments form a new one.
955
- *
956
- * NOT for sliced/streaming paths — the trim + blank-line collapse breaks
957
- * prefix alignment with `lastSentLength`; those paths use
958
- * `splitThinkTags(...).visible` instead.
959
- */
960
- function stripThinkSpans(text) {
961
- let out = text;
962
- let prev;
963
- do {
964
- prev = out;
965
- out = out.replace(/<think>[\s\S]*?<\/think>/g, "");
966
- } while (out !== prev);
967
- out = out.replace(/<think>[\s\S]*$/, "");
968
- return out.replace(/\n{3,}/g, "\n\n").trim();
1128
+ function validateAttachmentUrl(input, opts = {}) {
1129
+ if (!input || input.trim() === "") return {
1130
+ ok: false,
1131
+ reason: "empty"
1132
+ };
1133
+ let url;
1134
+ try {
1135
+ url = new URL(input);
1136
+ } catch {
1137
+ return {
1138
+ ok: false,
1139
+ reason: "invalid_url"
1140
+ };
1141
+ }
1142
+ if (url.protocol !== "https:") return {
1143
+ ok: false,
1144
+ reason: "not_https"
1145
+ };
1146
+ if (url.username !== "" || url.password !== "") return {
1147
+ ok: false,
1148
+ reason: "userinfo_not_allowed"
1149
+ };
1150
+ const host = url.hostname.toLowerCase();
1151
+ if (!host) return {
1152
+ ok: false,
1153
+ reason: "empty_host"
1154
+ };
1155
+ if (isIpLiteral(host)) return {
1156
+ ok: false,
1157
+ reason: "ip_literal"
1158
+ };
1159
+ if (host === "localhost" || host === "metadata" || host === "metadata.google.internal") return {
1160
+ ok: false,
1161
+ reason: "blocked_host"
1162
+ };
1163
+ const extra = parseExtraHosts(opts.extraHosts ?? process.env.ALFE_ATTACHMENT_ALLOWED_HOSTS);
1164
+ if (new Set([...DEFAULT_EXACT_HOSTS.map((h) => h.toLowerCase()), ...extra.exact]).has(host)) return { ok: true };
1165
+ if ([...DEFAULT_SUFFIX_HOSTS.map((h) => h.toLowerCase()), ...extra.suffix].some((rule) => host === rule.slice(1) || host.endsWith(rule))) return { ok: true };
1166
+ return {
1167
+ ok: false,
1168
+ reason: "host_not_in_allowlist"
1169
+ };
969
1170
  }
970
1171
  //#endregion
971
1172
  //#region src/plugin.ts
@@ -1622,7 +1823,7 @@ async function handleAgentRequest(request, log) {
1622
1823
  requestSessionKey: legacySessionKey,
1623
1824
  routeSessionKey
1624
1825
  };
1625
- runGate.sessionKey = (await dispatchInbound({
1826
+ const result = await dispatchInbound({
1626
1827
  cfg,
1627
1828
  runtime: { channel: runtime.channel },
1628
1829
  channel: "alfe",
@@ -1680,7 +1881,14 @@ async function handleAgentRequest(request, log) {
1680
1881
  onDispatchError: (err, info) => {
1681
1882
  log.error(`Dispatch error (${info.kind}): ${err instanceof Error ? err.message : String(err)}`);
1682
1883
  }
1683
- })).route.sessionKey;
1884
+ });
1885
+ runGate.sessionKey = result.route.sessionKey;
1886
+ recordRoute(sessionId, {
1887
+ sessionKey: result.route.sessionKey,
1888
+ storePath: result.storePath
1889
+ }).catch((err) => {
1890
+ log.warn(`recordRoute failed: ${err instanceof Error ? err.message : String(err)}`);
1891
+ });
1684
1892
  if (isA2A) {
1685
1893
  const a2aResolved = isA2AEndSignalled();
1686
1894
  chatClient?.notify("a2a-complete", buildA2ACompletePayload({
@@ -1723,6 +1931,20 @@ async function handleSessionsList(request, log) {
1723
1931
  chatClient?.sendResponse(request.id, false, { message: errMsg });
1724
1932
  }
1725
1933
  }
1934
+ /**
1935
+ * History activity for a conversation: OpenClaw's own transcript is the
1936
+ * PRIMARY source (full fidelity — tool results included — and retroactive
1937
+ * for conversations that predate the plugin's activity persistence); the
1938
+ * plugin's per-turn records are the fallback for missing/rotated
1939
+ * transcripts. Never throws — history must not fail a sessions.get.
1940
+ */
1941
+ async function resolveHistoryActivity(session) {
1942
+ try {
1943
+ const fromTranscript = await collectTranscriptActivity(session.sessionId, session.routes);
1944
+ if (fromTranscript.length > 0) return fromTranscript;
1945
+ } catch {}
1946
+ return (session.activity ?? []).map((a) => ({ ...a }));
1947
+ }
1726
1948
  async function handleSessionsGet(request, log) {
1727
1949
  try {
1728
1950
  const params = request.params;
@@ -1754,7 +1976,7 @@ async function handleSessionsGet(request, log) {
1754
1976
  content: m.content,
1755
1977
  timestamp: m.timestamp
1756
1978
  })),
1757
- activity: (session.activity ?? []).map((a) => ({ ...a }))
1979
+ activity: await resolveHistoryActivity(session)
1758
1980
  });
1759
1981
  } catch (err) {
1760
1982
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -1894,7 +2116,7 @@ const plugin = {
1894
2116
  content: m.content,
1895
2117
  timestamp: m.timestamp
1896
2118
  })),
1897
- activity: (session.activity ?? []).map((a) => ({ ...a }))
2119
+ activity: await resolveHistoryActivity(session)
1898
2120
  };
1899
2121
  });
1900
2122
  gw.__alfeChatGatewayMethodsRegistered = true;