@clawling/clawchat-plugin-openclaw 2026.10.9-3 → 2026.10.11-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/index.js +12 -0
- package/dist/src/approval-route.js +282 -0
- package/dist/src/host-tools-guard.js +120 -13
- package/dist/src/memory-scope.js +21 -0
- package/dist/src/outbound.js +42 -2
- package/dist/src/runtime.js +119 -8
- package/dist/src/skill-update.js +1 -1
- package/dist/src/storage.js +112 -13
- package/dist/src/terminal-send.js +69 -12
- package/dist/src/tools.js +216 -28
- package/dist/src/turn-boundary.js +17 -0
- package/index.ts +12 -0
- package/package.json +1 -1
- package/skills/manifest.json +3 -3
- package/src/approval-route.ts +346 -0
- package/src/config.ts +2 -2
- package/src/host-tools-guard.ts +160 -17
- package/src/memory-scope.ts +26 -0
- package/src/outbound.ts +53 -3
- package/src/runtime.ts +117 -9
- package/src/skill-update.ts +1 -1
- package/src/storage.ts +134 -15
- package/src/terminal-send.ts +97 -13
- package/src/tools.ts +230 -26
- package/src/turn-boundary.ts +18 -0
package/dist/src/runtime.js
CHANGED
|
@@ -33,6 +33,7 @@ import { clawChatDbPathForStateDir, getClawChatStore, makeOwnerProfilePersister,
|
|
|
33
33
|
import { getClawChatGroupPrompt, getClawChatPlatformPrompt, getClawChatUserPrompt } from "./plugin-prompts.js";
|
|
34
34
|
import { classifyChat, NON_OWNER_TOOL_ALLOWLIST } from "./turn-boundary.js";
|
|
35
35
|
import { resolveOwnerIdentity } from "./owner-identity.js";
|
|
36
|
+
import { createApprovalsWriteGuard, listActiveAccountIds, reconcileApprovalsPlugin, resolveApprovalRoute, storedIdentityLookup, } from "./approval-route.js";
|
|
36
37
|
import { loadClawChatPromptMetadata, renderClawChatProfilePrompt, resolveSenderRelation, } from "./profile-prompt.js";
|
|
37
38
|
import { buildNoteMemorySection } from "./note-injection.js";
|
|
38
39
|
import { buildGroupCatchUp, CATCH_UP_FETCH_ROWS, DeliveredGroupMessages } from "./group-catch-up.js";
|
|
@@ -110,6 +111,10 @@ const OPENCLAW_CONFIRM_SLASH_COMMANDS = new Set([
|
|
|
110
111
|
"nevermind",
|
|
111
112
|
]);
|
|
112
113
|
const GROUP_OWNER_ATTENTION_TITLE = "requires owner attention";
|
|
114
|
+
/** Group turns under `non-owner-host-tools: approve` (route live). */
|
|
115
|
+
const GROUP_APPROVAL_GUIDANCE = "Host tools here (commands, files, browser, sub-agents, scheduling and the like) run only if your owner approves each call: " +
|
|
116
|
+
"the request goes to your owner's direct chat and the call waits up to 5 minutes. " +
|
|
117
|
+
"Do not ask anyone in this group to approve it. If it is denied or times out, do not retry; say your owner did not approve it.";
|
|
113
118
|
// §C.1 — user-visible message emitted on permanent token expiry. Kept
|
|
114
119
|
// byte-identical to the Hermes plugin (parity spec §C.1.4). Points the owner
|
|
115
120
|
// at the reconnect prompt (a code bound to this identity) rather than at a
|
|
@@ -733,10 +738,7 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
733
738
|
hostAtLeast(runtime.version, HOST_TOOL_SEARCH_DEFAULT_ON) &&
|
|
734
739
|
hostToolSearchEnabled(cfg)) {
|
|
735
740
|
loggedHostToolSearch = true;
|
|
736
|
-
log?.info?.("clawchat-plugin-openclaw: host tool search is on; turns your owner did not start can still use only the allowlisted tools (set tools.toolSearch.enabled=false to turn tool search off; this affects every turn)");
|
|
737
|
-
}
|
|
738
|
-
if (!params.boundaryWarningIssued && account.nonOwnerHostTools === "approve") {
|
|
739
|
-
log?.warn?.("clawchat-plugin-openclaw: non-owner-host-tools approve is not supported yet; using off");
|
|
741
|
+
log?.info?.("clawchat-plugin-openclaw: host tool search is on; turns your owner did not start can still use only the allowlisted tools without asking (approve mode: other tools in group turns still need the owner's approval each call) (set tools.toolSearch.enabled=false to turn tool search off; this affects every turn)");
|
|
740
742
|
}
|
|
741
743
|
log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime start entered configured=${account.configured} enabled=${account.enabled} hasToken=${Boolean(account.token)} hasUserId=${Boolean(account.userId)} hasOwnerUserId=${Boolean(account.ownerUserId)} websocketUrl=${account.websocketUrl || "(empty)"}`);
|
|
742
744
|
// Freeze one report device_id for this process; reused for the paired report
|
|
@@ -2161,6 +2163,108 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2161
2163
|
// forwards) would be unreachable. Registered lazily — the activation
|
|
2162
2164
|
// conversation may be recorded after the client is constructed.
|
|
2163
2165
|
client.setOwnerDirectChatResolver(resolveOwnerDirectConversationId);
|
|
2166
|
+
// `non-owner-host-tools: approve` routes group host-tool approvals to the
|
|
2167
|
+
// owner's direct chat through the host's `approvals.plugin`
|
|
2168
|
+
// (approval-route.ts). Checked once at start and again from a group turn
|
|
2169
|
+
// that finds the route missing (the owner chat may be recorded later). One
|
|
2170
|
+
// attempt per owner chat per lifecycle, so a failing write cannot loop.
|
|
2171
|
+
const readLiveConfig = () => {
|
|
2172
|
+
const current = runtime.config?.current;
|
|
2173
|
+
return (typeof current === "function" ? current.call(runtime.config) : cfg);
|
|
2174
|
+
};
|
|
2175
|
+
const approvalReconcileAttempts = new Set();
|
|
2176
|
+
const hasStoredIdentity = storedIdentityLookup(store);
|
|
2177
|
+
const approvalsStateDir = (() => {
|
|
2178
|
+
try {
|
|
2179
|
+
return runtime.state?.resolveStateDir?.();
|
|
2180
|
+
}
|
|
2181
|
+
catch {
|
|
2182
|
+
return undefined;
|
|
2183
|
+
}
|
|
2184
|
+
})();
|
|
2185
|
+
const reconcileApprovalRoute = () => {
|
|
2186
|
+
const mutateConfigFile = resolveMutateConfigFile();
|
|
2187
|
+
if (!mutateConfigFile) {
|
|
2188
|
+
if (approvalReconcileAttempts.has("no-config-writes"))
|
|
2189
|
+
return;
|
|
2190
|
+
approvalReconcileAttempts.add("no-config-writes");
|
|
2191
|
+
try {
|
|
2192
|
+
const live = readLiveConfig();
|
|
2193
|
+
if (resolveOpenclawClawlingAccount(live, accountId).nonOwnerHostTools !== "approve")
|
|
2194
|
+
return;
|
|
2195
|
+
const route = resolveApprovalRoute({
|
|
2196
|
+
liveConfig: live,
|
|
2197
|
+
accountId,
|
|
2198
|
+
activeAccountIds: listActiveAccountIds(live, { knownActive: accountId, hasStoredIdentity }),
|
|
2199
|
+
approve: true,
|
|
2200
|
+
ownerConversationId: resolveOwnerDirectConversationId(),
|
|
2201
|
+
});
|
|
2202
|
+
if (route.ready)
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
2205
|
+
catch {
|
|
2206
|
+
// Fall through: say why approve is not routing.
|
|
2207
|
+
}
|
|
2208
|
+
log?.warn?.(`[${accountId}] clawchat-plugin-openclaw: non-owner-host-tools approve cannot write approvals.plugin (host config writes unavailable); group host-tool calls stay blocked`);
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
let ownerChat = null;
|
|
2212
|
+
try {
|
|
2213
|
+
ownerChat = resolveOwnerDirectConversationId();
|
|
2214
|
+
}
|
|
2215
|
+
catch {
|
|
2216
|
+
ownerChat = null;
|
|
2217
|
+
}
|
|
2218
|
+
const attemptKey = ownerChat ?? "";
|
|
2219
|
+
if (approvalReconcileAttempts.has(attemptKey))
|
|
2220
|
+
return;
|
|
2221
|
+
approvalReconcileAttempts.add(attemptKey);
|
|
2222
|
+
void reconcileApprovalsPlugin({
|
|
2223
|
+
readConfig: readLiveConfig,
|
|
2224
|
+
mutateConfigFile: mutateConfigFile,
|
|
2225
|
+
ownerConversation: (id) => (id === accountId ? ownerChat : null),
|
|
2226
|
+
knownActive: accountId,
|
|
2227
|
+
hasStoredIdentity,
|
|
2228
|
+
...(approvalsStateDir ? { writeGuard: createApprovalsWriteGuard(approvalsStateDir) } : {}),
|
|
2229
|
+
log: {
|
|
2230
|
+
info: (m) => log?.info?.(`[${accountId}] ${m}`),
|
|
2231
|
+
warn: (m) => {
|
|
2232
|
+
const warn = log?.warn;
|
|
2233
|
+
if (warn)
|
|
2234
|
+
warn(`[${accountId}] ${m}`);
|
|
2235
|
+
else
|
|
2236
|
+
log?.info?.(`[${accountId}] WARNING ${m}`);
|
|
2237
|
+
},
|
|
2238
|
+
error: (m) => log?.error?.(`[${accountId}] ${m}`),
|
|
2239
|
+
},
|
|
2240
|
+
}).catch(() => {
|
|
2241
|
+
// Logged inside; the route simply stays not ready (calls block).
|
|
2242
|
+
});
|
|
2243
|
+
};
|
|
2244
|
+
const groupApprovalRouteReady = (ownerChat) => {
|
|
2245
|
+
try {
|
|
2246
|
+
const live = readLiveConfig();
|
|
2247
|
+
const approve = resolveOpenclawClawlingAccount(live, accountId).nonOwnerHostTools === "approve";
|
|
2248
|
+
if (!approve)
|
|
2249
|
+
return false;
|
|
2250
|
+
const route = resolveApprovalRoute({
|
|
2251
|
+
liveConfig: live,
|
|
2252
|
+
accountId,
|
|
2253
|
+
activeAccountIds: listActiveAccountIds(live, { knownActive: accountId, hasStoredIdentity }),
|
|
2254
|
+
approve,
|
|
2255
|
+
ownerConversationId: ownerChat,
|
|
2256
|
+
});
|
|
2257
|
+
if (!route.ready && (route.reason === "not-applied" || route.reason === "no-owner-chat")) {
|
|
2258
|
+
reconcileApprovalRoute();
|
|
2259
|
+
}
|
|
2260
|
+
return route.ready;
|
|
2261
|
+
}
|
|
2262
|
+
catch {
|
|
2263
|
+
return false;
|
|
2264
|
+
}
|
|
2265
|
+
};
|
|
2266
|
+
if (!params.boundaryWarningIssued)
|
|
2267
|
+
reconcileApprovalRoute();
|
|
2164
2268
|
const sendOwnerSkillMessage = async (text) => {
|
|
2165
2269
|
const conversationId = resolveOwnerDirectConversationId();
|
|
2166
2270
|
if (!conversationId) {
|
|
@@ -2881,11 +2985,18 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2881
2985
|
chatId: turn.peer.id,
|
|
2882
2986
|
ownerDirectConversationId: ownerConversationId,
|
|
2883
2987
|
});
|
|
2884
|
-
|
|
2988
|
+
// `non-owner-host-tools: approve`: a group turn keeps the host tools once
|
|
2989
|
+
// the approval route to the owner's direct chat is live; the hook then
|
|
2990
|
+
// asks the owner for each call. Friend direct chats always narrow.
|
|
2991
|
+
const groupApproval = turnClass === "non-owner"
|
|
2992
|
+
&& turn.peer.kind === "group"
|
|
2993
|
+
&& groupApprovalRouteReady(ownerConversationId);
|
|
2994
|
+
const narrowHostTools = turnClass === "non-owner" && !groupApproval;
|
|
2885
2995
|
const baseTurnPrompt = await buildPromptForTurn(turn, memoryRoot);
|
|
2886
2996
|
const nonOwnerMediaGuidance = "To send an image or file here, include it in your final reply as an http(s) URL; local files cannot be sent from this chat.";
|
|
2887
2997
|
const turnPrompt = [
|
|
2888
|
-
...(
|
|
2998
|
+
...(turnClass === "non-owner" ? [getClawChatPlatformPrompt(), nonOwnerMediaGuidance] : []),
|
|
2999
|
+
...(groupApproval ? [GROUP_APPROVAL_GUIDANCE] : []),
|
|
2889
3000
|
baseTurnPrompt,
|
|
2890
3001
|
].filter(Boolean).join("\n\n");
|
|
2891
3002
|
const ctxPayload = resolveChannelContextBuilder(rt)({
|
|
@@ -3040,7 +3151,7 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
3040
3151
|
// conversation, not `ownerUserId` (a usr_… chat_id is rejected by
|
|
3041
3152
|
// member-backend). Passed as a thunk so it is resolved at send time.
|
|
3042
3153
|
resolveOwnerConversationId: resolveOwnerDirectConversationId,
|
|
3043
|
-
nonOwnerTurn:
|
|
3154
|
+
nonOwnerTurn: turnClass === "non-owner",
|
|
3044
3155
|
log,
|
|
3045
3156
|
});
|
|
3046
3157
|
const agentsConfigured = Object.keys(cfg.agents ?? {});
|
|
@@ -3049,7 +3160,7 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
3049
3160
|
const dispatchResult = await rt.reply.withReplyDispatcher({
|
|
3050
3161
|
dispatcher,
|
|
3051
3162
|
onSettled: () => markDispatchIdle(),
|
|
3052
|
-
run: () => runWithTerminalClawChatSendScope(terminalSendScopeId, () => rt.reply.dispatchReplyFromConfig({
|
|
3163
|
+
run: () => runWithTerminalClawChatSendScope({ scopeId: terminalSendScopeId, sessionKey: resolvedSessionKey }, () => rt.reply.dispatchReplyFromConfig({
|
|
3053
3164
|
ctx: ctxPayload,
|
|
3054
3165
|
cfg: dispatchCfg,
|
|
3055
3166
|
dispatcher,
|
package/dist/src/skill-update.js
CHANGED
|
@@ -66,7 +66,7 @@ export const OFFICIAL_SKILLS_BASE = "https://raw.githubusercontent.com/clawling/
|
|
|
66
66
|
* in the install-cli repo, bump this constant, ship it. `liveware-sample.ts`
|
|
67
67
|
* imports the same ref, so the `livewares` tree at that tag is pinned too.
|
|
68
68
|
*/
|
|
69
|
-
export const DEFAULT_SKILLS_REF = "skills-v1.17.
|
|
69
|
+
export const DEFAULT_SKILLS_REF = "skills-v1.17.1";
|
|
70
70
|
/** Refuse to treat an absurdly large response as a skill file (defence in depth). */
|
|
71
71
|
export const MAX_SKILL_BYTES = 256 * 1024;
|
|
72
72
|
/** This adapter's host target inside `skills/manifest.json`. */
|
package/dist/src/storage.js
CHANGED
|
@@ -184,6 +184,30 @@ CREATE TABLE IF NOT EXISTS recalled_messages (
|
|
|
184
184
|
recalled_at INTEGER NOT NULL,
|
|
185
185
|
PRIMARY KEY (platform, account_id, message_id)
|
|
186
186
|
);
|
|
187
|
+
`,
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
// Explicit message-id arguments are matched case-insensitively
|
|
191
|
+
// (findSeenMessageInChat); without these the lookup scans the whole
|
|
192
|
+
// unpruned ledger synchronously.
|
|
193
|
+
version: 11,
|
|
194
|
+
name: "message_id_nocase_indexes",
|
|
195
|
+
sql: `
|
|
196
|
+
CREATE INDEX IF NOT EXISTS idx_clawchat_messages_message_id_nocase
|
|
197
|
+
ON clawchat_messages(message_id COLLATE NOCASE);
|
|
198
|
+
CREATE INDEX IF NOT EXISTS idx_clawchat_messages_protocol_message_id_nocase
|
|
199
|
+
ON clawchat_messages(protocol_message_id COLLATE NOCASE);
|
|
200
|
+
`,
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
// A tool chat id from the model is mapped to the case already stored for
|
|
204
|
+
// that chat (findStoredChatId) before a row is written, so one chat never
|
|
205
|
+
// ends up under two cases.
|
|
206
|
+
version: 12,
|
|
207
|
+
name: "chat_id_nocase_index",
|
|
208
|
+
sql: `
|
|
209
|
+
CREATE INDEX IF NOT EXISTS idx_clawchat_messages_chat_id_nocase
|
|
210
|
+
ON clawchat_messages(chat_id COLLATE NOCASE);
|
|
187
211
|
`,
|
|
188
212
|
},
|
|
189
213
|
];
|
|
@@ -503,6 +527,55 @@ export class ClawChatStore {
|
|
|
503
527
|
.run(input.eventType, input.traceId ?? null, input.chatId ?? null, input.text ?? null, toJson(input.raw), input.accountId, input.kind, input.direction, input.messageId);
|
|
504
528
|
});
|
|
505
529
|
}
|
|
530
|
+
/**
|
|
531
|
+
* The stored chat id and message id of this account's ledger row that has
|
|
532
|
+
* `messageId` in `chatId`, both compared case-insensitively. The message id
|
|
533
|
+
* matches the client id (`message_id`) or the acked protocol id
|
|
534
|
+
* (`protocol_message_id`); the one that matched is returned, in its stored
|
|
535
|
+
* case, so callers put the real id on the wire. Answers `false` when no row
|
|
536
|
+
* matches and `null` when the store is unavailable, so callers can tell
|
|
537
|
+
* "not seen" from "cannot say". Seeks via the NOCASE message-id indexes
|
|
538
|
+
* (migration 11); platform, account and chat are post-filters.
|
|
539
|
+
*/
|
|
540
|
+
findSeenMessageInChat(input) {
|
|
541
|
+
if (!input.chatId || !input.messageId)
|
|
542
|
+
return false;
|
|
543
|
+
return this.read(() => {
|
|
544
|
+
const row = this.requireDb()
|
|
545
|
+
.prepare(FIND_SEEN_MESSAGE_SQL)
|
|
546
|
+
.get(input.messageId, input.platform, input.accountId, input.chatId, input.messageId, input.platform, input.accountId, input.chatId);
|
|
547
|
+
if (typeof row?.chat_id !== "string" || typeof row.matched_id !== "string")
|
|
548
|
+
return false;
|
|
549
|
+
return { chatId: row.chat_id, messageId: row.matched_id };
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* The chat id as this account's ledger already stores it for `chatId`
|
|
554
|
+
* (compared case-insensitively), preferring an inbound row, whose case is
|
|
555
|
+
* the server's. `false` when the ledger has no row for that chat, `null`
|
|
556
|
+
* when the store is unavailable. Seeks via the NOCASE chat-id index
|
|
557
|
+
* (migration 12); platform, account and direction are post-filters.
|
|
558
|
+
*/
|
|
559
|
+
findStoredChatId(input) {
|
|
560
|
+
if (!input.chatId)
|
|
561
|
+
return false;
|
|
562
|
+
return this.read(() => {
|
|
563
|
+
const row = this.requireDb()
|
|
564
|
+
.prepare(FIND_STORED_CHAT_ID_SQL)
|
|
565
|
+
.get(input.chatId, input.platform, input.accountId, input.chatId, input.platform, input.accountId);
|
|
566
|
+
return typeof row?.chat_id === "string" ? row.chat_id : false;
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
/** True when `message_id` was recalled for this (platform, account). */
|
|
570
|
+
isMessageRecalled(input) {
|
|
571
|
+
if (!input.messageId)
|
|
572
|
+
return false;
|
|
573
|
+
const row = this.requireDb()
|
|
574
|
+
.prepare(`SELECT 1 FROM recalled_messages
|
|
575
|
+
WHERE platform = ? AND account_id = ? AND message_id = ?`)
|
|
576
|
+
.get(input.platform, input.accountId, input.messageId);
|
|
577
|
+
return row !== undefined;
|
|
578
|
+
}
|
|
506
579
|
/**
|
|
507
580
|
* Erase every ledger row for one `message_id` — the local half of a
|
|
508
581
|
* server-authorised recall (`message.recall`, docs/client-integration.md §9.8).
|
|
@@ -521,19 +594,8 @@ export class ClawChatStore {
|
|
|
521
594
|
* the row may never have been stored (a message that arrived before this
|
|
522
595
|
* account was paired, or one dropped by the inbound filter). `null` means the
|
|
523
596
|
* store is unavailable.
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
isMessageRecalled(input) {
|
|
527
|
-
if (!input.messageId)
|
|
528
|
-
return false;
|
|
529
|
-
const row = this.requireDb()
|
|
530
|
-
.prepare(`SELECT 1 FROM recalled_messages
|
|
531
|
-
WHERE platform = ? AND account_id = ? AND message_id = ?`)
|
|
532
|
-
.get(input.platform, input.accountId, input.messageId);
|
|
533
|
-
return row !== undefined;
|
|
534
|
-
}
|
|
535
|
-
/**
|
|
536
|
-
* Record the permanent recall tombstone and purge any row already stored,
|
|
597
|
+
*
|
|
598
|
+
* Records the permanent recall tombstone and purges any row already stored,
|
|
537
599
|
* atomically. Returns the number of ledger rows deleted (0 when the recall
|
|
538
600
|
* arrived before the message did — the tombstone still stands and the later
|
|
539
601
|
* ingest is refused by {@link claimMessageOnce}).
|
|
@@ -860,6 +922,43 @@ export class ClawChatStore {
|
|
|
860
922
|
this.log?.error?.(`${prefix}: ${safeErrorMessage(err)}`);
|
|
861
923
|
}
|
|
862
924
|
}
|
|
925
|
+
/**
|
|
926
|
+
* Lookup behind {@link ClawChatStore.findStoredChatId}: an inbound row first,
|
|
927
|
+
* then any row. Both halves seek `idx_clawchat_messages_chat_id_nocase`.
|
|
928
|
+
* Exported so a test can pin the query plan.
|
|
929
|
+
*/
|
|
930
|
+
export const FIND_STORED_CHAT_ID_SQL = `SELECT chat_id FROM (
|
|
931
|
+
SELECT chat_id, 0 AS rank FROM (
|
|
932
|
+
SELECT chat_id FROM clawchat_messages
|
|
933
|
+
WHERE chat_id = ? COLLATE NOCASE AND platform = ? AND account_id = ? AND direction = 'inbound'
|
|
934
|
+
LIMIT 1
|
|
935
|
+
)
|
|
936
|
+
UNION ALL
|
|
937
|
+
SELECT chat_id, 1 AS rank FROM (
|
|
938
|
+
SELECT chat_id FROM clawchat_messages
|
|
939
|
+
WHERE chat_id = ? COLLATE NOCASE AND platform = ? AND account_id = ?
|
|
940
|
+
LIMIT 1
|
|
941
|
+
)
|
|
942
|
+
) ORDER BY rank LIMIT 1`;
|
|
943
|
+
/**
|
|
944
|
+
* Lookup behind {@link ClawChatStore.findSeenMessageInChat}. Two index seeks
|
|
945
|
+
* (`message_id` / `protocol_message_id`, both COLLATE NOCASE, migration 11);
|
|
946
|
+
* platform, account and chat are post-filters. A `message_id` match wins.
|
|
947
|
+
* `INDEXED BY` pins the seek: without table statistics the planner would
|
|
948
|
+
* otherwise pick the chat-id index (migration 12) and walk the whole chat.
|
|
949
|
+
* Exported so a test can pin the query plan.
|
|
950
|
+
*/
|
|
951
|
+
export const FIND_SEEN_MESSAGE_SQL = `SELECT chat_id, matched_id FROM (
|
|
952
|
+
SELECT chat_id, message_id AS matched_id, 0 AS rank
|
|
953
|
+
FROM clawchat_messages INDEXED BY idx_clawchat_messages_message_id_nocase
|
|
954
|
+
WHERE message_id = ? COLLATE NOCASE
|
|
955
|
+
AND platform = ? AND account_id = ? AND chat_id = ? COLLATE NOCASE
|
|
956
|
+
UNION ALL
|
|
957
|
+
SELECT chat_id, protocol_message_id AS matched_id, 1 AS rank
|
|
958
|
+
FROM clawchat_messages INDEXED BY idx_clawchat_messages_protocol_message_id_nocase
|
|
959
|
+
WHERE protocol_message_id = ? COLLATE NOCASE
|
|
960
|
+
AND platform = ? AND account_id = ? AND chat_id = ? COLLATE NOCASE
|
|
961
|
+
) ORDER BY rank LIMIT 1`;
|
|
863
962
|
let singleton = null;
|
|
864
963
|
export function createClawChatStore(options = {}) {
|
|
865
964
|
return new ClawChatStore(options);
|
|
@@ -1,12 +1,67 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
const DEFAULT_TTL_MS = 60_000;
|
|
3
3
|
const terminalSends = new Map();
|
|
4
|
-
const
|
|
4
|
+
const turnScopeStorage = new AsyncLocalStorage();
|
|
5
|
+
const openScopesBySession = new Map();
|
|
6
|
+
function normalizeSessionKey(sessionKey) {
|
|
7
|
+
const normalized = sessionKey?.trim().toLowerCase();
|
|
8
|
+
return normalized ? normalized : null;
|
|
9
|
+
}
|
|
5
10
|
function key(accountId, chatId, scopeId) {
|
|
6
11
|
return `${accountId}\0${chatId}\0${scopeId}`;
|
|
7
12
|
}
|
|
8
|
-
|
|
9
|
-
return
|
|
13
|
+
function isPromiseLike(value) {
|
|
14
|
+
return typeof value?.then === "function";
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Run one dispatched turn. `sessionKey` is the session the host runs the turn
|
|
18
|
+
* in; consumers only see this turn when their trusted session key matches.
|
|
19
|
+
* The scope closes when `fn` returns or its promise settles.
|
|
20
|
+
*/
|
|
21
|
+
export function runWithTerminalClawChatSendScope(init, fn) {
|
|
22
|
+
const scope = {
|
|
23
|
+
scopeId: init.scopeId,
|
|
24
|
+
sessionKey: normalizeSessionKey(init.sessionKey),
|
|
25
|
+
open: true,
|
|
26
|
+
superseded: false,
|
|
27
|
+
};
|
|
28
|
+
const sessionKey = scope.sessionKey;
|
|
29
|
+
if (sessionKey) {
|
|
30
|
+
const open = openScopesBySession.get(sessionKey) ?? new Set();
|
|
31
|
+
for (const earlier of open)
|
|
32
|
+
earlier.superseded = true;
|
|
33
|
+
open.add(scope);
|
|
34
|
+
openScopesBySession.set(sessionKey, open);
|
|
35
|
+
}
|
|
36
|
+
const close = () => {
|
|
37
|
+
scope.open = false;
|
|
38
|
+
if (!sessionKey)
|
|
39
|
+
return;
|
|
40
|
+
const open = openScopesBySession.get(sessionKey);
|
|
41
|
+
open?.delete(scope);
|
|
42
|
+
if (open?.size === 0)
|
|
43
|
+
openScopesBySession.delete(sessionKey);
|
|
44
|
+
};
|
|
45
|
+
let result;
|
|
46
|
+
try {
|
|
47
|
+
result = turnScopeStorage.run(scope, fn);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
close();
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
if (isPromiseLike(result)) {
|
|
54
|
+
return Promise.resolve(result).finally(close);
|
|
55
|
+
}
|
|
56
|
+
close();
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
/** The caller's own open turn, or null when it cannot be tied to the caller's session. */
|
|
60
|
+
function resolveCallerScope(sessionKey) {
|
|
61
|
+
const scope = turnScopeStorage.getStore();
|
|
62
|
+
if (!scope?.open || !scope.sessionKey)
|
|
63
|
+
return null;
|
|
64
|
+
return scope.sessionKey === normalizeSessionKey(sessionKey) ? scope : null;
|
|
10
65
|
}
|
|
11
66
|
/**
|
|
12
67
|
* Drop every marker whose TTL has lapsed. A marker is only otherwise deleted
|
|
@@ -22,7 +77,7 @@ function sweepExpiredTerminalSends(nowMs) {
|
|
|
22
77
|
}
|
|
23
78
|
}
|
|
24
79
|
export function markTerminalClawChatSend(params) {
|
|
25
|
-
const scopeId = params.scopeId ??
|
|
80
|
+
const scopeId = params.scopeId ?? resolveCallerScope(params.sessionKey)?.scopeId;
|
|
26
81
|
if (!scopeId)
|
|
27
82
|
return;
|
|
28
83
|
const nowMs = params.nowMs ?? Date.now();
|
|
@@ -34,7 +89,7 @@ export function markTerminalClawChatSend(params) {
|
|
|
34
89
|
});
|
|
35
90
|
}
|
|
36
91
|
export function consumeTerminalClawChatSend(params) {
|
|
37
|
-
const scopeId = params.scopeId
|
|
92
|
+
const scopeId = params.scopeId;
|
|
38
93
|
if (!scopeId)
|
|
39
94
|
return null;
|
|
40
95
|
const recordKey = key(params.accountId, params.chatId, scopeId);
|
|
@@ -48,17 +103,19 @@ export function consumeTerminalClawChatSend(params) {
|
|
|
48
103
|
}
|
|
49
104
|
export function clearTerminalClawChatSendsForTest() {
|
|
50
105
|
terminalSends.clear();
|
|
106
|
+
openScopesBySession.clear();
|
|
51
107
|
}
|
|
52
108
|
/**
|
|
53
|
-
*
|
|
54
|
-
* Returns null
|
|
55
|
-
*
|
|
109
|
+
* The calling turn's default reaction target (accountId\0chatId\0messageId).
|
|
110
|
+
* Returns null unless the tool call's trusted `sessionKey` matches the open
|
|
111
|
+
* turn in its async context and no later turn of that session has started
|
|
112
|
+
* since; also null for a non-tripartite scope id (e.g. a test-only one).
|
|
56
113
|
*/
|
|
57
|
-
export function getCurrentTerminalSendScope() {
|
|
58
|
-
const
|
|
59
|
-
if (!
|
|
114
|
+
export function getCurrentTerminalSendScope(params) {
|
|
115
|
+
const scope = resolveCallerScope(params.sessionKey);
|
|
116
|
+
if (!scope || scope.superseded)
|
|
60
117
|
return null;
|
|
61
|
-
const parts =
|
|
118
|
+
const parts = scope.scopeId.split("\0");
|
|
62
119
|
if (parts.length !== 3)
|
|
63
120
|
return null;
|
|
64
121
|
const [accountId, chatId, messageId] = parts;
|