@clawling/clawchat-plugin-openclaw 2026.5.12-39 → 2026.5.13-dev.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/src/storage.ts CHANGED
@@ -21,6 +21,8 @@ export type ActivationInput = {
21
21
  conversationId?: string | null;
22
22
  activatedAt?: number;
23
23
  loginMethod?: string | null;
24
+ /** §E — exact `X-Device-Id` used at connect time (OpenClaw: `CHANNEL_ID`). */
25
+ deviceId?: string | null;
24
26
  };
25
27
 
26
28
  export type ActivationCredentials = {
@@ -28,6 +30,25 @@ export type ActivationCredentials = {
28
30
  ownerUserId: string;
29
31
  accessToken: string;
30
32
  refreshToken: string | null;
33
+ /** §A.0 — epoch ms the row was activated; expiry fallback (`activatedAt + 24h`). */
34
+ activatedAt: number | null;
35
+ /** §E — connect-time device id used as `X-Device-Id` on refresh. */
36
+ deviceId: string | null;
37
+ };
38
+
39
+ /** §0/§A.3 — persist a rotated `{access,refresh}` pair BEFORE the in-memory swap. */
40
+ export type RotateActivationTokensInput = {
41
+ platform: string;
42
+ accountId: string;
43
+ accessToken: string;
44
+ refreshToken: string;
45
+ rotatedAt?: number;
46
+ };
47
+
48
+ /** §C.1 — blank the credential columns but KEEP identity for re-pair. */
49
+ export type ClearActivationCredentialsInput = {
50
+ platform: string;
51
+ accountId: string;
31
52
  };
32
53
 
33
54
  export type ActivationBootstrapInput = {
@@ -242,6 +263,13 @@ ALTER TABLE clawchat_messages ADD COLUMN send_status TEXT;
242
263
  ALTER TABLE clawchat_messages ADD COLUMN protocol_message_id TEXT;
243
264
  ALTER TABLE clawchat_messages ADD COLUMN acked_at INTEGER;
244
265
  ALTER TABLE clawchat_messages ADD COLUMN send_error TEXT;
266
+ `,
267
+ },
268
+ {
269
+ version: 7,
270
+ name: "activation_device_id",
271
+ sql: `
272
+ ALTER TABLE activations ADD COLUMN device_id TEXT;
245
273
  `,
246
274
  },
247
275
  ];
@@ -320,13 +348,14 @@ export class ClawChatStore {
320
348
  const ownerUserId = input.ownerUserId?.trim() || null;
321
349
  const accessToken = input.accessToken?.trim() || null;
322
350
  const refreshToken = input.refreshToken?.trim() || null;
351
+ const deviceId = input.deviceId?.trim() || null;
323
352
  this.requireDb()
324
353
  .prepare(
325
354
  `INSERT INTO activations(
326
355
  platform, account_id, user_id, owner_user_id, access_token, refresh_token,
327
356
  activated_at, login_method, conversation_id, bootstrap_sent,
328
- bootstrap_claimed_at, updated_at
329
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
357
+ bootstrap_claimed_at, device_id, updated_at
358
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
330
359
  ON CONFLICT(platform, account_id) DO UPDATE SET
331
360
  user_id = excluded.user_id,
332
361
  owner_user_id = excluded.owner_user_id,
@@ -337,6 +366,7 @@ export class ClawChatStore {
337
366
  conversation_id = excluded.conversation_id,
338
367
  bootstrap_sent = excluded.bootstrap_sent,
339
368
  bootstrap_claimed_at = NULL,
369
+ device_id = excluded.device_id,
340
370
  updated_at = excluded.updated_at`,
341
371
  )
342
372
  .run(
@@ -351,17 +381,68 @@ export class ClawChatStore {
351
381
  conversationId,
352
382
  conversationId ? 0 : 1,
353
383
  null,
384
+ deviceId,
354
385
  now,
355
386
  );
356
387
  void conversationId;
357
388
  });
358
389
  }
359
390
 
391
+ /**
392
+ * §0/§A.3 — persist a rotated access+refresh pair durably BEFORE the caller
393
+ * swaps the in-memory token. Identity columns are untouched. Returns whether
394
+ * a row was updated (false ⇒ no activation row exists yet).
395
+ */
396
+ rotateActivationTokens(input: RotateActivationTokensInput): boolean | null {
397
+ return this.write(() => {
398
+ const now = input.rotatedAt ?? Date.now();
399
+ const result = this.requireDb()
400
+ .prepare(
401
+ `UPDATE activations SET
402
+ access_token = ?,
403
+ refresh_token = ?,
404
+ activated_at = ?,
405
+ updated_at = ?
406
+ WHERE platform = ? AND account_id = ?`,
407
+ )
408
+ .run(
409
+ input.accessToken,
410
+ input.refreshToken,
411
+ now,
412
+ now,
413
+ input.platform,
414
+ input.accountId,
415
+ );
416
+ return result.changes > 0;
417
+ });
418
+ }
419
+
420
+ /**
421
+ * §C.1 — auto-logout: blank the `access_token` / `refresh_token` columns so
422
+ * the agent can no longer mint tokens, but KEEP `user_id` / `owner_user_id` /
423
+ * `device_id` so a `/clawchat-activate <code>` re-pair reuses the identity.
424
+ */
425
+ clearActivationCredentials(input: ClearActivationCredentialsInput): boolean | null {
426
+ return this.write(() => {
427
+ const now = Date.now();
428
+ const result = this.requireDb()
429
+ .prepare(
430
+ `UPDATE activations SET
431
+ access_token = NULL,
432
+ refresh_token = NULL,
433
+ updated_at = ?
434
+ WHERE platform = ? AND account_id = ?`,
435
+ )
436
+ .run(now, input.platform, input.accountId);
437
+ return result.changes > 0;
438
+ });
439
+ }
440
+
360
441
  getActivationCredentials(input: ConversationAccountInput): ActivationCredentials | null {
361
442
  return this.read(() => {
362
443
  const row = this.requireDb()
363
444
  .prepare(
364
- `SELECT user_id, owner_user_id, access_token, refresh_token
445
+ `SELECT user_id, owner_user_id, access_token, refresh_token, activated_at, device_id
365
446
  FROM activations
366
447
  WHERE platform = ?
367
448
  AND account_id = ?`,
@@ -372,6 +453,8 @@ export class ClawChatStore {
372
453
  owner_user_id?: unknown;
373
454
  access_token?: unknown;
374
455
  refresh_token?: unknown;
456
+ activated_at?: unknown;
457
+ device_id?: unknown;
375
458
  }
376
459
  | undefined;
377
460
  const userId = typeof row?.user_id === "string" ? row.user_id.trim() : "";
@@ -383,8 +466,16 @@ export class ClawChatStore {
383
466
  typeof row?.refresh_token === "string" && row.refresh_token.trim()
384
467
  ? row.refresh_token.trim()
385
468
  : null;
469
+ const activatedAt =
470
+ typeof row?.activated_at === "number" && Number.isFinite(row.activated_at)
471
+ ? row.activated_at
472
+ : null;
473
+ const deviceId =
474
+ typeof row?.device_id === "string" && row.device_id.trim()
475
+ ? row.device_id.trim()
476
+ : null;
386
477
  if (!userId || !ownerUserId || !accessToken) return null;
387
- return { userId, ownerUserId, accessToken, refreshToken };
478
+ return { userId, ownerUserId, accessToken, refreshToken, activatedAt, deviceId };
388
479
  }) ?? null;
389
480
  }
390
481
 
@@ -670,6 +761,35 @@ export class ClawChatStore {
670
761
  });
671
762
  }
