@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.
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
- if (opts?.name) registeredToolNames.push(opts.name);
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
- return jsonResponse(
669
- await searchClawChatMemory(root, {
670
- query: p.query,
671
- targetTypes: p.targetTypes,
672
- maxResults: p.maxResults,
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, targetFromParams(p));
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
- await writeClawChatMemoryBody(root, targetFromParams(p), p.mode, p.content);
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
- await editClawChatMemoryBody(root, targetFromParams(p), p.oldText, p.newText);
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
- "Omit targetMessageId and chatId to react to the message that triggered the current turn; pass targetMessageId to react to an earlier message. " +
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
+ }