@clawling/clawchat-plugin-openclaw 2026.10.8-1 → 2026.10.9-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { applyDmRecallDefault } from "./dm-recall-default.js";
1
2
  import { AckTimeoutError, AuthError, ProtocolError, StateError, TransportError, EVENT, recallTargetMessageId, } from "./protocol-types.js";
2
3
  import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle";
3
4
  import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
@@ -30,12 +31,14 @@ import { createNotifySignalObserver, createProtocolControlHandler, createReconne
30
31
  import { clawChatDbPathForStateDir, getClawChatStore, makeOwnerProfilePersister, } from "./storage.js";
31
32
  import { getClawChatGroupPrompt, getClawChatUserPrompt } from "./plugin-prompts.js";
32
33
  import { loadClawChatPromptMetadata, renderClawChatProfilePrompt, resolveSenderRelation, } from "./profile-prompt.js";
34
+ import { buildNoteMemorySection } from "./note-injection.js";
35
+ import { buildGroupCatchUp, CATCH_UP_FETCH_ROWS, DeliveredGroupMessages } from "./group-catch-up.js";
33
36
  import { refreshGroupProfile, syncFirstSeenClawChatProfiles } from "./profile-sync.js";
34
37
  import { pullGroupMetadata, pullOwnerMetadata } from "./clawchat-metadata.js";
35
38
  import { deleteClawChatMemoryFile, readClawChatMemoryFile } from "./clawchat-memory.js";
36
39
  import { registerMentionRosterResolver } from "./mention-autolink.js";
37
40
  import { clearClawChatPromptInjectionForSession, stageClawChatPromptInjection, } from "./prompt-injection.js";
38
- import { createGroupMessageCoalescer } from "./group-message-coalescer.js";
41
+ import { createGroupMessageCoalescer, relabelCoalescedGroupBody } from "./group-message-coalescer.js";
39
42
  import { openclawLlmContextDebug } from "./llm-context-debug.js";
40
43
  import { GroupSettingsCache } from "./group-settings.js";
41
44
  import { PermissionCache } from "./permissions.js";
@@ -90,7 +93,6 @@ function activationBootstrapInFlightCount(accountId) {
90
93
  // hermes plugin's GROUP_SETTINGS_READY_TIMEOUT_SECONDS (5s).
91
94
  const GROUP_SETTINGS_READY_TIMEOUT_MS = 5_000;
92
95
  /** Number of recent group messages to prepend as context when the agent is @-mentioned. */
93
- const MENTION_CONTEXT_N = 10;
94
96
  const CLAWCHAT_MEMORY_ROOT_UNAVAILABLE = "ClawChat memory root unavailable: OpenClaw workspaceDir could not be resolved";
95
97
  const OPENCLAW_CONFIRM_SLASH_COMMANDS = new Set([
96
98
  "approve",
@@ -936,6 +938,48 @@ export async function startOpenclawClawlingGateway(params) {
936
938
  ? runtimeConfig.mutateConfigFile
937
939
  : undefined;
938
940
  };
941
+ // OpenClaw >= 2026.8.1 recalls across a single agent's DMs unless DM isolation
942
+ // is visible in the gateway config; ours is in-memory only. Write the agent's
943
+ // `rememberAcrossConversations: false` once, unless the owner set it
944
+ // (dm-recall-default.ts). Fire-and-forget: never blocks or fails startup.
945
+ void (async () => {
946
+ const stateDir = runtime.state?.resolveStateDir?.();
947
+ const mutateConfigFile = resolveMutateConfigFile();
948
+ const readConfig = runtime.config?.current;
949
+ if (!stateDir || !mutateConfigFile || typeof readConfig !== "function")
950
+ return;
951
+ let agentId;
952
+ try {
953
+ agentId = runtime.channel.routing.resolveAgentRoute({
954
+ cfg: withClawChatSessionScope(cfg),
955
+ channel: CHANNEL_ID,
956
+ accountId,
957
+ peer: { kind: "direct", id: account.ownerUserId || "owner" },
958
+ }).agentId;
959
+ }
960
+ catch (err) {
961
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw rememberAcrossConversations default skipped: agent route unavailable (${err instanceof Error ? err.message : String(err)})`);
962
+ return;
963
+ }
964
+ await applyDmRecallDefault({
965
+ agentId,
966
+ hostVersion: runtime.version,
967
+ stateDir,
968
+ readConfig: () => readConfig.call(runtime.config),
969
+ mutateConfigFile: mutateConfigFile,
970
+ log: {
971
+ info: (m) => log?.info?.(`[${accountId}] ${m}`),
972
+ warn: (m) => {
973
+ const warn = log?.warn;
974
+ if (warn)
975
+ warn(`[${accountId}] ${m}`);
976
+ else
977
+ log?.info?.(`[${accountId}] WARNING ${m}`);
978
+ },
979
+ error: (m) => log?.error?.(`[${accountId}] ${m}`),
980
+ },
981
+ });
982
+ })();
939
983
  // §0/§C.1 — write the account slot's `token`/`refreshToken` keys. `tokens=null`
940
984
  // blanks them (auto-logout); otherwise persists the rotated pair.
941
985
  const persistConfigTokens = async (tokens) => {
@@ -2472,12 +2516,27 @@ export async function startOpenclawClawlingGateway(params) {
2472
2516
  ownerMetadata: promptMetadata.ownerMetadata,
2473
2517
  })
2474
2518
  : [];
2519
+ const noteMemorySection = await buildNoteMemorySection({
2520
+ memoryRoot,
2521
+ chatType: promptChatType,
2522
+ chatId: turn.peer.id,
2523
+ senderId: turn.senderId,
2524
+ senderIsOwner: senderRelation === "owner",
2525
+ selfId: account.userId,
2526
+ speakers: turn.peer.kind === "group"
2527
+ ? (turn.groupMessages && turn.groupMessages.length > 0
2528
+ ? turn.groupMessages.map((message) => ({ id: message.senderId, name: message.senderName }))
2529
+ : [{ id: turn.senderId, name: turn.senderNickName }])
2530
+ : [],
2531
+ caps: { user: account.noteCapUser, group: account.noteCapGroup, turn: account.noteCapTurn },
2532
+ });
2475
2533
  return renderClawChatProfilePrompt({
2476
2534
  basePrompt: turn.peer.kind === "group" ? getClawChatGroupPrompt() : getClawChatUserPrompt(),
2477
2535
  ...promptMetadata,
2478
2536
  ownerIdFallback: account.ownerUserId,
2479
2537
  currentAgentIdFallback: account.userId,
2480
2538
  groupParticipants,
2539
+ noteMemorySection,
2481
2540
  turn: {
2482
2541
  chatType: promptChatType,
2483
2542
  chatId: turn.peer.id,
@@ -2521,6 +2580,100 @@ export async function startOpenclawClawlingGateway(params) {
2521
2580
  // coalesced batch's transcript ("Alice:" rather than "usr_…:") without
2522
2581
  // touching disk.
2523
2582
  const knownNicknames = new Map();
2583
+ // Group message ids already handed to the agent, per chat (see group-catch-up.ts).
2584
+ const deliveredGroupMessages = new DeliveredGroupMessages();
2585
+ // One catch-up line: `Name (usr_…): text`, the name from the stored frame's
2586
+ // nick_name unless that is just the id, else the cached nickname.
2587
+ const formatCatchUpLine = (row) => {
2588
+ const text = (row.text ?? "").trim() || "(empty message)";
2589
+ let senderId = "";
2590
+ let nickName = "";
2591
+ try {
2592
+ const frame = row.raw_json ? JSON.parse(row.raw_json) : null;
2593
+ senderId = typeof frame?.sender?.id === "string" ? frame.sender.id : "";
2594
+ nickName = typeof frame?.sender?.nick_name === "string" ? frame.sender.nick_name : "";
2595
+ }
2596
+ catch {
2597
+ // A malformed stored frame still yields its text.
2598
+ }
2599
+ if (!senderId)
2600
+ return text;
2601
+ const name = (nickName && nickName !== senderId ? nickName : knownNicknames.get(senderId)) ?? "";
2602
+ return name ? `${name} (${senderId}): ${text}` : `${senderId}: ${text}`;
2603
+ };
2604
+ // The sender id of a stored row whose frame carried no real nickname (its
2605
+ // nick_name is missing or just the id), else "".
2606
+ const bareSenderIdOfRow = (row) => {
2607
+ try {
2608
+ const frame = row.raw_json
2609
+ ? JSON.parse(row.raw_json)
2610
+ : null;
2611
+ const id = typeof frame?.sender?.id === "string" ? frame.sender.id : "";
2612
+ const nick = typeof frame?.sender?.nick_name === "string" ? frame.sender.nick_name : "";
2613
+ return id && (!nick || nick === id) ? id : "";
2614
+ }
2615
+ catch {
2616
+ return "";
2617
+ }
2618
+ };
2619
+ // Last group-metadata pull made only to resolve names, per chat: a sender who
2620
+ // can never be resolved (deleted account) must not refetch on every batch.
2621
+ const nicknameRefreshAt = new Map();
2622
+ const NICKNAME_REFRESH_COOLDOWN_MS = 60_000;
2623
+ const NICKNAME_REFRESH_TIMEOUT_MS = 3_000;
2624
+ // Fill knownNicknames for `ids` from ClawChat memory; if some are still
2625
+ // missing in a group, pull the group's participants once (bounded, with a
2626
+ // cooldown) and read again. Never throws: a name is a nicety, the turn is not.
2627
+ const warmGroupNicknames = async (peer, ids) => {
2628
+ if (peer.kind !== "group")
2629
+ return;
2630
+ try {
2631
+ const memoryRoot = resolveMemoryRootForPeer(peer);
2632
+ if (!memoryRoot)
2633
+ return;
2634
+ const wanted = Array.from(new Set(Array.from(ids).filter((id) => id && id !== account.userId)));
2635
+ const missing = () => wanted.filter((id) => !knownNicknames.has(id));
2636
+ const readCached = async () => {
2637
+ for (const id of missing()) {
2638
+ const nickname = await cachedNicknameFor(memoryRoot, id);
2639
+ if (nickname && nickname !== id)
2640
+ knownNicknames.set(id, nickname);
2641
+ }
2642
+ };
2643
+ await readCached();
2644
+ if (missing().length === 0)
2645
+ return;
2646
+ const now = Date.now();
2647
+ const last = nicknameRefreshAt.get(peer.id);
2648
+ if (last !== undefined && now - last < NICKNAME_REFRESH_COOLDOWN_MS)
2649
+ return;
2650
+ nicknameRefreshAt.set(peer.id, now);
2651
+ let timer;
2652
+ await Promise.race([
2653
+ refreshGroupProfile({
2654
+ platform: "openclaw",
2655
+ accountId,
2656
+ accountUserId: account.userId,
2657
+ accountOwnerUserId: account.ownerUserId,
2658
+ conversationId: peer.id,
2659
+ api: getConversationApiClient(),
2660
+ store: {},
2661
+ memoryRoot,
2662
+ log: { error: (message) => log?.error?.(`[${accountId}] ${message}`) },
2663
+ }),
2664
+ new Promise((resolve) => {
2665
+ timer = setTimeout(resolve, NICKNAME_REFRESH_TIMEOUT_MS);
2666
+ }),
2667
+ ]).finally(() => {
2668
+ if (timer)
2669
+ clearTimeout(timer);
2670
+ });
2671
+ await readCached();
2672
+ }
2673
+ catch (err) {
2674
+ log?.error?.(`[${accountId}] clawchat-plugin-openclaw group sender name lookup failed: ${err instanceof Error ? err.message : String(err)}`);
2675
+ }
2676
+ };
2524
2677
  const withCachedGroupSenderNames = async (turn, memoryRoot) => {
2525
2678
  if (turn.peer.kind !== "group" || !memoryRoot)
2526
2679
  return turn;
@@ -2926,32 +3079,55 @@ export async function startOpenclawClawlingGateway(params) {
2926
3079
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw coalesced group batch dropped at flush (now mention-only, no mention) chat_id=${turn.peer.id} msg=${turn.messageId}`);
2927
3080
  return;
2928
3081
  }
3082
+ const chatId = turn.peer.id;
3083
+ const batchIds = new Set(turn.allMessageIds ?? [turn.messageId]);
3084
+ const catchUpRows = turn.wasMentioned && store?.listRecentGroupMessages
3085
+ ? store.listRecentGroupMessages("openclaw", accountId, chatId, CATCH_UP_FETCH_ROWS + batchIds.size) ?? []
3086
+ : [];
3087
+ // Ingest labels a batch from the in-memory nickname map, which is empty
3088
+ // after a restart, and catch-up lines read the same map: resolve every
3089
+ // sender named here before the turn goes out.
3090
+ await warmGroupNicknames(turn.peer, [
3091
+ ...(turn.groupMessages ?? [])
3092
+ .filter((message) => !message.senderName || message.senderName === message.senderId)
3093
+ .map((message) => message.senderId),
3094
+ ...(!turn.senderNickName || turn.senderNickName === turn.senderId ? [turn.senderId] : []),
3095
+ ...catchUpRows.map(bareSenderIdOfRow),
3096
+ ]);
3097
+ if (turn.groupMessages?.length) {
3098
+ turn = {
3099
+ ...turn,
3100
+ rawBody: relabelCoalescedGroupBody(turn.rawBody, turn.groupMessages, (id) => knownNicknames.get(id)),
3101
+ groupMessages: turn.groupMessages.map((message) => {
3102
+ const known = message.senderName === message.senderId ? knownNicknames.get(message.senderId) : undefined;
3103
+ return known ? { ...message, senderName: known } : message;
3104
+ }),
3105
+ };
3106
+ }
3107
+ if (turn.senderNickName === turn.senderId && knownNicknames.has(turn.senderId)) {
3108
+ turn = { ...turn, senderNickName: knownNicknames.get(turn.senderId) };
3109
+ }
3110
+ // An @-mention adds the group messages this session has not been given
3111
+ // since its last turn (e.g. ones a mention-only group held back), within
3112
+ // delta-budget-chars. Every batch handed to the agent is recorded as
3113
+ // delivered, so nothing it already has comes back. See group-catch-up.ts.
3114
+ let catchUp = null;
2929
3115
  if (turn.wasMentioned && store?.listRecentGroupMessages) {
2930
- const chatId = turn.peer.id;
2931
- // Dedupe: exclude rows whose message_id belongs to the current batch/turn.
2932
- // For a coalesced multi-message turn this covers every constituent ID,
2933
- // not just the latest one (turn.messageId), so none of the batched
2934
- // messages appear twice — once in the coalesced body and once in the
2935
- // prepended "Prior group context" block.
2936
- const batchIds = new Set(turn.allMessageIds ?? [turn.messageId]);
2937
- // Overfetch so the batch's own just-stored messages don't crowd out the
2938
- // MENTION_CONTEXT_N prior rows: fetch batch-size extra rows, drop the
2939
- // batch members, then keep the most recent MENTION_CONTEXT_N survivors.
2940
- const priorRows = store.listRecentGroupMessages("openclaw", accountId, chatId, MENTION_CONTEXT_N + batchIds.size) ?? [];
2941
- const contextRows = priorRows
2942
- .filter((row) => row.message_id != null && !batchIds.has(row.message_id))
2943
- .slice(-MENTION_CONTEXT_N);
2944
- if (contextRows.length > 0) {
2945
- const priorContextBody = [
2946
- "Prior group context:",
2947
- ...contextRows.map((row, i) => `[prior ${i + 1}] ${row.text ?? "(empty)"}`),
2948
- ].join("\n");
2949
- await dispatchTurnToAgent({
2950
- ...turn,
2951
- rawBody: `${priorContextBody}\n${turn.rawBody}`,
2952
- });
2953
- return;
2954
- }
3116
+ const fetchLimit = CATCH_UP_FETCH_ROWS + batchIds.size;
3117
+ catchUp = buildGroupCatchUp({
3118
+ chatId,
3119
+ rows: catchUpRows,
3120
+ fetchLimit,
3121
+ batchIds,
3122
+ delivered: deliveredGroupMessages,
3123
+ budgetChars: account.deltaBudgetChars,
3124
+ formatLine: formatCatchUpLine,
3125
+ });
3126
+ }
3127
+ deliveredGroupMessages.remember(chatId, batchIds);
3128
+ if (catchUp) {
3129
+ await dispatchTurnToAgent({ ...turn, rawBody: `${catchUp}\n${turn.rawBody}` });
3130
+ return;
2955
3131
  }
2956
3132
  await dispatchTurnToAgent(turn);
2957
3133
  },
@@ -3236,8 +3412,8 @@ export async function startOpenclawClawlingGateway(params) {
3236
3412
  })();
3237
3413
  });
3238
3414
  // `message.recall` — the sender withdrew a message. Erase our own ledger row
3239
- // so the "Prior group context" block above (MENTION_CONTEXT_N rows, no time
3240
- // predicate, no retention) can never read it back into a turn.
3415
+ // so the mention catch-up above (unseen group messages, no time predicate,
3416
+ // no retention) can never read it back into a turn.
3241
3417
  //
3242
3418
  // Ids only: the frame carries no content, and `payload.message_id` is the
3243
3419
  // server's `rcl:<target>` slot — the id to delete is `target_message_id`.
@@ -475,8 +475,8 @@ export class ClawChatStore {
475
475
  *
476
476
  * **The only DELETE in this store, and deliberately so.** There is no prune
477
477
  * and no retention on `clawchat_messages`: without this, a message recalled
478
- * ninety seconds after it was sent stays one @-mention away from being read
479
- * back aloud under "Prior group context" for as long as the group is quiet.
478
+ * ninety seconds after it was sent, before the agent was given it, stays
479
+ * one @-mention away from being read back in the mention catch-up.
480
480
  *
481
481
  * Scoped to `(platform, account_id)` so one account's recall cannot reach
482
482
  * another's rows, and matched on `message_id`, which seeks via the
@@ -128,6 +128,10 @@
128
128
  "awarenessNote": { "type": "boolean" },
129
129
  "friendGreeting": { "type": "boolean" },
130
130
  "livewareSample": { "type": "boolean" },
131
+ "note-cap-user": { "type": "integer" },
132
+ "note-cap-group": { "type": "integer" },
133
+ "note-cap-turn": { "type": "integer" },
134
+ "delta-budget-chars": { "type": "integer" },
131
135
  "reconnect": {
132
136
  "type": "object",
133
137
  "additionalProperties": false,
@@ -202,6 +206,10 @@
202
206
  "awarenessNote": { "type": "boolean" },
203
207
  "friendGreeting": { "type": "boolean" },
204
208
  "livewareSample": { "type": "boolean" },
209
+ "note-cap-user": { "type": "integer" },
210
+ "note-cap-group": { "type": "integer" },
211
+ "note-cap-turn": { "type": "integer" },
212
+ "delta-budget-chars": { "type": "integer" },
205
213
  "reconnect": {
206
214
  "type": "object",
207
215
  "additionalProperties": false,
@@ -282,6 +290,10 @@
282
290
  "awarenessNote": { "type": "boolean" },
283
291
  "friendGreeting": { "type": "boolean" },
284
292
  "livewareSample": { "type": "boolean" },
293
+ "note-cap-user": { "type": "integer" },
294
+ "note-cap-group": { "type": "integer" },
295
+ "note-cap-turn": { "type": "integer" },
296
+ "delta-budget-chars": { "type": "integer" },
285
297
  "reconnect": {
286
298
  "type": "object",
287
299
  "additionalProperties": false,
@@ -356,6 +368,10 @@
356
368
  "awarenessNote": { "type": "boolean" },
357
369
  "friendGreeting": { "type": "boolean" },
358
370
  "livewareSample": { "type": "boolean" },
371
+ "note-cap-user": { "type": "integer" },
372
+ "note-cap-group": { "type": "integer" },
373
+ "note-cap-turn": { "type": "integer" },
374
+ "delta-budget-chars": { "type": "integer" },
359
375
  "reconnect": {
360
376
  "type": "object",
361
377
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.10.8-1",
3
+ "version": "2026.10.9-1",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
package/src/config.ts CHANGED
@@ -120,6 +120,11 @@ export type OpenclawClawlingAccountConfig = {
120
120
  friendGreeting?: boolean;
121
121
  /** Auto-install the Liveware Sample demo app when no liveware app is registered (default true). */
122
122
  livewareSample?: boolean;
123
+ /** Shared ClawChat session / note keys (hyphenated; see SESSION_INT_KEYS). */
124
+ "note-cap-user"?: number;
125
+ "note-cap-group"?: number;
126
+ "note-cap-turn"?: number;
127
+ "delta-budget-chars"?: number;
123
128
  reconnect?: OpenclawClawlingReconnectConfig;
124
129
  heartbeat?: OpenclawClawlingHeartbeatConfig;
125
130
  ack?: OpenclawClawlingAckConfig;
@@ -181,6 +186,10 @@ export const openclawClawlingAccountConfigSchema = {
181
186
  awarenessNote: { type: "boolean" },
182
187
  friendGreeting: { type: "boolean" },
183
188
  livewareSample: { type: "boolean" },
189
+ "note-cap-user": { type: "integer" },
190
+ "note-cap-group": { type: "integer" },
191
+ "note-cap-turn": { type: "integer" },
192
+ "delta-budget-chars": { type: "integer" },
184
193
  reconnect: {
185
194
  type: "object",
186
195
  additionalProperties: false,
@@ -320,6 +329,14 @@ export type ResolvedOpenclawClawlingAccount = {
320
329
  awarenessNote: boolean;
321
330
  friendGreeting: boolean;
322
331
  livewareSample: boolean;
332
+ /** `note-cap-user`: characters of one person's note shown in a turn. */
333
+ noteCapUser: number;
334
+ /** `note-cap-group`: characters of the group's note shown in a group turn. */
335
+ noteCapGroup: number;
336
+ /** `note-cap-turn`: characters of all notes shown in one turn together. */
337
+ noteCapTurn: number;
338
+ /** `delta-budget-chars`: characters of unseen group messages an @-mention adds. */
339
+ deltaBudgetChars: number;
323
340
  allowFrom: string[];
324
341
  reconnect: Required<OpenclawClawlingReconnectConfig>;
325
342
  heartbeat: Required<OpenclawClawlingHeartbeatConfig>;
@@ -564,6 +581,31 @@ export function effectiveOutputVisibility(
564
581
  ?? "normal";
565
582
  }
566
583
 
584
+ /**
585
+ * Session / note keys shared with the other ClawChat agent channels (same
586
+ * hyphenated names, meaning and factory values as the Hermes plugin's
587
+ * `SESSION_INT_KEYS`). An out-of-range value is clamped to the nearest bound,
588
+ * a non-number falls back to the factory value. Every key here has a reader;
589
+ * the shared keys this plugin has no use for are listed in
590
+ * docs/clawchat-plugin-openclaw.md with the reason.
591
+ * key -> [factory, minimum, maximum]
592
+ */
593
+ export const SESSION_INT_KEYS = {
594
+ "note-cap-user": [1500, 300, 6000],
595
+ "note-cap-group": [2000, 300, 8000],
596
+ "note-cap-turn": [4000, 1000, 16000],
597
+ "delta-budget-chars": [8000, 2000, 32000],
598
+ } as const satisfies Record<string, readonly [number, number, number]>;
599
+
600
+ function readSessionInt(channel: Record<string, unknown>, key: keyof typeof SESSION_INT_KEYS): number {
601
+ const [factory, minimum, maximum] = SESSION_INT_KEYS[key];
602
+ const raw = channel[key];
603
+ const parsed =
604
+ typeof raw === "number" ? raw : typeof raw === "string" && raw.trim() ? Number(raw.trim()) : Number.NaN;
605
+ if (!Number.isFinite(parsed)) return factory;
606
+ return Math.max(minimum, Math.min(maximum, Math.trunc(parsed)));
607
+ }
608
+
567
609
  function readReconnect(raw: unknown): Required<OpenclawClawlingReconnectConfig> {
568
610
  const s = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
569
611
  return {
@@ -693,6 +735,10 @@ export function resolveOpenclawClawlingAccount(
693
735
  awarenessNote,
694
736
  friendGreeting,
695
737
  livewareSample,
738
+ noteCapUser: readSessionInt(channel, "note-cap-user"),
739
+ noteCapGroup: readSessionInt(channel, "note-cap-group"),
740
+ noteCapTurn: readSessionInt(channel, "note-cap-turn"),
741
+ deltaBudgetChars: readSessionInt(channel, "delta-budget-chars"),
696
742
  allowFrom: [],
697
743
  reconnect: readReconnect(channel.reconnect),
698
744
  heartbeat: readHeartbeat(channel.heartbeat),
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Turn off OpenClaw's cross-conversation recall for the agent ClawChat routes
3
+ * to, once, unless the owner already decided.
4
+ *
5
+ * OpenClaw >= 2026.8.1 has `memory.search.rememberAcrossConversations`. When it
6
+ * is not written anywhere, OpenClaw turns it on unless `session.dmScope` or a
7
+ * binding isolates DMs (`resolveRememberAcrossConversations`,
8
+ * packages/memory-host-sdk/src/host/config-utils.ts). The plugin isolates
9
+ * ClawChat DMs only in memory (`withClawChatSessionScope`), which OpenClaw
10
+ * cannot see, so recall stays on and a friend's DM can recall the owner's.
11
+ * Active Memory reads the live gateway config each turn, so a per-turn
12
+ * override does not reach it: the setting has to be in the config file.
13
+ *
14
+ * Where it is read (2026.8.1 / 2026.9.x): the agent's `agents.entries.<id>`
15
+ * or, when the roster is the legacy array, its `agents.list[]` entry (matched
16
+ * by `id`); then the global `memory.search`. With no roster in the file there
17
+ * is one implicit agent, so the global layer IS that agent's.
18
+ *
19
+ * Rules (relay openclaw-dm-recall, 10-08):
20
+ * - only on hosts >= 2026.8.1 (older ones have neither the key nor the recall);
21
+ * - only when neither layer sets it; an explicit `true` is the owner's choice:
22
+ * kept, warned once; an explicit `false` is kept silently;
23
+ * - only once per agent: a marker in the plugin's state dir records it, so
24
+ * whatever the owner changes later is never touched again.
25
+ */
26
+ import fs from "node:fs";
27
+ import path from "node:path";
28
+
29
+ export const DM_RECALL_MARKER_FILENAME = "clawchat-dm-recall-default.json";
30
+ export const DM_RECALL_MIN_HOST: readonly [number, number, number] = [2026, 8, 1];
31
+
32
+ export type DmRecallOutcome =
33
+ | "wrote-false"
34
+ | "kept-true"
35
+ | "kept-false"
36
+ | "already-handled"
37
+ | "unsupported-host"
38
+ | "agent-not-in-roster"
39
+ | "write-failed";
40
+
41
+ type Rec = Record<string, unknown>;
42
+
43
+ type MutateConfigFile = (params: {
44
+ afterWrite: { mode: "none"; reason: string };
45
+ mutate: (draft: Rec) => void;
46
+ }) => Promise<unknown>;
47
+
48
+ type Log = {
49
+ info?: (msg: string) => void;
50
+ warn?: (msg: string) => void;
51
+ error?: (msg: string) => void;
52
+ };
53
+
54
+ function isRec(value: unknown): value is Rec {
55
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
56
+ }
57
+
58
+ function normalizeId(id: unknown): string {
59
+ return typeof id === "string" ? id.trim().toLowerCase() : "";
60
+ }
61
+
62
+ /** True for a release host >= 2026.8.1. Pre-releases and unknown strings are false. */
63
+ export function hostHasRememberAcrossConversations(version: string | undefined | null): boolean {
64
+ const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec((version ?? "").trim());
65
+ if (!m) return false;
66
+ const parts = [Number(m[1]), Number(m[2]), Number(m[3])];
67
+ for (let i = 0; i < 3; i += 1) {
68
+ if (parts[i] !== DM_RECALL_MIN_HOST[i]) return parts[i] > DM_RECALL_MIN_HOST[i];
69
+ }
70
+ return true;
71
+ }
72
+
73
+ type AgentSlot =
74
+ | { kind: "entries"; key: string; entry: Rec }
75
+ | { kind: "list"; index: number; entry: Rec }
76
+ | { kind: "no-roster" }
77
+ | { kind: "missing" };
78
+
79
+ function findAgentSlot(cfg: Rec, agentId: string): AgentSlot {
80
+ const agents = isRec(cfg.agents) ? cfg.agents : undefined;
81
+ const id = normalizeId(agentId);
82
+ if (agents && Object.hasOwn(agents, "entries") && agents.entries !== undefined) {
83
+ if (!isRec(agents.entries)) return { kind: "missing" };
84
+ for (const key of Object.keys(agents.entries)) {
85
+ const entry = agents.entries[key];
86
+ if (normalizeId(key) === id && isRec(entry)) return { kind: "entries", key, entry };
87
+ }
88
+ return { kind: "missing" };
89
+ }
90
+ if (agents && Object.hasOwn(agents, "list") && agents.list !== undefined) {
91
+ if (!Array.isArray(agents.list)) return { kind: "missing" };
92
+ const index = agents.list.findIndex((e) => isRec(e) && normalizeId(e.id) === id);
93
+ return index >= 0 ? { kind: "list", index, entry: agents.list[index] as Rec } : { kind: "missing" };
94
+ }
95
+ return { kind: "no-roster" };
96
+ }
97
+
98
+ function readFlag(holder: unknown): boolean | undefined {
99
+ if (!isRec(holder) || !isRec(holder.memory) || !isRec(holder.memory.search)) return undefined;
100
+ const v = holder.memory.search.rememberAcrossConversations;
101
+ return typeof v === "boolean" ? v : undefined;
102
+ }
103
+
104
+ /** The value written at each layer, `undefined` where it is not written. */
105
+ export function readRememberAcrossConversations(
106
+ cfg: Rec,
107
+ agentId: string,
108
+ ): { agent: boolean | undefined; global: boolean | undefined } {
109
+ const slot = findAgentSlot(cfg, agentId);
110
+ const agent = slot.kind === "entries" || slot.kind === "list" ? readFlag(slot.entry) : undefined;
111
+ return { agent, global: readFlag(cfg) };
112
+ }
113
+
114
+ function setFlagFalse(holder: Rec): void {
115
+ const memory = isRec(holder.memory) ? holder.memory : {};
116
+ const search = isRec(memory.search) ? memory.search : {};
117
+ holder.memory = { ...memory, search: { ...search, rememberAcrossConversations: false } };
118
+ }
119
+
120
+ /**
121
+ * Write `rememberAcrossConversations: false` at the agent's own layer of
122
+ * `cfg` (mutated in place). Returns the dotted path written, or `null` when a
123
+ * roster exists but does not list the agent (nothing written).
124
+ */
125
+ export function setAgentRememberAcrossConversationsFalse(cfg: Rec, agentId: string): string | null {
126
+ const slot = findAgentSlot(cfg, agentId);
127
+ if (slot.kind === "entries") {
128
+ setFlagFalse(slot.entry);
129
+ return `agents.entries.${slot.key}.memory.search`;
130
+ }
131
+ if (slot.kind === "list") {
132
+ setFlagFalse(slot.entry);
133
+ return `agents.list[${slot.index}].memory.search`;
134
+ }
135
+ if (slot.kind === "no-roster") {
136
+ setFlagFalse(cfg);
137
+ return "memory.search";
138
+ }
139
+ return null;
140
+ }
141
+
142
+ type Marker = { agents?: Record<string, { outcome: DmRecallOutcome; at: string }> };
143
+
144
+ function readMarker(file: string): Marker {
145
+ try {
146
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
147
+ return isRec(parsed) ? (parsed as Marker) : {};
148
+ } catch {
149
+ return {};
150
+ }
151
+ }
152
+
153
+ function writeMarker(file: string, agentId: string, outcome: DmRecallOutcome): void {
154
+ const marker = readMarker(file);
155
+ const agents = isRec(marker.agents) ? marker.agents : {};
156
+ agents[normalizeId(agentId)] = { outcome, at: new Date().toISOString() };
157
+ fs.mkdirSync(path.dirname(file), { recursive: true });
158
+ fs.writeFileSync(file, `${JSON.stringify({ ...marker, agents }, null, 2)}\n`);
159
+ }
160
+
161
+ /** Apply the one-time default. Never throws. */
162
+ export async function applyDmRecallDefault(params: {
163
+ agentId: string;
164
+ hostVersion: string | undefined | null;
165
+ stateDir: string;
166
+ readConfig: () => unknown;
167
+ mutateConfigFile: MutateConfigFile;
168
+ log?: Log;
169
+ }): Promise<DmRecallOutcome> {
170
+ const { agentId, log } = params;
171
+ const markerFile = path.join(params.stateDir, DM_RECALL_MARKER_FILENAME);
172
+ try {
173
+ if (readMarker(markerFile).agents?.[normalizeId(agentId)]) return "already-handled";
174
+ if (!hostHasRememberAcrossConversations(params.hostVersion)) return "unsupported-host";
175
+
176
+ const cfg = params.readConfig();
177
+ const current = readRememberAcrossConversations(isRec(cfg) ? cfg : {}, agentId);
178
+ const explicit = current.agent ?? current.global;
179
+ if (explicit === true) {
180
+ log?.warn?.(
181
+ `clawchat-plugin-openclaw: memory.search.rememberAcrossConversations is true for agent '${agentId}', so a ` +
182
+ "friend's ClawChat direct chat can recall what the owner said in theirs. Kept as set. To turn it off: " +
183
+ `openclaw config set memory.search.rememberAcrossConversations false (or set it to false on agent '${agentId}'), then restart the gateway.`,
184
+ );
185
+ writeMarker(markerFile, agentId, "kept-true");
186
+ return "kept-true";
187
+ }
188
+ if (explicit === false) {
189
+ writeMarker(markerFile, agentId, "kept-false");
190
+ return "kept-false";
191
+ }
192
+
193
+ if (findAgentSlot(isRec(cfg) ? cfg : {}, agentId).kind === "missing") {
194
+ log?.info?.(
195
+ `clawchat-plugin-openclaw: agent '${agentId}' is not in the agents roster; rememberAcrossConversations left unset`,
196
+ );
197
+ return "agent-not-in-roster";
198
+ }
199
+
200
+ let written: string | null = null;
201
+ let raced: boolean | undefined;
202
+ await params.mutateConfigFile({
203
+ afterWrite: { mode: "none", reason: "clawchat-plugin-openclaw: no recall across ClawChat direct chats" },
204
+ mutate(draft) {
205
+ // Re-check on the locked, current file: someone may have set it meanwhile.
206
+ const now = readRememberAcrossConversations(draft, agentId);
207
+ raced = now.agent ?? now.global;
208
+ if (raced !== undefined) return;
209
+ written = setAgentRememberAcrossConversationsFalse(draft, agentId);
210
+ },
211
+ });
212
+ if (written === null) {
213
+ if (raced === undefined) return "agent-not-in-roster";
214
+ const outcome: DmRecallOutcome = raced ? "kept-true" : "kept-false";
215
+ writeMarker(markerFile, agentId, outcome);
216
+ return outcome;
217
+ }
218
+ writeMarker(markerFile, agentId, "wrote-false");
219
+ log?.info?.(
220
+ `clawchat-plugin-openclaw: set ${written}.rememberAcrossConversations=false for agent '${agentId}' ` +
221
+ "(ClawChat direct chats with different people no longer recall each other)",
222
+ );
223
+ return "wrote-false";
224
+ } catch (err) {
225
+ log?.error?.(
226
+ `clawchat-plugin-openclaw: could not apply the rememberAcrossConversations default for agent '${agentId}': ${err instanceof Error ? err.message : String(err)}`,
227
+ );
228
+ return "write-failed";
229
+ }
230
+ }