@clawling/clawchat-plugin-openclaw 2026.9.26-3 → 2026.10.7-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.
@@ -25,12 +25,14 @@ import {
25
25
  import {
26
26
  mintMessageId,
27
27
  sendOpenclawClawlingText,
28
+ type OutboundMessageMode,
28
29
  type OutboundReplyCtx,
29
30
  type SendResult,
30
31
  } from "./outbound.ts";
31
32
  import { isClawChatNoopResponseText } from "./profile-prompt.ts";
32
33
  import type { ClawChatStore } from "./storage.ts";
33
34
  import { consumeTerminalClawChatSend } from "./terminal-send.ts";
35
+ import { createReplyStream, type ReplyStream } from "./reply-stream.ts";
34
36
  import { openclawLlmContextDebug } from "./llm-context-debug.ts";
35
37
 
36
38
  export interface ReplyDispatcherOptions {
@@ -85,8 +87,11 @@ type TypedReplyDispatcherResult = ReturnType<
85
87
  type ClawChatReplyOptions = TypedReplyDispatcherResult["replyOptions"] &
86
88
  {
87
89
  sourceReplyDeliveryMode: "automatic";
90
+ /** Newer hosts: disable the host typing keepalive; the plugin runs its own. */
91
+ typingKeepalive?: boolean;
88
92
  disableBlockStreaming: boolean;
89
93
  suppressDefaultToolProgressMessages: boolean;
94
+ onPartialReply?: (payload: { text?: string }) => void | Promise<void>;
90
95
  allowProgressCallbacksWhenSourceDeliverySuppressed?: boolean;
91
96
  onReasoningStream?: (payload: ReplyPayload) => void | Promise<void>;
92
97
  onToolStart?: (payload: {
@@ -120,6 +125,15 @@ type RichInteractionFragment = {
120
125
  actions: RichAction[];
121
126
  };
122
127
 
128
+ /**
129
+ * Re-send cadence for `typing.update {is_typing:true}` while a reply is in
130
+ * flight: at most half of the client's typing display window (6 s), so a single
131
+ * lost frame never lets the indicator lapse.
132
+ */
133
+ export const TYPING_KEEPALIVE_MS = 3_000;
134
+ /** Safety cap for one reply's typing keepalive (15 min). */
135
+ export const TYPING_KEEPALIVE_MAX_MS = 15 * 60_000;
136
+
123
137
  const GROUP_OWNER_ATTENTION_TITLE = "requires owner attention";
124
138
  const APPROVAL_FALLBACK_COMMAND_RE = /(?:^|\s)\/(?:approve|deny)\b/i;
125
139
 
@@ -479,6 +493,39 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
479
493
  const outboundEventType = () => (replyCtx ? "message.reply" : "message.send");
480
494
  const outboundRaw = () => ({ target, replyCtx: replyCtx ?? null });
481
495
  const terminalSendScopeId = options.terminalSendScopeId ?? null;
496
+ // ----- Reply streaming (experimental, `streamReplies`) -----------------
497
+ //
498
+ // One open stream previews the assistant text being generated. The next
499
+ // normal materialized send claims it (`message.done` with the final text,
500
+ // then the final as `message.reply` reusing the stream's message_id, §8.4);
501
+ // a final that does not extend the preview, a no-reply, a terminal tool
502
+ // send, or an idle run with nothing delivered fails it. Host partials are snapshots of
503
+ // the current assistant message that may be rewritten, so a snapshot that no
504
+ // longer extends the preview fails the old stream and starts a new one.
505
+ const streamEnabled = account.streamReplies === true;
506
+ let replyStream: ReplyStream | null = null;
507
+ const failStream = (reason: string) => {
508
+ replyStream?.fail(reason);
509
+ replyStream = null;
510
+ };
511
+ const claimStreamMessageId = (finalText: string): string | null => {
512
+ const id = replyStream?.finish(finalText) ?? null;
513
+ replyStream = null;
514
+ return id;
515
+ };
516
+ const onStreamPartial = (text: string) => {
517
+ if (runDone || terminalReplySuppressed) return;
518
+ if (replyStream?.diverges(text)) failStream("superseded");
519
+ replyStream ??= createReplyStream({
520
+ client,
521
+ chatId: target.chatId,
522
+ mintMessageId,
523
+ log,
524
+ logPrefix: `[${account.accountId}] clawchat-plugin-openclaw`,
525
+ });
526
+ replyStream.update(text);
527
+ };
528
+
482
529
  const consumeTerminalSend = (reason: string): boolean => {
483
530
  if (terminalReplySuppressed) return true;
484
531
  if (!terminalSendScopeId) return false;
@@ -489,6 +536,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
489
536
  });
490
537
  if (!terminal) return false;
491
538
  terminalReplySuppressed = true;
539
+ failStream("terminal-send");
492
540
  log?.info?.(
493
541
  `[${account.accountId}] clawchat-plugin-openclaw suppressing ${reason} reply after terminal tool send msg=${terminal.messageId} to=${target.chatId}`,
494
542
  );
@@ -571,8 +619,9 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
571
619
  if (url && !bufferedOutputUrls.includes(url)) bufferedOutputUrls.push(url);
572
620
  }
573
621
  };
574
- const fullSegmentKey = (text: string, urls: string[]): string =>
622
+ const fullSegmentKey = (text: string, urls: string[], mode: OutboundMessageMode): string =>
575
623
  JSON.stringify({
624
+ mode,
576
625
  text: text.replace(/\s+/g, " ").trim(),
577
626
  urls: urls.filter(Boolean),
578
627
  });
@@ -606,6 +655,39 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
606
655
  }
607
656
  };
