@clawling/clawchat-plugin-openclaw 2026.10.9-1 → 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.
@@ -0,0 +1,191 @@
1
+ // Which ClawChat notes the conversation behind a memory / metadata tool call
2
+ // may read. A tool result stays in the calling session's history, and a group
3
+ // is one session for everyone in it, so a note read there can surface to
4
+ // anyone present later. Scope therefore follows the conversation (tool
5
+ // factory ctx), never a parameter or the process environment:
6
+ //
7
+ // owner DM (incl. plugin-started turns there) → every note
8
+ // friend DM → only users/<requesterSenderId>.md
9
+ // group → groups/<this chat>.md + users/<id>.md of present members
10
+ // anything else (unknown session, missing store / peer, contradictions,
11
+ // ambiguous chat-id case, resolution errors) → nothing
12
+ //
13
+ // Chat ids compare case-insensitively (the host lowercases session keys);
14
+ // the original case is recovered before any group file / SQL lookup. Member
15
+ // user ids stay case-sensitive.
16
+ import fs from "node:fs/promises";
17
+ import path from "node:path";
18
+ import { readClawChatMemoryFile, type ClawChatMemoryTarget } from "./clawchat-memory.ts";
19
+ import { resolveOwnerIdentity } from "./owner-identity.ts";
20
+ import type { ClawChatStore } from "./storage.ts";
21
+ import { classifyTurn, parseClawChatSessionKey } from "./turn-boundary.ts";
22
+
23
+ export type MemoryToolContext = {
24
+ sessionKey?: string;
25
+ requesterSenderId?: string | null;
26
+ deliveryContext?: { to?: string };
27
+ };
28
+
29
+ export type MemoryScope = {
30
+ kind: "owner" | "direct" | "group" | "unknown";
31
+ chatId: string;
32
+ members: ReadonlySet<string>;
33
+ };
34
+
35
+ export type MemoryScopeStore = Partial<Pick<ClawChatStore, "lookupActivationIdentity" | "listRecentGroupMessages">>;
36
+
37
+ /** Rows scanned for recent speakers when a group note has no participant list. */
38
+ const SPEAKER_FALLBACK_ROWS = 200;
39
+
40
+ const nothing = (): MemoryScope => ({ kind: "unknown", chatId: "", members: new Set() });
41
+
42
+ async function groupNoteIds(root: string): Promise<string[]> {
43
+ try {
44
+ return (await fs.readdir(path.join(root, "groups")))
45
+ .filter((name) => name.endsWith(".md"))
46
+ .map((name) => name.slice(0, -3));
47
+ } catch (error) {
48
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
49
+ throw error;
50
+ }
51
+ }
52
+
53
+ function participantIds(metadata: Record<string, string>): Set<string> {
54
+ return new Set((metadata.participant_ids ?? "").split(",").map((id) => id.trim()).filter(Boolean));
55
+ }
56
+
57
+ function inboundSenderId(rawJson: string | null): string | null {
58
+ if (!rawJson) return null;
59
+ const sender = (JSON.parse(rawJson) as { sender?: { id?: unknown } } | null)?.sender?.id;
60
+ return typeof sender === "string" && sender.trim() ? sender.trim() : null;
61
+ }
62
+
63
+ export async function resolveMemoryScope(input: {
64
+ root: string;
65
+ ctx: MemoryToolContext;
66
+ accountId: string;
67
+ configOwnerUserId?: string | null;
68
+ store: MemoryScopeStore | null;
69
+ warn?: (failureClass: string) => void;
70
+ }): Promise<MemoryScope> {
71
+ const { root, ctx, accountId, store, warn } = input;
72
+ try {
73
+ const parsed = parseClawChatSessionKey(ctx.sessionKey);
74
+ if (!parsed) return nothing();
75
+ if (!store) {
76
+ warn?.("store-unavailable");
77
+ return nothing();
78
+ }
79
+ const lowered = parsed.chatId; // the parser lowercases
80
+ let chatId = lowered;
81
+ let caseKnown = false;
82
+
83
+ // 1. The trusted delivery route usually carries the original-case id.
84
+ const destination = ctx.deliveryContext?.to?.trim()
85
+ .replace(/^(?:clawchat-plugin-openclaw:)?(?:group|direct):/i, "");
86
+ if (destination && destination.toLowerCase() === lowered) {
87
+ chatId = destination;
88
+ caseKnown = true;
89
+ }
90
+
91
+ // 2. A group note filename, which wins: it is the file that will be read.
92
+ // Two notes differing only in case are ambiguous.
93
+ const fileMatches = (await groupNoteIds(root)).filter((id) => id.toLowerCase() === lowered);
94
+ if (fileMatches.length > 1) {
95
+ warn?.("ambiguous-chat-case");
96
+ return nothing();
97
+ }
98
+ const groupFileId = fileMatches[0];
99
+ if (groupFileId !== undefined) {
100
+ chatId = groupFileId;
101
+ caseKnown = true;
102
+ }
103
+ const members = groupFileId === undefined
104
+ ? new Set<string>()
105
+ : participantIds((await readClawChatMemoryFile(root, { targetType: "group", targetId: groupFileId })).metadata);
106
+
107
+ if (parsed.kind === "direct") {
108
+ // A chat the plugin knows as a group can never be a direct chat.
109
+ if (members.size) {
110
+ warn?.("contradictory-chat-kind");
111
+ return nothing();
112
+ }
113
+ const ownerConversationId = resolveOwnerIdentity(store, accountId, input.configOwnerUserId, warn).conversationId;
114
+ if (classifyTurn({ sessionKey: ctx.sessionKey, ownerDirectConversationId: ownerConversationId }) === "owner") {
115
+ return { kind: "owner", chatId: ownerConversationId!, members: new Set() };
116
+ }
117
+ const peer = typeof ctx.requesterSenderId === "string" ? ctx.requesterSenderId.trim() : "";
118
+ if (!peer) warn?.("missing-dm-peer");
119
+ return { kind: "direct", chatId: parsed.chatId, members: new Set(peer ? [peer] : []) };
120
+ }
121
+
122
+ // 3. No cached participant list: recent inbound speakers. When neither the
123
+ // route nor a filename supplied the case, the case-insensitive query is
124
+ // the recovery path and its stored chat_id supplies the original case.
125
+ if (!members.size) {
126
+ if (!store.listRecentGroupMessages) {
127
+ warn?.("store-unavailable");
128
+ return nothing();
129
+ }
130
+ const rows = store.listRecentGroupMessages("openclaw", accountId, chatId, SPEAKER_FALLBACK_ROWS, {
131
+ ignoreCase: !caseKnown,
132
+ });
133
+ if (!caseKnown) {
134
+ const storedIds = new Set(rows.map((row) => row.chat_id)
135
+ .filter((id): id is string => typeof id === "string" && id.toLowerCase() === lowered));
136
+ if (storedIds.size > 1) {
137
+ warn?.("ambiguous-chat-case");
138
+ return nothing();
139
+ }
140
+ if (storedIds.size === 1) chatId = [...storedIds][0]!;
141
+ }
142
+ for (const row of rows) {
143
+ if (row.direction !== "inbound") continue;
144
+ try {
145
+ const sender = inboundSenderId(row.raw_json);
146
+ if (sender) members.add(sender);
147
+ } catch {
148
+ warn?.("malformed-group-row");
149
+ }
150
+ }
151
+ }
152
+ return { kind: "group", chatId, members };
153
+ } catch {
154
+ warn?.("scope-resolution");
155
+ return nothing();
156
+ }
157
+ }
158
+
159
+ export function canReadMemory(scope: MemoryScope, target: ClawChatMemoryTarget): boolean {
160
+ if (scope.kind === "owner") return true;
161
+ if (scope.kind === "unknown" || target.targetType === "owner") return false;
162
+ if (target.targetType === "user") return scope.members.has(target.targetId);
163
+ return scope.kind === "group" && target.targetType === "group" && target.targetId === scope.chatId;
164
+ }
165
+
166
+ export function memoryScopeRefusal(scope: MemoryScope, target: ClawChatMemoryTarget): {
167
+ error: "not_readable_here";
168
+ code: "memory_scope";
169
+ message: string;
170
+ } {
171
+ const message = target.targetType === "owner"
172
+ ? "owner.md can only be read in your owner's direct chat. Tool results stay in this conversation's history. Do not try to get the owner's notes another way."
173
+ : scope.kind === "direct"
174
+ ? "In this direct chat you can only read the note about the person you are talking to."
175
+ : scope.kind === "group"
176
+ ? "In a group you can only read this group's note and its members' notes."
177
+ : "This conversation could not be identified; no ClawChat notes are readable here.";
178
+ return { error: "not_readable_here", code: "memory_scope", message };
179
+ }
180
+
181
+ /** What a restricted search left out, for the model (never counts). */
182
+ export function memoryScopeNote(scope: MemoryScope): string | undefined {
183
+ if (scope.kind === "direct") {
184
+ return "In this direct chat only the note about the person you are talking to is searched; owner.md, other people's notes and group notes are left out.";
185
+ }
186
+ if (scope.kind === "group") {
187
+ return "In a group only this group's note and its members' notes are searched; owner.md, other groups' notes and notes about non-members are left out.";
188
+ }
189
+ if (scope.kind === "unknown") return "This conversation could not be identified; no ClawChat notes were searched.";
190
+ return undefined;
191
+ }
@@ -0,0 +1,49 @@
1
+ // The one definition of "who is the owner, and which chat is the owner's DM"
2
+ // shared by dispatch, the host-tool guard and memory scope. The SQLite
3
+ // activation row decides; the config `ownerUserId` mirror is only a fallback
4
+ // once a working store has proved there is no activation row at all. An
5
+ // unavailable store, a failed read, a cleared/rejected token or an incomplete
6
+ // row never falls back to the mirror — the caller sees no owner (fail closed).
7
+
8
+ export type ActivationIdentityLookup =
9
+ | { status: "unavailable" }
10
+ | { status: "absent" }
11
+ | {
12
+ status: "row";
13
+ row: { ownerUserId: string | null; conversationId: string | null; accessToken: string | null };
14
+ };
15
+
16
+ /** Structural so focused fixtures can omit unrelated store methods. */
17
+ export type OwnerIdentityStore = {
18
+ lookupActivationIdentity?(input: { platform: string; accountId: string }): ActivationIdentityLookup;
19
+ };
20
+
21
+ export type OwnerIdentity = { ownerUserId: string | null; conversationId: string | null };
22
+
23
+ export function resolveOwnerIdentity(
24
+ store: OwnerIdentityStore | null | undefined,
25
+ accountId: string,
26
+ configOwnerUserId?: string | null,
27
+ warn?: (failure: string) => void,
28
+ rejectedActivationToken?: string,
29
+ ): OwnerIdentity {
30
+ const denied: OwnerIdentity = { ownerUserId: null, conversationId: null };
31
+ try {
32
+ const lookup = store?.lookupActivationIdentity?.({ platform: "openclaw", accountId });
33
+ if (!lookup || lookup.status === "unavailable") {
34
+ warn?.("owner-identity");
35
+ return denied;
36
+ }
37
+ if (lookup.status === "absent") {
38
+ return { ownerUserId: configOwnerUserId?.trim() || null, conversationId: null };
39
+ }
40
+ const { row } = lookup;
41
+ // Mirror the runtime overlay: never trust the activation rejected during this lifecycle.
42
+ if (!row.accessToken || row.accessToken === rejectedActivationToken) return denied;
43
+ const ownerUserId = row.ownerUserId?.trim() || null;
44
+ return { ownerUserId, conversationId: ownerUserId ? row.conversationId?.trim() || null : null };
45
+ } catch {
46
+ warn?.("owner-identity");
47
+ return denied;
48
+ }
49
+ }
@@ -88,7 +88,13 @@ export interface ReplyDispatcherOptions {
88
88
  * framework error final; null/absent means English.
89
89
  */
90
90
  resolveOwnerLocale?: () => string | null;
91
- log?: { info?: (m: string) => void; error?: (m: string) => void };
91
+ /**
92
+ * True when the turn was not started by the owner (any group turn, any
93
+ * non-owner direct chat). Reply media is then limited to http(s) URLs; local
94
+ * paths are never loaded. Legacy standalone callers default to owner behavior.
95
+ */
96
+ nonOwnerTurn?: boolean;
97
+ log?: { info?: (m: string) => void; warn?: (m: string) => void; error?: (m: string) => void };
92
98
  }
