@clawling/clawchat-plugin-openclaw 2026.8.20-1 → 2026.8.26-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.
@@ -8,6 +8,7 @@ export const EVENT = {
8
8
  MESSAGE_ERROR: "message.error",
9
9
  MESSAGE_REPLY: "message.reply",
10
10
  MESSAGE_REACTION: "message.reaction",
11
+ MESSAGE_RECALL: "message.recall",
11
12
  MESSAGE_CREATED: "message.created",
12
13
  MESSAGE_ADD: "message.add",
13
14
  MESSAGE_DONE: "message.done",
@@ -90,3 +91,20 @@ export class StateError extends Error {
90
91
  export function isBusinessDispatchEvent(event) {
91
92
  return event === EVENT.MESSAGE_SEND || event === EVENT.MESSAGE_REPLY;
92
93
  }
94
+ /**
95
+ * Pull the id to delete out of a `message.recall` payload.
96
+ *
97
+ * Exists as a pure function, apart from the socket, for the same reason the
98
+ * mistake it guards is worth guarding: `payload.message_id` is right there and
99
+ * looks like the answer, but it is the `rcl:` slot. Deleting by it removes
100
+ * nothing and reads exactly like working code.
101
+ *
102
+ * Answers `null` for anything unusable, so a recall we cannot resolve deletes
103
+ * nothing rather than something adjacent.
104
+ */
105
+ export function recallTargetMessageId(payload) {
106
+ if (!payload || typeof payload !== "object")
107
+ return null;
108
+ const target = payload.target_message_id;
109
+ return typeof target === "string" && target.length > 0 ? target : null;
110
+ }
@@ -1,4 +1,4 @@
1
- import { AckTimeoutError, AuthError, ProtocolError, StateError, TransportError, EVENT, } from "./protocol-types.js";
1
+ import { AckTimeoutError, AuthError, ProtocolError, StateError, TransportError, EVENT, recallTargetMessageId, } from "./protocol-types.js";
2
2
  import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle";
3
3
  import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
4
4
  import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
@@ -1534,7 +1534,6 @@ export async function startOpenclawClawlingGateway(params) {
1534
1534
  const protocolControlLogger = createProtocolControlHandler({
1535
1535
  accountId,
1536
1536
  log: (msg) => log?.info?.(msg),
1537
- send: () => { },
1538
1537
  context: wsLogContext,
1539
1538
  });
1540
1539
  const notifySignalObserver = createNotifySignalObserver({
@@ -2907,6 +2906,38 @@ export async function startOpenclawClawlingGateway(params) {
2907
2906
  await handleInboundEnvelope(env);
2908
2907
  })();
2909
2908
  });
2909
+ // `message.recall` — the sender withdrew a message. Erase our own ledger row
2910
+ // so the "Prior group context" block above (MENTION_CONTEXT_N rows, no time
2911
+ // predicate, no retention) can never read it back into a turn.
2912
+ //
2913
+ // Ids only: the frame carries no content, and `payload.message_id` is the
2914
+ // server's `rcl:<target>` slot — the id to delete is `target_message_id`.
2915
+ //
2916
+ // Synchronous and fire-and-forget on purpose: there is no ack to send (the
2917
+ // frame is BestEffort and non-ackable).
2918
+ //
2919
+ // §9.8 requires a permanent tombstone consulted on every ingest path, and
2920
+ // `store.recallMessage` writes one in the same transaction as the delete.
2921
+ // That is what makes this handler's timing irrelevant: the inbound ledger
2922
+ // write sits behind an await, so on a replay carrying the original and its
2923
+ // recall back-to-back the DELETE runs first — and the tombstone then refuses
2924
+ // the INSERT. It also closes the Kafka-redelivery resurrection this comment
2925
+ // previously accepted as a known limitation.
2926
+ client.on("message:recall", (env) => {
2927
+ const target = recallTargetMessageId(env.payload);
2928
+ if (!target) {
2929
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw recall ignored (no target_message_id) chat_id=${String(env.chat_id ?? "")}`);
2930
+ return;
2931
+ }
2932
+ const removed = store?.recallMessage?.({
2933
+ platform: "openclaw",
2934
+ accountId,
2935
+ messageId: target,
2936
+ });
2937
+ const rowsLabel = typeof removed === "number" ? String(removed) : "unavailable";
2938
+ const logFn = typeof removed === "number" ? log?.info : log?.error;
2939
+ logFn?.(`[${accountId}] clawchat-plugin-openclaw recall purged chat_id=${String(env.chat_id ?? "")} msg=${target} rows=${rowsLabel}`);
2940
+ });
2910
2941
  // Initial pull of per-group agent settings at startup (best-effort; errors are
2911
2942
  // logged). `bypassRefresh: true` (Finding 4) keeps this pre-connect pull from
2912
2943
  // rotating `account.token` out from under the already-constructed WS client.
@@ -171,6 +171,19 @@ CREATE TABLE IF NOT EXISTS owner_profile (
171
171
  updated_at INTEGER NOT NULL,
172
172
  PRIMARY KEY (platform, account_id)
173
173
  );
174
+ `,
175
+ },
176
+ {
177
+ version: 10,
178
+ name: "recalled_messages",
179
+ sql: `
180
+ CREATE TABLE IF NOT EXISTS recalled_messages (
181
+ platform TEXT NOT NULL,
182
+ account_id TEXT NOT NULL,
183
+ message_id TEXT NOT NULL,
184
+ recalled_at INTEGER NOT NULL,
185
+ PRIMARY KEY (platform, account_id, message_id)
186
+ );
174
187
  `,
175
188
  },
176
189
  ];
@@ -412,6 +425,14 @@ export class ClawChatStore {
412
425
  }
413
426
  claimMessageOnce(input) {
414
427
  return this.write(() => {
428
+ // Protocol v2 §9.8: consult the recall tombstone on every ingest path.
429
+ // Doing it inside this same write transaction is what makes the delete
430
+ // and the insert order-independent: whichever frame the server delivers
431
+ // first, a recalled message_id can never end up in the ledger. Without
432
+ // it, a replay carrying the original and its recall back-to-back ran the
433
+ // DELETE before the INSERT and the row survived.
434
+ if (input.messageId && this.isMessageRecalled(input))
435
+ return false;
415
436
  const result = this.requireDb()
416
437
  .prepare(`INSERT OR IGNORE INTO clawchat_messages(
417
438
  platform, account_id, kind, direction, event_type, trace_id, chat_id,
@@ -443,6 +464,75 @@ export class ClawChatStore {
443
464
  .run(input.eventType, input.traceId ?? null, input.chatId ?? null, input.text ?? null, toJson(input.raw), input.accountId, input.kind, input.direction, input.messageId);
444
465
  });
445
466
  }
467
+ /**
468
+ * Erase every ledger row for one `message_id` — the local half of a
469
+ * server-authorised recall (`message.recall`, docs/client-integration.md §9.8).
470
+ *
471
+ * **The only DELETE in this store, and deliberately so.** There is no prune
472
+ * and no retention on `clawchat_messages`: without this, a message recalled
473
+ * ninety seconds after it was sent stays one @-mention away from being read
474
+ * back aloud under "Prior group context" for as long as the group is quiet.
475
+ *
476
+ * Scoped to `(platform, account_id)` so one account's recall cannot reach
477
+ * another's rows, and matched on `message_id`, which seeks via the
478
+ * single-column `idx_clawchat_messages_message_id` before the `platform` /
479
+ * `account_id` filter is applied — so this is a lookup, not a table scan.
480
+ *
481
+ * Answers the number of rows removed. `0` is a normal outcome, not a failure:
482
+ * the row may never have been stored (a message that arrived before this
483
+ * account was paired, or one dropped by the inbound filter). `null` means the
484
+ * store is unavailable.
485
+ */
486
+ /** True when `message_id` was recalled for this (platform, account). */
487
+ isMessageRecalled(input) {
488
+ if (!input.messageId)
489
+ return false;
490
+ const row = this.requireDb()
491
+ .prepare(`SELECT 1 FROM recalled_messages
492
+ WHERE platform = ? AND account_id = ? AND message_id = ?`)
493
+ .get(input.platform, input.accountId, input.messageId);
494
+ return row !== undefined;
495
+ }
496
+ /**
497
+ * Record the permanent recall tombstone and purge any row already stored,
498
+ * atomically. Returns the number of ledger rows deleted (0 when the recall
499
+ * arrived before the message did — the tombstone still stands and the later
500
+ * ingest is refused by {@link claimMessageOnce}).
501
+ */
502
+ recallMessage(input) {
503
+ // An empty id would tombstone and delete rows keyed by '' — every row the
504
+ // store never managed to key. Refuse rather than guess.
505
+ if (!input.messageId)
506
+ return 0;
507
+ return this.write(() => {
508
+ const db = this.requireDb();
509
+ db.prepare(`INSERT OR IGNORE INTO recalled_messages(platform, account_id, message_id, recalled_at)
510
+ VALUES (?, ?, ?, ?)`).run(input.platform, input.accountId, input.messageId, input.recalledAt ?? Date.now());
511
+ const result = db
512
+ .prepare(`DELETE FROM clawchat_messages
513
+ WHERE platform = ? AND account_id = ? AND message_id = ?`)
514
+ .run(input.platform, input.accountId, input.messageId);
515
+ return Number(result.changes);
516
+ });
517
+ }
518
+ /**
519
+ * Low-level purge by message id. NOT the recall path: it writes no tombstone,
520
+ * so a recall routed through here loses to a redelivery or to an ingest that
521
+ * is still behind an await. Use {@link recallMessage} for `message.recall`.
522
+ */
523
+ deleteMessagesByMessageId(input) {
524
+ // An empty id would match every row whose message_id is '' — deleting
525
+ // everything the store never managed to key. Refuse rather than guess.
526
+ if (!input.messageId)
527
+ return 0;
528
+ return this.write(() => {
529
+ const result = this.requireDb()
530
+ .prepare(`DELETE FROM clawchat_messages
531
+ WHERE platform = ? AND account_id = ? AND message_id = ?`)
532
+ .run(input.platform, input.accountId, input.messageId);
533
+ return Number(result.changes);
534
+ });
535
+ }
446
536
  startConnection(input) {
447
537
  return this.write(() => {
448
538
  const now = input.connectStartedAt ?? Date.now();
@@ -171,15 +171,12 @@ export function createProtocolControlHandler(options) {
171
171
  return {
172
172
  handleInbound(env) {
173
173
  if (env.event === "ping") {
174
- logControl("protocol_ping_received", "send_pong", [["trace_id", env.trace_id]]);
175
- options.send(JSON.stringify({
176
- version: "2",
177
- event: "pong",
178
- trace_id: env.trace_id ?? "-",
179
- // §12: echo the sender's emitted_at verbatim (do not restamp).
180
- emitted_at: env.emitted_at ?? Date.now(),
181
- payload: {},
182
- }));
174
+ // §12: the server never emits a JSON-level `ping`, so this is an
175
+ // anomaly worth seeing — but answering it would send exactly the
176
+ // unsolicited `pong` the spec bans, which the server drops as an
177
+ // unknown uplink event. Log it and drop it; this handler has no send
178
+ // capability, so the banned frame cannot be produced here at all.
179
+ logControl("protocol_ping_received", "drop", [["trace_id", env.trace_id]]);
183
180
  return true;
184
181
  }
185
182
  if (env.event === "pong") {
@@ -548,8 +548,10 @@ export class ClawChatClient extends EventEmitter {
548
548
  return this.onHelloOk(env);
549
549
  if (env.event === EVENT.HELLO_FAIL)
550
550
  return this.onHelloFail(env);
551
- if (env.event === EVENT.PING)
552
- return this.onPing(env);
551
+ // §12: the server never emits a JSON-level `ping`, and an unsolicited
552
+ // `pong` is an unknown uplink event the server drops. We therefore do NOT
553
+ // answer a downlink ping. `pong` below is still handled: it is the reply
554
+ // to the client-initiated heartbeat probe §12 does allow.
553
555
  if (env.event === EVENT.PONG)
554
556
  return this.onPong();
555
557
  if (env.event === EVENT.MESSAGE_ACK)
@@ -558,6 +560,11 @@ export class ClawChatClient extends EventEmitter {
558
560
  return this.onMessageError(env);
559
561
  if (isBusinessDispatchEvent(env.event))
560
562
  this.emit("message", env);
563
+ // Deliberately NOT a business dispatch event: `message` feeds the turn
564
+ // pipeline, and a recall has no body to turn into a prompt. It is an
565
+ // instruction to delete, so it gets its own emitter and its own consumer.
566
+ if (env.event === EVENT.MESSAGE_RECALL)
567
+ this.emit("message:recall", env);
561
568
  if (env.event === EVENT.MESSAGE_CREATED)
562
569
  this.emit("message:created", env);
563
570
  if (env.event === EVENT.MESSAGE_ADD)
@@ -699,15 +706,6 @@ export class ClawChatClient extends EventEmitter {
699
706
  this.opts.transport.close(4001, "auth failed");
700
707
  }
701
708
  }
702
- onPing(env) {
703
- this.sendRawEnvelope({
704
- version: "2",
705
- event: EVENT.PONG,
706
- trace_id: env.trace_id,
707
- emitted_at: env.emitted_at,
708
- payload: {},
709
- });
710
- }
711
709
  onPong() {
712
710
  if (this.pongTimer)
713
711
  clearTimeout(this.pongTimer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.8.20-1",
3
+ "version": "2026.8.26-1",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
@@ -8,6 +8,7 @@ export const EVENT = {
8
8
  MESSAGE_ERROR: "message.error",
9
9
  MESSAGE_REPLY: "message.reply",
10
10
  MESSAGE_REACTION: "message.reaction",
11
+ MESSAGE_RECALL: "message.recall",
11
12
  MESSAGE_CREATED: "message.created",
12
13
  MESSAGE_ADD: "message.add",
13
14
  MESSAGE_DONE: "message.done",
@@ -361,3 +362,39 @@ export class StateError extends Error {
361
362
  export function isBusinessDispatchEvent(event: string): boolean {
362
363
  return event === EVENT.MESSAGE_SEND || event === EVENT.MESSAGE_REPLY;
363
364
  }
365
+
366
+ /**
367
+ * `message.recall` downlink payload (docs/client-integration.md §9.8).
368
+ *
369
+ * **Ids only, never content.** The frame relays an instruction to delete, not
370
+ * a copy of what is being deleted — which is what makes it compatible with the
371
+ * feature's "no trace anywhere" requirement.
372
+ */
373
+ export interface MessageRecallPayload {
374
+ /**
375
+ * The server's deterministic slot id, `rcl:<target_message_id>`. Present so
376
+ * the marker can occupy one durable inbox row per recalled message. It is
377
+ * NOT the id to delete.
378
+ */
379
+ message_id: string;
380
+
381
+ /** The id of the message being erased. This is the one to delete by. */
382
+ target_message_id: string;
383
+ }
384
+
385
+ /**
386
+ * Pull the id to delete out of a `message.recall` payload.
387
+ *
388
+ * Exists as a pure function, apart from the socket, for the same reason the
389
+ * mistake it guards is worth guarding: `payload.message_id` is right there and
390
+ * looks like the answer, but it is the `rcl:` slot. Deleting by it removes
391
+ * nothing and reads exactly like working code.
392
+ *
393
+ * Answers `null` for anything unusable, so a recall we cannot resolve deletes
394
+ * nothing rather than something adjacent.
395
+ */
396
+ export function recallTargetMessageId(payload: unknown): string | null {
397
+ if (!payload || typeof payload !== "object") return null;
398
+ const target = (payload as { target_message_id?: unknown }).target_message_id;
399
+ return typeof target === "string" && target.length > 0 ? target : null;
400
+ }
package/src/runtime.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  StateError,
6
6
  TransportError,
7
7
  EVENT,
8
+ recallTargetMessageId,
8
9
  type Envelope,
9
10
  type Transport,
10
11
  } from "./protocol-types.ts";
@@ -123,6 +124,7 @@ type RuntimeConnectionStore = Pick<
123
124
  | "getActivationConversation"
124
125
  | "getLastResolvedDeviceId"
125
126
  | "listRecentGroupMessages"
127
+ | "recallMessage"
126
128
  | "upsertOwnerProfile"
127
129
  >
128
130
  >;
@@ -1920,7 +1922,6 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1920
1922
  const protocolControlLogger = createProtocolControlHandler({
1921
1923
  accountId,
1922
1924
  log: (msg) => log?.info?.(msg),
1923
- send: () => {},
1924
1925
  context: wsLogContext,
1925
1926
  });
1926
1927
  const notifySignalObserver = createNotifySignalObserver({
@@ -3447,6 +3448,43 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3447
3448
  })();
3448
3449
  });
3449
3450
 
3451
+ // `message.recall` — the sender withdrew a message. Erase our own ledger row
3452
+ // so the "Prior group context" block above (MENTION_CONTEXT_N rows, no time
3453
+ // predicate, no retention) can never read it back into a turn.
3454
+ //
3455
+ // Ids only: the frame carries no content, and `payload.message_id` is the
3456
+ // server's `rcl:<target>` slot — the id to delete is `target_message_id`.
3457
+ //
3458
+ // Synchronous and fire-and-forget on purpose: there is no ack to send (the
3459
+ // frame is BestEffort and non-ackable).
3460
+ //
3461
+ // §9.8 requires a permanent tombstone consulted on every ingest path, and
3462
+ // `store.recallMessage` writes one in the same transaction as the delete.
3463
+ // That is what makes this handler's timing irrelevant: the inbound ledger
3464
+ // write sits behind an await, so on a replay carrying the original and its
3465
+ // recall back-to-back the DELETE runs first — and the tombstone then refuses
3466
+ // the INSERT. It also closes the Kafka-redelivery resurrection this comment
3467
+ // previously accepted as a known limitation.
3468
+ client.on("message:recall", (env: Envelope) => {
3469
+ const target = recallTargetMessageId(env.payload);
3470
+ if (!target) {
3471
+ log?.info?.(
3472
+ `[${accountId}] clawchat-plugin-openclaw recall ignored (no target_message_id) chat_id=${String(env.chat_id ?? "")}`,
3473
+ );
3474
+ return;
3475
+ }
3476
+ const removed = store?.recallMessage?.({
3477
+ platform: "openclaw",
3478
+ accountId,
3479
+ messageId: target,
3480
+ });
3481
+ const rowsLabel = typeof removed === "number" ? String(removed) : "unavailable";
3482
+ const logFn = typeof removed === "number" ? log?.info : log?.error;
3483
+ logFn?.(
3484
+ `[${accountId}] clawchat-plugin-openclaw recall purged chat_id=${String(env.chat_id ?? "")} msg=${target} rows=${rowsLabel}`,
3485
+ );
3486
+ });
3487
+
3450
3488
  // Initial pull of per-group agent settings at startup (best-effort; errors are
3451
3489
  // logged). `bypassRefresh: true` (Finding 4) keeps this pre-connect pull from
3452
3490
  // rotating `account.token` out from under the already-constructed WS client.
package/src/storage.ts CHANGED
@@ -389,6 +389,19 @@ CREATE TABLE IF NOT EXISTS owner_profile (
389
389
  updated_at INTEGER NOT NULL,
390
390
  PRIMARY KEY (platform, account_id)
391
391
  );
392
+ `,
393
+ },
394
+ {
395
+ version: 10,
396
+ name: "recalled_messages",
397
+ sql: `
398
+ CREATE TABLE IF NOT EXISTS recalled_messages (
399
+ platform TEXT NOT NULL,
400
+ account_id TEXT NOT NULL,
401
+ message_id TEXT NOT NULL,
402
+ recalled_at INTEGER NOT NULL,
403
+ PRIMARY KEY (platform, account_id, message_id)
404
+ );
392
405
  `,
393
406
  },
394
407
  ];
@@ -722,6 +735,13 @@ export class ClawChatStore {
722
735
 
723
736
  claimMessageOnce(input: MessageInput): true | false | null {
724
737
  return this.write(() => {
738
+ // Protocol v2 §9.8: consult the recall tombstone on every ingest path.
739
+ // Doing it inside this same write transaction is what makes the delete
740
+ // and the insert order-independent: whichever frame the server delivers
741
+ // first, a recalled message_id can never end up in the ledger. Without
742
+ // it, a replay carrying the original and its recall back-to-back ran the
743
+ // DELETE before the INSERT and the row survived.
744
+ if (input.messageId && this.isMessageRecalled(input)) return false;
725
745
  const result = this.requireDb()
726
746
  .prepare(
727
747
  `INSERT OR IGNORE INTO clawchat_messages(
@@ -802,6 +822,92 @@ export class ClawChatStore {
802
822
  });
803
823
  }
804
824
 
825
+ /**
826
+ * Erase every ledger row for one `message_id` — the local half of a
827
+ * server-authorised recall (`message.recall`, docs/client-integration.md §9.8).
828
+ *
829
+ * **The only DELETE in this store, and deliberately so.** There is no prune
830
+ * and no retention on `clawchat_messages`: without this, a message recalled
831
+ * ninety seconds after it was sent stays one @-mention away from being read
832
+ * back aloud under "Prior group context" for as long as the group is quiet.
833
+ *
834
+ * Scoped to `(platform, account_id)` so one account's recall cannot reach
835
+ * another's rows, and matched on `message_id`, which seeks via the
836
+ * single-column `idx_clawchat_messages_message_id` before the `platform` /
837
+ * `account_id` filter is applied — so this is a lookup, not a table scan.
838
+ *
839
+ * Answers the number of rows removed. `0` is a normal outcome, not a failure:
840
+ * the row may never have been stored (a message that arrived before this
841
+ * account was paired, or one dropped by the inbound filter). `null` means the
842
+ * store is unavailable.
843
+ */
844
+ /** True when `message_id` was recalled for this (platform, account). */
845
+ isMessageRecalled(input: { platform: string; accountId: string; messageId?: string | null }): boolean {
846
+ if (!input.messageId) return false;
847
+ const row = this.requireDb()
848
+ .prepare(
849
+ `SELECT 1 FROM recalled_messages
850
+ WHERE platform = ? AND account_id = ? AND message_id = ?`,
851
+ )
852
+ .get(input.platform, input.accountId, input.messageId);
853
+ return row !== undefined;
854
+ }
855
+
856
+ /**
857
+ * Record the permanent recall tombstone and purge any row already stored,
858
+ * atomically. Returns the number of ledger rows deleted (0 when the recall
859
+ * arrived before the message did — the tombstone still stands and the later
860
+ * ingest is refused by {@link claimMessageOnce}).
861
+ */
862
+ recallMessage(input: {
863
+ platform: string;
864
+ accountId: string;
865
+ messageId: string;
866
+ recalledAt?: number;
867
+ }): number | null {
868
+ // An empty id would tombstone and delete rows keyed by '' — every row the
869
+ // store never managed to key. Refuse rather than guess.
870
+ if (!input.messageId) return 0;
871
+ return this.write(() => {
872
+ const db = this.requireDb();
873
+ db.prepare(
874
+ `INSERT OR IGNORE INTO recalled_messages(platform, account_id, message_id, recalled_at)
875
+ VALUES (?, ?, ?, ?)`,
876
+ ).run(input.platform, input.accountId, input.messageId, input.recalledAt ?? Date.now());
877
+ const result = db
878
+ .prepare(
879
+ `DELETE FROM clawchat_messages
880
+ WHERE platform = ? AND account_id = ? AND message_id = ?`,
881
+ )
882
+ .run(input.platform, input.accountId, input.messageId);
883
+ return Number(result.changes);
884
+ });
885
+ }
886
+
887
+ /**
888
+ * Low-level purge by message id. NOT the recall path: it writes no tombstone,
889
+ * so a recall routed through here loses to a redelivery or to an ingest that
890
+ * is still behind an await. Use {@link recallMessage} for `message.recall`.
891
+ */
892
+ deleteMessagesByMessageId(input: {
893
+ platform: string;
894
+ accountId: string;
895
+ messageId: string;
896
+ }): number | null {
897
+ // An empty id would match every row whose message_id is '' — deleting
898
+ // everything the store never managed to key. Refuse rather than guess.
899
+ if (!input.messageId) return 0;
900
+ return this.write(() => {
901
+ const result = this.requireDb()
902
+ .prepare(
903
+ `DELETE FROM clawchat_messages
904
+ WHERE platform = ? AND account_id = ? AND message_id = ?`,
905
+ )
906
+ .run(input.platform, input.accountId, input.messageId);
907
+ return Number(result.changes);
908
+ });
909
+ }
910
+
805
911
  startConnection(input: StartConnectionInput): number | null {
806
912
  return this.write(() => {
807
913
  const now = input.connectStartedAt ?? Date.now();
@@ -238,7 +238,6 @@ export interface ProtocolControlEnvelope {
238
238
  export interface CreateProtocolControlHandlerOptions {
239
239
  accountId: string;
240
240
  log: (msg: string) => void;
241
- send: (wire: string) => void;
242
241
  scheduleReconnect?: (reason: string) => void;
243
242
  attempt?: number;
244
243
  reconnectCount?: number;
@@ -275,17 +274,12 @@ export function createProtocolControlHandler(options: CreateProtocolControlHandl
275
274
  return {
276
275
  handleInbound(env: ProtocolControlEnvelope): boolean {
277
276
  if (env.event === "ping") {
278
- logControl("protocol_ping_received", "send_pong", [["trace_id", env.trace_id]]);
279
- options.send(
280
- JSON.stringify({
281
- version: "2",
282
- event: "pong",
283
- trace_id: env.trace_id ?? "-",
284
- // §12: echo the sender's emitted_at verbatim (do not restamp).
285
- emitted_at: env.emitted_at ?? Date.now(),
286
- payload: {},
287
- }),
288
- );
277
+ // §12: the server never emits a JSON-level `ping`, so this is an
278
+ // anomaly worth seeing — but answering it would send exactly the
279
+ // unsolicited `pong` the spec bans, which the server drops as an
280
+ // unknown uplink event. Log it and drop it; this handler has no send
281
+ // capability, so the banned frame cannot be produced here at all.
282
+ logControl("protocol_ping_received", "drop", [["trace_id", env.trace_id]]);
289
283
  return true;
290
284
  }
291
285
  if (env.event === "pong") {
package/src/ws-client.ts CHANGED
@@ -646,11 +646,18 @@ export class ClawChatClient extends EventEmitter {
646
646
  if (env.event === EVENT.CONNECT_CHALLENGE) return this.onChallenge(env);
647
647
  if (env.event === EVENT.HELLO_OK) return this.onHelloOk(env);
648
648
  if (env.event === EVENT.HELLO_FAIL) return this.onHelloFail(env);
649
- if (env.event === EVENT.PING) return this.onPing(env);
649
+ // §12: the server never emits a JSON-level `ping`, and an unsolicited
650
+ // `pong` is an unknown uplink event the server drops. We therefore do NOT
651
+ // answer a downlink ping. `pong` below is still handled: it is the reply
652
+ // to the client-initiated heartbeat probe §12 does allow.
650
653
  if (env.event === EVENT.PONG) return this.onPong();
651
654
  if (env.event === EVENT.MESSAGE_ACK) return this.onAck(env as Envelope<MessageAckPayload>);
652
655
  if (env.event === EVENT.MESSAGE_ERROR) return this.onMessageError(env as Envelope<MessageErrorPayload>);
653
656
  if (isBusinessDispatchEvent(env.event)) this.emit("message", env);
657
+ // Deliberately NOT a business dispatch event: `message` feeds the turn
658
+ // pipeline, and a recall has no body to turn into a prompt. It is an
659
+ // instruction to delete, so it gets its own emitter and its own consumer.
660
+ if (env.event === EVENT.MESSAGE_RECALL) this.emit("message:recall", env);
654
661
  if (env.event === EVENT.MESSAGE_CREATED) this.emit("message:created", env);
655
662
  if (env.event === EVENT.MESSAGE_ADD) this.emit("message:add", env);
656
663
  if (env.event === EVENT.MESSAGE_DONE) this.emit("message:done", env);
@@ -781,16 +788,6 @@ export class ClawChatClient extends EventEmitter {
781
788
  }
782
789
  }
783
790
 
784
- private onPing(env: Envelope): void {
785
- this.sendRawEnvelope({
786
- version: "2",
787
- event: EVENT.PONG,
788
- trace_id: env.trace_id,
789
- emitted_at: env.emitted_at,
790
- payload: {} satisfies EmptyPayload,
791
- });
792
- }
793
-
794
791
  private onPong(): void {
795
792
  if (this.pongTimer) clearTimeout(this.pongTimer);
796
793
  this.pongTimer = undefined;