@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.
- package/dist/src/config.js +31 -0
- package/dist/src/dm-recall-default.js +179 -0
- package/dist/src/group-catch-up.js +90 -0
- package/dist/src/group-message-coalescer.js +30 -0
- package/dist/src/note-injection.js +158 -0
- package/dist/src/profile-prompt.js +2 -0
- package/dist/src/runtime.js +205 -29
- package/dist/src/storage.js +2 -2
- package/openclaw.plugin.json +16 -0
- package/package.json +1 -1
- package/src/config.ts +46 -0
- package/src/dm-recall-default.ts +230 -0
- package/src/group-catch-up.ts +112 -0
- package/src/group-message-coalescer.ts +32 -0
- package/src/note-injection.ts +168 -0
- package/src/profile-prompt.ts +3 -0
- package/src/runtime.ts +200 -35
- package/src/storage.ts +2 -2
package/dist/src/runtime.js
CHANGED
|
@@ -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
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
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
|
|
3240
|
-
//
|
|
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`.
|
package/dist/src/storage.js
CHANGED
|
@@ -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
|
|
479
|
-
*
|
|
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
|
package/openclaw.plugin.json
CHANGED
|
@@ -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
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
|
+
}
|