@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/dist/src/runtime.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { applyDmRecallDefault } from "./dm-recall-default.js";
|
|
2
|
+
import { HOST_FLOOR, HOST_TOOL_SEARCH_DEFAULT_ON, hostAtLeast, hostToolSearchEnabled } from "./host-version.js";
|
|
2
3
|
import { AckTimeoutError, AuthError, ProtocolError, StateError, TransportError, EVENT, recallTargetMessageId, } from "./protocol-types.js";
|
|
3
4
|
import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle";
|
|
4
5
|
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
|
|
@@ -29,7 +30,9 @@ import { flushAlignedOutboundQueue, getAlignedOutboundQueueSize, sendOpenclawCla
|
|
|
29
30
|
import { formatWsLog } from "./ws-log.js";
|
|
30
31
|
import { createNotifySignalObserver, createProtocolControlHandler, createReconnectTracker } from "./ws-alignment.js";
|
|
31
32
|
import { clawChatDbPathForStateDir, getClawChatStore, makeOwnerProfilePersister, } from "./storage.js";
|
|
32
|
-
import { getClawChatGroupPrompt, getClawChatUserPrompt } from "./plugin-prompts.js";
|
|
33
|
+
import { getClawChatGroupPrompt, getClawChatPlatformPrompt, getClawChatUserPrompt } from "./plugin-prompts.js";
|
|
34
|
+
import { classifyChat, NON_OWNER_TOOL_ALLOWLIST } from "./turn-boundary.js";
|
|
35
|
+
import { resolveOwnerIdentity } from "./owner-identity.js";
|
|
33
36
|
import { loadClawChatPromptMetadata, renderClawChatProfilePrompt, resolveSenderRelation, } from "./profile-prompt.js";
|
|
34
37
|
import { buildNoteMemorySection } from "./note-injection.js";
|
|
35
38
|
import { buildGroupCatchUp, CATCH_UP_FETCH_ROWS, DeliveredGroupMessages } from "./group-catch-up.js";
|
|
@@ -713,6 +716,8 @@ async function waitForActivationCredentials(params) {
|
|
|
713
716
|
});
|
|
714
717
|
return null;
|
|
715
718
|
}
|
|
719
|
+
let warnedBelowHostFloor = false;
|
|
720
|
+
let loggedHostToolSearch = false;
|
|
716
721
|
export async function startOpenclawClawlingGateway(params) {
|
|
717
722
|
const { cfg, abortSignal, setStatus, getStatus, log } = params;
|
|
718
723
|
let account = params.account;
|
|
@@ -720,6 +725,19 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
720
725
|
const runtime = getOpenclawClawlingRuntime();
|
|
721
726
|
const accountId = account.accountId;
|
|
722
727
|
const store = resolveConnectionStore(params, runtime);
|
|
728
|
+
if (!warnedBelowHostFloor && !hostAtLeast(runtime.version, HOST_FLOOR)) {
|
|
729
|
+
warnedBelowHostFloor = true;
|
|
730
|
+
log?.warn?.(`clawchat-plugin-openclaw: host ${runtime.version ?? "unknown"} is below ${HOST_FLOOR}; turns your owner did not start keep the host tools in their schema and rely on the before_tool_call block`);
|
|
731
|
+
}
|
|
732
|
+
if (!loggedHostToolSearch &&
|
|
733
|
+
hostAtLeast(runtime.version, HOST_TOOL_SEARCH_DEFAULT_ON) &&
|
|
734
|
+
hostToolSearchEnabled(cfg)) {
|
|
735
|
+
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");
|
|
740
|
+
}
|
|
723
741
|
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)"}`);
|
|
724
742
|
// Freeze one report device_id for this process; reused for the paired report
|
|
725
743
|
// so the backend links both to the same row. Imported from ./client.ts.
|
|
@@ -1488,6 +1506,7 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
1488
1506
|
return;
|
|
1489
1507
|
void startOpenclawClawlingGateway({
|
|
1490
1508
|
...params,
|
|
1509
|
+
boundaryWarningIssued: true,
|
|
1491
1510
|
account: { ...params.account },
|
|
1492
1511
|
// Carry the live refresh token forward (a rotation earlier this lifecycle
|
|
1493
1512
|
// leaves the in-memory `cfg` stale; see refreshTokenOverride).
|
|
@@ -1535,6 +1554,7 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
1535
1554
|
// agent must NOT re-read it — thread the live refresh token forward instead.
|
|
1536
1555
|
await startOpenclawClawlingGateway({
|
|
1537
1556
|
...params,
|
|
1557
|
+
boundaryWarningIssued: true,
|
|
1538
1558
|
account: {
|
|
1539
1559
|
...params.account,
|
|
1540
1560
|
configured: true,
|
|
@@ -2116,15 +2136,25 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2116
2136
|
// "invalid conversation id" and msghub never acks the send. Proactive owner
|
|
2117
2137
|
// messages must be addressed to the direct conversation recorded at
|
|
2118
2138
|
// activation (parity with the hermes adapter's _owner_direct_chat_id).
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2139
|
+
// One warning per failure class for this lifecycle; never logs ids or SQL.
|
|
2140
|
+
const ownerIdentityWarnings = new Set();
|
|
2141
|
+
const warnOwnerIdentity = (failure) => {
|
|
2142
|
+
if (ownerIdentityWarnings.has(failure))
|
|
2143
|
+
return;
|
|
2144
|
+
ownerIdentityWarnings.add(failure);
|
|
2145
|
+
try {
|
|
2146
|
+
log?.warn?.(`clawchat-plugin-openclaw: ${failure}; owner identity unavailable`);
|
|
2147
|
+
}
|
|
2148
|
+
catch {
|
|
2149
|
+
// Logging cannot change the (fail-closed) answer.
|
|
2150
|
+
}
|
|
2127
2151
|
};
|
|
2152
|
+
const resolveOwnerDirectConversationId = () =>
|
|
2153
|
+
// Shared strict resolver (src/owner-identity.ts): the SQLite activation
|
|
2154
|
+
// row decides; the config `ownerUserId` mirror may be stale or absent and
|
|
2155
|
+
// can neither hide nor invent the owner's direct chat. Unavailable storage
|
|
2156
|
+
// or a cleared/rejected token yields no owner DM (fail closed).
|
|
2157
|
+
resolveOwnerIdentity(store, accountId, account.ownerUserId, warnOwnerIdentity, params.rejectedActivationToken).conversationId;
|
|
2128
2158
|
// R3: the owner's direct conversation is never gated by the dead-chat send
|
|
2129
2159
|
// guard. If it were, the very message explaining why the agent went dark
|
|
2130
2160
|
// (skill-update notices, auth/logout notices, group owner-attention
|
|
@@ -2836,8 +2866,28 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2836
2866
|
}
|
|
2837
2867
|
turn = await withCachedGroupSenderNames(turn, memoryRoot);
|
|
2838
2868
|
const conversationTarget = `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`;
|
|
2839
|
-
const turnPrompt = await buildPromptForTurn(turn, memoryRoot);
|
|
2840
2869
|
const senderIsOwner = turn.senderIsOwner === true || turn.senderId === account.ownerUserId;
|
|
2870
|
+
// Owner turn <=> the owner's activation direct chat. Resolved per turn;
|
|
2871
|
+
// any failure leaves it null, which classifies as non-owner (fail closed).
|
|
2872
|
+
let ownerConversationId = null;
|
|
2873
|
+
try {
|
|
2874
|
+
ownerConversationId = resolveOwnerDirectConversationId();
|
|
2875
|
+
}
|
|
2876
|
+
catch {
|
|
2877
|
+
ownerConversationId = null;
|
|
2878
|
+
}
|
|
2879
|
+
const turnClass = classifyChat({
|
|
2880
|
+
kind: turn.peer.kind,
|
|
2881
|
+
chatId: turn.peer.id,
|
|
2882
|
+
ownerDirectConversationId: ownerConversationId,
|
|
2883
|
+
});
|
|
2884
|
+
const narrowHostTools = turnClass === "non-owner";
|
|
2885
|
+
const baseTurnPrompt = await buildPromptForTurn(turn, memoryRoot);
|
|
2886
|
+
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
|
+
const turnPrompt = [
|
|
2888
|
+
...(narrowHostTools ? [getClawChatPlatformPrompt(), nonOwnerMediaGuidance] : []),
|
|
2889
|
+
baseTurnPrompt,
|
|
2890
|
+
].filter(Boolean).join("\n\n");
|
|
2841
2891
|
const ctxPayload = resolveChannelContextBuilder(rt)({
|
|
2842
2892
|
channel: CHANNEL_ID,
|
|
2843
2893
|
accountId: route.accountId ?? accountId,
|
|
@@ -2883,6 +2933,8 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2883
2933
|
...(isOpenClawTextSlashCommand(turn.rawBody, cfg) && senderIsOwner
|
|
2884
2934
|
? { commands: { authorized: true, source: "text" } }
|
|
2885
2935
|
: {}),
|
|
2936
|
+
// toolPolicy: host 2026.8.1+ (floor)
|
|
2937
|
+
...(narrowHostTools ? { toolPolicy: { allow: [...NON_OWNER_TOOL_ALLOWLIST] } } : {}),
|
|
2886
2938
|
},
|
|
2887
2939
|
...(memoryRoot ? { extra: { memoryRoot } } : {}),
|
|
2888
2940
|
// Deliver the rendered ClawChat per-turn prompt (owner agent_behavior,
|
|
@@ -2934,7 +2986,7 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2934
2986
|
if (stagedDirectPrompt) {
|
|
2935
2987
|
stageClawChatPromptInjection({
|
|
2936
2988
|
sessionKey: resolvedSessionKey,
|
|
2937
|
-
prompt:
|
|
2989
|
+
prompt: baseTurnPrompt,
|
|
2938
2990
|
});
|
|
2939
2991
|
}
|
|
2940
2992
|
try {
|
|
@@ -2988,6 +3040,7 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2988
3040
|
// conversation, not `ownerUserId` (a usr_… chat_id is rejected by
|
|
2989
3041
|
// member-backend). Passed as a thunk so it is resolved at send time.
|
|
2990
3042
|
resolveOwnerConversationId: resolveOwnerDirectConversationId,
|
|
3043
|
+
nonOwnerTurn: narrowHostTools,
|
|
2991
3044
|
log,
|
|
2992
3045
|
});
|
|
2993
3046
|
const agentsConfigured = Object.keys(cfg.agents ?? {});
|
|
@@ -2996,7 +3049,14 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2996
3049
|
const dispatchResult = await rt.reply.withReplyDispatcher({
|
|
2997
3050
|
dispatcher,
|
|
2998
3051
|
onSettled: () => markDispatchIdle(),
|
|
2999
|
-
run: () => runWithTerminalClawChatSendScope(terminalSendScopeId, () => rt.reply.dispatchReplyFromConfig({
|
|
3052
|
+
run: () => runWithTerminalClawChatSendScope({ scopeId: terminalSendScopeId, sessionKey: resolvedSessionKey }, () => rt.reply.dispatchReplyFromConfig({
|
|
3053
|
+
ctx: ctxPayload,
|
|
3054
|
+
cfg: dispatchCfg,
|
|
3055
|
+
dispatcher,
|
|
3056
|
+
replyOptions: narrowHostTools
|
|
3057
|
+
? { ...replyOptions, toolsAllow: [...NON_OWNER_TOOL_ALLOWLIST] }
|
|
3058
|
+
: replyOptions,
|
|
3059
|
+
})),
|
|
3000
3060
|
});
|
|
3001
3061
|
const counts = dispatchResult?.counts ?? {};
|
|
3002
3062
|
const queuedFinal = Boolean(dispatchResult?.queuedFinal);
|
|
@@ -3516,6 +3576,7 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
3516
3576
|
if (activationAccount.source === "sqlite" && !abortSignal.aborted) {
|
|
3517
3577
|
await startOpenclawClawlingGateway({
|
|
3518
3578
|
...params,
|
|
3579
|
+
boundaryWarningIssued: true,
|
|
3519
3580
|
account: {
|
|
3520
3581
|
...params.account,
|
|
3521
3582
|
configured: false,
|
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
|
];
|
|
@@ -362,6 +386,40 @@ export class ClawChatStore {
|
|
|
362
386
|
};
|
|
363
387
|
}) ?? null;
|
|
364
388
|
}
|
|
389
|
+
/**
|
|
390
|
+
* Strict owner-identity read for the non-owner turn boundary. Unlike the
|
|
391
|
+
* other getters (which collapse failure into null), this keeps "storage
|
|
392
|
+
* unavailable", "no activation row" and "row" apart — including incomplete
|
|
393
|
+
* rows — because only a proven-absent row may fall back to the config mirror
|
|
394
|
+
* (see `resolveOwnerIdentity`). Nothing is logged here: the caller warns
|
|
395
|
+
* once per failure class, and no SQL or credentials reach the log.
|
|
396
|
+
*/
|
|
397
|
+
lookupActivationIdentity(input) {
|
|
398
|
+
if (!this.ensureInitialized())
|
|
399
|
+
return { status: "unavailable" };
|
|
400
|
+
try {
|
|
401
|
+
const row = this.requireDb()
|
|
402
|
+
.prepare("SELECT owner_user_id, conversation_id, access_token FROM activations WHERE platform = ? AND account_id = ?")
|
|
403
|
+
.get(input.platform, input.accountId);
|
|
404
|
+
if (!row)
|
|
405
|
+
return { status: "absent" };
|
|
406
|
+
const value = (key) => {
|
|
407
|
+
const raw = row[key];
|
|
408
|
+
return typeof raw === "string" ? raw.trim() || null : null;
|
|
409
|
+
};
|
|
410
|
+
return {
|
|
411
|
+
status: "row",
|
|
412
|
+
row: {
|
|
413
|
+
ownerUserId: value("owner_user_id"),
|
|
414
|
+
conversationId: value("conversation_id"),
|
|
415
|
+
accessToken: value("access_token"),
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
return { status: "unavailable" };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
365
423
|
claimPendingActivationBootstrap(input) {
|
|
366
424
|
return this.write(() => {
|
|
367
425
|
const now = Date.now();
|
|
@@ -469,6 +527,55 @@ export class ClawChatStore {
|
|
|
469
527
|
.run(input.eventType, input.traceId ?? null, input.chatId ?? null, input.text ?? null, toJson(input.raw), input.accountId, input.kind, input.direction, input.messageId);
|
|
470
528
|
});
|
|
471
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
|
+
}
|
|
472
579
|
/**
|
|
473
580
|
* Erase every ledger row for one `message_id` — the local half of a
|
|
474
581
|
* server-authorised recall (`message.recall`, docs/client-integration.md §9.8).
|
|
@@ -487,19 +594,8 @@ export class ClawChatStore {
|
|
|
487
594
|
* the row may never have been stored (a message that arrived before this
|
|
488
595
|
* account was paired, or one dropped by the inbound filter). `null` means the
|
|
489
596
|
* store is unavailable.
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
isMessageRecalled(input) {
|
|
493
|
-
if (!input.messageId)
|
|
494
|
-
return false;
|
|
495
|
-
const row = this.requireDb()
|
|
496
|
-
.prepare(`SELECT 1 FROM recalled_messages
|
|
497
|
-
WHERE platform = ? AND account_id = ? AND message_id = ?`)
|
|
498
|
-
.get(input.platform, input.accountId, input.messageId);
|
|
499
|
-
return row !== undefined;
|
|
500
|
-
}
|
|
501
|
-
/**
|
|
502
|
-
* Record the permanent recall tombstone and purge any row already stored,
|
|
597
|
+
*
|
|
598
|
+
* Records the permanent recall tombstone and purges any row already stored,
|
|
503
599
|
* atomically. Returns the number of ledger rows deleted (0 when the recall
|
|
504
600
|
* arrived before the message did — the tombstone still stands and the later
|
|
505
601
|
* ingest is refused by {@link claimMessageOnce}).
|
|
@@ -710,13 +806,18 @@ export class ClawChatStore {
|
|
|
710
806
|
* as `'thinking'` are excluded so the @-mention prior-context block never
|
|
711
807
|
* surfaces the agent's intermediate output as conversation history.
|
|
712
808
|
* Uses `idx_clawchat_messages_chat_created` for performance.
|
|
809
|
+
* `ignoreCase` matches `chat_id` case-insensitively (rows keep their stored
|
|
810
|
+
* case) so memory scope can recover a group id's original case from a
|
|
811
|
+
* lowercased session key; it cannot use that index, so callers that already
|
|
812
|
+
* know the exact id leave it off.
|
|
713
813
|
* Returns an empty array if the store is unavailable.
|
|
714
814
|
*/
|
|
715
|
-
listRecentGroupMessages(platform, accountId, chatId, limit) {
|
|
815
|
+
listRecentGroupMessages(platform, accountId, chatId, limit, options = {}) {
|
|
816
|
+
const chatMatch = options.ignoreCase ? "chat_id = ? COLLATE NOCASE" : "chat_id = ?";
|
|
716
817
|
return this.read(() => {
|
|
717
818
|
const rows = this.requireDb()
|
|
718
819
|
.prepare(`SELECT * FROM clawchat_messages
|
|
719
|
-
WHERE platform = ? AND account_id = ? AND
|
|
820
|
+
WHERE platform = ? AND account_id = ? AND ${chatMatch} AND kind = 'message'
|
|
720
821
|
ORDER BY created_at DESC, rowid DESC LIMIT ?`)
|
|
721
822
|
.all(platform, accountId, chatId, limit);
|
|
722
823
|
return rows.reverse(); // oldest-first for prompt order
|
|
@@ -821,6 +922,43 @@ export class ClawChatStore {
|
|
|
821
922
|
this.log?.error?.(`${prefix}: ${safeErrorMessage(err)}`);
|
|
822
923
|
}
|
|
823
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`;
|
|
824
962
|
let singleton = null;
|
|
825
963
|
export function createClawChatStore(options = {}) {
|
|
826
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;
|