@clawling/clawchat-plugin-openclaw 2026.10.9-3 → 2026.10.9-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.
package/src/storage.ts CHANGED
@@ -403,6 +403,30 @@ CREATE TABLE IF NOT EXISTS recalled_messages (
403
403
  recalled_at INTEGER NOT NULL,
404
404
  PRIMARY KEY (platform, account_id, message_id)
405
405
  );
406
+ `,
407
+ },
408
+ {
409
+ // Explicit message-id arguments are matched case-insensitively
410
+ // (findSeenMessageInChat); without these the lookup scans the whole
411
+ // unpruned ledger synchronously.
412
+ version: 11,
413
+ name: "message_id_nocase_indexes",
414
+ sql: `
415
+ CREATE INDEX IF NOT EXISTS idx_clawchat_messages_message_id_nocase
416
+ ON clawchat_messages(message_id COLLATE NOCASE);
417
+ CREATE INDEX IF NOT EXISTS idx_clawchat_messages_protocol_message_id_nocase
418
+ ON clawchat_messages(protocol_message_id COLLATE NOCASE);
419
+ `,
420
+ },
421
+ {
422
+ // A tool chat id from the model is mapped to the case already stored for
423
+ // that chat (findStoredChatId) before a row is written, so one chat never
424
+ // ends up under two cases.
425
+ version: 12,
426
+ name: "chat_id_nocase_index",
427
+ sql: `
428
+ CREATE INDEX IF NOT EXISTS idx_clawchat_messages_chat_id_nocase
429
+ ON clawchat_messages(chat_id COLLATE NOCASE);
406
430
  `,
407
431
  },
408
432
  ];
@@ -862,6 +886,72 @@ export class ClawChatStore {
862
886
  });
863
887
  }
864
888
 
889
+ /**
890
+ * The stored chat id and message id of this account's ledger row that has
891
+ * `messageId` in `chatId`, both compared case-insensitively. The message id
892
+ * matches the client id (`message_id`) or the acked protocol id
893
+ * (`protocol_message_id`); the one that matched is returned, in its stored
894
+ * case, so callers put the real id on the wire. Answers `false` when no row
895
+ * matches and `null` when the store is unavailable, so callers can tell
896
+ * "not seen" from "cannot say". Seeks via the NOCASE message-id indexes
897
+ * (migration 11); platform, account and chat are post-filters.
898
+ */
899
+ findSeenMessageInChat(input: {
900
+ platform: string;
901
+ accountId: string;
902
+ chatId: string;
903
+ messageId: string;
904
+ }): SeenMessage | false | null {
905
+ if (!input.chatId || !input.messageId) return false;
906
+ return this.read(() => {
907
+ const row = this.requireDb()
908
+ .prepare(FIND_SEEN_MESSAGE_SQL)
909
+ .get(
910
+ input.messageId,
911
+ input.platform,
912
+ input.accountId,
913
+ input.chatId,
914
+ input.messageId,
915
+ input.platform,
916
+ input.accountId,
917
+ input.chatId,
918
+ ) as { chat_id?: unknown; matched_id?: unknown } | undefined;
919
+ if (typeof row?.chat_id !== "string" || typeof row.matched_id !== "string") return false;
920
+ return { chatId: row.chat_id, messageId: row.matched_id };
921
+ });
922
+ }
923
+
924
+ /**
925
+ * The chat id as this account's ledger already stores it for `chatId`
926
+ * (compared case-insensitively), preferring an inbound row, whose case is
927
+ * the server's. `false` when the ledger has no row for that chat, `null`
928
+ * when the store is unavailable. Seeks via the NOCASE chat-id index
929
+ * (migration 12); platform, account and direction are post-filters.
930
+ */
931
+ findStoredChatId(input: { platform: string; accountId: string; chatId: string }): string | false | null {
932
+ if (!input.chatId) return false;
933
+ return this.read(() => {
934
+ const row = this.requireDb()
935
+ .prepare(FIND_STORED_CHAT_ID_SQL)
936
+ .get(input.chatId, input.platform, input.accountId, input.chatId, input.platform, input.accountId) as
937
+ | { chat_id?: unknown }
938
+ | undefined;
939
+ return typeof row?.chat_id === "string" ? row.chat_id : false;
940
+ });
941
+ }
942
+
943
+ /** True when `message_id` was recalled for this (platform, account). */
944
+ isMessageRecalled(input: { platform: string; accountId: string; messageId?: string | null }): boolean {
945
+ if (!input.messageId) return false;
946
+ const row = this.requireDb()
947
+ .prepare(
948
+ `SELECT 1 FROM recalled_messages
949
+ WHERE platform = ? AND account_id = ? AND message_id = ?`,
950
+ )
951
+ .get(input.platform, input.accountId, input.messageId);
952
+ return row !== undefined;
953
+ }
954
+
865
955
  /**
866
956
  * Erase every ledger row for one `message_id` — the local half of a
867
957
  * server-authorised recall (`message.recall`, docs/client-integration.md §9.8).
@@ -880,21 +970,8 @@ export class ClawChatStore {
880
970
  * the row may never have been stored (a message that arrived before this
881
971
  * account was paired, or one dropped by the inbound filter). `null` means the
882
972
  * store is unavailable.
883
- */
884
- /** True when `message_id` was recalled for this (platform, account). */
885
- isMessageRecalled(input: { platform: string; accountId: string; messageId?: string | null }): boolean {
886
- if (!input.messageId) return false;
887
- const row = this.requireDb()
888
- .prepare(
889
- `SELECT 1 FROM recalled_messages
890
- WHERE platform = ? AND account_id = ? AND message_id = ?`,
891
- )
892
- .get(input.platform, input.accountId, input.messageId);
893
- return row !== undefined;
894
- }
895
-
896
- /**
897
- * Record the permanent recall tombstone and purge any row already stored,
973
+ *
974
+ * Records the permanent recall tombstone and purges any row already stored,
898
975
  * atomically. Returns the number of ledger rows deleted (0 when the recall
899
976
  * arrived before the message did — the tombstone still stands and the later
900
977
  * ingest is refused by {@link claimMessageOnce}).
@@ -1344,6 +1421,48 @@ export class ClawChatStore {
1344
1421
  }
1345
1422
  }
1346
1423
 
1424
+ /**
1425
+ * Lookup behind {@link ClawChatStore.findStoredChatId}: an inbound row first,
1426
+ * then any row. Both halves seek `idx_clawchat_messages_chat_id_nocase`.
1427
+ * Exported so a test can pin the query plan.
1428
+ */
1429
+ export const FIND_STORED_CHAT_ID_SQL = `SELECT chat_id FROM (
1430
+ SELECT chat_id, 0 AS rank FROM (
1431
+ SELECT chat_id FROM clawchat_messages
1432
+ WHERE chat_id = ? COLLATE NOCASE AND platform = ? AND account_id = ? AND direction = 'inbound'
1433
+ LIMIT 1
1434
+ )
1435
+ UNION ALL
1436
+ SELECT chat_id, 1 AS rank FROM (
1437
+ SELECT chat_id FROM clawchat_messages
1438
+ WHERE chat_id = ? COLLATE NOCASE AND platform = ? AND account_id = ?
1439
+ LIMIT 1
1440
+ )
1441
+ ) ORDER BY rank LIMIT 1`;
1442
+
1443
+ /** A ledger message as stored: its chat id and the message id that matched. */
1444
+ export type SeenMessage = { chatId: string; messageId: string };
1445
+
1446
+ /**
1447
+ * Lookup behind {@link ClawChatStore.findSeenMessageInChat}. Two index seeks
1448
+ * (`message_id` / `protocol_message_id`, both COLLATE NOCASE, migration 11);
1449
+ * platform, account and chat are post-filters. A `message_id` match wins.
1450
+ * `INDEXED BY` pins the seek: without table statistics the planner would
1451
+ * otherwise pick the chat-id index (migration 12) and walk the whole chat.
1452
+ * Exported so a test can pin the query plan.
1453
+ */
1454
+ export const FIND_SEEN_MESSAGE_SQL = `SELECT chat_id, matched_id FROM (
1455
+ SELECT chat_id, message_id AS matched_id, 0 AS rank
1456
+ FROM clawchat_messages INDEXED BY idx_clawchat_messages_message_id_nocase
1457
+ WHERE message_id = ? COLLATE NOCASE
1458
+ AND platform = ? AND account_id = ? AND chat_id = ? COLLATE NOCASE
1459
+ UNION ALL
1460
+ SELECT chat_id, protocol_message_id AS matched_id, 1 AS rank
1461
+ FROM clawchat_messages INDEXED BY idx_clawchat_messages_protocol_message_id_nocase
1462
+ WHERE protocol_message_id = ? COLLATE NOCASE
1463
+ AND platform = ? AND account_id = ? AND chat_id = ? COLLATE NOCASE
1464
+ ) ORDER BY rank LIMIT 1`;
1465
+
1347
1466
  let singleton: ClawChatStore | null = null;
1348
1467
 
1349
1468
  export function createClawChatStore(options: ClawChatStoreOptions = {}): ClawChatStore {
@@ -8,14 +8,93 @@ export type TerminalClawChatSendRecord = {
8
8
 
9
9
  const DEFAULT_TTL_MS = 60_000;
10
10
  const terminalSends = new Map<string, TerminalClawChatSendRecord>();
11
- const terminalSendScope = new AsyncLocalStorage<string>();
11
+
12
+ /**
13
+ * One dispatched ClawChat turn, as seen by the tools that run inside it.
14
+ *
15
+ * The turn travels in AsyncLocalStorage, but async context alone does not say
16
+ * which turn a tool call belongs to: the host can start a queued agent run
17
+ * from another run's continuation (its follow-up queue drains from the
18
+ * finishing turn's context; hosts before 2026.8.1 also pumped the command
19
+ * lanes without a context snapshot), so a run can carry an older or a
20
+ * different chat's store. A scope is therefore only used when it is still
21
+ * open AND was registered for the same session key as the trusted tool
22
+ * context. A scope that a later turn of the same session has superseded no
23
+ * longer supplies default reaction ids (the later message may have been
24
+ * steered into this run), but it still owns its terminal-send markers because
25
+ * the run's replies still go through its dispatcher.
26
+ */
27
+ type TurnScope = {
28
+ scopeId: string;
29
+ sessionKey: string | null;
30
+ open: boolean;
31
+ superseded: boolean;
32
+ };
33
+
34
+ const turnScopeStorage = new AsyncLocalStorage<TurnScope>();
35
+ const openScopesBySession = new Map<string, Set<TurnScope>>();
36
+
37
+ function normalizeSessionKey(sessionKey: string | null | undefined): string | null {
38
+ const normalized = sessionKey?.trim().toLowerCase();
39
+ return normalized ? normalized : null;
40
+ }
12
41
 
13
42
  function key(accountId: string, chatId: string, scopeId: string): string {
14
43
  return `${accountId}\0${chatId}\0${scopeId}`;
15
44
  }
16
45
 
17
- export function runWithTerminalClawChatSendScope<T>(scopeId: string, fn: () => T): T {
18
- return terminalSendScope.run(scopeId, fn);
46
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
47
+ return typeof (value as { then?: unknown } | null)?.then === "function";
48
+ }
49
+
50
+ /**
51
+ * Run one dispatched turn. `sessionKey` is the session the host runs the turn
52
+ * in; consumers only see this turn when their trusted session key matches.
53
+ * The scope closes when `fn` returns or its promise settles.
54
+ */
55
+ export function runWithTerminalClawChatSendScope<T>(
56
+ init: { scopeId: string; sessionKey: string | null | undefined },
57
+ fn: () => T,
58
+ ): T {
59
+ const scope: TurnScope = {
60
+ scopeId: init.scopeId,
61
+ sessionKey: normalizeSessionKey(init.sessionKey),
62
+ open: true,
63
+ superseded: false,
64
+ };
65
+ const sessionKey = scope.sessionKey;
66
+ if (sessionKey) {
67
+ const open = openScopesBySession.get(sessionKey) ?? new Set<TurnScope>();
68
+ for (const earlier of open) earlier.superseded = true;
69
+ open.add(scope);
70
+ openScopesBySession.set(sessionKey, open);
71
+ }
72
+ const close = () => {
73
+ scope.open = false;
74
+ if (!sessionKey) return;
75
+ const open = openScopesBySession.get(sessionKey);
76
+ open?.delete(scope);
77
+ if (open?.size === 0) openScopesBySession.delete(sessionKey);
78
+ };
79
+ let result: T;
80
+ try {
81
+ result = turnScopeStorage.run(scope, fn);
82
+ } catch (err) {
83
+ close();
84
+ throw err;
85
+ }
86
+ if (isPromiseLike(result)) {
87
+ return Promise.resolve(result).finally(close) as T;
88
+ }
89
+ close();
90
+ return result;
91
+ }
92
+
93
+ /** The caller's own open turn, or null when it cannot be tied to the caller's session. */
94
+ function resolveCallerScope(sessionKey: string | null | undefined): TurnScope | null {
95
+ const scope = turnScopeStorage.getStore();
96
+ if (!scope?.open || !scope.sessionKey) return null;
97
+ return scope.sessionKey === normalizeSessionKey(sessionKey) ? scope : null;
19
98
  }
20
99
 
21
100
  /**
@@ -35,11 +114,14 @@ export function markTerminalClawChatSend(params: {
35
114
  accountId: string;
36
115
  chatId: string;
37
116
  messageId: string;
117
+ /** Explicit scope; otherwise the caller's own turn, resolved via `sessionKey`. */
38
118
  scopeId?: string;
119
+ /** Trusted session key of the tool call. Without it no turn is attributed. */
120
+ sessionKey?: string | null;
39
121
  ttlMs?: number;
40
122
  nowMs?: number;
41
123
  }): void {
42
- const scopeId = params.scopeId ?? terminalSendScope.getStore();
124
+ const scopeId = params.scopeId ?? resolveCallerScope(params.sessionKey)?.scopeId;
43
125
  if (!scopeId) return;
44
126
  const nowMs = params.nowMs ?? Date.now();
45
127
  sweepExpiredTerminalSends(nowMs);
@@ -53,10 +135,10 @@ export function markTerminalClawChatSend(params: {
53
135
  export function consumeTerminalClawChatSend(params: {
54
136
  accountId: string;
55
137
  chatId: string;
56
- scopeId?: string;
138
+ scopeId: string;
57
139
  nowMs?: number;
58
140
  }): TerminalClawChatSendRecord | null {
59
- const scopeId = params.scopeId ?? terminalSendScope.getStore();
141
+ const scopeId = params.scopeId;
60
142
  if (!scopeId) return null;
61
143
  const recordKey = key(params.accountId, params.chatId, scopeId);
62
144
  const record = terminalSends.get(recordKey);
@@ -68,6 +150,7 @@ export function consumeTerminalClawChatSend(params: {
68
150
 
69
151
  export function clearTerminalClawChatSendsForTest(): void {
70
152
  terminalSends.clear();
153
+ openScopesBySession.clear();
71
154
  }
72
155
 
73
156
  export interface TerminalSendScope {
@@ -77,14 +160,15 @@ export interface TerminalSendScope {
77
160
  }
78
161
 
79
162
  /**
80
- * Read the current turn's ambient send scope (accountId\0chatId\0messageId).
81
- * Returns null when not inside a dispatch scope, or when the scope id is not
82
- * the tripartite form (e.g. a test-only opaque scope id).
163
+ * The calling turn's default reaction target (accountId\0chatId\0messageId).
164
+ * Returns null unless the tool call's trusted `sessionKey` matches the open
165
+ * turn in its async context and no later turn of that session has started
166
+ * since; also null for a non-tripartite scope id (e.g. a test-only one).
83
167
  */
84
- export function getCurrentTerminalSendScope(): TerminalSendScope | null {
85
- const store = terminalSendScope.getStore();
86
- if (!store) return null;
87
- const parts = store.split("\0");
168
+ export function getCurrentTerminalSendScope(params: { sessionKey: string | null | undefined }): TerminalSendScope | null {
169
+ const scope = resolveCallerScope(params.sessionKey);
170
+ if (!scope || scope.superseded) return null;
171
+ const parts = scope.scopeId.split("\0");
88
172
  if (parts.length !== 3) return null;
89
173
  const [accountId, chatId, messageId] = parts;
90
174
  if (!accountId || !chatId || !messageId) return null;