@clawling/clawchat-plugin-openclaw 2026.10.7-2 → 2026.10.8-2
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/clawchat-metadata.js +10 -2
- package/dist/src/config.js +31 -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/reply-dispatcher.js +80 -8
- package/dist/src/reply-stream.js +14 -12
- package/dist/src/runtime.js +194 -32
- package/dist/src/storage.js +2 -2
- package/openclaw.plugin.json +16 -0
- package/package.json +1 -1
- package/src/clawchat-metadata.ts +9 -2
- package/src/config.ts +46 -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/reply-dispatcher.ts +81 -7
- package/src/reply-stream.ts +20 -15
- package/src/runtime.ts +186 -39
- package/src/storage.ts +2 -2
package/src/runtime.ts
CHANGED
|
@@ -93,6 +93,8 @@ import {
|
|
|
93
93
|
renderClawChatProfilePrompt,
|
|
94
94
|
resolveSenderRelation,
|
|
95
95
|
} from "./profile-prompt.ts";
|
|
96
|
+
import { buildNoteMemorySection } from "./note-injection.ts";
|
|
97
|
+
import { buildGroupCatchUp, CATCH_UP_FETCH_ROWS, DeliveredGroupMessages } from "./group-catch-up.ts";
|
|
96
98
|
import { refreshGroupProfile, syncFirstSeenClawChatProfiles } from "./profile-sync.ts";
|
|
97
99
|
import { pullGroupMetadata, pullOwnerMetadata } from "./clawchat-metadata.ts";
|
|
98
100
|
import { deleteClawChatMemoryFile, readClawChatMemoryFile } from "./clawchat-memory.ts";
|
|
@@ -101,7 +103,7 @@ import {
|
|
|
101
103
|
clearClawChatPromptInjectionForSession,
|
|
102
104
|
stageClawChatPromptInjection,
|
|
103
105
|
} from "./prompt-injection.ts";
|
|
104
|
-
import { createGroupMessageCoalescer } from "./group-message-coalescer.ts";
|
|
106
|
+
import { createGroupMessageCoalescer, relabelCoalescedGroupBody } from "./group-message-coalescer.ts";
|
|
105
107
|
import { openclawLlmContextDebug } from "./llm-context-debug.ts";
|
|
106
108
|
import { GroupSettingsCache } from "./group-settings.ts";
|
|
107
109
|
import { PermissionCache } from "./permissions.ts";
|
|
@@ -198,7 +200,6 @@ function activationBootstrapInFlightCount(accountId: string): number {
|
|
|
198
200
|
// hermes plugin's GROUP_SETTINGS_READY_TIMEOUT_SECONDS (5s).
|
|
199
201
|
const GROUP_SETTINGS_READY_TIMEOUT_MS = 5_000;
|
|
200
202
|
/** Number of recent group messages to prepend as context when the agent is @-mentioned. */
|
|
201
|
-
const MENTION_CONTEXT_N = 10;
|
|
202
203
|
const CLAWCHAT_MEMORY_ROOT_UNAVAILABLE =
|
|
203
204
|
"ClawChat memory root unavailable: OpenClaw workspaceDir could not be resolved";
|
|
204
205
|
const OPENCLAW_CONFIRM_SLASH_COMMANDS = new Set([
|
|
@@ -2968,12 +2969,27 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
2968
2969
|
ownerMetadata: promptMetadata.ownerMetadata,
|
|
2969
2970
|
})
|
|
2970
2971
|
: [];
|
|
2972
|
+
const noteMemorySection = await buildNoteMemorySection({
|
|
2973
|
+
memoryRoot,
|
|
2974
|
+
chatType: promptChatType,
|
|
2975
|
+
chatId: turn.peer.id,
|
|
2976
|
+
senderId: turn.senderId,
|
|
2977
|
+
senderIsOwner: senderRelation === "owner",
|
|
2978
|
+
selfId: account.userId,
|
|
2979
|
+
speakers: turn.peer.kind === "group"
|
|
2980
|
+
? (turn.groupMessages && turn.groupMessages.length > 0
|
|
2981
|
+
? turn.groupMessages.map((message) => ({ id: message.senderId, name: message.senderName }))
|
|
2982
|
+
: [{ id: turn.senderId, name: turn.senderNickName }])
|
|
2983
|
+
: [],
|
|
2984
|
+
caps: { user: account.noteCapUser, group: account.noteCapGroup, turn: account.noteCapTurn },
|
|
2985
|
+
});
|
|
2971
2986
|
return renderClawChatProfilePrompt({
|
|
2972
2987
|
basePrompt: turn.peer.kind === "group" ? getClawChatGroupPrompt() : getClawChatUserPrompt(),
|
|
2973
2988
|
...promptMetadata,
|
|
2974
2989
|
ownerIdFallback: account.ownerUserId,
|
|
2975
2990
|
currentAgentIdFallback: account.userId,
|
|
2976
2991
|
groupParticipants,
|
|
2992
|
+
noteMemorySection,
|
|
2977
2993
|
turn: {
|
|
2978
2994
|
chatType: promptChatType,
|
|
2979
2995
|
chatId: turn.peer.id,
|
|
@@ -3017,6 +3033,96 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3017
3033
|
// coalesced batch's transcript ("Alice:" rather than "usr_…:") without
|
|
3018
3034
|
// touching disk.
|
|
3019
3035
|
const knownNicknames = new Map<string, string>();
|
|
3036
|
+
// Group message ids already handed to the agent, per chat (see group-catch-up.ts).
|
|
3037
|
+
const deliveredGroupMessages = new DeliveredGroupMessages();
|
|
3038
|
+
// One catch-up line: `Name (usr_…): text`, the name from the stored frame's
|
|
3039
|
+
// nick_name unless that is just the id, else the cached nickname.
|
|
3040
|
+
const formatCatchUpLine = (row: MessageRow): string => {
|
|
3041
|
+
const text = (row.text ?? "").trim() || "(empty message)";
|
|
3042
|
+
let senderId = "";
|
|
3043
|
+
let nickName = "";
|
|
3044
|
+
try {
|
|
3045
|
+
const frame = row.raw_json ? (JSON.parse(row.raw_json) as { sender?: { id?: unknown; nick_name?: unknown } }) : null;
|
|
3046
|
+
senderId = typeof frame?.sender?.id === "string" ? frame.sender.id : "";
|
|
3047
|
+
nickName = typeof frame?.sender?.nick_name === "string" ? frame.sender.nick_name : "";
|
|
3048
|
+
} catch {
|
|
3049
|
+
// A malformed stored frame still yields its text.
|
|
3050
|
+
}
|
|
3051
|
+
if (!senderId) return text;
|
|
3052
|
+
const name = (nickName && nickName !== senderId ? nickName : knownNicknames.get(senderId)) ?? "";
|
|
3053
|
+
return name ? `${name} (${senderId}): ${text}` : `${senderId}: ${text}`;
|
|
3054
|
+
};
|
|
3055
|
+
|
|
3056
|
+
// The sender id of a stored row whose frame carried no real nickname (its
|
|
3057
|
+
// nick_name is missing or just the id), else "".
|
|
3058
|
+
const bareSenderIdOfRow = (row: MessageRow): string => {
|
|
3059
|
+
try {
|
|
3060
|
+
const frame = row.raw_json
|
|
3061
|
+
? (JSON.parse(row.raw_json) as { sender?: { id?: unknown; nick_name?: unknown } })
|
|
3062
|
+
: null;
|
|
3063
|
+
const id = typeof frame?.sender?.id === "string" ? frame.sender.id : "";
|
|
3064
|
+
const nick = typeof frame?.sender?.nick_name === "string" ? frame.sender.nick_name : "";
|
|
3065
|
+
return id && (!nick || nick === id) ? id : "";
|
|
3066
|
+
} catch {
|
|
3067
|
+
return "";
|
|
3068
|
+
}
|
|
3069
|
+
};
|
|
3070
|
+
// Last group-metadata pull made only to resolve names, per chat: a sender who
|
|
3071
|
+
// can never be resolved (deleted account) must not refetch on every batch.
|
|
3072
|
+
const nicknameRefreshAt = new Map<string, number>();
|
|
3073
|
+
const NICKNAME_REFRESH_COOLDOWN_MS = 60_000;
|
|
3074
|
+
const NICKNAME_REFRESH_TIMEOUT_MS = 3_000;
|
|
3075
|
+
// Fill knownNicknames for `ids` from ClawChat memory; if some are still
|
|
3076
|
+
// missing in a group, pull the group's participants once (bounded, with a
|
|
3077
|
+
// cooldown) and read again. Never throws: a name is a nicety, the turn is not.
|
|
3078
|
+
const warmGroupNicknames = async (
|
|
3079
|
+
peer: { kind: "direct" | "group"; id: string },
|
|
3080
|
+
ids: Iterable<string>,
|
|
3081
|
+
): Promise<void> => {
|
|
3082
|
+
if (peer.kind !== "group") return;
|
|
3083
|
+
try {
|
|
3084
|
+
const memoryRoot = resolveMemoryRootForPeer(peer);
|
|
3085
|
+
if (!memoryRoot) return;
|
|
3086
|
+
const wanted = Array.from(new Set(Array.from(ids).filter((id) => id && id !== account.userId)));
|
|
3087
|
+
const missing = () => wanted.filter((id) => !knownNicknames.has(id));
|
|
3088
|
+
const readCached = async () => {
|
|
3089
|
+
for (const id of missing()) {
|
|
3090
|
+
const nickname = await cachedNicknameFor(memoryRoot, id);
|
|
3091
|
+
if (nickname && nickname !== id) knownNicknames.set(id, nickname);
|
|
3092
|
+
}
|
|
3093
|
+
};
|
|
3094
|
+
await readCached();
|
|
3095
|
+
if (missing().length === 0) return;
|
|
3096
|
+
const now = Date.now();
|
|
3097
|
+
const last = nicknameRefreshAt.get(peer.id);
|
|
3098
|
+
if (last !== undefined && now - last < NICKNAME_REFRESH_COOLDOWN_MS) return;
|
|
3099
|
+
nicknameRefreshAt.set(peer.id, now);
|
|
3100
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
3101
|
+
await Promise.race([
|
|
3102
|
+
refreshGroupProfile({
|
|
3103
|
+
platform: "openclaw",
|
|
3104
|
+
accountId,
|
|
3105
|
+
accountUserId: account.userId,
|
|
3106
|
+
accountOwnerUserId: account.ownerUserId,
|
|
3107
|
+
conversationId: peer.id,
|
|
3108
|
+
api: getConversationApiClient(),
|
|
3109
|
+
store: {},
|
|
3110
|
+
memoryRoot,
|
|
3111
|
+
log: { error: (message) => log?.error?.(`[${accountId}] ${message}`) },
|
|
3112
|
+
}),
|
|
3113
|
+
new Promise<void>((resolve) => {
|
|
3114
|
+
timer = setTimeout(resolve, NICKNAME_REFRESH_TIMEOUT_MS);
|
|
3115
|
+
}),
|
|
3116
|
+
]).finally(() => {
|
|
3117
|
+
if (timer) clearTimeout(timer);
|
|
3118
|
+
});
|
|
3119
|
+
await readCached();
|
|
3120
|
+
} catch (err) {
|
|
3121
|
+
log?.error?.(
|
|
3122
|
+
`[${accountId}] clawchat-plugin-openclaw group sender name lookup failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
3123
|
+
);
|
|
3124
|
+
}
|
|
3125
|
+
};
|
|
3020
3126
|
|
|
3021
3127
|
const withCachedGroupSenderNames = async <T extends IngestTurnParams>(
|
|
3022
3128
|
turn: T,
|
|
@@ -3048,12 +3154,36 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3048
3154
|
const senderNickName = isBareId(turn.senderNickName, turn.senderId)
|
|
3049
3155
|
? (await lookup(turn.senderId)) ?? turn.senderNickName
|
|
3050
3156
|
: turn.senderNickName;
|
|
3157
|
+
// The wire carries no sender type, so a batch message from another agent
|
|
3158
|
+
// (Hermes, a second OpenClaw…) would read as a person. users/<id>.md holds
|
|
3159
|
+
// the backend's user type; this agent is always an agent.
|
|
3160
|
+
const typeCache = new Map<string, string | null>();
|
|
3161
|
+
const lookupProfileType = async (userId: string): Promise<string | null> => {
|
|
3162
|
+
if (userId === account.userId) return "agent";
|
|
3163
|
+
if (!typeCache.has(userId)) {
|
|
3164
|
+
let profileType: string | null = null;
|
|
3165
|
+
try {
|
|
3166
|
+
const userFile = await readClawChatMemoryFile(memoryRoot, { targetType: "user", targetId: userId });
|
|
3167
|
+
profileType = userFile.exists && userFile.metadata.profile_type ? userFile.metadata.profile_type : null;
|
|
3168
|
+
} catch {
|
|
3169
|
+
profileType = null;
|
|
3170
|
+
}
|
|
3171
|
+
typeCache.set(userId, profileType);
|
|
3172
|
+
}
|
|
3173
|
+
return typeCache.get(userId) ?? null;
|
|
3174
|
+
};
|
|
3051
3175
|
const groupMessages = turn.groupMessages
|
|
3052
|
-
? await Promise.all(turn.groupMessages.map(async (message) =>
|
|
3053
|
-
isBareId(message.senderName, message.senderId)
|
|
3176
|
+
? await Promise.all(turn.groupMessages.map(async (message) => {
|
|
3177
|
+
const named = isBareId(message.senderName, message.senderId)
|
|
3054
3178
|
? { ...message, senderName: (await lookup(message.senderId)) ?? message.senderName }
|
|
3055
|
-
: message
|
|
3056
|
-
|
|
3179
|
+
: message;
|
|
3180
|
+
const profileType = await lookupProfileType(message.senderId);
|
|
3181
|
+
if (!profileType || profileType === named.senderProfileType) return named;
|
|
3182
|
+
const senderRelation = profileType === "agent" && named.senderRelation === "peer_user"
|
|
3183
|
+
? "peer_agent" as const
|
|
3184
|
+
: named.senderRelation;
|
|
3185
|
+
return { ...named, senderProfileType: profileType, senderRelation };
|
|
3186
|
+
}))
|
|
3057
3187
|
: turn.groupMessages;
|
|
3058
3188
|
return { ...turn, senderNickName, ...(groupMessages ? { groupMessages } : {}) };
|
|
3059
3189
|
};
|
|
@@ -3440,38 +3570,55 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3440
3570
|
);
|
|
3441
3571
|
return;
|
|
3442
3572
|
}
|
|
3573
|
+
const chatId = turn.peer.id;
|
|
3574
|
+
const batchIds = new Set(turn.allMessageIds ?? [turn.messageId]);
|
|
3575
|
+
const catchUpRows = turn.wasMentioned && store?.listRecentGroupMessages
|
|
3576
|
+
? store.listRecentGroupMessages("openclaw", accountId, chatId, CATCH_UP_FETCH_ROWS + batchIds.size) ?? []
|
|
3577
|
+
: [];
|
|
3578
|
+
// Ingest labels a batch from the in-memory nickname map, which is empty
|
|
3579
|
+
// after a restart, and catch-up lines read the same map: resolve every
|
|
3580
|
+
// sender named here before the turn goes out.
|
|
3581
|
+
await warmGroupNicknames(turn.peer, [
|
|
3582
|
+
...(turn.groupMessages ?? [])
|
|
3583
|
+
.filter((message) => !message.senderName || message.senderName === message.senderId)
|
|
3584
|
+
.map((message) => message.senderId),
|
|
3585
|
+
...(!turn.senderNickName || turn.senderNickName === turn.senderId ? [turn.senderId] : []),
|
|
3586
|
+
...catchUpRows.map(bareSenderIdOfRow),
|
|
3587
|
+
]);
|
|
3588
|
+
if (turn.groupMessages?.length) {
|
|
3589
|
+
turn = {
|
|
3590
|
+
...turn,
|
|
3591
|
+
rawBody: relabelCoalescedGroupBody(turn.rawBody, turn.groupMessages, (id) => knownNicknames.get(id)),
|
|
3592
|
+
groupMessages: turn.groupMessages.map((message) => {
|
|
3593
|
+
const known = message.senderName === message.senderId ? knownNicknames.get(message.senderId) : undefined;
|
|
3594
|
+
return known ? { ...message, senderName: known } : message;
|
|
3595
|
+
}),
|
|
3596
|
+
};
|
|
3597
|
+
}
|
|
3598
|
+
if (turn.senderNickName === turn.senderId && knownNicknames.has(turn.senderId)) {
|
|
3599
|
+
turn = { ...turn, senderNickName: knownNicknames.get(turn.senderId)! };
|
|
3600
|
+
}
|
|
3601
|
+
// An @-mention adds the group messages this session has not been given
|
|
3602
|
+
// since its last turn (e.g. ones a mention-only group held back), within
|
|
3603
|
+
// delta-budget-chars. Every batch handed to the agent is recorded as
|
|
3604
|
+
// delivered, so nothing it already has comes back. See group-catch-up.ts.
|
|
3605
|
+
let catchUp: string | null = null;
|
|
3443
3606
|
if (turn.wasMentioned && store?.listRecentGroupMessages) {
|
|
3444
|
-
const
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
MENTION_CONTEXT_N + batchIds.size,
|
|
3460
|
-
) ?? [];
|
|
3461
|
-
const contextRows = priorRows
|
|
3462
|
-
.filter((row) => row.message_id != null && !batchIds.has(row.message_id))
|
|
3463
|
-
.slice(-MENTION_CONTEXT_N);
|
|
3464
|
-
if (contextRows.length > 0) {
|
|
3465
|
-
const priorContextBody = [
|
|
3466
|
-
"Prior group context:",
|
|
3467
|
-
...contextRows.map((row, i) => `[prior ${i + 1}] ${row.text ?? "(empty)"}`),
|
|
3468
|
-
].join("\n");
|
|
3469
|
-
await dispatchTurnToAgent({
|
|
3470
|
-
...turn,
|
|
3471
|
-
rawBody: `${priorContextBody}\n${turn.rawBody}`,
|
|
3472
|
-
});
|
|
3473
|
-
return;
|
|
3474
|
-
}
|
|
3607
|
+
const fetchLimit = CATCH_UP_FETCH_ROWS + batchIds.size;
|
|
3608
|
+
catchUp = buildGroupCatchUp({
|
|
3609
|
+
chatId,
|
|
3610
|
+
rows: catchUpRows,
|
|
3611
|
+
fetchLimit,
|
|
3612
|
+
batchIds,
|
|
3613
|
+
delivered: deliveredGroupMessages,
|
|
3614
|
+
budgetChars: account.deltaBudgetChars,
|
|
3615
|
+
formatLine: formatCatchUpLine,
|
|
3616
|
+
});
|
|
3617
|
+
}
|
|
3618
|
+
deliveredGroupMessages.remember(chatId, batchIds);
|
|
3619
|
+
if (catchUp) {
|
|
3620
|
+
await dispatchTurnToAgent({ ...turn, rawBody: `${catchUp}\n${turn.rawBody}` });
|
|
3621
|
+
return;
|
|
3475
3622
|
}
|
|
3476
3623
|
await dispatchTurnToAgent(turn);
|
|
3477
3624
|
},
|
|
@@ -3796,8 +3943,8 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3796
3943
|
});
|
|
3797
3944
|
|
|
3798
3945
|
// `message.recall` — the sender withdrew a message. Erase our own ledger row
|
|
3799
|
-
// so the
|
|
3800
|
-
//
|
|
3946
|
+
// so the mention catch-up above (unseen group messages, no time predicate,
|
|
3947
|
+
// no retention) can never read it back into a turn.
|
|
3801
3948
|
//
|
|
3802
3949
|
// Ids only: the frame carries no content, and `payload.message_id` is the
|
|
3803
3950
|
// server's `rcl:<target>` slot — the id to delete is `target_message_id`.
|
package/src/storage.ts
CHANGED
|
@@ -833,8 +833,8 @@ export class ClawChatStore {
|
|
|
833
833
|
*
|
|
834
834
|
* **The only DELETE in this store, and deliberately so.** There is no prune
|
|
835
835
|
* and no retention on `clawchat_messages`: without this, a message recalled
|
|
836
|
-
* ninety seconds after it was sent
|
|
837
|
-
*
|
|
836
|
+
* ninety seconds after it was sent, before the agent was given it, stays
|
|
837
|
+
* one @-mention away from being read back in the mention catch-up.
|
|
838
838
|
*
|
|
839
839
|
* Scoped to `(platform, account_id)` so one account's recall cannot reach
|
|
840
840
|
* another's rows, and matched on `message_id`, which seeks via the
|