608
657
 
658
+ // Plugin-owned typing keepalive. The client lights "typing" for a fixed
659
+ // window per `is_typing:true` frame, so the sender must re-send at no more
660
+ // than half that window (TYPING_KEEPALIVE_MS) for as long as the reply is in
661
+ // flight, and send `false` exactly once at the end. The host's own keepalive
662
+ // (default 6 s, global config only) equals the client window, so one lost or
663
+ // late frame makes the indicator flash; we ask the host to turn it off
664
+ // (`typingKeepalive: false`, honoured by newer hosts) and ignore its repeated
665
+ // `onReplyStart` ticks on older hosts.
666
+ let typingTimer: ReturnType<typeof setInterval> | null = null;
667
+ let typingCapTimer: ReturnType<typeof setTimeout> | null = null;
668
+ let typingFinished = false;
669
+ const stopTyping = () => {
670
+ if (typingTimer) clearInterval(typingTimer);
671
+ if (typingCapTimer) clearTimeout(typingCapTimer);
672
+ typingTimer = null;
673
+ typingCapTimer = null;
674
+ typingFinished = true;
675
+ emitTyping(false);
676
+ };
677
+ const startTyping = () => {
678
+ if (typingFinished || typingTimer) return;
679
+ emitTyping(true);
680
+ typingTimer = setInterval(() => emitTyping(true), TYPING_KEEPALIVE_MS);
681
+ typingTimer.unref?.();
682
+ typingCapTimer = setTimeout(() => {
683
+ log?.info?.(
684
+ `[${account.accountId}] clawchat-plugin-openclaw typing keepalive cap reached; stopping to=${target.chatId}`,
685
+ );
686
+ stopTyping();
687
+ }, TYPING_KEEPALIVE_MAX_MS);
688
+ typingCapTimer.unref?.();
689
+ };
690
+
609
691
  const sendOwnerAttention = async (
610
692
  fallbackText: string,
611
693
  richFragment: Fragment | null = null,
@@ -637,14 +719,16 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
637
719
  text: string,
638
720
  mediaFragments: ClawlingMediaFragment[] = [],
639
721
  richFragments: Fragment[] = [],
640
- options: { recordMessage?: boolean } = {},
722
+ options: { recordMessage?: boolean; messageMode?: OutboundMessageMode } = {},
641
723
  ): Promise<SendResult | null> => {
724
+ const messageMode = options.messageMode ?? "normal";
642
725
  if (
643
726
  isClawChatNoopResponseText(text) &&
644
727
  mediaFragments.length === 0 &&
645
728
  richFragments.length === 0
646
729
  ) {
647
730
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw outbound suppressed: no-reply token`);
731
+ if (messageMode === "normal") failStream("no-reply");
648
732
  openclawLlmContextDebug.writeSnapshot({
649
733
  visibility: "host_event",
650
734
  trace: {
@@ -667,12 +751,13 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
667
751
  if (!text.trim() && mediaFragments.length === 0 && richFragments.length === 0) return null;
668
752
  if (consumeTerminalSend("static")) return null;
669
753
  log?.info?.(
670
- `[${account.accountId}] clawchat-plugin-openclaw sending static text_len=${text.length} media=${mediaFragments.length} rich=${richFragments.length} to=${target.chatId}`,
754
+ `[${account.accountId}] clawchat-plugin-openclaw sending static mode=${messageMode} text_len=${text.length} media=${mediaFragments.length} rich=${richFragments.length} to=${target.chatId}`,
671
755
  );
672
- const messageId = mintStaticMessageId();
756
+ const streamMessageId = messageMode === "normal" ? claimStreamMessageId(text) : null;
757
+ const messageId = streamMessageId ?? mintStaticMessageId();
673
758
  const raw = { target, replyCtx: replyCtx ?? null, mode: "static" };
674
759
  const claimed = options.recordMessage
675
- ? claimOutbound(outboundEventType(), messageId, text, raw)
760
+ ? claimOutbound(streamMessageId ? "message.reply" : outboundEventType(), messageId, text, raw)
676
761
  : true;
677
762
  if (claimed === false) {
678
763
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw outbound duplicate skipped msg=${messageId}`);
@@ -691,10 +776,14 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
691
776
  ...(replyCtx ? { replyCtx } : {}),
692
777
  ...(richFragments.length > 0 ? { richFragments } : {}),
693
778
  ...(mediaFragments.length > 0 ? { mediaFragments } : {}),
779
+ ...(messageMode !== "normal" ? { messageMode } : {}),
780
+ ...(streamMessageId ? { finalizesStream: true } : {}),
694
781
  log,
695
782
  });