672
763
 
764
+ /**
765
+ * Return the most recent server-resolved `device_id` recorded from a
766
+ * `hello-ok` for this account, if any. Reused on reconnect so a pod restart
767
+ * (which mints a fresh hostname and therefore a fresh derived device id)
768
+ * does not present a brand-new device to the server — that would trigger a
769
+ * full inbox replay and orphan the previous device's cursor.
770
+ */
771
+ getLastResolvedDeviceId(input: ConversationAccountInput): string | null {
772
+ return this.read(() => {
773
+ const row = this.requireDb()
774
+ .prepare(
775
+ `SELECT resolved_device_id
776
+ FROM connections
777
+ WHERE platform = ?
778
+ AND account_id = ?
779
+ AND resolved_device_id IS NOT NULL
780
+ AND resolved_device_id <> ''
781
+ ORDER BY id DESC
782
+ LIMIT 1`,
783
+ )
784
+ .get(input.platform, input.accountId) as
785
+ | { resolved_device_id?: unknown }
786
+ | undefined;
787
+ return typeof row?.resolved_device_id === "string" && row.resolved_device_id.trim()
788
+ ? row.resolved_device_id.trim()
789
+ : null;
790
+ }) ?? null;
791
+ }
792
+
673
793
  recordToolCall(input: ToolCallInput): void {
674
794
  this.write(() => {
675
795
  const startedAt = input.startedAt ?? Date.now();
@@ -254,7 +254,8 @@ export function createProtocolControlHandler(options: CreateProtocolControlHandl
254
254
  version: "2",
255
255
  event: "pong",
256
256
  trace_id: env.trace_id ?? "-",
257
- emitted_at: Date.now(),
257
+ // §12: echo the sender's emitted_at verbatim (do not restamp).
258
+ emitted_at: env.emitted_at ?? Date.now(),
258
259
  payload: {},
259
260
  }),
260
261
  );
@@ -273,3 +274,100 @@ export function createProtocolControlHandler(options: CreateProtocolControlHandl
273
274
  },
274
275
  };