93
99
 
94
100
  type TypedReplyDispatcherResult = ReturnType<
@@ -435,6 +441,19 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
435
441
  log,
436
442
  } = options;
437
443
  const isGroupTarget = target.chatType === "group";
444
+ let warnedLocalReplyMedia = false;
445
+ // Non-owner turns may only send remote (http/https) media. Local sources are
446
+ // dropped here, before any load, because the host's local media roots include
447
+ // the agent workspace. Warns once per dispatcher and never names the source.
448
+ function filterReplyMedia(urls: string[]): string[] {
449
+ if (!options.nonOwnerTurn) return urls;
450
+ const remote = urls.filter((source) => /^https?:\/\//i.test(source.trim()));
451
+ if (remote.length !== urls.length && !warnedLocalReplyMedia) {
452
+ warnedLocalReplyMedia = true;
453
+ log?.warn?.("clawchat-plugin-openclaw: non-owner reply media dropped; only http(s) URLs are allowed");
454
+ }
455
+ return remote;
456
+ }
438
457
  const outputVisibility = effectiveOutputVisibility(account, target.chatId, target.chatType);
439
458
  const splitFullOutput = outputVisibility === "full";
440
459
  const splitNormalBlockOutput = outputVisibility === "normal";
@@ -442,11 +461,10 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
442
461
  // owner's usr_… id: chat_id is resolved by msghub through member-backend, and
443
462
  // a usr_… comes back "invalid conversation id" — the frame is dropped with no
444
463
  // negative ack to this client, so the owner silently never gets the approval
445
- // request. Both ids are required: ownerUserId gates whether an owner exists at
446
- // all, the conversation id is where a frame can actually be delivered.
464
+ // request. The resolver is the shared strict owner-identity lookup
465
+ // (src/owner-identity.ts): it yields a conversation only for an activation
466
+ // row with an owner, so the config `ownerUserId` mirror is not consulted.
447
467
  const ownerDirectTarget = (): { chatId: string; chatType: "direct" } | null => {
448
- const ownerUserId = account.ownerUserId?.trim();
449
- if (!ownerUserId) return null;
450
468
  const conversationId = resolveOwnerConversationId?.()?.trim();
451
469
  return conversationId ? { chatId: conversationId, chatType: "direct" as const } : null;
452
470
  };
@@ -462,7 +480,8 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
462
480
  });
