@alfe.ai/openclaw-chat 0.5.1 → 0.6.0

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