@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/dist/src/api-client.js +146 -26
- package/dist/src/client.js +4 -1
- package/dist/src/inbound.js +21 -4
- package/dist/src/login.runtime.js +4 -0
- package/dist/src/outbound.js +43 -8
- package/dist/src/protocol-types.js +2 -0
- package/dist/src/refresh-manager.js +278 -0
- package/dist/src/reply-dispatcher.js +24 -27
- package/dist/src/runtime.js +564 -28
- package/dist/src/storage.js +81 -5
- package/dist/src/ws-alignment.js +69 -1
- package/dist/src/ws-client.js +55 -5
- package/package.json +1 -1
- package/skills/clawchat/SKILL.md +0 -13
- package/src/api-client.ts +174 -31
- package/src/client.ts +12 -1
- package/src/inbound.ts +24 -5
- package/src/login.runtime.ts +4 -0
- package/src/outbound.ts +47 -9
- package/src/protocol-types.ts +34 -2
- package/src/refresh-manager.ts +371 -0
- package/src/reply-dispatcher.ts +26 -28
- package/src/runtime.ts +646 -25
- package/src/storage.ts +124 -4
- package/src/ws-alignment.ts +99 -1
- package/src/ws-client.ts +51 -5
- package/dist/src/buffered-stream.js +0 -177
- package/dist/src/streaming.js +0 -65
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();
|
package/src/ws-alignment.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
495
|
-
|
|
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
|
-
}
|
package/dist/src/streaming.js
DELETED
|
@@ -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
|
-
}
|