696
783
  recordOutboundAck("message", messageId, result);
697
- if (result) outboundDeliveredCount += 1;
784
+ // Process (thinking) messages are not replies: they must not make a
785
+ // bootstrap greeting look delivered.
786
+ if (result && messageMode === "normal") outboundDeliveredCount += 1;
698
787
  log?.info?.(
699
788
  `[${account.accountId}] clawchat-plugin-openclaw send complete to=${target.chatId}`,
700
789
  );
@@ -722,7 +811,11 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
722
811
  return result;
723
812
  };
724
813
 
725
- const emitFullSegment = async (text: string, urls: string[] = []): Promise<void> => {
814
+ const emitFullSegment = async (
815
+ text: string,
816
+ urls: string[] = [],
817
+ messageMode: OutboundMessageMode = "normal",
818
+ ): Promise<void> => {
726
819
  if (outputVisibility !== "full" && !splitNormalBlockOutput) {
727
820
  appendBufferedText(text);
728
821
  appendBufferedUrls(urls);
@@ -735,11 +828,11 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
735
828
  }
736
829
  const trimmed = text.trim();
737
830
  if (!trimmed && urls.length === 0) return;
738
- const segmentKey = fullSegmentKey(trimmed, urls);
831
+ const segmentKey = fullSegmentKey(trimmed, urls, messageMode);
739
832
  if (emittedFullSegmentSet.has(segmentKey)) return;
740
833
  emittedFullSegmentSet.add(segmentKey);
741
834
  const mediaFragments = await uploadMediaUrls(urls);
742
- await sendStatic(trimmed, mediaFragments, [], { recordMessage: true });
835
+ await sendStatic(trimmed, mediaFragments, [], { recordMessage: true, messageMode });
743
836
  };
744
837
 
745
838
  const emitFullRuntimeText = async (
@@ -749,15 +842,19 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
749
842
  ): Promise<void> => {
750
843
  const summary = summarizeValue(text);
751
844
  if (!summary && urls.length === 0) return;
752
- await emitFullSegment(summary ? `[${label}] ${summary}` : "", urls);
845
+ await emitFullSegment(summary ? `[${label}] ${summary}` : "", urls, "thinking");
753
846
  };
754
847
 
848
+ /** Process output (tool / command / patch / compaction / reasoning): `message_mode: "thinking"`. */
849
+ const emitProcessSegment = (text: string, urls: string[] = []): Promise<void> =>
850
+ emitFullSegment(text, urls, "thinking");
851
+
755
852
  // ----- Dispatcher -------------------------------------------------------
756
853
 
757
854
  const base = runtime.channel.reply.createReplyDispatcherWithTyping({
758
855
  humanDelay,
759
856
  onReplyStart: async () => {
760
- emitTyping(true);
857
+ startTyping();
761
858
  },
762
859
  deliver: async (payload: ReplyPayload, info?: { kind: "tool" | "block" | "final" }) => {
763
860
  if (consumeTerminalSend(info?.kind ?? "unknown")) return;
@@ -786,7 +883,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
786
883
 
787
884
  if (payload.isReasoning) {
788
885
  if (outputVisibility !== "full") return;
789
- await emitFullSegment(text, urls);
886
+ await emitProcessSegment(text, urls);
790
887
  const trimmed = text.trim();
791
888
  if (trimmed) reasoningText = reasoningText ? `${reasoningText}\n${trimmed}` : trimmed;
792
889
  return;
@@ -816,6 +913,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
816
913
  finalDeliverySeen = true;
817
914
  if (isClawChatNoopResponseText(text) && !richFragment && urls.length === 0) {
818
915
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw final suppressed: no-reply token`);
916
+ failStream("no-reply");
819
917
  openclawLlmContextDebug.writeSnapshot({
820
918
  visibility: "host_event",
821
919
  trace: {
@@ -843,6 +941,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
843
941
  finalUrls.length === 0
844
942
  ) {
845
943
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw final suppressed: no-reply token`);
944
+ failStream("no-reply");
846
945
  return;
847
946
  }
848
947
  const mediaFragments = await uploadMediaUrls(finalUrls);
@@ -866,19 +965,24 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
866
965
  if (outputVisibility === "full") void emitFullRuntimeText("error", errorText);
867
966
  },
868
967
  onIdle: async () => {
869
- emitTyping(false);
968
+ stopTyping();
870
969
  if (runDone) return;
871
970
  runDone = true;
872
- if (finalDeliverySeen) return;
873
- const fallbackText = bufferedOutputText.trim();
874
- const fallbackUrls = bufferedOutputUrls.slice();
875
- if (!fallbackText && fallbackUrls.length === 0) return;
876
- const mediaFragments = await uploadMediaUrls(fallbackUrls);
877
- const result = await sendStatic(fallbackText, mediaFragments, [], { recordMessage: true });
878
- if (result?.messageId) recordThinkingIfLinked(result.messageId);
971
+ try {
972
+ if (finalDeliverySeen) return;
973
+ const fallbackText = bufferedOutputText.trim();
974
+ const fallbackUrls = bufferedOutputUrls.slice();
975
+ if (!fallbackText && fallbackUrls.length === 0) return;
976
+ const mediaFragments = await uploadMediaUrls(fallbackUrls);
977
+ const result = await sendStatic(fallbackText, mediaFragments, [], { recordMessage: true });
978
+ if (result?.messageId) recordThinkingIfLinked(result.messageId);
979
+ } finally {
980
+ // A preview nothing materialized must not linger as an open stream.
981
+ failStream("undelivered");
982
+ }
879
983
  },
880
984
  onCleanup: () => {
881
- emitTyping(false);
985
+ stopTyping();
882
986
  },
883
987
  });
884
988
 
@@ -888,7 +992,15 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
888
992
  replyOptions: {
889
993
  ...base.replyOptions,
890
994
  sourceReplyDeliveryMode: "automatic",
891
- disableBlockStreaming: !splitNormalBlockOutput,
995
+ typingKeepalive: false,
996
+ // Streaming previews the assistant message and the final materializes it,
997
+ // so host block chunks (which split one message arbitrarily) stay off.
998
+ disableBlockStreaming: streamEnabled ? true : !splitNormalBlockOutput,
999
+ onPartialReply: streamEnabled
1000
+ ? (payload: { text?: string }) => {
1001
+ onStreamPartial(typeof payload?.text === "string" ? payload.text : "");
1002
+ }
1003
+ : undefined,
892
1004
  suppressDefaultToolProgressMessages: true,
893
1005
  allowProgressCallbacksWhenSourceDeliverySuppressed: splitFullOutput ? true : undefined,
894
1006
  onReasoningStream: splitFullOutput
@@ -903,7 +1015,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
903
1015
  onToolStart: splitFullOutput
904
1016
  ? async (payload) => {
905
1017
  if (consumeTerminalSend("tool-start")) return;
906
- await emitFullSegment(formatToolStartSummary(payload));
1018
+ await emitProcessSegment(formatToolStartSummary(payload));
907
1019
  }
908
1020
  : undefined,
909
1021
  onToolResult: splitFullOutput
@@ -930,25 +1042,25 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
930
1042
  onCommandOutput: splitFullOutput
931
1043
  ? async (payload: Record<string, unknown>) => {
932
1044
  if (consumeTerminalSend("command-output")) return;
933
- await emitFullSegment(formatCommandOutputSummary(payload));
1045
+ await emitProcessSegment(formatCommandOutputSummary(payload));
934
1046
  }
935
1047
  : undefined,
936
1048
  onPatchSummary: splitFullOutput
937
1049
  ? async (payload: Record<string, unknown>) => {
938
1050
  if (consumeTerminalSend("patch-summary")) return;
939
- await emitFullSegment(formatPatchSummary(payload));
1051
+ await emitProcessSegment(formatPatchSummary(payload));
940
1052
  }
941
1053
  : undefined,
942
1054
  onCompactionStart: splitFullOutput
943
1055
  ? async () => {
944
1056
  if (consumeTerminalSend("compaction-start")) return;
945
- await emitFullSegment("[compaction] started");
1057
+ await emitProcessSegment("[compaction] started");
946
1058
  }
947
1059
  : undefined,
948
1060
  onCompactionEnd: splitFullOutput
949
1061
  ? async () => {
950
1062
  if (consumeTerminalSend("compaction-end")) return;
951
- await emitFullSegment("[compaction] finished");
1063
+ await emitProcessSegment("[compaction] finished");
952
1064
  }
953
1065
  : undefined,
954
1066
  },
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Outbound reply streaming (protocol §8) for one assistant message.
3
+ *
4
+ * Lifecycle on the wire (§8.4 finalize-reply pattern):
5
+ *
6
+ * message.created(M) → message.add(M, seq 0..n)* → message.done(M)
7
+ * → message.reply (message_id = M, the polished final)
8
+ *
9
+ * `message.done` carries the FINAL text, not the last partial: adds are
10
+ * throttled, so the last one that went out can be well short of the answer,
11
+ * and the server materializes its merged reply from the stream. `finish`
12
+ * therefore takes the final text, sends whatever tail of it the throttle held
13
+ * back as one last add, and closes with the whole text. A final that does not
14
+ * extend what was shown cannot be appended (§8.2) — the stream is failed and
15
+ * the final goes out under its own id.
16
+ *
17
+ * or, when the reply turns out not to be sendable (no-reply token, run
18
+ * aborted, nothing delivered): message.created(M) → … → message.failed(M),
19
+ * which aborts the stream without materializing anything.
20
+ *
21
+ * Streaming frames get no ack (§8.5) and no server-side mention repair, so the
22
+ * stream only ever carries plain text; mentions, media and rich fragments ride
23
+ * on the final materialized message, which reuses M so the stored row collapses
24
+ * to the final reply.
25
+ *
26
+ * No-reply guard. A partial must never show something that later turns out to
27
+ * be a no-reply: nothing is opened until the text is at least
28
+ * STREAM_HOLD_CHARS long and is not a no-reply / silence marker; a trailing
29
+ * fragment that could be the start of a no-reply token is held back; and if a
30
+ * token shows up later the stream stops growing and is failed at finalize.
31
+ */
32
+ import { EVENT } from "./protocol-types.ts";
33
+ import { isClawChatNoopResponseText } from "./profile-prompt.ts";
34
+ import { isNoReplyTokenPrefix } from "./no-reply.ts";
35
+ import type { ClawlingChatClient } from "./ws-client.ts";
36
+
37
+ /** Minimum visible (trimmed) length before a stream is opened. */
38
+ export const STREAM_HOLD_CHARS = 32;
39
+ /** Minimum spacing between two `message.add` frames for one stream. */
40
+ export const STREAM_MIN_ADD_INTERVAL_MS = 250;
41
+ /** How far back from the end a partial no-reply token is looked for. */
42
+ const TOKEN_TAIL_WINDOW = 40;
43
+
44
+ export type StreamableText =
45
+ | { kind: "hold" }
46
+ | { kind: "poisoned" }
47
+ | { kind: "ok"; text: string };
48
+
49
+ /**
50
+ * Decide how much of a cumulative partial `text` may be shown.
51
+ * - `poisoned`: the text is (or contains) a no-reply token; never show it.
52
+ * - `hold`: too short to be sure yet.
53
+ * - `ok`: `text` with any trailing possible-token prefix cut off.
54
+ */
55
+ export function streamableText(text: string): StreamableText {
56
+ if (isClawChatNoopResponseText(text)) return { kind: "poisoned" };
57
+ let safe = text;
58
+ for (let i = Math.max(0, text.length - TOKEN_TAIL_WINDOW); i < text.length; i += 1) {
59
+ const ch = text[i]!;
60
+ if (/\s/.test(ch)) continue;
61
+ if (isNoReplyTokenPrefix(text.slice(i))) {
62
+ safe = text.slice(0, i);
63
+ break;
64
+ }
65
+ }
66
+ if (safe.trim().length < STREAM_HOLD_CHARS) return { kind: "hold" };
67
+ return { kind: "ok", text: safe };
68
+ }
69
+
70
+ export interface ReplyStreamOptions {
71
+ client: ClawlingChatClient;
72
+ chatId: string;
73
+ mintMessageId: () => string;
74
+ now?: () => number;
75
+ log?: { info?: (m: string) => void; error?: (m: string) => void };
76
+ logPrefix?: string;
77
+ }
78
+
79
+ export interface ReplyStream {
80
+ /** Feed the cumulative text of the current assistant message. */
81
+ update(text: string): void;
82
+ /**
83
+ * True when `text` is showable but does not extend what this open stream
84
+ * already shows: a new assistant message or a host rewrite. The caller then
85
+ * fails this stream and starts a new one. Held / poisoned text never
86
+ * diverges (nothing new would be shown).
87
+ */
88
+ diverges(text: string): boolean;
89
+ /** True once `message.created` went out and the stream is not closed. */
90
+ isOpen(): boolean;
91
+ /** Text the recipient currently sees for this stream. */
92
+ shownText(): string;
93
+ /**
94
+ * Close the stream with the final reply text: an unthrottled last
95
+ * `message.add` for any tail not yet shown, then `message.done` carrying
96
+ * `finalText`. Returns the message_id for the final materialized message to
97
+ * reuse. Returns null — the final then uses a fresh id — when no stream is
98
+ * open, or when `finalText` does not extend the shown text (or is a no-reply),
99
+ * in which case the stream is failed instead.
100
+ */
101
+ finish(finalText: string): string | null;
102
+ /** Abort an open stream with `message.failed`. No-op when nothing is open. */
103
+ fail(reason: string): void;
104
+ }
105
+
106
+ export function createReplyStream(options: ReplyStreamOptions): ReplyStream {
107
+ const now = options.now ?? Date.now;
108
+ const prefix = options.logPrefix ?? "clawchat-plugin-openclaw";
109
+ let messageId: string | null = null;
110
+ let closed = false;
111
+ let poisoned = false;
112
+ let diverged = false;
113
+ let sent = "";
114
+ let sequence = -1;
115
+ let lastAddAt = 0;
116
+ let startedAt = 0;
117
+
118
+ const canSend = (): boolean => {
119
+ const client = options.client as { transportState?: string; state?: string };
120
+ if (client.transportState !== undefined && client.transportState !== "open") return false;
121
+ if (client.state !== undefined && client.state !== "connected") return false;
122
+ return true;
123
+ };
124
+
125
+ const emit = (event: string, payload: Record<string, unknown>): boolean => {
126
+ try {
127
+ options.client.emitRaw(event, payload, { chat_id: options.chatId });
128
+ return true;
129
+ } catch (error) {
130
+ options.log?.error?.(`${prefix} stream ${event} failed msg=${messageId}: ${String(error)}`);
131
+ return false;
132
+ }
133
+ };
134
+
135
+ const streamingState = (status: "streaming" | "done" | "failed", completedAt: number | null) => ({
136
+ status,
137
+ sequence: Math.max(sequence, 0),
138
+ mutation_policy: "append_text_only",
139
+ started_at: startedAt,
140
+ completed_at: completedAt,
141
+ });
142
+
143
+ const sendAdd = (next: string, at: number): boolean => {
144
+ sequence += 1;
145
+ const ok = emit(EVENT.MESSAGE_ADD, {
146
+ message_id: messageId,
147
+ sequence,
148
+ mutation: { type: "append", target_fragment_index: 0 },
149
+ fragments: [{ kind: "text", text: next, delta: next.slice(sent.length) }],
150
+ streaming: streamingState("streaming", null),
151
+ added_at: at,
152
+ });
153
+ if (!ok) {
154
+ sequence -= 1;
155
+ return false;
156
+ }
157
+ sent = next;
158
+ lastAddAt = at;
159
+ return true;
160
+ };
161
+
162
+ const fail = (reason: string): void => {
163
+ if (!messageId || closed) return;
164
+ closed = true;
165
+ const at = now();
166
+ emit(EVENT.MESSAGE_FAILED, {
167
+ message_id: messageId,
168
+ fragments: [{ kind: "text", text: sent }],
169
+ streaming: streamingState("failed", at),
170
+ completed_at: at,
171
+ });
172
+ options.log?.info?.(`${prefix} stream failed msg=${messageId} reason=${reason}`);
173
+ };
174
+
175
+ return {
176
+ update(text: string) {
177
+ if (closed || poisoned || diverged) return;
178
+ const decision = streamableText(text);
179
+ if (decision.kind === "poisoned") {
180
+ poisoned = true;
181
+ options.log?.info?.(`${prefix} stream halted: no-reply token in partial msg=${messageId ?? "-"}`);
182
+ return;
183
+ }
184
+ if (decision.kind === "hold") return;
185
+ const next = decision.text;
186
+ if (messageId && !next.startsWith(sent)) {
187
+ // §8.2 delta invariant cannot hold for a rewrite; stop growing the
188
+ // stream and let the final message (same id) carry the real text.
189
+ diverged = true;
190
+ return;
191
+ }
192
+ if (next === sent) return;
193
+ if (!canSend()) return;
194
+ if (!messageId) {
195
+ const id = options.mintMessageId();
196
+ startedAt = now();
197
+ if (!emit(EVENT.MESSAGE_CREATED, { message_id: id, message_mode: "normal" })) return;
198
+ messageId = id;
199
+ options.log?.info?.(`${prefix} stream opened msg=${id} to=${options.chatId}`);
200
+ } else if (now() - lastAddAt < STREAM_MIN_ADD_INTERVAL_MS) {
201
+ return;
202
+ }
203
+ sendAdd(next, now());
204
+ },
205
+ diverges(text: string) {
206
+ if (!messageId || closed) return false;
207
+ const decision = streamableText(text);
208
+ return decision.kind === "ok" && !decision.text.startsWith(sent);
209
+ },
210
+ isOpen() {
211
+ return messageId !== null && !closed;
212
+ },
213
+ shownText() {
214
+ return sent;
215
+ },
216
+ finish(finalText: string) {
217
+ if (!messageId || closed) return null;
218
+ if (streamableText(finalText).kind === "poisoned") {
219
+ fail("no-reply");
220
+ return null;
221
+ }
222
+ // Trailing whitespace a partial carried may be trimmed off the final.
223
+ if (!finalText.startsWith(sent.trimEnd())) {
224
+ fail("superseded");
225
+ return null;
226
+ }
227
+ if (finalText.length > sent.length && finalText.startsWith(sent)) {
228
+ // Past the throttle: this is the last frame before done.
229
+ sendAdd(finalText, now());
230
+ }
231
+ closed = true;
232
+ const at = now();
233
+ emit(EVENT.MESSAGE_DONE, {
234
+ message_id: messageId,
235
+ fragments: [{ kind: "text", text: finalText }],
236
+ streaming: streamingState("done", at),
237
+ completed_at: at,
238
+ });
239
+ options.log?.info?.(`${prefix} stream done msg=${messageId} text_len=${finalText.length}`);
240
+ return messageId;
241
+ },
242
+ fail,
243
+ };
244
+ }
package/src/runtime.ts CHANGED
@@ -96,6 +96,7 @@ import {
96
96
  import { refreshGroupProfile, syncFirstSeenClawChatProfiles } from "./profile-sync.ts";
97
97
  import { pullGroupMetadata, pullOwnerMetadata } from "./clawchat-metadata.ts";
98
98
  import { deleteClawChatMemoryFile, readClawChatMemoryFile } from "./clawchat-memory.ts";
99
+ import { registerMentionRosterResolver } from "./mention-autolink.ts";
99
100
  import {
100
101
  clearClawChatPromptInjectionForSession,
101
102
  stageClawChatPromptInjection,
@@ -3831,6 +3832,21 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3831
3832
  return;
3832
3833
  }
3833
3834
  activeClients.set(accountId, client);
3835
+ // Text "@name" autolinking on outbound group sends reads the same cached
3836
+ // roster (participant ids + nicknames) the group prompt shows the model.
3837
+ const unregisterMentionRoster = registerMentionRosterResolver(accountId, async (groupId) => {
3838
+ const memoryRoot = resolveMemoryRootForPeer({ kind: "group", id: groupId });
3839
+ if (!memoryRoot) return [];
3840
+ let ownerMetadata: ClawChatPromptMetadata | null = null;
3841
+ try {
3842
+ const ownerFile = await readClawChatMemoryFile(memoryRoot, { targetType: "owner", targetId: "owner" });
3843
+ ownerMetadata = ownerFile.exists ? ownerFile.metadata : null;
3844
+ } catch {
3845
+ ownerMetadata = null;
3846
+ }
3847
+ const participants = await loadGroupParticipantsForPrompt({ memoryRoot, groupId, ownerMetadata });
3848
+ return participants.map((participant) => ({ userId: participant.id, name: participant.name }));
3849
+ });
3834
3850
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime active client registered`);
3835
3851
  setStatus({
3836
3852
  ...getStatus(),
@@ -3843,6 +3859,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3843
3859
  await waitUntilAbort(abortSignal, async () => {
3844
3860
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime abort received; closing client`);
3845
3861
  activeClients.delete(accountId);
3862
+ unregisterMentionRoster();
3846
3863
  closingForAbort = true;
3847
3864
  clearBootstrapRetry();
3848
3865
  // §A.1 — stop the proactive refresh timer on shutdown.
@@ -71,7 +71,7 @@ export const OFFICIAL_SKILLS_BASE =
71
71
  * in the install-cli repo, bump this constant, ship it. `liveware-sample.ts`
72
72
  * imports the same ref, so the `livewares` tree at that tag is pinned too.
73
73
  */
74
- export const DEFAULT_SKILLS_REF = "skills-v1.15.2";
74
+ export const DEFAULT_SKILLS_REF = "skills-v1.16.0";
75
75
 
76
76
  /** Refuse to treat an absurdly large response as a skill file (defence in depth). */
77
77
  export const MAX_SKILL_BYTES = 256 * 1024;