@clawling/clawchat-plugin-openclaw 2026.10.9-2 → 2026.10.9-3
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 +163 -0
- package/dist/src/host-version.js +35 -0
- package/dist/src/memory-scope.js +166 -0
- 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 +41 -2
- package/dist/src/tools.js +122 -16
- package/dist/src/turn-boundary.js +51 -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 +186 -0
- package/src/host-version.ts +35 -0
- package/src/memory-scope.ts +191 -0
- package/src/owner-identity.ts +49 -0
- package/src/reply-dispatcher.ts +31 -13
- package/src/runtime.ts +80 -12
- package/src/storage.ts +42 -1
- package/src/tools.ts +133 -20
- package/src/turn-boundary.ts +61 -0
package/src/runtime.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { applyDmRecallDefault } from "./dm-recall-default.ts";
|
|
2
|
+
import { HOST_FLOOR, HOST_TOOL_SEARCH_DEFAULT_ON, hostAtLeast, hostToolSearchEnabled } from "./host-version.ts";
|
|
2
3
|
import {
|
|
3
4
|
AckTimeoutError,
|
|
4
5
|
AuthError,
|
|
@@ -86,7 +87,9 @@ import {
|
|
|
86
87
|
type ClawChatStore,
|
|
87
88
|
type MessageRow,
|
|
88
89
|
} from "./storage.ts";
|
|
89
|
-
import { getClawChatGroupPrompt, getClawChatUserPrompt } from "./plugin-prompts.ts";
|
|
90
|
+
import { getClawChatGroupPrompt, getClawChatPlatformPrompt, getClawChatUserPrompt } from "./plugin-prompts.ts";
|
|
91
|
+
import { classifyChat, NON_OWNER_TOOL_ALLOWLIST } from "./turn-boundary.ts";
|
|
92
|
+
import { resolveOwnerIdentity } from "./owner-identity.ts";
|
|
90
93
|
import {
|
|
91
94
|
loadClawChatPromptMetadata,
|
|
92
95
|
type ClawChatGroupParticipantPrompt,
|
|
@@ -111,7 +114,8 @@ import { PermissionCache } from "./permissions.ts";
|
|
|
111
114
|
import { buildAwarenessNoteEnvelope, buildMomentCommentNoteEnvelope } from "./awareness-note-turn.ts";
|
|
112
115
|
import { handlePermissionResult } from "./permission-result.ts";
|
|
113
116
|
|
|
114
|
-
type Log = { debug?: (m: string) => void; info?: (m: string) => void;
|
|
117
|
+
type Log = { debug?: (m: string) => void; info?: (m: string) => void;
|
|
118
|
+
warn?: (m: string) => void; error?: (m: string) => void };
|
|
115
119
|
type ChannelContextBuilder = (params: Record<string, unknown>) => unknown;
|
|
116
120
|
type CompatibleChannelRuntime = PluginRuntime["channel"] & {
|
|
117
121
|
inbound?: { buildContext?: ChannelContextBuilder };
|
|
@@ -140,6 +144,7 @@ type RuntimeConnectionStore = Pick<
|
|
|
140
144
|
| "releaseActivationBootstrapClaim"
|
|
141
145
|
| "markActivationBootstrapSent"
|
|
142
146
|
| "getActivationConversation"
|
|
147
|
+
| "lookupActivationIdentity"
|
|
143
148
|
| "getLastResolvedDeviceId"
|
|
144
149
|
| "listRecentGroupMessages"
|
|
145
150
|
| "recallMessage"
|
|
@@ -585,6 +590,8 @@ export interface StartGatewayParams {
|
|
|
585
590
|
setStatus: (next: ChannelAccountSnapshot) => void;
|
|
586
591
|
getStatus: () => ChannelAccountSnapshot;
|
|
587
592
|
log?: Log;
|
|
593
|
+
/** Internal — set on recursive re-entry so the approve-unsupported warning is issued once per lifecycle. */
|
|
594
|
+
boundaryWarningIssued?: boolean;
|
|
588
595
|
/** Test hook only. */
|
|
589
596
|
store?: RuntimeConnectionStore | null;
|
|
590
597
|
/** Test hook only. */
|
|
@@ -1057,6 +1064,9 @@ async function waitForActivationCredentials(params: {
|
|
|
1057
1064
|
return null;
|
|
1058
1065
|
}
|
|
1059
1066
|
|
|
1067
|
+
let warnedBelowHostFloor = false;
|
|
1068
|
+
let loggedHostToolSearch = false;
|
|
1069
|
+
|
|
1060
1070
|
export async function startOpenclawClawlingGateway(params: StartGatewayParams): Promise<void> {
|
|
1061
1071
|
const { cfg, abortSignal, setStatus, getStatus, log } = params;
|
|
1062
1072
|
let account = params.account;
|
|
@@ -1064,6 +1074,21 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1064
1074
|
const runtime = getOpenclawClawlingRuntime();
|
|
1065
1075
|
const accountId = account.accountId;
|
|
1066
1076
|
const store = resolveConnectionStore(params, runtime);
|
|
1077
|
+
if (!warnedBelowHostFloor && !hostAtLeast(runtime.version, HOST_FLOOR)) {
|
|
1078
|
+
warnedBelowHostFloor = true;
|
|
1079
|
+
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`);
|
|
1080
|
+
}
|
|
1081
|
+
if (
|
|
1082
|
+
!loggedHostToolSearch &&
|
|
1083
|
+
hostAtLeast(runtime.version, HOST_TOOL_SEARCH_DEFAULT_ON) &&
|
|
1084
|
+
hostToolSearchEnabled(cfg)
|
|
1085
|
+
) {
|
|
1086
|
+
loggedHostToolSearch = true;
|
|
1087
|
+
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)");
|
|
1088
|
+
}
|
|
1089
|
+
if (!params.boundaryWarningIssued && account.nonOwnerHostTools === "approve") {
|
|
1090
|
+
log?.warn?.("clawchat-plugin-openclaw: non-owner-host-tools approve is not supported yet; using off");
|
|
1091
|
+
}
|
|
1067
1092
|
log?.info?.(
|
|
1068
1093
|
`[${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)"}`,
|
|
1069
1094
|
);
|
|
@@ -1897,6 +1922,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1897
1922
|
if (abortSignal.aborted) return;
|
|
1898
1923
|
void startOpenclawClawlingGateway({
|
|
1899
1924
|
...params,
|
|
1925
|
+
boundaryWarningIssued: true,
|
|
1900
1926
|
account: { ...params.account },
|
|
1901
1927
|
// Carry the live refresh token forward (a rotation earlier this lifecycle
|
|
1902
1928
|
// leaves the in-memory `cfg` stale; see refreshTokenOverride).
|
|
@@ -1944,6 +1970,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1944
1970
|
// agent must NOT re-read it — thread the live refresh token forward instead.
|
|
1945
1971
|
await startOpenclawClawlingGateway({
|
|
1946
1972
|
...params,
|
|
1973
|
+
boundaryWarningIssued: true,
|
|
1947
1974
|
account: {
|
|
1948
1975
|
...params.account,
|
|
1949
1976
|
configured: true,
|
|
@@ -2552,14 +2579,24 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
2552
2579
|
// "invalid conversation id" and msghub never acks the send. Proactive owner
|
|
2553
2580
|
// messages must be addressed to the direct conversation recorded at
|
|
2554
2581
|
// activation (parity with the hermes adapter's _owner_direct_chat_id).
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2582
|
+
// One warning per failure class for this lifecycle; never logs ids or SQL.
|
|
2583
|
+
const ownerIdentityWarnings = new Set<string>();
|
|
2584
|
+
const warnOwnerIdentity = (failure: string) => {
|
|
2585
|
+
if (ownerIdentityWarnings.has(failure)) return;
|
|
2586
|
+
ownerIdentityWarnings.add(failure);
|
|
2587
|
+
try {
|
|
2588
|
+
log?.warn?.(`clawchat-plugin-openclaw: ${failure}; owner identity unavailable`);
|
|
2589
|
+
} catch {
|
|
2590
|
+
// Logging cannot change the (fail-closed) answer.
|
|
2591
|
+
}
|
|
2562
2592
|
};
|
|
2593
|
+
const resolveOwnerDirectConversationId = (): string | null =>
|
|
2594
|
+
// Shared strict resolver (src/owner-identity.ts): the SQLite activation
|
|
2595
|
+
// row decides; the config `ownerUserId` mirror may be stale or absent and
|
|
2596
|
+
// can neither hide nor invent the owner's direct chat. Unavailable storage
|
|
2597
|
+
// or a cleared/rejected token yields no owner DM (fail closed).
|
|
2598
|
+
resolveOwnerIdentity(store, accountId, account.ownerUserId, warnOwnerIdentity,
|
|
2599
|
+
params.rejectedActivationToken).conversationId;
|
|
2563
2600
|
// R3: the owner's direct conversation is never gated by the dead-chat send
|
|
2564
2601
|
// guard. If it were, the very message explaining why the agent went dark
|
|
2565
2602
|
// (skill-update notices, auth/logout notices, group owner-attention
|
|
@@ -3338,8 +3375,28 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3338
3375
|
}
|
|
3339
3376
|
turn = await withCachedGroupSenderNames(turn, memoryRoot);
|
|
3340
3377
|
const conversationTarget = `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`;
|
|
3341
|
-
const turnPrompt = await buildPromptForTurn(turn, memoryRoot);
|
|
3342
3378
|
const senderIsOwner = turn.senderIsOwner === true || turn.senderId === account.ownerUserId;
|
|
3379
|
+
// Owner turn <=> the owner's activation direct chat. Resolved per turn;
|
|
3380
|
+
// any failure leaves it null, which classifies as non-owner (fail closed).
|
|
3381
|
+
let ownerConversationId: string | null = null;
|
|
3382
|
+
try {
|
|
3383
|
+
ownerConversationId = resolveOwnerDirectConversationId();
|
|
3384
|
+
} catch {
|
|
3385
|
+
ownerConversationId = null;
|
|
3386
|
+
}
|
|
3387
|
+
const turnClass = classifyChat({
|
|
3388
|
+
kind: turn.peer.kind,
|
|
3389
|
+
chatId: turn.peer.id,
|
|
3390
|
+
ownerDirectConversationId: ownerConversationId,
|
|
3391
|
+
});
|
|
3392
|
+
const narrowHostTools = turnClass === "non-owner";
|
|
3393
|
+
const baseTurnPrompt = await buildPromptForTurn(turn, memoryRoot);
|
|
3394
|
+
const nonOwnerMediaGuidance =
|
|
3395
|
+
"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.";
|
|
3396
|
+
const turnPrompt = [
|
|
3397
|
+
...(narrowHostTools ? [getClawChatPlatformPrompt(), nonOwnerMediaGuidance] : []),
|
|
3398
|
+
baseTurnPrompt,
|
|
3399
|
+
].filter(Boolean).join("\n\n");
|
|
3343
3400
|
const ctxPayload = resolveChannelContextBuilder(rt)({
|
|
3344
3401
|
channel: CHANNEL_ID,
|
|
3345
3402
|
accountId: route.accountId ?? accountId,
|
|
@@ -3385,6 +3442,8 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3385
3442
|
...(isOpenClawTextSlashCommand(turn.rawBody, cfg) && senderIsOwner
|
|
3386
3443
|
? { commands: { authorized: true, source: "text" } }
|
|
3387
3444
|
: {}),
|
|
3445
|
+
// toolPolicy: host 2026.8.1+ (floor)
|
|
3446
|
+
...(narrowHostTools ? { toolPolicy: { allow: [...NON_OWNER_TOOL_ALLOWLIST] } } : {}),
|
|
3388
3447
|
},
|
|
3389
3448
|
...(memoryRoot ? { extra: { memoryRoot } } : {}),
|
|
3390
3449
|
// Deliver the rendered ClawChat per-turn prompt (owner agent_behavior,
|
|
@@ -3440,7 +3499,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3440
3499
|
if (stagedDirectPrompt) {
|
|
3441
3500
|
stageClawChatPromptInjection({
|
|
3442
3501
|
sessionKey: resolvedSessionKey,
|
|
3443
|
-
prompt:
|
|
3502
|
+
prompt: baseTurnPrompt,
|
|
3444
3503
|
});
|
|
3445
3504
|
}
|
|
3446
3505
|
|
|
@@ -3501,6 +3560,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3501
3560
|
// conversation, not `ownerUserId` (a usr_… chat_id is rejected by
|
|
3502
3561
|
// member-backend). Passed as a thunk so it is resolved at send time.
|
|
3503
3562
|
resolveOwnerConversationId: resolveOwnerDirectConversationId,
|
|
3563
|
+
nonOwnerTurn: narrowHostTools,
|
|
3504
3564
|
log,
|
|
3505
3565
|
});
|
|
3506
3566
|
|
|
@@ -3515,7 +3575,14 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3515
3575
|
onSettled: () => markDispatchIdle(),
|
|
3516
3576
|
run: () =>
|
|
3517
3577
|
runWithTerminalClawChatSendScope(terminalSendScopeId, () =>
|
|
3518
|
-
rt.reply.dispatchReplyFromConfig({
|
|
3578
|
+
rt.reply.dispatchReplyFromConfig({
|
|
3579
|
+
ctx: ctxPayload,
|
|
3580
|
+
cfg: dispatchCfg,
|
|
3581
|
+
dispatcher,
|
|
3582
|
+
replyOptions: narrowHostTools
|
|
3583
|
+
? { ...replyOptions, toolsAllow: [...NON_OWNER_TOOL_ALLOWLIST] }
|
|
3584
|
+
: replyOptions,
|
|
3585
|
+
}),
|
|
3519
3586
|
),
|
|
3520
3587
|
});
|
|
3521
3588
|
const counts = (dispatchResult as { counts?: Record<string, number> } | undefined)?.counts ?? {};
|
|
@@ -4101,6 +4168,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
4101
4168
|
if (activationAccount.source === "sqlite" && !abortSignal.aborted) {
|
|
4102
4169
|
await startOpenclawClawlingGateway({
|
|
4103
4170
|
...params,
|
|
4171
|
+
boundaryWarningIssued: true,
|
|
4104
4172
|
account: {
|
|
4105
4173
|
...params.account,
|
|
4106
4174
|
configured: false,
|
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
|
|
|
@@ -643,6 +644,40 @@ export class ClawChatStore {
|
|
|
643
644
|
}) ?? null;
|
|
644
645
|
}
|
|
645
646
|
|
|
647
|
+
/**
|
|
648
|
+
* Strict owner-identity read for the non-owner turn boundary. Unlike the
|
|
649
|
+
* other getters (which collapse failure into null), this keeps "storage
|
|
650
|
+
* unavailable", "no activation row" and "row" apart — including incomplete
|
|
651
|
+
* rows — because only a proven-absent row may fall back to the config mirror
|
|
652
|
+
* (see `resolveOwnerIdentity`). Nothing is logged here: the caller warns
|
|
653
|
+
* once per failure class, and no SQL or credentials reach the log.
|
|
654
|
+
*/
|
|
655
|
+
lookupActivationIdentity(input: ConversationAccountInput): ActivationIdentityLookup {
|
|
656
|
+
if (!this.ensureInitialized()) return { status: "unavailable" };
|
|
657
|
+
try {
|
|
658
|
+
const row = this.requireDb()
|
|
659
|
+
.prepare(
|
|
660
|
+
"SELECT owner_user_id, conversation_id, access_token FROM activations WHERE platform = ? AND account_id = ?",
|
|
661
|
+
)
|
|
662
|
+
.get(input.platform, input.accountId) as Record<string, unknown> | undefined;
|
|
663
|
+
if (!row) return { status: "absent" };
|
|
664
|
+
const value = (key: string) => {
|
|
665
|
+
const raw = row[key];
|
|
666
|
+
return typeof raw === "string" ? raw.trim() || null : null;
|
|
667
|
+
};
|
|
668
|
+
return {
|
|
669
|
+
status: "row",
|
|
670
|
+
row: {
|
|
671
|
+
ownerUserId: value("owner_user_id"),
|
|
672
|
+
conversationId: value("conversation_id"),
|
|
673
|
+
accessToken: value("access_token"),
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
} catch {
|
|
677
|
+
return { status: "unavailable" };
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
646
681
|
claimPendingActivationBootstrap(
|
|
647
682
|
input: ActivationBootstrapInput,
|
|
648
683
|
): ActivationBootstrapConversation | null {
|
|
@@ -1184,6 +1219,10 @@ export class ClawChatStore {
|
|
|
1184
1219
|
* as `'thinking'` are excluded so the @-mention prior-context block never
|
|
1185
1220
|
* surfaces the agent's intermediate output as conversation history.
|
|
1186
1221
|
* Uses `idx_clawchat_messages_chat_created` for performance.
|
|
1222
|
+
* `ignoreCase` matches `chat_id` case-insensitively (rows keep their stored
|
|
1223
|
+
* case) so memory scope can recover a group id's original case from a
|
|
1224
|
+
* lowercased session key; it cannot use that index, so callers that already
|
|
1225
|
+
* know the exact id leave it off.
|
|
1187
1226
|
* Returns an empty array if the store is unavailable.
|
|
1188
1227
|
*/
|
|
1189
1228
|
listRecentGroupMessages(
|
|
@@ -1191,12 +1230,14 @@ export class ClawChatStore {
|
|
|
1191
1230
|
accountId: string,
|
|
1192
1231
|
chatId: string,
|
|
1193
1232
|
limit: number,
|
|
1233
|
+
options: { ignoreCase?: boolean } = {},
|
|
1194
1234
|
): MessageRow[] {
|
|
1235
|
+
const chatMatch = options.ignoreCase ? "chat_id = ? COLLATE NOCASE" : "chat_id = ?";
|
|
1195
1236
|
return this.read(() => {
|
|
1196
1237
|
const rows = this.requireDb()
|
|
1197
1238
|
.prepare(
|
|
1198
1239
|
`SELECT * FROM clawchat_messages
|
|
1199
|
-
WHERE platform = ? AND account_id = ? AND
|
|
1240
|
+
WHERE platform = ? AND account_id = ? AND ${chatMatch} AND kind = 'message'
|
|
1200
1241
|
ORDER BY created_at DESC, rowid DESC LIMIT ?`,
|
|
1201
1242
|
)
|
|
1202
1243
|
.all(platform, accountId, chatId, limit) as MessageRow[];
|
package/src/tools.ts
CHANGED
|
@@ -36,6 +36,14 @@ import {
|
|
|
36
36
|
writeClawChatMemoryBody,
|
|
37
37
|
type ClawChatMemoryTargetType,
|
|
38
38
|
} from "./clawchat-memory.ts";
|
|
39
|
+
import {
|
|
40
|
+
canReadMemory,
|
|
41
|
+
memoryScopeNote,
|
|
42
|
+
memoryScopeRefusal,
|
|
43
|
+
resolveMemoryScope,
|
|
44
|
+
type MemoryScope,
|
|
45
|
+
} from "./memory-scope.ts";
|
|
46
|
+
import { clawChatSessionAccountId, NON_OWNER_TOOL_ALLOWLIST } from "./turn-boundary.ts";
|
|
39
47
|
import {
|
|
40
48
|
pullGroupMetadata,
|
|
41
49
|
pullOwnerMetadata,
|
|
@@ -254,14 +262,18 @@ const MEMORY_READ_MAX_LIMIT = 100_000;
|
|
|
254
262
|
type ToolStore = Pick<
|
|
255
263
|
ClawChatStore,
|
|
256
264
|
"recordToolCall"
|
|
257
|
-
|
|
265
|
+
> &
|
|
266
|
+
Partial<Pick<ClawChatStore, "lookupActivationIdentity" | "listRecentGroupMessages">>;
|
|
258
267
|
|
|
259
268
|
function toolDescription(...parts: string[]): string {
|
|
260
269
|
return `${parts.join("")} ${DIRECT_TOOL_GUARD}`;
|
|
261
270
|
}
|
|
262
271
|
|
|
272
|
+
const MEMORY_SCOPE_DESCRIPTION =
|
|
273
|
+
"In a non-owner direct chat only that person's note is readable; in a group only that group's note and its members' notes are readable. owner.md is readable only in the owner's direct chat. Replace, edit and metadata operations require a readable target; append may add facts to any target without revealing existing content.";
|
|
274
|
+
|
|
263
275
|
function memoryToolDescription(...parts: string[]): string {
|
|
264
|
-
return parts.join("")
|
|
276
|
+
return `${parts.join("")} ${MEMORY_SCOPE_DESCRIPTION}`;
|
|
265
277
|
}
|
|
266
278
|
|
|
267
279
|
function inferMimeFromPath(filePath: string): string {
|
|
@@ -392,6 +404,36 @@ export function resolveOpenclawClawlingToolAccountId(ctx: OpenClawPluginToolCont
|
|
|
392
404
|
return null;
|
|
393
405
|
}
|
|
394
406
|
|
|
407
|
+
// Names this plugin registered through its wrapper below. The host-tool guard
|
|
408
|
+
// lets an allowlisted `clawchat_*` name through only when it is in this set,
|
|
409
|
+
// so another plugin's tool under a lookalike name is not trusted by prefix.
|
|
410
|
+
const registeredClawChatToolNames = new Set<string>();
|
|
411
|
+
|
|
412
|
+
/** Snapshot of the tool names this plugin registered; callers cannot mutate the record. */
|
|
413
|
+
export function getRegisteredClawChatToolNames(): ReadonlySet<string> {
|
|
414
|
+
return new Set(registeredClawChatToolNames);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
type RegistrableTool = Parameters<OpenClawPluginApi["registerTool"]>[0];
|
|
418
|
+
type PluginTool = Exclude<RegistrableTool, (...args: never[]) => unknown>;
|
|
419
|
+
|
|
420
|
+
// Host tool search (OpenClaw 2026.9.6+) hides every tool not marked
|
|
421
|
+
// `catalogMode: "direct-only"` behind tool_search/tool_call, whose hook ctx has
|
|
422
|
+
// no requester. The allowlisted tools stay directly callable so non-owner
|
|
423
|
+
// turns keep the full hook ctx; older hosts ignore the field.
|
|
424
|
+
function markDirectOnly(tool: PluginTool): PluginTool {
|
|
425
|
+
return Object.assign(tool, { catalogMode: "direct-only" as const });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function directOnlyTool(tool: RegistrableTool): RegistrableTool {
|
|
429
|
+
if (typeof tool !== "function") return markDirectOnly(tool);
|
|
430
|
+
return (ctx) => {
|
|
431
|
+
const resolved = tool(ctx);
|
|
432
|
+
if (Array.isArray(resolved)) return resolved.map(markDirectOnly);
|
|
433
|
+
return resolved ? markDirectOnly(resolved) : resolved;
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
395
437
|
export function registerOpenclawClawlingTools(
|
|
396
438
|
api: OpenClawPluginApi,
|
|
397
439
|
options: { store?: ToolStore | null } = {},
|
|
@@ -405,8 +447,14 @@ export function registerOpenclawClawlingTools(
|
|
|
405
447
|
// the end is derived from what was actually registered, not hand-maintained.
|
|
406
448
|
const registeredToolNames: string[] = [];
|
|
407
449
|
const registerTool: OpenClawPluginApi["registerTool"] = (tool, opts) => {
|
|
408
|
-
|
|
409
|
-
api.registerTool(tool, opts);
|
|
450
|
+
const directOnly = Boolean(opts?.name && NON_OWNER_TOOL_ALLOWLIST.includes(opts.name));
|
|
451
|
+
api.registerTool(directOnly ? directOnlyTool(tool) : tool, opts);
|
|
452
|
+
// Recorded only after registerTool returned without throwing (the SDK
|
|
453
|
+
// returns void, so host-side duplicate rejection is not observable here).
|
|
454
|
+
if (opts?.name) {
|
|
455
|
+
registeredToolNames.push(opts.name);
|
|
456
|
+
registeredClawChatToolNames.add(opts.name);
|
|
457
|
+
}
|
|
410
458
|
};
|
|
411
459
|
|
|
412
460
|
// Re-resolve at call time so config reloads pick up new tokens / baseUrl.
|
|
@@ -636,6 +684,48 @@ export function registerOpenclawClawlingTools(
|
|
|
636
684
|
return /Unsafe clawchat memory|Resolved clawchat memory path is outside root/i.test(message);
|
|
637
685
|
}
|
|
638
686
|
|
|
687
|
+
// One warning per failure class per registration; never tool arguments.
|
|
688
|
+
const scopeWarnings = new Set<string>();
|
|
689
|
+
function warnScopeOnce(failure: string): void {
|
|
690
|
+
if (scopeWarnings.has(failure)) return;
|
|
691
|
+
scopeWarnings.add(failure);
|
|
692
|
+
try {
|
|
693
|
+
api.logger.warn(`clawchat-plugin-openclaw: memory scope ${failure}; no additional notes readable`);
|
|
694
|
+
} catch {
|
|
695
|
+
// Logging must not widen or break the (restrictive) scope.
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Scope of the conversation behind this call, resolved fresh on every call
|
|
701
|
+
* (activation and group membership can change between calls). The account
|
|
702
|
+
* comes from the direct session key and/or the trusted tool context; a
|
|
703
|
+
* missing or contradictory account reads nothing.
|
|
704
|
+
*/
|
|
705
|
+
async function memoryScopeFor(ctx: OpenClawPluginToolContext, root: string): Promise<MemoryScope> {
|
|
706
|
+
const sessionAccount = clawChatSessionAccountId(ctx.sessionKey) ?? null;
|
|
707
|
+
const contextAccount = resolveOpenclawClawlingToolAccountId(ctx);
|
|
708
|
+
if (sessionAccount && contextAccount && sessionAccount !== contextAccount) {
|
|
709
|
+
warnScopeOnce("account-mismatch");
|
|
710
|
+
return { kind: "unknown", chatId: "", members: new Set() };
|
|
711
|
+
}
|
|
712
|
+
const accountId = sessionAccount ?? contextAccount;
|
|
713
|
+
if (!accountId) return { kind: "unknown", chatId: "", members: new Set() };
|
|
714
|
+
const delivery = ctx.deliveryContext;
|
|
715
|
+
return resolveMemoryScope({
|
|
716
|
+
root,
|
|
717
|
+
ctx: {
|
|
718
|
+
sessionKey: ctx.sessionKey,
|
|
719
|
+
requesterSenderId: ctx.requesterSenderId,
|
|
720
|
+
deliveryContext: delivery?.channel === CHANNEL_ID ? { to: delivery.to } : undefined,
|
|
721
|
+
},
|
|
722
|
+
accountId,
|
|
723
|
+
configOwnerUserId: resolveCurrent(accountId).ownerUserId,
|
|
724
|
+
store: resolveStore(),
|
|
725
|
+
warn: warnScopeOnce,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
639
729
|
function memoryToolError(err: unknown) {
|
|
640
730
|
if (err instanceof ClawlingApiError) return apiError(err);
|
|
641
731
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -665,13 +755,14 @@ export function registerOpenclawClawlingTools(
|
|
|
665
755
|
}
|
|
666
756
|
try {
|
|
667
757
|
const p = params as ClawchatMemorySearchParams;
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
);
|
|
758
|
+
const scope = await memoryScopeFor(ctx, root);
|
|
759
|
+
const result = await searchClawChatMemory(root, {
|
|
760
|
+
query: p.query,
|
|
761
|
+
targetTypes: p.targetTypes,
|
|
762
|
+
maxResults: p.maxResults,
|
|
763
|
+
}, { readable: (target) => canReadMemory(scope, target) });
|
|
764
|
+
const scopeNote = memoryScopeNote(scope);
|
|
765
|
+
return jsonResponse({ ...result, ...(scopeNote ? { scopeNote } : {}) });
|
|
675
766
|
} catch (err) {
|
|
676
767
|
return memoryToolError(err);
|
|
677
768
|
}
|
|
@@ -700,8 +791,11 @@ export function registerOpenclawClawlingTools(
|
|
|
700
791
|
const p = params as ClawchatMemoryReadParams;
|
|
701
792
|
const targetError = validateTarget(root, p);
|
|
702
793
|
if (targetError) return memoryToolError(new Error(targetError));
|
|
794
|
+
const target = targetFromParams(p);
|
|
795
|
+
const scope = await memoryScopeFor(ctx, root);
|
|
796
|
+
if (!canReadMemory(scope, target)) return jsonResponse(memoryScopeRefusal(scope, target));
|
|
703
797
|
const { offset, limit } = normalizeReadBounds(p);
|
|
704
|
-
const file = await readClawChatMemoryFile(root,
|
|
798
|
+
const file = await readClawChatMemoryFile(root, target);
|
|
705
799
|
if (!file.exists) {
|
|
706
800
|
return jsonResponse({
|
|
707
801
|
exists: false,
|
|
@@ -751,13 +845,23 @@ export function registerOpenclawClawlingTools(
|
|
|
751
845
|
if (!root) {
|
|
752
846
|
return validationError("clawchat-plugin-openclaw: OpenClaw workspaceDir is required");
|
|
753
847
|
}
|
|
848
|
+
const p = params as ClawchatMemoryWriteParams;
|
|
849
|
+
let readable = false;
|
|
754
850
|
try {
|
|
755
|
-
const p = params as ClawchatMemoryWriteParams;
|
|
756
851
|
const targetError = validateTarget(root, p);
|
|
757
852
|
if (targetError) return memoryToolError(new Error(targetError));
|
|
758
|
-
|
|
853
|
+
const target = targetFromParams(p);
|
|
854
|
+
const scope = await memoryScopeFor(ctx, root);
|
|
855
|
+
readable = canReadMemory(scope, target);
|
|
856
|
+
// Replacing wipes what is there; append may go to any note.
|
|
857
|
+
if (p.mode !== "append" && !readable) return jsonResponse(memoryScopeRefusal(scope, target));
|
|
858
|
+
await writeClawChatMemoryBody(root, target, p.mode, p.content);
|
|
759
859
|
return jsonResponse({ ok: true, targetType: p.targetType, targetId: p.targetId, mode: p.mode });
|
|
760
860
|
} catch (err) {
|
|
861
|
+
// An unreadable note's failure text could reveal its state.
|
|
862
|
+
if (p.mode === "append" && !readable) {
|
|
863
|
+
return jsonResponse({ error: "memory_write_failed", message: "The memory append could not be completed." });
|
|
864
|
+
}
|
|
761
865
|
return memoryToolError(err);
|
|
762
866
|
}
|
|
763
867
|
},
|
|
@@ -787,7 +891,10 @@ export function registerOpenclawClawlingTools(
|
|
|
787
891
|
const p = params as ClawchatMemoryEditParams;
|
|
788
892
|
const targetError = validateTarget(root, p);
|
|
789
893
|
if (targetError) return memoryToolError(new Error(targetError));
|
|
790
|
-
|
|
894
|
+
const target = targetFromParams(p);
|
|
895
|
+
const scope = await memoryScopeFor(ctx, root);
|
|
896
|
+
if (!canReadMemory(scope, target)) return jsonResponse(memoryScopeRefusal(scope, target));
|
|
897
|
+
await editClawChatMemoryBody(root, target, p.oldText, p.newText);
|
|
791
898
|
return jsonResponse({ ok: true, targetType: p.targetType, targetId: p.targetId });
|
|
792
899
|
} catch (err) {
|
|
793
900
|
return memoryToolError(err);
|
|
@@ -818,12 +925,15 @@ export function registerOpenclawClawlingTools(
|
|
|
818
925
|
if (!root) {
|
|
819
926
|
return validationError("clawchat-plugin-openclaw: OpenClaw workspaceDir is required");
|
|
820
927
|
}
|
|
821
|
-
const built = buildClient(accountId);
|
|
822
|
-
if (!built.ok) return built.error;
|
|
823
928
|
try {
|
|
824
929
|
const p = params as ClawchatMetadataSyncParams;
|
|
825
930
|
const targetError = validateTarget(root, p);
|
|
826
931
|
if (targetError) return memoryToolError(new Error(targetError));
|
|
932
|
+
const target = targetFromParams(p);
|
|
933
|
+
const scope = await memoryScopeFor(ctx, root);
|
|
934
|
+
if (!canReadMemory(scope, target)) return jsonResponse(memoryScopeRefusal(scope, target));
|
|
935
|
+
const built = buildClient(accountId);
|
|
936
|
+
if (!built.ok) return built.error;
|
|
827
937
|
const account = resolveCurrent(accountId);
|
|
828
938
|
if (p.direction === "push") {
|
|
829
939
|
if (!Array.isArray(p.fields) || p.fields.length === 0) {
|
|
@@ -899,14 +1009,17 @@ export function registerOpenclawClawlingTools(
|
|
|
899
1009
|
if (!root) {
|
|
900
1010
|
return validationError("clawchat-plugin-openclaw: OpenClaw workspaceDir is required");
|
|
901
1011
|
}
|
|
902
|
-
const built = buildClient(accountId);
|
|
903
|
-
if (!built.ok) return built.error;
|
|
904
1012
|
try {
|
|
905
1013
|
const p = params as ClawchatMetadataUpdateParams;
|
|
906
1014
|
const targetError = validateTarget(root, p);
|
|
907
1015
|
if (targetError) return memoryToolError(new Error(targetError));
|
|
908
1016
|
const patchError = validateMetadataUpdatePatch(p);
|
|
909
1017
|
if (patchError) return validationError(patchError);
|
|
1018
|
+
const target = targetFromParams(p);
|
|
1019
|
+
const scope = await memoryScopeFor(ctx, root);
|
|
1020
|
+
if (!canReadMemory(scope, target)) return jsonResponse(memoryScopeRefusal(scope, target));
|
|
1021
|
+
const built = buildClient(accountId);
|
|
1022
|
+
if (!built.ok) return built.error;
|
|
910
1023
|
const account = resolveCurrent(accountId);
|
|
911
1024
|
if (p.targetType === "owner" && !account.agentId) {
|
|
912
1025
|
return configError("clawchat-plugin-openclaw: agentId is required");
|
|
@@ -1376,7 +1489,7 @@ export function registerOpenclawClawlingTools(
|
|
|
1376
1489
|
description: toolDescription(
|
|
1377
1490
|
"React to a ClawChat message with a single quick emoji (the bubble long-press reaction), instead of sending a text message. " +
|
|
1378
1491
|
"TRIGGER - invoke when a short acknowledgement or emotional beat (agreement, thanks, laughter, celebration, sympathy) reads more naturally as an emoji on the message than as a sentence, e.g. a simple 👍 to \"done\" or ❤️ to good news. " +
|
|
1379
|
-
"
|
|
1492
|
+
"In the owner's direct chat, omit targetMessageId and chatId to react to the message that triggered the current turn; pass targetMessageId to react to an earlier message. In group chats and other direct chats, always pass this chat's chatId. " +
|
|
1380
1493
|
"Set remove:true to take back your reaction. " +
|
|
1381
1494
|
"Prefer the quick set 👍 ❤️ 😂 😮 😢 🙏 🎉 👏 🔥 😍 🤔. " +
|
|
1382
1495
|
"When a reaction is all you want to send, do not also send a normal text reply.",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Who started this turn? The owner's turns are exactly the ones in the owner's
|
|
2
|
+
// activation direct chat (whoever the sender is — plugin-started turns there
|
|
3
|
+
// count). Every group turn and every other direct chat is someone else's.
|
|
4
|
+
// Anything we cannot place is treated as someone else's (fail closed).
|
|
5
|
+
import { CHANNEL_ID, normalizeOpenclawClawlingAccountId } from "./config.ts";
|
|
6
|
+
|
|
7
|
+
export type TurnClass = "owner" | "non-owner";
|
|
8
|
+
export type ParsedClawChatSession = { kind: "direct" | "group"; chatId: string } | null;
|
|
9
|
+
|
|
10
|
+
/** Tools a turn the owner did not start may still see and call. */
|
|
11
|
+
export const NON_OWNER_TOOL_ALLOWLIST: readonly string[] = Object.freeze([
|
|
12
|
+
"clawchat_memory_search", "clawchat_memory_read", "clawchat_memory_write", "clawchat_memory_edit",
|
|
13
|
+
"clawchat_mention_message", "clawchat_react_message", "clawchat_get_user_profile", "clawchat_metadata_sync",
|
|
14
|
+
"web_search",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const norm = (v: string | undefined | null) => (typeof v === "string" ? v.trim().toLowerCase() : "");
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Host session keys (dmScope per-account-channel-peer):
|
|
21
|
+
* agent:<agent>:<channel>:<account>:direct:<chat>
|
|
22
|
+
* agent:<agent>:<channel>:group:<chat>
|
|
23
|
+
*/
|
|
24
|
+
export function parseClawChatSessionKey(sessionKey: string | undefined | null): ParsedClawChatSession {
|
|
25
|
+
const parts = norm(sessionKey).split(":");
|
|
26
|
+
if (parts.length < 5 || parts[0] !== "agent" || parts[2] !== CHANNEL_ID) return null;
|
|
27
|
+
if (parts[3] === "group" && parts.length === 5 && parts[4]) return { kind: "group", chatId: parts[4] };
|
|
28
|
+
if (parts[4] === "direct" && parts.length === 6 && parts[5]) return { kind: "direct", chatId: parts[5] };
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Account id carried by a ClawChat direct session key, normalized exactly once
|
|
34
|
+
* with the plugin's account-id normalizer. Group keys carry no account
|
|
35
|
+
* segment, and a blank segment is missing (never silently "default").
|
|
36
|
+
*/
|
|
37
|
+
export function clawChatSessionAccountId(sessionKey: string | undefined | null): string | undefined {
|
|
38
|
+
if (parseClawChatSessionKey(sessionKey)?.kind !== "direct") return undefined;
|
|
39
|
+
const segment = (sessionKey ?? "").trim().split(":")[3]?.trim();
|
|
40
|
+
return segment ? normalizeOpenclawClawlingAccountId(segment) : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function classifyChat(input: {
|
|
44
|
+
kind: "direct" | "group" | string;
|
|
45
|
+
chatId?: string | undefined | null;
|
|
46
|
+
ownerDirectConversationId?: string | undefined | null;
|
|
47
|
+
}): TurnClass {
|
|
48
|
+
const owner = norm(input.ownerDirectConversationId);
|
|
49
|
+
const chat = norm(input.chatId);
|
|
50
|
+
if (input.kind !== "direct" || !owner || !chat) return "non-owner";
|
|
51
|
+
return chat === owner ? "owner" : "non-owner";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function classifyTurn(input: {
|
|
55
|
+
sessionKey?: string | null;
|
|
56
|
+
ownerDirectConversationId?: string | null;
|
|
57
|
+
}): TurnClass {
|
|
58
|
+
const parsed = parseClawChatSessionKey(input.sessionKey);
|
|
59
|
+
if (!parsed) return "non-owner";
|
|
60
|
+
return classifyChat({ ...parsed, ownerDirectConversationId: input.ownerDirectConversationId });
|
|
61
|
+
}
|