275
276
  }
277
+
278
+ export interface NotifySignalEnvelope {
279
+ event?: string;
280
+ trace_id?: string;
281
+ payload?: unknown;
282
+ }
283
+
284
+ export interface CreateNotifySignalObserverOptions {
285
+ accountId: string;
286
+ log: (msg: string) => void;
287
+ context?: () => WsLogContext;
288
+ /** Upper bound on retained event_ids for dedup (FIFO eviction). */
289
+ maxSeen?: number;
290
+ }
291
+
292
+ export type NotifySignalOutcome = "observed" | "duplicate" | "invalid";
293
+
294
+ /**
295
+ * Observes reliable `notify.signal` frames (§9.4). The agent plugin keeps no
296
+ * friend/roster cache (friends are fetched on demand via REST tools), so there
297
+ * is nothing to invalidate — this is a pure observability hook: it dedups by
298
+ * `event_id` (the live frame and its reliable-inbox replay collapse to one),
299
+ * structured-logs the signal, and returns the outcome. It deliberately takes no
300
+ * action on the agent; wire a real reaction here if the product later needs one.
301
+ */
302
+ export function createNotifySignalObserver(options: CreateNotifySignalObserverOptions) {
303
+ const maxSeen = options.maxSeen ?? 512;
304
+ const seen = new Set<string>();
305
+ const order: string[] = [];
306
+
307
+ const context = (): WsLogContext =>
308
+ options.context?.() ?? { attempt: 1, reconnectCount: 0, state: "ready" };
309
+
310
+ const logSignal = (
311
+ event: string,
312
+ action: string,
313
+ fields: Array<[string, string | number | boolean | null | undefined]>,
314
+ ) => {
315
+ const current = context();
316
+ options.log(
317
+ formatWsLog({
318
+ event,
319
+ accountId: options.accountId,
320
+ attempt: current.attempt,
321
+ reconnectCount: current.reconnectCount,
322
+ state: current.state,
323
+ action,
324
+ fields,
325
+ }),
326
+ );
327
+ };
328
+
329
+ return {
330
+ /** Returns whether this signal was newly observed, a duplicate, or malformed. */
331
+ observe(env: NotifySignalEnvelope): NotifySignalOutcome {
332
+ const payload = env.payload && typeof env.payload === "object"
333
+ ? env.payload as Record<string, unknown>
334
+ : undefined;
335
+ const eventId = typeof payload?.event_id === "string" ? payload.event_id : "";
336
+ const type = typeof payload?.type === "string" ? payload.type : "";
337
+ const entityId = typeof payload?.entity_id === "string" ? payload.entity_id : "";
338
+ const version = typeof payload?.version === "number" ? payload.version : undefined;
339
+
340
+ if (!eventId || !type) {
341
+ logSignal("notify_signal_invalid", "ignore", [
342
+ ["trace_id", env.trace_id],
343
+ ["type", type || null],
344
+ ["event_id", eventId || null],
345
+ ]);
346
+ return "invalid";
347
+ }
348
+
349
+ if (seen.has(eventId)) {
350
+ logSignal("notify_signal_duplicate", "ignore", [
351
+ ["type", type],
352
+ ["event_id", eventId],
353
+ ]);
354
+ return "duplicate";
355
+ }
356
+
357
+ seen.add(eventId);
358
+ order.push(eventId);
359
+ while (order.length > maxSeen) {
360
+ const evicted = order.shift();
361
+ if (evicted !== undefined) seen.delete(evicted);
362
+ }
363
+
364
+ logSignal("notify_signal_observed", "observe", [
365
+ ["type", type],
366
+ ["entity_id", entityId || null],
367
+ ["version", version ?? null],
368
+ ["event_id", eventId],
369
+ ]);
370
+ return "observed";
371
+ },
372
+ };
373
+ }
package/src/ws-client.ts CHANGED
@@ -369,6 +369,8 @@ export class ClawChatClient extends EventEmitter {
369
369
  if (env.event === EVENT.MESSAGE_FAILED) this.emit("message:failed", env);
370
370
  if (env.event === EVENT.TYPING_UPDATE) this.emit("typing", env);
371
371
  if (env.event === EVENT.CHAT_METADATA_INVALIDATED) this.emit("metadata:invalidated", env);
372
+ if (env.event === EVENT.NOTIFY_SIGNAL) this.emit("notify:signal", env);
373
+ if (env.event === EVENT.REPLAY_DONE) this.emit("replay:done", env);
372
374
  if (env.event === EVENT.OFFLINE_DONE) this.emit("offline:done");
373
375
  }