463
481
  };
464
482
 
465
- async function uploadMediaUrls(urls: string[]): Promise<ClawlingMediaFragment[]> {
483
+ async function uploadMediaUrls(inputUrls: string[]): Promise<ClawlingMediaFragment[]> {
484
+ const urls = filterReplyMedia(inputUrls);
466
485
  if (urls.length === 0) return [];
467
486
  const apiClient = buildApiClient();
468
487
  if (!apiClient) {
@@ -720,11 +739,8 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
720
739
  ): Promise<void> => {
721
740
  const ownerTarget = ownerDirectTarget();
722
741
  if (!ownerTarget) {
723
- const reason = account.ownerUserId?.trim()
724
- ? "missing_owner_conversation"
725
- : "missing_owner_user_id";
726
742
  log?.error?.(
727
- `[${account.accountId}] clawchat-plugin-openclaw group owner attention suppressed reason=${reason} group=${target.chatId}`,
743
+ `[${account.accountId}] clawchat-plugin-openclaw group owner attention suppressed reason=missing_owner_conversation group=${target.chatId}`,
728
744
  );
729
745
  return;
730
746
  }
@@ -917,7 +933,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
917
933
  if (consumeTerminalSend(info?.kind ?? "unknown")) return;
918
934
  const richFragment = buildRichInteractionFragment(payload);
919
935
  const text = richFragment && account.richInteractions ? "" : resolvePayloadText(payload);
920
- const urls = resolveOutboundMediaUrls(payload).filter(Boolean);
936
+ const urls = filterReplyMedia(resolveOutboundMediaUrls(payload).filter(Boolean));
921
937
  log?.info?.(
922
938
  `[${account.accountId}] clawchat-plugin-openclaw deliver kind=${info?.kind ?? "unknown"} text_len=${text.length} media_urls=${urls.length} reasoning=${payload.isReasoning === true}`,
923
939
  );
@@ -1021,6 +1037,8 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
1021
1037
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw final skipped: already delivered at a message boundary`);
1022
1038
  return;
1023
1039
  }
1040
+ // A non-owner final whose only content was dropped local media sends nothing.
1041
+ if (options.nonOwnerTurn && !finalText.trim() && !richFragment && finalUrls.length === 0) return;
1024
1042
  const mediaFragments = await uploadMediaUrls(finalUrls);
1025
1043
  const result = await sendStatic(
1026
1044
  finalText,
@@ -1097,7 +1115,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
1097
1115
  ? async (payload: ReplyPayload) => {
1098
1116
  if (consumeTerminalSend("reasoning")) return;
1099
1117
  const text = resolvePayloadText(payload);
1100
- await emitFullRuntimeText("reasoning", text, resolveOutboundMediaUrls(payload).filter(Boolean));
1118
+ await emitFullRuntimeText("reasoning", text, filterReplyMedia(resolveOutboundMediaUrls(payload).filter(Boolean)));
1101
1119
  const trimmed = text.trim();
1102
1120
  if (trimmed) reasoningText = reasoningText ? `${reasoningText}\n${trimmed}` : trimmed;
1103
1121
  }
@@ -1120,7 +1138,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
1120
1138
  if (consumeTerminalSend("tool-result")) return;
1121
1139
  const text = resolvePayloadText(payload);
1122
1140
  if (isDefaultToolResultText(text)) return;
1123
- await emitFullRuntimeText("tool result", text, resolveOutboundMediaUrls(payload).filter(Boolean));
1141
+ await emitFullRuntimeText("tool result", text, filterReplyMedia(resolveOutboundMediaUrls(payload).filter(Boolean)));
1124
1142
  }
1125
1143
  : undefined,
1126
1144
  onItemEvent: splitFullOutput
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; error?: (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
- const resolveOwnerDirectConversationId = (): string | null => {
2556
- const ownerUserId = account.ownerUserId?.trim();
2557
- if (!ownerUserId) return null;
2558
- const conversationId = store
2559
- ?.getActivationConversation?.({ platform: "openclaw", accountId })
2560
- ?.conversationId?.trim();
2561
- return conversationId || null;
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: turnPrompt,
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({ ctx: ctxPayload, cfg: dispatchCfg, dispatcher, replyOptions }),
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 chat_id = ? AND kind = 'message'
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[];