@newbase-clawchat/openclaw-clawchat 2026.4.29 → 2026.5.4

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.
Files changed (49) hide show
  1. package/README.md +37 -11
  2. package/dist/index.js +27 -0
  3. package/dist/src/api-client.js +156 -0
  4. package/dist/src/api-types.js +17 -0
  5. package/dist/src/buffered-stream.js +177 -0
  6. package/dist/src/channel.js +200 -0
  7. package/dist/src/client.js +176 -0
  8. package/dist/src/commands.js +35 -0
  9. package/dist/src/config.js +226 -0
  10. package/dist/src/inbound.js +133 -0
  11. package/dist/src/login.runtime.js +132 -0
  12. package/dist/src/media-runtime.js +85 -0
  13. package/dist/src/message-mapper.js +82 -0
  14. package/dist/src/outbound.js +181 -0
  15. package/dist/src/protocol.js +38 -0
  16. package/dist/src/reply-dispatcher.js +440 -0
  17. package/dist/src/runtime.js +288 -0
  18. package/dist/src/streaming.js +65 -0
  19. package/dist/src/tools-schema.js +38 -0
  20. package/dist/src/tools.js +287 -0
  21. package/openclaw.plugin.json +21 -0
  22. package/package.json +27 -5
  23. package/skills/clawchat-activate/SKILL.md +18 -9
  24. package/src/buffered-stream.test.ts +10 -0
  25. package/src/buffered-stream.ts +6 -6
  26. package/src/channel.outbound.test.ts +3 -3
  27. package/src/channel.test.ts +7 -1
  28. package/src/channel.ts +27 -8
  29. package/src/client.test.ts +8 -1
  30. package/src/client.ts +11 -10
  31. package/src/commands.test.ts +6 -0
  32. package/src/commands.ts +5 -1
  33. package/src/config.test.ts +47 -0
  34. package/src/config.ts +28 -5
  35. package/src/inbound.test.ts +4 -1
  36. package/src/inbound.ts +11 -10
  37. package/src/login.runtime.test.ts +36 -0
  38. package/src/login.runtime.ts +57 -27
  39. package/src/manifest.test.ts +156 -30
  40. package/src/outbound.test.ts +6 -5
  41. package/src/outbound.ts +8 -7
  42. package/src/plugin-entry.test.ts +7 -1
  43. package/src/reply-dispatcher.test.ts +418 -3
  44. package/src/reply-dispatcher.ts +137 -12
  45. package/src/runtime.ts +1 -0
  46. package/src/streaming.test.ts +12 -9
  47. package/src/streaming.ts +6 -6
  48. package/src/tools.test.ts +81 -18
  49. package/src/tools.ts +65 -74