374
376
 
@@ -389,7 +391,19 @@ export class ClawChatClient extends EventEmitter {
389
391
  token: this.opts.token,
390
392
  nonce,
391
393
  ...(this.opts.deviceId ? { device_id: this.opts.deviceId } : {}),
392
- capabilities: { multi_device: true, device_replay: true, chat_meta_events: true },
394
+ // Agent runtime is single-device: multi_device stays off so the server
395
+ // never self-fans-out this connection's own messages. notify_signals is
396
+ // advertised because we now handle the notify.signal frame (§9.4).
397
+ // history_sync (§11.4) is intentionally omitted: it is only required to
398
+ // *send* history.transit, which a single-device agent never does, and we
399
+ // do not handle inbound history.transit — advertising it would invite
400
+ // frames we cannot process. This is a deliberate, spec-legal omission.
401
+ capabilities: {
402
+ multi_device: false,
403
+ device_replay: true,
404
+ chat_meta_events: true,
405
+ notify_signals: true,
406
+ },
393
407
  };
394
408
  const traceId = this.nextTraceId();
395
409
  this.expectedConnectTraceId = traceId;
@@ -441,7 +455,32 @@ export class ClawChatClient extends EventEmitter {
441
455
  this.failHandshake(new ProtocolError("invalid hello-fail payload", env), 4002, "protocol error");
442
456
  return;
443
457
  }
