@clawling/clawchat-plugin-openclaw 2026.10.9-2 → 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/README.md +1 -1
- package/dist/index.js +4 -0
- package/dist/src/clawchat-memory.js +6 -1
- package/dist/src/config.js +6 -0
- package/dist/src/host-tools-guard.js +165 -0
- package/dist/src/host-version.js +35 -0
- package/dist/src/memory-scope.js +166 -0
- package/dist/src/outbound.js +28 -2
- package/dist/src/owner-identity.js +29 -0
- package/dist/src/reply-dispatcher.js +26 -13
- package/dist/src/runtime.js +73 -12
- package/dist/src/storage.js +153 -15
- package/dist/src/terminal-send.js +69 -12
- package/dist/src/tools.js +324 -36
- package/dist/src/turn-boundary.js +68 -0
- package/index.ts +4 -0
- package/openclaw.plugin.json +4 -0
- package/package.json +3 -3
- package/src/clawchat-memory.ts +13 -0
- package/src/config.ts +11 -0
- package/src/host-tools-guard.ts +189 -0
- package/src/host-version.ts +35 -0
- package/src/memory-scope.ts +191 -0
- package/src/outbound.ts +39 -3
- package/src/owner-identity.ts +49 -0
- package/src/reply-dispatcher.ts +31 -13
- package/src/runtime.ts +81 -13
- package/src/storage.ts +176 -16
- package/src/terminal-send.ts +97 -13
- package/src/tools.ts +347 -40
- package/src/turn-boundary.ts +79 -0
package/src/storage.ts
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { DatabaseSync } from "node:sqlite";
|
|
4
4
|
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
|
5
5
|
import { resolveStateDirFromEnv } from "./state-paths.ts";
|
|
6
|
+
import type { ActivationIdentityLookup } from "./owner-identity.ts";
|
|
6
7
|
|
|
7
8
|
type Log = { error?: (message: string) => void };
|
|
8
9
|
|
|
@@ -402,6 +403,30 @@ CREATE TABLE IF NOT EXISTS recalled_messages (
|
|
|
402
403
|
recalled_at INTEGER NOT NULL,
|
|
403
404
|
PRIMARY KEY (platform, account_id, message_id)
|
|
404
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);
|
|
405
430
|
`,
|
|
406
431
|
},
|
|
407
432
|
];
|
|
@@ -643,6 +668,40 @@ export class ClawChatStore {
|
|
|
643
668
|
}) ?? null;
|
|
644
669
|
}
|
|
645
670
|
|
|
671
|
+
/**
|
|
672
|
+
* Strict owner-identity read for the non-owner turn boundary. Unlike the
|
|
673
|
+
* other getters (which collapse failure into null), this keeps "storage
|
|
674
|
+
* unavailable", "no activation row" and "row" apart — including incomplete
|
|
675
|
+
* rows — because only a proven-absent row may fall back to the config mirror
|
|
676
|
+
* (see `resolveOwnerIdentity`). Nothing is logged here: the caller warns
|
|
677
|
+
* once per failure class, and no SQL or credentials reach the log.
|
|
678
|
+
*/
|
|
679
|
+
lookupActivationIdentity(input: ConversationAccountInput): ActivationIdentityLookup {
|
|
680
|
+
if (!this.ensureInitialized()) return { status: "unavailable" };
|
|
681
|
+
try {
|
|
682
|
+
const row = this.requireDb()
|
|
683
|
+
.prepare(
|
|
684
|
+
"SELECT owner_user_id, conversation_id, access_token FROM activations WHERE platform = ? AND account_id = ?",
|
|
685
|
+
)
|
|
686
|
+
.get(input.platform, input.accountId) as Record<string, unknown> | undefined;
|
|
687
|
+
if (!row) return { status: "absent" };
|
|
688
|
+
const value = (key: string) => {
|
|
689
|
+
const raw = row[key];
|
|
690
|
+
return typeof raw === "string" ? raw.trim() || null : null;
|
|
691
|
+
};
|
|
692
|
+
return {
|
|
693
|
+
status: "row",
|
|
694
|
+
row: {
|
|
695
|
+
ownerUserId: value("owner_user_id"),
|
|
696
|
+
conversationId: value("conversation_id"),
|
|
697
|
+
accessToken: value("access_token"),
|
|
698
|
+
},
|
|
699
|
+
};
|
|
700
|
+
} catch {
|
|
701
|
+
return { status: "unavailable" };
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
646
705
|
claimPendingActivationBootstrap(
|
|
647
706
|
input: ActivationBootstrapInput,
|
|
648
707
|
): ActivationBootstrapConversation | null {
|
|
@@ -827,6 +886,72 @@ export class ClawChatStore {
|
|
|
827
886
|
});
|
|
828
887
|
}
|
|
829
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
|
+
|
|
830
955
|
/**
|
|
831
956
|
* Erase every ledger row for one `message_id` — the local half of a
|
|
832
957
|
* server-authorised recall (`message.recall`, docs/client-integration.md §9.8).
|
|
@@ -845,21 +970,8 @@ export class ClawChatStore {
|
|
|
845
970
|
* the row may never have been stored (a message that arrived before this
|
|
846
971
|
* account was paired, or one dropped by the inbound filter). `null` means the
|
|
847
972
|
* store is unavailable.
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
isMessageRecalled(input: { platform: string; accountId: string; messageId?: string | null }): boolean {
|
|
851
|
-
if (!input.messageId) return false;
|
|
852
|
-
const row = this.requireDb()
|
|
853
|
-
.prepare(
|
|
854
|
-
`SELECT 1 FROM recalled_messages
|
|
855
|
-
WHERE platform = ? AND account_id = ? AND message_id = ?`,
|
|
856
|
-
)
|
|
857
|
-
.get(input.platform, input.accountId, input.messageId);
|
|
858
|
-
return row !== undefined;
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
/**
|
|
862
|
-
* Record the permanent recall tombstone and purge any row already stored,
|
|
973
|
+
*
|
|
974
|
+
* Records the permanent recall tombstone and purges any row already stored,
|
|
863
975
|
* atomically. Returns the number of ledger rows deleted (0 when the recall
|
|
864
976
|
* arrived before the message did — the tombstone still stands and the later
|
|
865
977
|
* ingest is refused by {@link claimMessageOnce}).
|
|
@@ -1184,6 +1296,10 @@ export class ClawChatStore {
|
|
|
1184
1296
|
* as `'thinking'` are excluded so the @-mention prior-context block never
|
|
1185
1297
|
* surfaces the agent's intermediate output as conversation history.
|
|
1186
1298
|
* Uses `idx_clawchat_messages_chat_created` for performance.
|
|
1299
|
+
* `ignoreCase` matches `chat_id` case-insensitively (rows keep their stored
|
|
1300
|
+
* case) so memory scope can recover a group id's original case from a
|
|
1301
|
+
* lowercased session key; it cannot use that index, so callers that already
|
|
1302
|
+
* know the exact id leave it off.
|
|
1187
1303
|
* Returns an empty array if the store is unavailable.
|
|
1188
1304
|
*/
|
|
1189
1305
|
listRecentGroupMessages(
|
|
@@ -1191,12 +1307,14 @@ export class ClawChatStore {
|
|
|
1191
1307
|
accountId: string,
|
|
1192
1308
|
chatId: string,
|
|
1193
1309
|
limit: number,
|
|
1310
|
+
options: { ignoreCase?: boolean } = {},
|
|
1194
1311
|
): MessageRow[] {
|
|
1312
|
+
const chatMatch = options.ignoreCase ? "chat_id = ? COLLATE NOCASE" : "chat_id = ?";
|
|
1195
1313
|
return this.read(() => {
|
|
1196
1314
|
const rows = this.requireDb()
|
|
1197
1315
|
.prepare(
|
|
1198
1316
|
`SELECT * FROM clawchat_messages
|
|
1199
|
-
WHERE platform = ? AND account_id = ? AND
|
|
1317
|
+
WHERE platform = ? AND account_id = ? AND ${chatMatch} AND kind = 'message'
|
|
1200
1318
|
ORDER BY created_at DESC, rowid DESC LIMIT ?`,
|
|
1201
1319
|
)
|
|
1202
1320
|
.all(platform, accountId, chatId, limit) as MessageRow[];
|
|
@@ -1303,6 +1421,48 @@ export class ClawChatStore {
|
|
|
1303
1421
|
}
|
|
1304
1422
|
}
|
|
1305
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
|
+
|
|
1306
1466
|
let singleton: ClawChatStore | null = null;
|
|
1307
1467
|
|
|
1308
1468
|
export function createClawChatStore(options: ClawChatStoreOptions = {}): ClawChatStore {
|
package/src/terminal-send.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
18
|
-
return
|
|
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 ??
|
|
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
|
|
138
|
+
scopeId: string;
|
|
57
139
|
nowMs?: number;
|
|
58
140
|
}): TerminalClawChatSendRecord | null {
|
|
59
|
-
const scopeId = params.scopeId
|
|
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
|
-
*
|
|
81
|
-
* Returns null
|
|
82
|
-
*
|
|
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
|
|
86
|
-
if (!
|
|
87
|
-
const parts =
|
|
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;
|