@clawling/clawchat-plugin-openclaw 2026.9.26-2 → 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.
- package/dist/src/config.js +3 -0
- package/dist/src/group-message-coalescer.js +1 -0
- package/dist/src/mention-autolink.js +155 -0
- package/dist/src/no-reply.js +7 -6
- package/dist/src/outbound.js +28 -7
- package/dist/src/profile-prompt.js +9 -5
- package/dist/src/reply-dispatcher.js +133 -29
- package/dist/src/reply-stream.js +208 -0
- package/dist/src/runtime.js +19 -0
- package/dist/src/skill-update.js +1 -1
- package/dist/src/tools.js +15 -4
- package/openclaw.plugin.json +4 -0
- package/package.json +1 -1
- package/prompts/default-group-bio.md +6 -7
- package/prompts/default-owner-behavior.md +6 -8
- package/prompts/platform.md +2 -0
- package/skills/clawchat-liveware-sample/SKILL.md +3 -2
- package/skills/clawchat-orchestration/SKILL.md +29 -1
- package/skills/manifest.json +15 -15
- package/src/config.ts +7 -0
- package/src/group-message-coalescer.ts +3 -0
- package/src/inbound.ts +1 -0
- package/src/mention-autolink.ts +200 -0
- package/src/no-reply.ts +7 -6
- package/src/outbound.ts +58 -7
- package/src/profile-prompt.ts +10 -5
- package/src/reply-dispatcher.ts +139 -27
- package/src/reply-stream.ts +244 -0
- package/src/runtime.ts +17 -0
- package/src/skill-update.ts +1 -1
- package/src/tools.ts +13 -2
|
@@ -6,7 +6,16 @@ import { describeOutboundMediaShortfall, uploadOutboundMedia, } from "./media-ru
|
|
|
6
6
|
import { mintMessageId, sendOpenclawClawlingText, } from "./outbound.js";
|
|
7
7
|
import { isClawChatNoopResponseText } from "./profile-prompt.js";
|
|
8
8
|
import { consumeTerminalClawChatSend } from "./terminal-send.js";
|
|
9
|
+
import { createReplyStream } from "./reply-stream.js";
|
|
9
10
|
import { openclawLlmContextDebug } from "./llm-context-debug.js";
|
|
11
|
+
/**
|
|
12
|
+
* Re-send cadence for `typing.update {is_typing:true}` while a reply is in
|
|
13
|
+
* flight: at most half of the client's typing display window (6 s), so a single
|
|
14
|
+
* lost frame never lets the indicator lapse.
|
|
15
|
+
*/
|
|
16
|
+
export const TYPING_KEEPALIVE_MS = 3_000;
|
|
17
|
+
/** Safety cap for one reply's typing keepalive (15 min). */
|
|
18
|
+
export const TYPING_KEEPALIVE_MAX_MS = 15 * 60_000;
|
|
10
19
|
const GROUP_OWNER_ATTENTION_TITLE = "requires owner attention";
|
|
11
20
|
const APPROVAL_FALLBACK_COMMAND_RE = /(?:^|\s)\/(?:approve|deny)\b/i;
|
|
12
21
|
function ownerAttentionText(groupId, fallbackText) {
|
|
@@ -326,6 +335,40 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
326
335
|
const outboundEventType = () => (replyCtx ? "message.reply" : "message.send");
|
|
327
336
|
const outboundRaw = () => ({ target, replyCtx: replyCtx ?? null });
|
|
328
337
|
const terminalSendScopeId = options.terminalSendScopeId ?? null;
|
|
338
|
+
// ----- Reply streaming (experimental, `streamReplies`) -----------------
|
|
339
|
+
//
|
|
340
|
+
// One open stream previews the assistant text being generated. The next
|
|
341
|
+
// normal materialized send claims it (`message.done` with the final text,
|
|
342
|
+
// then the final as `message.reply` reusing the stream's message_id, §8.4);
|
|
343
|
+
// a final that does not extend the preview, a no-reply, a terminal tool
|
|
344
|
+
// send, or an idle run with nothing delivered fails it. Host partials are snapshots of
|
|
345
|
+
// the current assistant message that may be rewritten, so a snapshot that no
|
|
346
|
+
// longer extends the preview fails the old stream and starts a new one.
|
|
347
|
+
const streamEnabled = account.streamReplies === true;
|
|
348
|
+
let replyStream = null;
|
|
349
|
+
const failStream = (reason) => {
|
|
350
|
+
replyStream?.fail(reason);
|
|
351
|
+
replyStream = null;
|
|
352
|
+
};
|
|
353
|
+
const claimStreamMessageId = (finalText) => {
|
|
354
|
+
const id = replyStream?.finish(finalText) ?? null;
|
|
355
|
+
replyStream = null;
|
|
356
|
+
return id;
|
|
357
|
+
};
|
|
358
|
+
const onStreamPartial = (text) => {
|
|
359
|
+
if (runDone || terminalReplySuppressed)
|
|
360
|
+
return;
|
|
361
|
+
if (replyStream?.diverges(text))
|
|
362
|
+
failStream("superseded");
|
|
363
|
+
replyStream ??= createReplyStream({
|
|
364
|
+
client,
|
|
365
|
+
chatId: target.chatId,
|
|
366
|
+
mintMessageId,
|
|
367
|
+
log,
|
|
368
|
+
logPrefix: `[${account.accountId}] clawchat-plugin-openclaw`,
|
|
369
|
+
});
|
|
370
|
+
replyStream.update(text);
|
|
371
|
+
};
|
|
329
372
|
const consumeTerminalSend = (reason) => {
|
|
330
373
|
if (terminalReplySuppressed)
|
|
331
374
|
return true;
|
|
@@ -339,6 +382,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
339
382
|
if (!terminal)
|
|
340
383
|
return false;
|
|
341
384
|
terminalReplySuppressed = true;
|
|
385
|
+
failStream("terminal-send");
|
|
342
386
|
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw suppressing ${reason} reply after terminal tool send msg=${terminal.messageId} to=${target.chatId}`);
|
|
343
387
|
return true;
|
|
344
388
|
};
|
|
@@ -424,7 +468,8 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
424
468
|
bufferedOutputUrls.push(url);
|
|
425
469
|
}
|
|
426
470
|
};
|
|
427
|
-
const fullSegmentKey = (text, urls) => JSON.stringify({
|
|
471
|
+
const fullSegmentKey = (text, urls, mode) => JSON.stringify({
|
|
472
|
+
mode,
|
|
428
473
|
text: text.replace(/\s+/g, " ").trim(),
|
|
429
474
|
urls: urls.filter(Boolean),
|
|
430
475
|
});
|
|
@@ -458,6 +503,39 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
458
503
|
log?.error?.(`[${account.accountId}] clawchat-plugin-openclaw typing update failed: ${String(error)}`);
|
|
459
504
|
}
|
|
460
505
|
};
|
|
506
|
+
// Plugin-owned typing keepalive. The client lights "typing" for a fixed
|
|
507
|
+
// window per `is_typing:true` frame, so the sender must re-send at no more
|
|
508
|
+
// than half that window (TYPING_KEEPALIVE_MS) for as long as the reply is in
|
|
509
|
+
// flight, and send `false` exactly once at the end. The host's own keepalive
|
|
510
|
+
// (default 6 s, global config only) equals the client window, so one lost or
|
|
511
|
+
// late frame makes the indicator flash; we ask the host to turn it off
|
|
512
|
+
// (`typingKeepalive: false`, honoured by newer hosts) and ignore its repeated
|
|
513
|
+
// `onReplyStart` ticks on older hosts.
|
|
514
|
+
let typingTimer = null;
|
|
515
|
+
let typingCapTimer = null;
|
|
516
|
+
let typingFinished = false;
|
|
517
|
+
const stopTyping = () => {
|
|
518
|
+
if (typingTimer)
|
|
519
|
+
clearInterval(typingTimer);
|
|
520
|
+
if (typingCapTimer)
|
|
521
|
+
clearTimeout(typingCapTimer);
|
|
522
|
+
typingTimer = null;
|
|
523
|
+
typingCapTimer = null;
|
|
524
|
+
typingFinished = true;
|
|
525
|
+
emitTyping(false);
|
|
526
|
+
};
|
|
527
|
+
const startTyping = () => {
|
|
528
|
+
if (typingFinished || typingTimer)
|
|
529
|
+
return;
|
|
530
|
+
emitTyping(true);
|
|
531
|
+
typingTimer = setInterval(() => emitTyping(true), TYPING_KEEPALIVE_MS);
|
|
532
|
+
typingTimer.unref?.();
|
|
533
|
+
typingCapTimer = setTimeout(() => {
|
|
534
|
+
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw typing keepalive cap reached; stopping to=${target.chatId}`);
|
|
535
|
+
stopTyping();
|
|
536
|
+
}, TYPING_KEEPALIVE_MAX_MS);
|
|
537
|
+
typingCapTimer.unref?.();
|
|
538
|
+
};
|
|
461
539
|
const sendOwnerAttention = async (fallbackText, richFragment = null) => {
|
|
462
540
|
const ownerTarget = ownerDirectTarget();
|
|
463
541
|
if (!ownerTarget) {
|
|
@@ -479,10 +557,13 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
479
557
|
};
|
|
480
558
|
// ----- Static send ------------------------------------------------------
|
|
481
559
|
const sendStatic = async (text, mediaFragments = [], richFragments = [], options = {}) => {
|
|
560
|
+
const messageMode = options.messageMode ?? "normal";
|
|
482
561
|
if (isClawChatNoopResponseText(text) &&
|
|
483
562
|
mediaFragments.length === 0 &&
|
|
484
563
|
richFragments.length === 0) {
|
|
485
564
|
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw outbound suppressed: no-reply token`);
|
|
565
|
+
if (messageMode === "normal")
|
|
566
|
+
failStream("no-reply");
|
|
486
567
|
openclawLlmContextDebug.writeSnapshot({
|
|
487
568
|
visibility: "host_event",
|
|
488
569
|
trace: {
|
|
@@ -506,11 +587,12 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
506
587
|
return null;
|
|
507
588
|
if (consumeTerminalSend("static"))
|
|
508
589
|
return null;
|
|
509
|
-
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw sending static text_len=${text.length} media=${mediaFragments.length} rich=${richFragments.length} to=${target.chatId}`);
|
|
510
|
-
const
|
|
590
|
+
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw sending static mode=${messageMode} text_len=${text.length} media=${mediaFragments.length} rich=${richFragments.length} to=${target.chatId}`);
|
|
591
|
+
const streamMessageId = messageMode === "normal" ? claimStreamMessageId(text) : null;
|
|
592
|
+
const messageId = streamMessageId ?? mintStaticMessageId();
|
|
511
593
|
const raw = { target, replyCtx: replyCtx ?? null, mode: "static" };
|
|
512
594
|
const claimed = options.recordMessage
|
|
513
|
-
? claimOutbound(outboundEventType(), messageId, text, raw)
|
|
595
|
+
? claimOutbound(streamMessageId ? "message.reply" : outboundEventType(), messageId, text, raw)
|
|
514
596
|
: true;
|
|
515
597
|
if (claimed === false) {
|
|
516
598
|
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw outbound duplicate skipped msg=${messageId}`);
|
|
@@ -529,10 +611,14 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
529
611
|
...(replyCtx ? { replyCtx } : {}),
|
|
530
612
|
...(richFragments.length > 0 ? { richFragments } : {}),
|
|
531
613
|
...(mediaFragments.length > 0 ? { mediaFragments } : {}),
|
|
614
|
+
...(messageMode !== "normal" ? { messageMode } : {}),
|
|
615
|
+
...(streamMessageId ? { finalizesStream: true } : {}),
|
|
532
616
|
log,
|
|
533
617
|
});
|
|
534
618
|
recordOutboundAck("message", messageId, result);
|
|
535
|
-
|
|
619
|
+
// Process (thinking) messages are not replies: they must not make a
|
|
620
|
+
// bootstrap greeting look delivered.
|
|
621
|
+
if (result && messageMode === "normal")
|
|
536
622
|
outboundDeliveredCount += 1;
|
|
537
623
|
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw send complete to=${target.chatId}`);
|
|
538
624
|
openclawLlmContextDebug.writeSnapshot({
|
|
@@ -558,7 +644,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
558
644
|
});
|
|
559
645
|
return result;
|
|
560
646
|
};
|
|
561
|
-
const emitFullSegment = async (text, urls = []) => {
|
|
647
|
+
const emitFullSegment = async (text, urls = [], messageMode = "normal") => {
|
|
562
648
|
if (outputVisibility !== "full" && !splitNormalBlockOutput) {
|
|
563
649
|
appendBufferedText(text);
|
|
564
650
|
appendBufferedUrls(urls);
|
|
@@ -572,24 +658,26 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
572
658
|
const trimmed = text.trim();
|
|
573
659
|
if (!trimmed && urls.length === 0)
|
|
574
660
|
return;
|
|
575
|
-
const segmentKey = fullSegmentKey(trimmed, urls);
|
|
661
|
+
const segmentKey = fullSegmentKey(trimmed, urls, messageMode);
|
|
576
662
|
if (emittedFullSegmentSet.has(segmentKey))
|
|
577
663
|
return;
|
|
578
664
|
emittedFullSegmentSet.add(segmentKey);
|
|
579
665
|
const mediaFragments = await uploadMediaUrls(urls);
|
|
580
|
-
await sendStatic(trimmed, mediaFragments, [], { recordMessage: true });
|
|
666
|
+
await sendStatic(trimmed, mediaFragments, [], { recordMessage: true, messageMode });
|
|
581
667
|
};
|
|
582
668
|
const emitFullRuntimeText = async (label, text, urls = []) => {
|
|
583
669
|
const summary = summarizeValue(text);
|
|
584
670
|
if (!summary && urls.length === 0)
|
|
585
671
|
return;
|
|
586
|
-
await emitFullSegment(summary ? `[${label}] ${summary}` : "", urls);
|
|
672
|
+
await emitFullSegment(summary ? `[${label}] ${summary}` : "", urls, "thinking");
|
|
587
673
|
};
|
|
674
|
+
/** Process output (tool / command / patch / compaction / reasoning): `message_mode: "thinking"`. */
|
|
675
|
+
const emitProcessSegment = (text, urls = []) => emitFullSegment(text, urls, "thinking");
|
|
588
676
|
// ----- Dispatcher -------------------------------------------------------
|
|
589
677
|
const base = runtime.channel.reply.createReplyDispatcherWithTyping({
|
|
590
678
|
humanDelay,
|
|
591
679
|
onReplyStart: async () => {
|
|
592
|
-
|
|
680
|
+
startTyping();
|
|
593
681
|
},
|
|
594
682
|
deliver: async (payload, info) => {
|
|
595
683
|
if (consumeTerminalSend(info?.kind ?? "unknown"))
|
|
@@ -614,7 +702,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
614
702
|
if (payload.isReasoning) {
|
|
615
703
|
if (outputVisibility !== "full")
|
|
616
704
|
return;
|
|
617
|
-
await
|
|
705
|
+
await emitProcessSegment(text, urls);
|
|
618
706
|
const trimmed = text.trim();
|
|
619
707
|
if (trimmed)
|
|
620
708
|
reasoningText = reasoningText ? `${reasoningText}\n${trimmed}` : trimmed;
|
|
@@ -645,6 +733,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
645
733
|
finalDeliverySeen = true;
|
|
646
734
|
if (isClawChatNoopResponseText(text) && !richFragment && urls.length === 0) {
|
|
647
735
|
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw final suppressed: no-reply token`);
|
|
736
|
+
failStream("no-reply");
|
|
648
737
|
openclawLlmContextDebug.writeSnapshot({
|
|
649
738
|
visibility: "host_event",
|
|
650
739
|
trace: {
|
|
@@ -670,6 +759,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
670
759
|
!richFragment &&
|
|
671
760
|
finalUrls.length === 0) {
|
|
672
761
|
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw final suppressed: no-reply token`);
|
|
762
|
+
failStream("no-reply");
|
|
673
763
|
return;
|
|
674
764
|
}
|
|
675
765
|
const mediaFragments = await uploadMediaUrls(finalUrls);
|
|
@@ -687,23 +777,29 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
687
777
|
void emitFullRuntimeText("error", errorText);
|
|
688
778
|
},
|
|
689
779
|
onIdle: async () => {
|
|
690
|
-
|
|
780
|
+
stopTyping();
|
|
691
781
|
if (runDone)
|
|
692
782
|
return;
|
|
693
783
|
runDone = true;
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
784
|
+
try {
|
|
785
|
+
if (finalDeliverySeen)
|
|
786
|
+
return;
|
|
787
|
+
const fallbackText = bufferedOutputText.trim();
|
|
788
|
+
const fallbackUrls = bufferedOutputUrls.slice();
|
|
789
|
+
if (!fallbackText && fallbackUrls.length === 0)
|
|
790
|
+
return;
|
|
791
|
+
const mediaFragments = await uploadMediaUrls(fallbackUrls);
|
|
792
|
+
const result = await sendStatic(fallbackText, mediaFragments, [], { recordMessage: true });
|
|
793
|
+
if (result?.messageId)
|
|
794
|
+
recordThinkingIfLinked(result.messageId);
|
|
795
|
+
}
|
|
796
|
+
finally {
|
|
797
|
+
// A preview nothing materialized must not linger as an open stream.
|
|
798
|
+
failStream("undelivered");
|
|
799
|
+
}
|
|
704
800
|
},
|
|
705
801
|
onCleanup: () => {
|
|
706
|
-
|
|
802
|
+
stopTyping();
|
|
707
803
|
},
|
|
708
804
|
});
|
|
709
805
|
return {
|
|
@@ -712,7 +808,15 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
712
808
|
replyOptions: {
|
|
713
809
|
...base.replyOptions,
|
|
714
810
|
sourceReplyDeliveryMode: "automatic",
|
|
715
|
-
|
|
811
|
+
typingKeepalive: false,
|
|
812
|
+
// Streaming previews the assistant message and the final materializes it,
|
|
813
|
+
// so host block chunks (which split one message arbitrarily) stay off.
|
|
814
|
+
disableBlockStreaming: streamEnabled ? true : !splitNormalBlockOutput,
|
|
815
|
+
onPartialReply: streamEnabled
|
|
816
|
+
? (payload) => {
|
|
817
|
+
onStreamPartial(typeof payload?.text === "string" ? payload.text : "");
|
|
818
|
+
}
|
|
819
|
+
: undefined,
|
|
716
820
|
suppressDefaultToolProgressMessages: true,
|
|
717
821
|
allowProgressCallbacksWhenSourceDeliverySuppressed: splitFullOutput ? true : undefined,
|
|
718
822
|
onReasoningStream: splitFullOutput
|
|
@@ -730,7 +834,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
730
834
|
? async (payload) => {
|
|
731
835
|
if (consumeTerminalSend("tool-start"))
|
|
732
836
|
return;
|
|
733
|
-
await
|
|
837
|
+
await emitProcessSegment(formatToolStartSummary(payload));
|
|
734
838
|
}
|
|
735
839
|
: undefined,
|
|
736
840
|
onToolResult: splitFullOutput
|
|
@@ -763,28 +867,28 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
763
867
|
? async (payload) => {
|
|
764
868
|
if (consumeTerminalSend("command-output"))
|
|
765
869
|
return;
|
|
766
|
-
await
|
|
870
|
+
await emitProcessSegment(formatCommandOutputSummary(payload));
|
|
767
871
|
}
|
|
768
872
|
: undefined,
|
|
769
873
|
onPatchSummary: splitFullOutput
|
|
770
874
|
? async (payload) => {
|
|
771
875
|
if (consumeTerminalSend("patch-summary"))
|
|
772
876
|
return;
|
|
773
|
-
await
|
|
877
|
+
await emitProcessSegment(formatPatchSummary(payload));
|
|
774
878
|
}
|
|
775
879
|
: undefined,
|
|
776
880
|
onCompactionStart: splitFullOutput
|
|
777
881
|
? async () => {
|
|
778
882
|
if (consumeTerminalSend("compaction-start"))
|
|
779
883
|
return;
|
|
780
|
-
await
|
|
884
|
+
await emitProcessSegment("[compaction] started");
|
|
781
885
|
}
|
|
782
886
|
: undefined,
|
|
783
887
|
onCompactionEnd: splitFullOutput
|
|
784
888
|
? async () => {
|
|
785
889
|
if (consumeTerminalSend("compaction-end"))
|
|
786
890
|
return;
|
|
787
|
-
await
|
|
891
|
+
await emitProcessSegment("[compaction] finished");
|
|
788
892
|
}
|
|
789
893
|
: undefined,
|
|
790
894
|
},
|
|
@@ -0,0 +1,208 @@
|
|
|
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.js";
|
|
33
|
+
import { isClawChatNoopResponseText } from "./profile-prompt.js";
|
|
34
|
+
import { isNoReplyTokenPrefix } from "./no-reply.js";
|
|
35
|
+
/** Minimum visible (trimmed) length before a stream is opened. */
|
|
36
|
+
export const STREAM_HOLD_CHARS = 32;
|
|
37
|
+
/** Minimum spacing between two `message.add` frames for one stream. */
|
|
38
|
+
export const STREAM_MIN_ADD_INTERVAL_MS = 250;
|
|
39
|
+
/** How far back from the end a partial no-reply token is looked for. */
|
|
40
|
+
const TOKEN_TAIL_WINDOW = 40;
|
|
41
|
+
/**
|
|
42
|
+
* Decide how much of a cumulative partial `text` may be shown.
|
|
43
|
+
* - `poisoned`: the text is (or contains) a no-reply token; never show it.
|
|
44
|
+
* - `hold`: too short to be sure yet.
|
|
45
|
+
* - `ok`: `text` with any trailing possible-token prefix cut off.
|
|
46
|
+
*/
|
|
47
|
+
export function streamableText(text) {
|
|
48
|
+
if (isClawChatNoopResponseText(text))
|
|
49
|
+
return { kind: "poisoned" };
|
|
50
|
+
let safe = text;
|
|
51
|
+
for (let i = Math.max(0, text.length - TOKEN_TAIL_WINDOW); i < text.length; i += 1) {
|
|
52
|
+
const ch = text[i];
|
|
53
|
+
if (/\s/.test(ch))
|
|
54
|
+
continue;
|
|
55
|
+
if (isNoReplyTokenPrefix(text.slice(i))) {
|
|
56
|
+
safe = text.slice(0, i);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (safe.trim().length < STREAM_HOLD_CHARS)
|
|
61
|
+
return { kind: "hold" };
|
|
62
|
+
return { kind: "ok", text: safe };
|
|
63
|
+
}
|
|
64
|
+
export function createReplyStream(options) {
|
|
65
|
+
const now = options.now ?? Date.now;
|
|
66
|
+
const prefix = options.logPrefix ?? "clawchat-plugin-openclaw";
|
|
67
|
+
let messageId = null;
|
|
68
|
+
let closed = false;
|
|
69
|
+
let poisoned = false;
|
|
70
|
+
let diverged = false;
|
|
71
|
+
let sent = "";
|
|
72
|
+
let sequence = -1;
|
|
73
|
+
let lastAddAt = 0;
|
|
74
|
+
let startedAt = 0;
|
|
75
|
+
const canSend = () => {
|
|
76
|
+
const client = options.client;
|
|
77
|
+
if (client.transportState !== undefined && client.transportState !== "open")
|
|
78
|
+
return false;
|
|
79
|
+
if (client.state !== undefined && client.state !== "connected")
|
|
80
|
+
return false;
|
|
81
|
+
return true;
|
|
82
|
+
};
|
|
83
|
+
const emit = (event, payload) => {
|
|
84
|
+
try {
|
|
85
|
+
options.client.emitRaw(event, payload, { chat_id: options.chatId });
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
options.log?.error?.(`${prefix} stream ${event} failed msg=${messageId}: ${String(error)}`);
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const streamingState = (status, completedAt) => ({
|
|
94
|
+
status,
|
|
95
|
+
sequence: Math.max(sequence, 0),
|
|
96
|
+
mutation_policy: "append_text_only",
|
|
97
|
+
started_at: startedAt,
|
|
98
|
+
completed_at: completedAt,
|
|
99
|
+
});
|
|
100
|
+
const sendAdd = (next, at) => {
|
|
101
|
+
sequence += 1;
|
|
102
|
+
const ok = emit(EVENT.MESSAGE_ADD, {
|
|
103
|
+
message_id: messageId,
|
|
104
|
+
sequence,
|
|
105
|
+
mutation: { type: "append", target_fragment_index: 0 },
|
|
106
|
+
fragments: [{ kind: "text", text: next, delta: next.slice(sent.length) }],
|
|
107
|
+
streaming: streamingState("streaming", null),
|
|
108
|
+
added_at: at,
|
|
109
|
+
});
|
|
110
|
+
if (!ok) {
|
|
111
|
+
sequence -= 1;
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
sent = next;
|
|
115
|
+
lastAddAt = at;
|
|
116
|
+
return true;
|
|
117
|
+
};
|
|
118
|
+
const fail = (reason) => {
|
|
119
|
+
if (!messageId || closed)
|
|
120
|
+
return;
|
|
121
|
+
closed = true;
|
|
122
|
+
const at = now();
|
|
123
|
+
emit(EVENT.MESSAGE_FAILED, {
|
|
124
|
+
message_id: messageId,
|
|
125
|
+
fragments: [{ kind: "text", text: sent }],
|
|
126
|
+
streaming: streamingState("failed", at),
|
|
127
|
+
completed_at: at,
|
|
128
|
+
});
|
|
129
|
+
options.log?.info?.(`${prefix} stream failed msg=${messageId} reason=${reason}`);
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
update(text) {
|
|
133
|
+
if (closed || poisoned || diverged)
|
|
134
|
+
return;
|
|
135
|
+
const decision = streamableText(text);
|
|
136
|
+
if (decision.kind === "poisoned") {
|
|
137
|
+
poisoned = true;
|
|
138
|
+
options.log?.info?.(`${prefix} stream halted: no-reply token in partial msg=${messageId ?? "-"}`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (decision.kind === "hold")
|
|
142
|
+
return;
|
|
143
|
+
const next = decision.text;
|
|
144
|
+
if (messageId && !next.startsWith(sent)) {
|
|
145
|
+
// §8.2 delta invariant cannot hold for a rewrite; stop growing the
|
|
146
|
+
// stream and let the final message (same id) carry the real text.
|
|
147
|
+
diverged = true;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (next === sent)
|
|
151
|
+
return;
|
|
152
|
+
if (!canSend())
|
|
153
|
+
return;
|
|
154
|
+
if (!messageId) {
|
|
155
|
+
const id = options.mintMessageId();
|
|
156
|
+
startedAt = now();
|
|
157
|
+
if (!emit(EVENT.MESSAGE_CREATED, { message_id: id, message_mode: "normal" }))
|
|
158
|
+
return;
|
|
159
|
+
messageId = id;
|
|
160
|
+
options.log?.info?.(`${prefix} stream opened msg=${id} to=${options.chatId}`);
|
|
161
|
+
}
|
|
162
|
+
else if (now() - lastAddAt < STREAM_MIN_ADD_INTERVAL_MS) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
sendAdd(next, now());
|
|
166
|
+
},
|
|
167
|
+
diverges(text) {
|
|
168
|
+
if (!messageId || closed)
|
|
169
|
+
return false;
|
|
170
|
+
const decision = streamableText(text);
|
|
171
|
+
return decision.kind === "ok" && !decision.text.startsWith(sent);
|
|
172
|
+
},
|
|
173
|
+
isOpen() {
|
|
174
|
+
return messageId !== null && !closed;
|
|
175
|
+
},
|
|
176
|
+
shownText() {
|
|
177
|
+
return sent;
|
|
178
|
+
},
|
|
179
|
+
finish(finalText) {
|
|
180
|
+
if (!messageId || closed)
|
|
181
|
+
return null;
|
|
182
|
+
if (streamableText(finalText).kind === "poisoned") {
|
|
183
|
+
fail("no-reply");
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
// Trailing whitespace a partial carried may be trimmed off the final.
|
|
187
|
+
if (!finalText.startsWith(sent.trimEnd())) {
|
|
188
|
+
fail("superseded");
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
if (finalText.length > sent.length && finalText.startsWith(sent)) {
|
|
192
|
+
// Past the throttle: this is the last frame before done.
|
|
193
|
+
sendAdd(finalText, now());
|
|
194
|
+
}
|
|
195
|
+
closed = true;
|
|
196
|
+
const at = now();
|
|
197
|
+
emit(EVENT.MESSAGE_DONE, {
|
|
198
|
+
message_id: messageId,
|
|
199
|
+
fragments: [{ kind: "text", text: finalText }],
|
|
200
|
+
streaming: streamingState("done", at),
|
|
201
|
+
completed_at: at,
|
|
202
|
+
});
|
|
203
|
+
options.log?.info?.(`${prefix} stream done msg=${messageId} text_len=${finalText.length}`);
|
|
204
|
+
return messageId;
|
|
205
|
+
},
|
|
206
|
+
fail,
|
|
207
|
+
};
|
|
208
|
+
}
|
package/dist/src/runtime.js
CHANGED
|
@@ -33,6 +33,7 @@ import { loadClawChatPromptMetadata, renderClawChatProfilePrompt, resolveSenderR
|
|
|
33
33
|
import { refreshGroupProfile, syncFirstSeenClawChatProfiles } from "./profile-sync.js";
|
|
34
34
|
import { pullGroupMetadata, pullOwnerMetadata } from "./clawchat-metadata.js";
|
|
35
35
|
import { deleteClawChatMemoryFile, readClawChatMemoryFile } from "./clawchat-memory.js";
|
|
36
|
+
import { registerMentionRosterResolver } from "./mention-autolink.js";
|
|
36
37
|
import { clearClawChatPromptInjectionForSession, stageClawChatPromptInjection, } from "./prompt-injection.js";
|
|
37
38
|
import { createGroupMessageCoalescer } from "./group-message-coalescer.js";
|
|
38
39
|
import { openclawLlmContextDebug } from "./llm-context-debug.js";
|
|
@@ -3229,6 +3230,23 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
3229
3230
|
return;
|
|
3230
3231
|
}
|
|
3231
3232
|
activeClients.set(accountId, client);
|
|
3233
|
+
// Text "@name" autolinking on outbound group sends reads the same cached
|
|
3234
|
+
// roster (participant ids + nicknames) the group prompt shows the model.
|
|
3235
|
+
const unregisterMentionRoster = registerMentionRosterResolver(accountId, async (groupId) => {
|
|
3236
|
+
const memoryRoot = resolveMemoryRootForPeer({ kind: "group", id: groupId });
|
|
3237
|
+
if (!memoryRoot)
|
|
3238
|
+
return [];
|
|
3239
|
+
let ownerMetadata = null;
|
|
3240
|
+
try {
|
|
3241
|
+
const ownerFile = await readClawChatMemoryFile(memoryRoot, { targetType: "owner", targetId: "owner" });
|
|
3242
|
+
ownerMetadata = ownerFile.exists ? ownerFile.metadata : null;
|
|
3243
|
+
}
|
|
3244
|
+
catch {
|
|
3245
|
+
ownerMetadata = null;
|
|
3246
|
+
}
|
|
3247
|
+
const participants = await loadGroupParticipantsForPrompt({ memoryRoot, groupId, ownerMetadata });
|
|
3248
|
+
return participants.map((participant) => ({ userId: participant.id, name: participant.name }));
|
|
3249
|
+
});
|
|
3232
3250
|
log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime active client registered`);
|
|
3233
3251
|
setStatus({
|
|
3234
3252
|
...getStatus(),
|
|
@@ -3240,6 +3258,7 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
3240
3258
|
await waitUntilAbort(abortSignal, async () => {
|
|
3241
3259
|
log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime abort received; closing client`);
|
|
3242
3260
|
activeClients.delete(accountId);
|
|
3261
|
+
unregisterMentionRoster();
|
|
3243
3262
|
closingForAbort = true;
|
|
3244
3263
|
clearBootstrapRetry();
|
|
3245
3264
|
// §A.1 — stop the proactive refresh timer on shutdown.
|
package/dist/src/skill-update.js
CHANGED
|
@@ -66,7 +66,7 @@ export const OFFICIAL_SKILLS_BASE = "https://raw.githubusercontent.com/clawling/
|
|
|
66
66
|
* in the install-cli repo, bump this constant, ship it. `liveware-sample.ts`
|
|
67
67
|
* imports the same ref, so the `livewares` tree at that tag is pinned too.
|
|
68
68
|
*/
|
|
69
|
-
export const DEFAULT_SKILLS_REF = "skills-v1.
|
|
69
|
+
export const DEFAULT_SKILLS_REF = "skills-v1.16.0";
|
|
70
70
|
/** Refuse to treat an absurdly large response as a skill file (defence in depth). */
|
|
71
71
|
export const MAX_SKILL_BYTES = 256 * 1024;
|
|
72
72
|
/** This adapter's host target inside `skills/manifest.json`. */
|
package/dist/src/tools.js
CHANGED
|
@@ -1449,11 +1449,18 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
1449
1449
|
return {
|
|
1450
1450
|
name: "clawchat_orchestrate_set_agent_behavior",
|
|
1451
1451
|
label: "Rewrite A Sibling's System Prompt",
|
|
1452
|
-
description: toolDescription("
|
|
1452
|
+
description: toolDescription("Stage/room speaking rules go in the group description, not here; for a stage, append the stage sentence below to off-desktop members (clawchat-orchestration skill). " +
|
|
1453
|
+
"Replace the WHOLE system prompt (behavior) of one of the owner's other agents. " +
|
|
1453
1454
|
"TRIGGER — invoke when the owner asks to change how another agent behaves, talks, or what it is for. " +
|
|
1454
1455
|
"This REPLACES the entire field — call clawchat_orchestrate_get_agent first, edit the text you got back, and send the full new value. " +
|
|
1455
1456
|
"Sending a fragment deletes everything else and the owner cannot recover it. Max 3000 runes. " +
|
|
1456
|
-
"Nickname and bio cannot be changed here."
|
|
1457
|
+
"Nickname and bio cannot be changed here. " +
|
|
1458
|
+
"Behavior follows an agent into every room, so one room's rules never go here — they go in that group's description (clawchat_orchestrate_set_group_prompt). " +
|
|
1459
|
+
"A stage (a group whose description has its agents chime in freely) adds only one line here, the same for every room: " +
|
|
1460
|
+
"give each member that the ClawChat desktop app does not run on the owner's computer — Hermes and OpenClaw plugin agents among them — this sentence, verbatim, " +
|
|
1461
|
+
"appended without deleting anything and without naming the room: " +
|
|
1462
|
+
"\"In a group whose description makes it a stage, you are one of the players: pick up the other characters' lines without waiting to be called.\" " +
|
|
1463
|
+
"Older plugin versions rank the group description below their own reply rules; this line is what lets them speak on a stage."),
|
|
1457
1464
|
parameters: ClawchatOrchSetAgentBehaviorSchema,
|
|
1458
1465
|
async execute(_callId, params) {
|
|
1459
1466
|
return await recordClawchatToolCall(accountId, "clawchat_orchestrate_set_agent_behavior", params, async () => {
|
|
@@ -1497,11 +1504,15 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
1497
1504
|
return {
|
|
1498
1505
|
name: "clawchat_orchestrate_set_group_prompt",
|
|
1499
1506
|
label: "Rewrite A Group's System Prompt",
|
|
1500
|
-
description: toolDescription("
|
|
1507
|
+
description: toolDescription("Stage/room speaking rules go here, in the group description; for a stage, also append the stage sentence to off-desktop members' behavior (clawchat-orchestration skill). " +
|
|
1508
|
+
"Replace the WHOLE system prompt (description) of a group the owner administers. " +
|
|
1501
1509
|
"TRIGGER — invoke when the owner asks to change how a group behaves as a whole. " +
|
|
1502
1510
|
"This REPLACES the entire field — call clawchat_orchestrate_get_group first and send the full new value. " +
|
|
1503
1511
|
"Sending a fragment deletes everything else and the owner cannot recover it. Max 3000 runes. " +
|
|
1504
|
-
"The group title cannot be changed here."
|
|
1512
|
+
"The group title cannot be changed here. " +
|
|
1513
|
+
"A room where agents should chime in freely, a stage, says so in its description, and its own rules go nowhere else: behavior follows an agent into every room. " +
|
|
1514
|
+
"A stage also needs one room-independent line in the behavior of each member the ClawChat desktop app does not run on the owner's computer, appended verbatim with clawchat_orchestrate_set_agent_behavior: " +
|
|
1515
|
+
"\"In a group whose description makes it a stage, you are one of the players: pick up the other characters' lines without waiting to be called.\""),
|
|
1505
1516
|
parameters: ClawchatOrchSetGroupPromptSchema,
|
|
1506
1517
|
async execute(_callId, params) {
|
|
1507
1518
|
return await recordClawchatToolCall(accountId, "clawchat_orchestrate_set_group_prompt", params, async () => {
|
package/openclaw.plugin.json
CHANGED
|
@@ -124,6 +124,7 @@
|
|
|
124
124
|
"forwardThinking": { "type": "boolean" },
|
|
125
125
|
"forwardToolCalls": { "type": "boolean" },
|
|
126
126
|
"richInteractions": { "type": "boolean" },
|
|
127
|
+
"streamReplies": { "type": "boolean" },
|
|
127
128
|
"awarenessNote": { "type": "boolean" },
|
|
128
129
|
"friendGreeting": { "type": "boolean" },
|
|
129
130
|
"livewareSample": { "type": "boolean" },
|
|
@@ -197,6 +198,7 @@
|
|
|
197
198
|
"forwardThinking": { "type": "boolean" },
|
|
198
199
|
"forwardToolCalls": { "type": "boolean" },
|
|
199
200
|
"richInteractions": { "type": "boolean" },
|
|
201
|
+
"streamReplies": { "type": "boolean" },
|
|
200
202
|
"awarenessNote": { "type": "boolean" },
|
|
201
203
|
"friendGreeting": { "type": "boolean" },
|
|
202
204
|
"livewareSample": { "type": "boolean" },
|
|
@@ -276,6 +278,7 @@
|
|
|
276
278
|
"forwardThinking": { "type": "boolean" },
|
|
277
279
|
"forwardToolCalls": { "type": "boolean" },
|
|
278
280
|
"richInteractions": { "type": "boolean" },
|
|
281
|
+
"streamReplies": { "type": "boolean" },
|
|
279
282
|
"awarenessNote": { "type": "boolean" },
|
|
280
283
|
"friendGreeting": { "type": "boolean" },
|
|
281
284
|
"livewareSample": { "type": "boolean" },
|
|
@@ -349,6 +352,7 @@
|
|
|
349
352
|
"forwardThinking": { "type": "boolean" },
|
|
350
353
|
"forwardToolCalls": { "type": "boolean" },
|
|
351
354
|
"richInteractions": { "type": "boolean" },
|
|
355
|
+
"streamReplies": { "type": "boolean" },
|
|
352
356
|
"awarenessNote": { "type": "boolean" },
|
|
353
357
|
"friendGreeting": { "type": "boolean" },
|
|
354
358
|
"livewareSample": { "type": "boolean" },
|