444
- const err = new AuthError(typeof reason === "string" ? reason : "authentication failed");
458
+ // §14.1: distinguish upstream auth-service unavailability (5xx) from token
459
+ // rejection (4xx). On a 5xx the token may still be valid and the auth backend
460
+ // (member-backend) is down — backoff-reconnect with the SAME token and do
461
+ // NOT refresh (a 5xx storm must not become a mass token-refresh storm). Until
462
+ // the server emits the distinct 5xx reason, every other hello-fail is treated
463
+ // as a terminal token rejection (the caller acquires a fresh token first).
464
+ if (/auth service unavailable/i.test(reason)) {
465
+ const err = new TransportError(reason);
466
+ this.expectedConnectTraceId = undefined;
467
+ this.clearTimers();
468
+ this.rejectPending(err);
469
+ this.connectReject?.(err);
470
+ this.connectResolve = undefined;
471
+ this.connectReject = undefined;
472
+ this.emitError(err);
473
+ if (this.opts.transport.state !== "closed") {
474
+ // Close WITHOUT marking closing/authFailed so handleClose backoff-reconnects.
475
+ this.opts.transport.close(4001, "auth service unavailable");
476
+ } else if (!this.closing && this.opts.reconnect.enabled) {
477
+ this.scheduleReconnect(reason);
478
+ } else {
479
+ this.transition("disconnected");
480
+ }
481
+ return;
482
+ }
483
+ const err = new AuthError(reason);
445
484
  this.authFailed = true;
446
485
  this.expectedConnectTraceId = undefined;
447
486
  this.sendQueue.length = 0;
@@ -489,10 +528,17 @@ export class ClawChatClient extends EventEmitter {
489
528
  }
490
529
  clearTimeout(entry.timer);
491
530
  this.pending.delete(env.trace_id);
492
- const payload = env.payload && typeof env.payload === "object" ? env.payload : undefined;
531
+ const payload = env.payload && typeof env.payload === "object"
532
+ ? env.payload as { code?: unknown; reason?: unknown; message?: unknown }
533
+ : undefined;
493
534
  const code = typeof payload?.code === "string" && payload.code ? payload.code : "unknown";
494
- const message = typeof payload?.message === "string" && payload.message ? payload.message : "message send failed";
495
- entry.reject(new MessageSendError(env.trace_id, code, message, env.chat_id));
535
+ // §14.3: the human-readable hint is `reason` (fall back to legacy `message`).
536
+ const hint = typeof payload?.reason === "string" && payload.reason
537
+ ? payload.reason
538
+ : typeof payload?.message === "string" && payload.message
539
+ ? payload.message
540
+ : "message send failed";
541
+ entry.reject(new MessageSendError(env.trace_id, code, hint, env.chat_id));
496
542
  }
497
543
 