@@ -0,0 +1,440 @@
1
+ import { interactiveReplyToPresentation, renderMessagePresentationFallbackText, } from "openclaw/plugin-sdk/interactive-runtime";
2
+ import { createOpenclawClawlingApiClient } from "./api-client.js";
3
+ import { openBufferedStreamingSession, mergeStreamingText, } from "./buffered-stream.js";
4
+ import { emitFinalStreamReply } from "./client.js";
5
+ import { textToFragments } from "./message-mapper.js";
6
+ import { uploadOutboundMedia } from "./media-runtime.js";
7
+ import { sendOpenclawClawlingText } from "./outbound.js";
8
+ import { sendStreamingFailure } from "./streaming.js";
9
+ function normalizeReplyErrorText(error) {
10
+ const raw = String(error);
11
+ const retryWrapped = raw.match(/^Error: Retry failed for delivery [^:]+:\s*(.+)$/s);
12
+ if (retryWrapped?.[1]?.trim())
13
+ return retryWrapped[1].trim();
14
+ const retryWrappedBare = raw.match(/^Retry failed for delivery [^:]+:\s*(.+)$/s);
15
+ if (retryWrappedBare?.[1]?.trim())
16
+ return retryWrappedBare[1].trim();
17
+ return raw;
18
+ }
19
+ function isMessagePresentation(value) {
20
+ return Boolean(value &&
21
+ typeof value === "object" &&
22
+ Array.isArray(value.blocks));
23
+ }
24
+ function resolvePresentation(payload) {
25
+ if (isMessagePresentation(payload.presentation))
26
+ return payload.presentation;
27
+ if (payload.interactive)
28
+ return interactiveReplyToPresentation(payload.interactive);
29
+ return undefined;
30
+ }
31
+ function normalizeActionId(value, label, index) {
32
+ const raw = value?.trim() || label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-");
33
+ return raw.replace(/^-+|-+$/g, "") || `action-${index + 1}`;
34
+ }
35
+ function collectPresentationActions(blocks) {
36
+ const actions = [];
37
+ for (const block of blocks) {
38
+ if (block.type === "buttons") {
39
+ for (const button of block.buttons) {
40
+ const value = button.value?.trim();
41
+ const url = button.url?.trim();
42
+ const action = {
43
+ id: normalizeActionId(value ?? url, button.label, actions.length),
44
+ label: button.label,
45
+ ...(button.style ? { style: button.style } : {}),
46
+ ...(value || url ? { payload: { ...(value ? { value } : {}), ...(url ? { url } : {}) } } : {}),
47
+ };
48
+ actions.push(action);
49
+ }
50
+ }
51
+ if (block.type === "select") {
52
+ for (const option of block.options) {
53
+ actions.push({
54
+ id: normalizeActionId(option.value, option.label, actions.length),
55
+ label: option.label,
56
+ style: "secondary",
57
+ payload: { value: option.value },
58
+ });
59
+ }
60
+ }
61
+ }
62
+ return actions;
63
+ }
64
+ function looksLikeApproval(actions, presentation) {
65
+ if (presentation.tone === "warning" || presentation.tone === "danger")
66
+ return true;
67
+ const ids = new Set(actions.map((action) => action.id.toLowerCase()));
68
+ return ids.has("approve") || ids.has("deny") || ids.has("reject");
69
+ }
70
+ function buildRichInteractionFragment(payload) {
71
+ const presentation = resolvePresentation(payload);
72
+ if (!presentation)
73
+ return null;
74
+ const actions = collectPresentationActions(presentation.blocks);
75
+ if (actions.length === 0)
76
+ return null;
77
+ const fallbackText = renderMessagePresentationFallbackText({
78
+ presentation,
79
+ text: payload.text ?? null,
80
+ }).trim();
81
+ if (!fallbackText)
82
+ return null;
83
+ return {
84
+ kind: looksLikeApproval(actions, presentation) ? "approval_request" : "action_card",
85
+ ...(presentation.title?.trim() ? { title: presentation.title.trim() } : {}),
86
+ fallback_text: fallbackText,
87
+ state: "pending",
88
+ actions,
89
+ };
90
+ }
91
+ function resolvePayloadText(payload) {
92
+ const presentation = resolvePresentation(payload);
93
+ if (!presentation)
94
+ return payload.text ?? "";
95
+ return renderMessagePresentationFallbackText({ presentation, text: payload.text ?? null });
96
+ }
97
+ /**
98
+ * Reply dispatcher for openclaw-clawchat.
99
+ *
100
+ * Streaming mode (`account.replyMode === "stream"`, no replyCtx):
101
+ * 1. `onReplyStart` just resets accumulators. It does NOT open the session
102
+ * yet — that would leave a ghost `message.created + done` bubble if the
103
+ * run produces nothing (e.g., agent not configured, send-policy denied).
104
+ * 2. On first real content (deliver block/tool or partial/reasoning
105
+ * snapshot), `queueStreamSnapshot` lazily opens the session, which emits
106
+ * `message.created` plus the first `message.add`.
107
+ * 3. Subsequent snapshots/deltas emit `message.add` frames (chunked by
108
+ * `stream.flushIntervalMs` + `stream.minChunkChars`).
109
+ * 4. On run end (`onIdle`), the session flushes pending buffer, emits
110
+ * `message.done`, then the merged full text plus any accumulated
111
+ * media is emitted as a separate `message.send` / `message.reply` —
112
+ * mirroring the clawling-channel v1 pattern where streaming `agent`
113
+ * events are followed by a consolidated `chat` final. Empty runs emit
114
+ * nothing at all (no created/done/reply) — they only log a skip line.
115
+ *
116
+ * Static mode or replyCtx: bypass streaming and emit one `message.send` /
117
+ * `message.reply` per deliver with text + media.
118
+ */
119
+ export function createOpenclawClawlingReplyDispatcher(options) {
120
+ const { cfg, runtime, account, client, target, replyCtx, inboundMessageId, inboundForFinalReply, log, } = options;
121
+ const routing = { chatId: target.chatId, chatType: target.chatType };
122
+ const humanDelay = runtime.channel.reply.resolveHumanDelayConfig(cfg, account.userId);
123
+ const streamingEnabled = account.replyMode === "stream" && !replyCtx;
124
+ const buildApiClient = () => {
125
+ if (!account.baseUrl || !account.token)
126
+ return null;
127
+ return createOpenclawClawlingApiClient({
128
+ baseUrl: account.baseUrl,
129
+ token: account.token,
130
+ userId: account.userId,
131
+ });
132
+ };
133
+ async function uploadMediaUrls(urls) {
134
+ if (urls.length === 0)
135
+ return [];
136
+ const apiClient = buildApiClient();
137
+ if (!apiClient) {
138
+ log?.info?.(`[${account.accountId}] openclaw-clawchat outbound media skipped: baseUrl not configured`);
139
+ return [];
140
+ }
141
+ return await uploadOutboundMedia(urls, { apiClient, runtime, log });
142
+ }
143
+ // ----- Streaming session state -----------------------------------------
144
+ let streamingSession = null;
145
+ let streamingMessageId = "";
146
+ let streamText = "";
147
+ let reasoningText = "";
148
+ const accumulatedMediaUrls = [];
149
+ const finalRichFragments = [];
150
+ let finalEmitted = false;
151
+ let streamingClosed = false;
152
+ let runFailed = false;
153
+ let runDone = false;
154
+ // `streamCreatedEmitted` is the authoritative guard: once a `message.created`
155
+ // has been emitted for this dispatcher instance, never emit another — even
156
+ // if `onReplyStart` fires again or a pre-onReplyStart `onPartialReply`
157
+ // raced the lazy open path.
158
+ let streamCreatedEmitted = false;
159
+ const mintStreamingMessageId = () => `${account.userId}-stream-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
160
+ const openSessionIfNeeded = () => {
161
+ if (!streamingEnabled || streamingSession || streamCreatedEmitted)
162
+ return;
163
+ streamCreatedEmitted = true;
164
+ // Mint a fresh agent-side message_id at `message.created` time. All
165
+ // subsequent `message.add` / `message.done` / `message.reply` frames for
166
+ // this stream reuse it. Once the stream finalizes (done or reply), this
167
+ // id is retired — the next inbound turn spawns a new dispatcher instance
168
+ // which mints its own id. The inbound user message_id lives on
169
+ // `replyTo.msgId`; keeping the two distinct avoids the agent's reply
170
+ // frames shadowing the user turn they answer.
171
+ streamingMessageId = mintStreamingMessageId();
172
+ streamingSession = openBufferedStreamingSession({
173
+ client,
174
+ routing,
175
+ sender: {
176
+ id: account.userId,
177
+ type: target.chatType,
178
+ nick_name: account.userId,
179
+ },
180
+ messageId: streamingMessageId,
181
+ flushIntervalMs: account.stream.flushIntervalMs,
182
+ minChunkChars: account.stream.minChunkChars,
183
+ maxBufferChars: account.stream.maxBufferChars,
184
+ });
185
+ log?.info?.(`[${account.accountId}] openclaw-clawchat streaming opened msg=${streamingMessageId}`);
186
+ };
187
+ const buildCombinedStreamSnapshot = () => {
188
+ if (!reasoningText && !streamText)
189
+ return "";
190
+ if (!reasoningText)
191
+ return streamText;
192
+ if (!streamText)
193
+ return reasoningText;
194
+ return `${reasoningText}\n\n${streamText}`;
195
+ };
196
+ const queueStreamSnapshot = async () => {
197
+ openSessionIfNeeded();
198
+ if (!streamingSession)
199
+ return;
200
+ const combined = buildCombinedStreamSnapshot();
201
+ if (!combined)
202
+ return;
203
+ await streamingSession.queueSnapshot(combined);
204
+ };
205
+ const closeStreamingSession = async (reason, failReason) => {
206
+ if (!streamingSession || streamingClosed)
207
+ return;
208
+ streamingClosed = true;
209
+ if (reason === "fail") {
210
+ await streamingSession.fail(failReason);
211
+ }
212
+ else {
213
+ await streamingSession.done();
214
+ }
215
+ log?.info?.(`[${account.accountId}] openclaw-clawchat streaming closed msg=${streamingMessageId} reason=${reason ?? "done"}`);
216
+ };
217
+ // ----- Static send ------------------------------------------------------
218
+ const sendStatic = async (text, mediaFragments = [], richFragments = []) => {
219
+ if (!text.trim() && mediaFragments.length === 0 && richFragments.length === 0)
220
+ return;
221
+ log?.info?.(`[${account.accountId}] openclaw-clawchat sending static text_len=${text.length} media=${mediaFragments.length} rich=${richFragments.length} to=${target.chatId}`);
222
+ await sendOpenclawClawlingText({
223
+ client,
224
+ account,
225
+ to: target,
226
+ text,
227
+ ...(replyCtx ? { replyCtx } : {}),
228
+ ...(richFragments.length > 0 ? { richFragments } : {}),
229
+ ...(mediaFragments.length > 0 ? { mediaFragments } : {}),
230
+ log,
231
+ });
232
+ log?.info?.(`[${account.accountId}] openclaw-clawchat send complete to=${target.chatId}`);
233
+ };
234
+ const emitFinalConsolidatedMessage = async () => {
235
+ if (finalEmitted)
236
+ return;
237
+ finalEmitted = true;
238
+ const mergedMedia = await uploadMediaUrls(accumulatedMediaUrls.slice());
239
+ const mergedText = streamText.trim();
240
+ if (!mergedText && finalRichFragments.length === 0 && mergedMedia.length === 0) {
241
+ log?.info?.(`[${account.accountId}] openclaw-clawchat no merged final content; skip consolidated reply`);
242
+ return;
243
+ }
244
+ log?.info?.(`[${account.accountId}] openclaw-clawchat emitting consolidated final (message.reply) msg=${streamingMessageId} text_len=${mergedText.length} media=${mergedMedia.length}`);
245
+ const bodyFragments = [
246
+ ...(mergedText ? textToFragments(mergedText) : []),
247
+ ...finalRichFragments,
248
+ // mediaFragments is the local wide shape; cast at SDK boundary as
249
+ // we do in outbound.ts.
250
+ ...mergedMedia,
251
+ ];
252
+ // Streaming message_id must match the created/add/done frames so the
253
+ // backend can correlate the consolidated reply with the stream.
254
+ emitFinalStreamReply(client, {
255
+ messageId: streamingMessageId,
256
+ routing,
257
+ replyTo: {
258
+ msgId: inboundMessageId ?? streamingMessageId,
259
+ previewId: inboundForFinalReply?.chatId ?? target.chatId,
260
+ nickName: inboundForFinalReply?.senderNickName ?? inboundForFinalReply?.senderId ?? target.chatId,
261
+ fragments: inboundForFinalReply?.bodyText
262
+ ? [{ kind: "text", text: inboundForFinalReply.bodyText }]
263
+ : [],
264
+ },
265
+ body: { fragments: bodyFragments },
266
+ });
267
+ };
268
+ const ingestFinalPayload = (payload, text, richFragment) => {
269
+ if (richFragment && account.richInteractions) {
270
+ finalRichFragments.push(richFragment);
271
+ }
272
+ if (text)
273
+ streamText = mergeStreamingText(streamText, text);
274
+ const urls = [
275
+ ...(payload.mediaUrl ? [payload.mediaUrl] : []),
276
+ ...(payload.mediaUrls ?? []),
277
+ ].filter((u) => Boolean(u));
278
+ for (const url of urls) {
279
+ if (!accumulatedMediaUrls.includes(url))
280
+ accumulatedMediaUrls.push(url);
281
+ }
282
+ };
283
+ const ingestBlockText = async (text) => {
284
+ if (!text)
285
+ return;
286
+ streamText = `${streamText}${text}`;
287
+ await queueStreamSnapshot();
288
+ };
289
+ // ----- Dispatcher -------------------------------------------------------
290
+ const base = runtime.channel.reply.createReplyDispatcherWithTyping({
291
+ humanDelay,
292
+ onReplyStart: async () => {
293
+ // Only clear transient accumulators the first time the run starts.
294
+ // If `onReplyStart` fires again during the same dispatcher instance
295
+ // (e.g. typing controller re-entry), we must NOT tear down the stream
296
+ // session — that would cause a second `message.created`.
297
+ //
298
+ // We deliberately do NOT open the streaming session here. Opening it
299
+ // eagerly would emit `message.created` even for runs that ultimately
300
+ // produce nothing (unknown agent, send-policy denied, etc.), leaving a
301
+ // ghost empty bubble. The session opens lazily via queueStreamSnapshot
302
+ // on the first real content.
303
+ if (!streamCreatedEmitted) {
304
+ streamText = "";
305
+ reasoningText = "";
306
+ accumulatedMediaUrls.length = 0;
307
+ finalRichFragments.length = 0;
308
+ finalEmitted = false;
309
+ streamingClosed = false;
310
+ runDone = false;
311
+ }
312
+ },
313
+ deliver: async (payload, info) => {
314
+ const richFragment = buildRichInteractionFragment(payload);
315
+ const text = richFragment && account.richInteractions ? "" : resolvePayloadText(payload);
316
+ const urls = [
317
+ ...(payload.mediaUrl ? [payload.mediaUrl] : []),
318
+ ...(payload.mediaUrls ?? []),
319
+ ].filter((u) => Boolean(u));
320
+ log?.info?.(`[${account.accountId}] openclaw-clawchat deliver kind=${info?.kind ?? "unknown"} text_len=${text.length} media_urls=${urls.length} reasoning=${payload.isReasoning === true}`);
321
+ if (payload.isReasoning) {
322
+ if (!account.forwardThinking)
323
+ return;
324
+ if (streamingEnabled) {
325
+ reasoningText = mergeStreamingText(reasoningText, text);
326
+ await queueStreamSnapshot();
327
+ }
328
+ else {
329
+ await sendStatic(text);
330
+ }
331
+ return;
332
+ }
333
+ if (info?.kind === "tool" && !account.forwardToolCalls)
334
+ return;
335
+ if (info?.kind === "final") {
336
+ ingestFinalPayload(payload, text, richFragment && account.richInteractions ? richFragment : null);
337
+ // For streaming: consolidated final is emitted in onIdle after done.
338
+ // For static: emit immediately.
339
+ if (!streamingEnabled) {
340
+ const mediaFragments = await uploadMediaUrls(urls);
341
+ await sendStatic(text, mediaFragments, richFragment && account.richInteractions ? [richFragment] : []);
342
+ }
343
+ return;
344
+ }
345
+ // kind === "block" or "tool" — text during the run
346
+ if (streamingEnabled) {
347
+ if (text)
348
+ await ingestBlockText(text);
349
+ if (urls.length > 0) {
350
+ const mediaFragments = await uploadMediaUrls(urls);
351
+ if (mediaFragments.length > 0) {
352
+ log?.info?.(`[${account.accountId}] openclaw-clawchat mid-stream media emitted as separate message (count=${mediaFragments.length})`);
353
+ await sendOpenclawClawlingText({
354
+ client,
355
+ account,
356
+ to: target,
357
+ text: "",
358
+ mediaFragments,
359
+ log,
360
+ });
361
+ }
362
+ }
363
+ }
364
+ else {
365
+ const mediaFragments = await uploadMediaUrls(urls);
366
+ const richFragments = richFragment && account.richInteractions ? [richFragment] : [];
367
+ if (text.trim() || mediaFragments.length > 0 || richFragments.length > 0) {
368
+ await sendStatic(text, mediaFragments, richFragments);
369
+ }
370
+ }
371
+ },
372
+ onError: (error, info) => {
373
+ const errorText = normalizeReplyErrorText(error);
374
+ log?.error?.(`[${account.accountId}] openclaw-clawchat ${info.kind} reply failed: ${errorText}`);
375
+ if (!streamingEnabled) {
376
+ void sendStatic(errorText);
377
+ return;
378
+ }
379
+ runFailed = true;
380
+ if (streamingSession && !streamingClosed) {
381
+ void closeStreamingSession("fail", errorText);
382
+ return;
383
+ }
384
+ if (streamingClosed)
385
+ return;
386
+ streamingClosed = true;
387
+ if (!streamingMessageId)
388
+ streamingMessageId = mintStreamingMessageId();
389
+ void sendStreamingFailure({
390
+ client,
391
+ routing,
392
+ messageId: streamingMessageId,
393
+ currentSequence: 0,
394
+ reason: errorText,
395
+ });
396
+ },
397
+ onIdle: async () => {
398
+ if (runDone)
399
+ return;
400
+ runDone = true;
401
+ if (!streamingEnabled)
402
+ return;
403
+ if (runFailed)
404
+ return;
405
+ await closeStreamingSession("done");
406
+ await emitFinalConsolidatedMessage();
407
+ },
408
+ });
409
+ const streamingHooks = streamingEnabled
410
+ ? {
411
+ onPartialReply: async (payload) => {
412
+ if (!payload.text || payload.isReasoning)
413
+ return;
414
+ // onPartialReply gives progressive snapshots of the current assistant
415
+ // message text (not deltas). Use mergeStreamingText so overlapping
416
+ // prefixes collapse into a single growing snapshot.
417
+ streamText = mergeStreamingText(streamText, payload.text);
418
+ await queueStreamSnapshot();
419
+ },
420
+ ...(account.forwardThinking
421
+ ? {
422
+ onReasoningStream: async (payload) => {
423
+ if (!payload.text)
424
+ return;
425
+ reasoningText = mergeStreamingText(reasoningText, payload.text);
426
+ await queueStreamSnapshot();
427
+ },
428
+ }
429
+ : {}),
430
+ }
431
+ : {};
432
+ return {
433
+ dispatcher: base.dispatcher,
434
+ replyOptions: {
435
+ ...base.replyOptions,
436
+ ...streamingHooks,
437
+ },
438
+ markDispatchIdle: base.markDispatchIdle,
439
+ };
440
+ }