@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.
- package/dist/src/config.js +3 -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/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/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/mention-autolink.ts +200 -0
- package/src/no-reply.ts +7 -6
- package/src/outbound.ts +58 -7
- 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
package/dist/src/config.js
CHANGED
|
@@ -82,6 +82,7 @@ export const openclawClawlingAccountConfigSchema = {
|
|
|
82
82
|
forwardThinking: { type: "boolean" },
|
|
83
83
|
forwardToolCalls: { type: "boolean" },
|
|
84
84
|
richInteractions: { type: "boolean" },
|
|
85
|
+
streamReplies: { type: "boolean" },
|
|
85
86
|
awarenessNote: { type: "boolean" },
|
|
86
87
|
friendGreeting: { type: "boolean" },
|
|
87
88
|
livewareSample: { type: "boolean" },
|
|
@@ -448,6 +449,7 @@ export function resolveOpenclawClawlingAccount(cfg, accountId, env = process.env
|
|
|
448
449
|
const forwardThinking = typeof channel.forwardThinking === "boolean" ? channel.forwardThinking : true;
|
|
449
450
|
const forwardToolCalls = typeof channel.forwardToolCalls === "boolean" ? channel.forwardToolCalls : false;
|
|
450
451
|
const richInteractions = typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
|
|
452
|
+
const streamReplies = typeof channel.streamReplies === "boolean" ? channel.streamReplies : false;
|
|
451
453
|
const awarenessNote = typeof channel.awarenessNote === "boolean" ? channel.awarenessNote : false;
|
|
452
454
|
const friendGreeting = typeof channel.friendGreeting === "boolean" ? channel.friendGreeting : true;
|
|
453
455
|
const livewareSample = typeof channel.livewareSample === "boolean" ? channel.livewareSample : true;
|
|
@@ -476,6 +478,7 @@ export function resolveOpenclawClawlingAccount(cfg, accountId, env = process.env
|
|
|
476
478
|
forwardThinking,
|
|
477
479
|
forwardToolCalls,
|
|
478
480
|
richInteractions,
|
|
481
|
+
streamReplies,
|
|
479
482
|
awarenessNote,
|
|
480
483
|
friendGreeting,
|
|
481
484
|
livewareSample,
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/** The `@everyone` sentinel user id; never auto-linked from text. */
|
|
2
|
+
export const MENTION_ALL_SENTINEL = "all";
|
|
3
|
+
const AT = 0x40;
|
|
4
|
+
function isAsciiAlnum(c) {
|
|
5
|
+
return (c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a);
|
|
6
|
+
}
|
|
7
|
+
function isEmailLocal(c) {
|
|
8
|
+
return isAsciiAlnum(c) || c === 0x2e || c === 0x5f || c === 0x25 || c === 0x2b || c === 0x2d;
|
|
9
|
+
}
|
|
10
|
+
function openBoundary(text, at) {
|
|
11
|
+
if (at === 0)
|
|
12
|
+
return true;
|
|
13
|
+
return !isEmailLocal(text.charCodeAt(at - 1));
|
|
14
|
+
}
|
|
15
|
+
function lowerAscii(c) {
|
|
16
|
+
return c >= 0x41 && c <= 0x5a ? c + 0x20 : c;
|
|
17
|
+
}
|
|
18
|
+
function matches(text, start, name, fold) {
|
|
19
|
+
const end = start + name.length;
|
|
20
|
+
if (end > text.length)
|
|
21
|
+
return false;
|
|
22
|
+
for (let k = 0; k < name.length; k += 1) {
|
|
23
|
+
let a = text.charCodeAt(start + k);
|
|
24
|
+
let b = name.charCodeAt(k);
|
|
25
|
+
if (fold) {
|
|
26
|
+
a = lowerAscii(a);
|
|
27
|
+
b = lowerAscii(b);
|
|
28
|
+
}
|
|
29
|
+
if (a !== b)
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
if (end === text.length)
|
|
33
|
+
return true;
|
|
34
|
+
return !(isAsciiAlnum(name.charCodeAt(name.length - 1)) && isAsciiAlnum(text.charCodeAt(end)));
|
|
35
|
+
}
|
|
36
|
+
function matchAt(text, start, targets) {
|
|
37
|
+
let best = null;
|
|
38
|
+
let bestExact = false;
|
|
39
|
+
let ambiguous = false;
|
|
40
|
+
for (const t of targets) {
|
|
41
|
+
const exact = matches(text, start, t.name, false);
|
|
42
|
+
const hit = exact || matches(text, start, t.name, true);
|
|
43
|
+
if (!hit)
|
|
44
|
+
continue;
|
|
45
|
+
if (!best ||
|
|
46
|
+
t.name.length > best.name.length ||
|
|
47
|
+
(t.name.length === best.name.length && exact && !bestExact)) {
|
|
48
|
+
best = t;
|
|
49
|
+
bestExact = exact;
|
|
50
|
+
ambiguous = false;
|
|
51
|
+
}
|
|
52
|
+
else if (t.name.length === best.name.length && exact === bestExact && t.userId !== best.userId) {
|
|
53
|
+
ambiguous = true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return ambiguous ? null : best;
|
|
57
|
+
}
|
|
58
|
+
function targetsOf(roster, ownUserId) {
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const m of roster) {
|
|
61
|
+
const name = typeof m.name === "string" ? m.name.trim() : "";
|
|
62
|
+
const userId = typeof m.userId === "string" ? m.userId.trim() : "";
|
|
63
|
+
if (!name || !userId)
|
|
64
|
+
continue;
|
|
65
|
+
if (userId === ownUserId)
|
|
66
|
+
continue;
|
|
67
|
+
if (userId === MENTION_ALL_SENTINEL)
|
|
68
|
+
continue;
|
|
69
|
+
out.push({ userId, name });
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/** Whether `text` has an `@` worth fetching the group roster for. */
|
|
74
|
+
export function hasMentionCandidate(text) {
|
|
75
|
+
for (let i = 0; i < text.length - 1; i += 1) {
|
|
76
|
+
if (text.charCodeAt(i) === AT && openBoundary(text, i))
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Split `text` into text / mention fragments using `roster`. The `@` is
|
|
83
|
+
* consumed by the mention fragment (`display` is the bare name; clients
|
|
84
|
+
* render `@display`). With no hit the result is a single text fragment
|
|
85
|
+
* (or `[]` for empty text).
|
|
86
|
+
*/
|
|
87
|
+
export function autolinkMentions(text, roster, options) {
|
|
88
|
+
if (!text)
|
|
89
|
+
return [];
|
|
90
|
+
const targets = targetsOf(roster, options.ownUserId);
|
|
91
|
+
if (targets.length === 0)
|
|
92
|
+
return [{ kind: "text", text }];
|
|
93
|
+
const out = [];
|
|
94
|
+
let cursor = 0;
|
|
95
|
+
let i = 0;
|
|
96
|
+
while (i < text.length) {
|
|
97
|
+
if (text.charCodeAt(i) !== AT || !openBoundary(text, i)) {
|
|
98
|
+
i += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const hit = matchAt(text, i + 1, targets);
|
|
102
|
+
if (!hit) {
|
|
103
|
+
i += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (i > cursor)
|
|
107
|
+
out.push({ kind: "text", text: text.slice(cursor, i) });
|
|
108
|
+
out.push({ kind: "mention", user_id: hit.userId, display: hit.name });
|
|
109
|
+
i += 1 + hit.name.length;
|
|
110
|
+
cursor = i;
|
|
111
|
+
}
|
|
112
|
+
if (cursor < text.length)
|
|
113
|
+
out.push({ kind: "text", text: text.slice(cursor) });
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
/** The `context.mentions` half: mention fragments de-duplicated by user id. */
|
|
117
|
+
export function mentionsIn(fragments) {
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const f of fragments) {
|
|
121
|
+
if (f.kind !== "mention")
|
|
122
|
+
continue;
|
|
123
|
+
const id = typeof f.user_id === "string" ? f.user_id : "";
|
|
124
|
+
if (!id || seen.has(id))
|
|
125
|
+
continue;
|
|
126
|
+
seen.add(id);
|
|
127
|
+
out.push({ kind: "mention", user_id: id, ...(f.display ? { display: f.display } : {}) });
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
const rosterResolvers = new Map();
|
|
132
|
+
/** Register the roster source for one account; returns an unregister function. */
|
|
133
|
+
export function registerMentionRosterResolver(accountId, resolver) {
|
|
134
|
+
rosterResolvers.set(accountId, resolver);
|
|
135
|
+
return () => {
|
|
136
|
+
if (rosterResolvers.get(accountId) === resolver)
|
|
137
|
+
rosterResolvers.delete(accountId);
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/** Best-effort roster lookup: any failure or missing resolver yields `[]`. */
|
|
141
|
+
export async function resolveMentionRoster(accountId, groupId) {
|
|
142
|
+
const resolver = rosterResolvers.get(accountId);
|
|
143
|
+
if (!resolver)
|
|
144
|
+
return [];
|
|
145
|
+
try {
|
|
146
|
+
const roster = await resolver(groupId);
|
|
147
|
+
return Array.isArray(roster) ? roster : [];
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export function clearMentionRosterResolversForTest() {
|
|
154
|
+
rosterResolvers.clear();
|
|
155
|
+
}
|
package/dist/src/no-reply.js
CHANGED
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
* tolerated for free and must not be written into the pattern itself.
|
|
10
10
|
*
|
|
11
11
|
* RULE B — bare runtime silence markers (`NO_REPLY` / `[SILENT]` / `SILENT` /
|
|
12
|
-
* `NO REPLY`), matched as a WHOLE STRING ONLY.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* `NO REPLY` / `HEARTBEAT_OK`), matched as a WHOLE STRING ONLY. These are
|
|
13
|
+
* carried because agent runtimes define them, so a model may fall back to one
|
|
14
|
+
* instead of the `clawchat:` form; `HEARTBEAT_OK` is the OpenClaw host's
|
|
15
|
+
* heartbeat acknowledgement, which some host versions let through as a reply.
|
|
16
|
+
* They are ordinary words — substring-matching them would swallow prose such as
|
|
17
|
+
* "there is no reply from the server".
|
|
17
18
|
*
|
|
18
19
|
* This module MUST stay a literal mirror of the Hermes plugin's equivalent
|
|
19
20
|
* module. When one side changes, change the other in the same breath.
|
|
@@ -33,7 +34,7 @@ const CONTAINS_RE = new RegExp(CORE, "i");
|
|
|
33
34
|
const DECORATION_OPEN = "[<\\[{`*_~]*";
|
|
34
35
|
const DECORATION_CLOSE = "[/>\\]}`*_~]*";
|
|
35
36
|
const STRIP_RE = new RegExp(DECORATION_OPEN + CORE + DECORATION_CLOSE, "gi");
|
|
36
|
-
const HOST_MARKERS = new Set(["[SILENT]", "SILENT", "NO_REPLY", "NO REPLY"]);
|
|
37
|
+
const HOST_MARKERS = new Set(["[SILENT]", "SILENT", "NO_REPLY", "NO REPLY", "HEARTBEAT_OK"]);
|
|
37
38
|
const HOST_MARKER_MAX_LEN = 64;
|
|
38
39
|
// General_Category=Punctuation, matching the host's `unicodedata.category()`
|
|
39
40
|
// check. Note `*` is Po (stripped) while `~` is Sm (kept) — same as the host.
|
package/dist/src/outbound.js
CHANGED
|
@@ -7,6 +7,7 @@ import { createOpenclawClawlingApiClient } from "./api-client.js";
|
|
|
7
7
|
import { CHANNEL_ID, resolveOpenclawClawlingAccount } from "./config.js";
|
|
8
8
|
import { applyTextMentionLabels, buildMentionMessageFragments, normalizeMentionTargets, textToFragments, } from "./message-mapper.js";
|
|
9
9
|
import { describeOutboundMediaShortfall, uploadOutboundMedia, } from "./media-runtime.js";
|
|
10
|
+
import { autolinkMentions, hasMentionCandidate, mentionsIn, resolveMentionRoster, } from "./mention-autolink.js";
|
|
10
11
|
import { isClawChatNoopResponseText } from "./profile-prompt.js";
|
|
11
12
|
import { stripNoReplyTokens } from "./no-reply.js";
|
|
12
13
|
import { getOpenclawClawlingClient, getOpenclawClawlingRuntime, waitForOpenclawClawlingClient, } from "./runtime.js";
|
|
@@ -423,8 +424,27 @@ export async function sendOpenclawClawlingText(params) {
|
|
|
423
424
|
params.log?.info?.(`[${params.account.accountId}] clawchat-plugin-openclaw outbound suppressed: empty text and no media`);
|
|
424
425
|
return null;
|
|
425
426
|
}
|
|
426
|
-
const
|
|
427
|
-
|
|
427
|
+
const messageMode = params.messageMode ?? "normal";
|
|
428
|
+
let mentions = params.mentions ?? [];
|
|
429
|
+
let textFragments = text ? textToFragments(text) : [];
|
|
430
|
+
// Text "@name" → structured mention (group, normal messages only). Process
|
|
431
|
+
// (thinking) output must not wake anyone. Best-effort: an unavailable roster
|
|
432
|
+
// leaves the text plain and never blocks the send.
|
|
433
|
+
if (textFragments.length > 0
|
|
434
|
+
&& params.to.chatType === "group"
|
|
435
|
+
&& messageMode === "normal"
|
|
436
|
+
&& hasMentionCandidate(text)) {
|
|
437
|
+
const roster = params.mentionRoster
|
|
438
|
+
?? await resolveMentionRoster(params.account.accountId, params.to.chatId);
|
|
439
|
+
const linked = autolinkMentions(text, roster, { ownUserId: params.account.userId ?? "" });
|
|
440
|
+
const linkedMentions = mentionsIn(linked);
|
|
441
|
+
if (linkedMentions.length > 0) {
|
|
442
|
+
textFragments = linked;
|
|
443
|
+
const seen = new Set(mentions.map((m) => m.user_id).filter(Boolean));
|
|
444
|
+
mentions = [...mentions, ...linkedMentions.filter((m) => !seen.has(m.user_id))];
|
|
445
|
+
params.log?.info?.(`[${params.account.accountId}] clawchat-plugin-openclaw outbound text mentions linked count=${linkedMentions.length} to=${params.to.chatId}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
428
448
|
// Each MediaItem object is structurally compatible
|
|
429
449
|
// with one of the local narrow Fragment members (ImageFragment / FileFragment /
|
|
430
450
|
// AudioFragment / VideoFragment) based on its runtime `kind`. The wide local
|
|
@@ -445,7 +465,7 @@ export async function sendOpenclawClawlingText(params) {
|
|
|
445
465
|
mode = "reply";
|
|
446
466
|
const payload = {
|
|
447
467
|
message_id: messageId,
|
|
448
|
-
message_mode:
|
|
468
|
+
message_mode: messageMode,
|
|
449
469
|
message: {
|
|
450
470
|
body: { fragments },
|
|
451
471
|
context: {
|
|
@@ -471,7 +491,8 @@ export async function sendOpenclawClawlingText(params) {
|
|
|
471
491
|
});
|
|
472
492
|
}
|
|
473
493
|
else {
|
|
474
|
-
|
|
494
|
+
// A stream's final is a message.reply (§8.4) whether or not it quotes.
|
|
495
|
+
mode = params.finalizesStream ? "reply" : "send";
|
|
475
496
|
const reply = params.replyCtx
|
|
476
497
|
? {
|
|
477
498
|
reply_to_msg_id: params.replyCtx.replyToMessageId,
|
|
@@ -480,7 +501,7 @@ export async function sendOpenclawClawlingText(params) {
|
|
|
480
501
|
: null;
|
|
481
502
|
const payload = {
|
|
482
503
|
message_id: messageId,
|
|
483
|
-
message_mode:
|
|
504
|
+
message_mode: messageMode,
|
|
484
505
|
message: {
|
|
485
506
|
body: { fragments },
|
|
486
507
|
context: { mentions, reply },
|
|
@@ -489,7 +510,7 @@ export async function sendOpenclawClawlingText(params) {
|
|
|
489
510
|
ack = await sendAlignedAckableEnvelope({
|
|
490
511
|
client: params.client,
|
|
491
512
|
account: params.account,
|
|
492
|
-
eventName: "message.send",
|
|
513
|
+
eventName: mode === "reply" ? "message.reply" : "message.send",
|
|
493
514
|
chatId: params.to.chatId,
|
|
494
515
|
payload,
|
|
495
516
|
...(params.log ? { log: params.log } : {}),
|
|
@@ -498,7 +519,7 @@ export async function sendOpenclawClawlingText(params) {
|
|
|
498
519
|
if (ack.payload.message_id !== messageId) {
|
|
499
520
|
throw new Error(`ack message_id mismatch: expected ${messageId} got ${ack.payload.message_id}`);
|
|
500
521
|
}
|
|
501
|
-
params.log?.info?.(`[${params.account.accountId}] clawchat-plugin-openclaw outbound mode=${mode} msg=${ack.payload.message_id} text_len=${text.length} media=${mediaFragments.length} trace=${ack.trace_id}`);
|
|
522
|
+
params.log?.info?.(`[${params.account.accountId}] clawchat-plugin-openclaw outbound mode=${mode} message_mode=${messageMode} msg=${ack.payload.message_id} text_len=${text.length} media=${mediaFragments.length} trace=${ack.trace_id}`);
|
|
502
523
|
return {
|
|
503
524
|
messageId: ack.payload.message_id,
|
|
504
525
|
acceptedAt: ack.payload.accepted_at,
|
|
@@ -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
|
},
|