498
544
  private startHeartbeat(): void {
@@ -1,177 +0,0 @@
1
- import { emitStreamAdd, emitStreamCreated, emitStreamDone, emitStreamFailed, } from "./client.js";
2
- /**
3
- * Merge two views of the same progressively-revealed text.
4
- *
5
- * The agent runner may give us either:
6
- * - full snapshots ("Hel", "Hello", "Hello, world") where each item is
7
- * a superset of the previous; or
8
- * - overlapping slices ("hello ", "world hello ") that don't share a
9
- * prefix but share an overlap at the join.
10
- *
11
- * This helper returns a longest-sensible combined string. Ported from
12
- * `clawling-channel/src/reply-dispatcher.ts`.
13
- */
14
- export function mergeStreamingText(previousText, nextText) {
15
- const currentSnapshot = typeof previousText === "string" ? previousText : "";
16
- const incomingText = typeof nextText === "string" ? nextText : "";
17
- if (!incomingText)
18
- return currentSnapshot;
19
- if (!currentSnapshot || incomingText === currentSnapshot)
20
- return incomingText;
21
- if (incomingText.startsWith(currentSnapshot))
22
- return incomingText;
23
- if (currentSnapshot.startsWith(incomingText))
24
- return currentSnapshot;
25
- if (incomingText.includes(currentSnapshot))
26
- return incomingText;
27
- if (currentSnapshot.includes(incomingText))
28
- return currentSnapshot;
29
- const maxOverlap = Math.min(currentSnapshot.length, incomingText.length);
30
- for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
31
- if (currentSnapshot.slice(-overlap) === incomingText.slice(0, overlap)) {
32
- return `${currentSnapshot}${incomingText.slice(overlap)}`;
33
- }
34
- }
35
- return `${currentSnapshot}${incomingText}`;
36
- }
37
- function resolveRouting(options) {
38
- if (options.routing)
39
- return options.routing;
40
- if (options.to)
41
- return { chatId: options.to.id, chatType: options.to.type };
42
- throw new Error("openclaw-clawchat buffered stream requires routing");
43
- }
44
- /**
45
- * Build a streaming session wrapper around message.created/add/done events.
46
- *
47
- * Usage pattern (matching clawling-channel):
48
- * const session = openBufferedStreamingSession({...});
49
- * await session.queueSnapshot("Hel");
50
- * await session.queueSnapshot("Hello");
51
- * await session.queueDelta(", world");
52
- * await session.done();
53
- */
54
- export function openBufferedStreamingSession(options) {
55
- const routing = resolveRouting(options);
56
- const emitTyping = options.emitTyping !== false;
57
- if (emitTyping)
58
- options.client.typing(routing.chatId, true);
59
- emitStreamCreated(options.client, {
60
- messageId: options.messageId,
61
- routing,
62
- });
63
- let bufferedSnapshot = "";
64
- let flushedSnapshot = "";
65
- let sequence = -1;
66
- let flushTimer = null;
67
- let pendingFlush = Promise.resolve();
68
- let closed = false;
69
- const clearTimer = () => {
70
- if (flushTimer) {
71
- clearTimeout(flushTimer);
72
- flushTimer = null;
73
- }
74
- };
75
- const performFlush = async () => {
76
- clearTimer();
77
- if (closed)
78
- return;
79
- if (bufferedSnapshot === flushedSnapshot)
80
- return;
81
- const snapshot = bufferedSnapshot;
82
- const delta = snapshot.slice(flushedSnapshot.length);
83
- if (!delta)
84
- return;
85
- sequence += 1;
86
- emitStreamAdd(options.client, {
87
- messageId: options.messageId,
88
- routing,
89
- sequence,
90
- fullText: snapshot,
91
- textDelta: delta,
92
- });
93
- flushedSnapshot = snapshot;
94
- };
95
- const flush = async () => {
96
- pendingFlush = pendingFlush.then(performFlush);
97
- await pendingFlush;
98
- };
99
- const scheduleFlush = () => {
100
- if (flushTimer || closed)
101
- return;
102
- flushTimer = setTimeout(() => {
103
- flushTimer = null;
104
- void flush();
105
- }, options.flushIntervalMs);
106
- };
107
- const queueSnapshot = async (snapshot) => {
108
- if (closed || !snapshot)
109
- return;
110
- const base = bufferedSnapshot.length >= flushedSnapshot.length ? bufferedSnapshot : flushedSnapshot;
111
- const merged = mergeStreamingText(base, snapshot);
112
- if (merged === bufferedSnapshot)
113
- return;
114
- bufferedSnapshot = merged;
115
- const deltaChars = Math.max(0, bufferedSnapshot.length - flushedSnapshot.length);
116
- if (deltaChars >= options.minChunkChars || bufferedSnapshot.length >= options.maxBufferChars) {
117
- await flush();
118
- }
119
- else {
120
- scheduleFlush();
121
- }
122
- };
123
- const queueDelta = async (delta) => {
124
- if (closed || !delta)
125
- return;
126
- bufferedSnapshot = `${bufferedSnapshot}${delta}`;
127
- const deltaChars = Math.max(0, bufferedSnapshot.length - flushedSnapshot.length);
128
- if (deltaChars >= options.minChunkChars || bufferedSnapshot.length >= options.maxBufferChars) {
129
- await flush();
130
- }
131
- else {
132
- scheduleFlush();
133
- }
134
- };
135
- const done = async () => {
136
- if (closed)
137
- return;
138
- await flush();
139
- closed = true;
140
- clearTimer();
141
- emitStreamDone(options.client, {
142
- messageId: options.messageId,
143
- routing,
144
- finalSequence: Math.max(sequence, 0),
145
- finalText: bufferedSnapshot,
146
- });
147
- if (emitTyping)
148
- options.client.typing(routing.chatId, false);
149
- };
150
- const fail = async (reason) => {
151
- if (closed)
152
- return;
153
- closed = true;
154
- clearTimer();
155
- emitStreamFailed(options.client, {
156
- messageId: options.messageId,
157
- routing,
158
- sequence: Math.max(sequence, 0),
159
- ...(reason !== undefined ? { reason } : {}),
160
- });
161
- if (emitTyping)
162
- options.client.typing(routing.chatId, false);
163
- };
164
- return {
165
- get currentText() {
166
- return bufferedSnapshot;
167
- },
168
- get flushedText() {
169
- return flushedSnapshot;
170
- },
171
- queueSnapshot,
172
- queueDelta,
173
- flush,
174
- done,
175
- fail,
176
- };
177
- }
@@ -1,65 +0,0 @@
1
- import { emitStreamAdd, emitStreamCreated, emitStreamDone, emitStreamFailed, } from "./client.js";
2
- function resolveRouting(params) {
3
- if (params.routing)
4
- return params.routing;
5
- if (params.to)
6
- return { chatId: params.to.id, chatType: params.to.type };
7
- throw new Error("openclaw-clawchat streaming requires routing");
8
- }
9
- /**
10
- * Emit one full streaming lifecycle for a pre-chunked reply.
11
- *
12
- * Sequence:
13
- * typing(true)
14
- * message.created (sequence 0)
15
- * message.add (sequence 1..N, one per chunk)
16
- * message.done (sequence N)
17
- * typing(false)
18
- *
19
- * With zero chunks: typing(true) -> created -> done -> typing(false).
20
- */
21
- export async function sendStreamingText(params) {
22
- const routing = resolveRouting(params);
23
- const emitTyping = params.emitTyping !== false;
24
- if (emitTyping) {
25
- params.client.typing(routing.chatId, true);
26
- }
27
- emitStreamCreated(params.client, {
28
- messageId: params.messageId,
29
- routing,
30
- });
31
- let sequence = -1;
32
- let fullText = "";
33
- for (const chunk of params.chunks) {
34
- sequence += 1;
35
- fullText += chunk;
36
- emitStreamAdd(params.client, {
37
- messageId: params.messageId,
38
- routing,
39
- sequence,
40
- fullText,
41
- textDelta: chunk,
42
- });
43
- }
44
- emitStreamDone(params.client, {
45
- messageId: params.messageId,
46
- routing,
47
- finalSequence: Math.max(sequence, 0),
48
- finalText: fullText,
49
- });
50
- if (emitTyping) {
51
- params.client.typing(routing.chatId, false);
52
- }
53
- }
54
- export async function sendStreamingFailure(params) {
55
- const routing = resolveRouting(params);
56
- emitStreamFailed(params.client, {
57
- messageId: params.messageId,
58
- routing,
59
- sequence: params.currentSequence,
60
- reason: params.reason,
61
- });
62
- if (params.emitTyping !== false) {
63
- params.client.typing(routing.chatId, false);
64
- }
65
- }