@clawling/clawchat-plugin-openclaw 2026.5.12-28
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/INSTALL.md +64 -0
- package/README.md +227 -0
- package/dist/index.js +20 -0
- package/dist/setup-entry.js +3 -0
- package/dist/src/api-client.js +263 -0
- package/dist/src/api-types.js +17 -0
- package/dist/src/api-types.test-d.js +10 -0
- package/dist/src/buffered-stream.js +177 -0
- package/dist/src/channel.js +66 -0
- package/dist/src/channel.setup.js +119 -0
- package/dist/src/clawchat-memory.js +403 -0
- package/dist/src/clawchat-metadata.js +310 -0
- package/dist/src/client.js +35 -0
- package/dist/src/commands.js +35 -0
- package/dist/src/config.js +274 -0
- package/dist/src/group-message-coalescer.js +119 -0
- package/dist/src/inbound.js +170 -0
- package/dist/src/llm-context-debug.js +86 -0
- package/dist/src/login.runtime.js +204 -0
- package/dist/src/media-runtime.js +85 -0
- package/dist/src/message-mapper.js +146 -0
- package/dist/src/mock-transport.js +31 -0
- package/dist/src/outbound.js +628 -0
- package/dist/src/plugin-prompts.js +89 -0
- package/dist/src/profile-prompt.js +269 -0
- package/dist/src/profile-sync.js +110 -0
- package/dist/src/prompt-injection.js +25 -0
- package/dist/src/protocol-types.js +63 -0
- package/dist/src/protocol-types.typecheck.js +1 -0
- package/dist/src/protocol.js +33 -0
- package/dist/src/reply-dispatcher.js +422 -0
- package/dist/src/runtime.js +1254 -0
- package/dist/src/storage.js +525 -0
- package/dist/src/streaming.js +65 -0
- package/dist/src/terminal-send.js +36 -0
- package/dist/src/tools-schema.js +208 -0
- package/dist/src/tools.js +920 -0
- package/dist/src/ws-alignment.js +178 -0
- package/dist/src/ws-client.js +588 -0
- package/dist/src/ws-log.js +19 -0
- package/index.ts +24 -0
- package/openclaw.plugin.json +169 -0
- package/package.json +80 -0
- package/prompts/default-group-bio.md +19 -0
- package/prompts/default-owner-behavior.md +27 -0
- package/prompts/platform.md +13 -0
- package/setup-entry.ts +4 -0
- package/skills/clawchat/SKILL.md +91 -0
- package/src/api-client.test.ts +827 -0
- package/src/api-client.ts +414 -0
- package/src/api-types.ts +146 -0
- package/src/channel.outbound.test.ts +433 -0
- package/src/channel.setup.ts +145 -0
- package/src/channel.test.ts +262 -0
- package/src/channel.ts +81 -0
- package/src/clawchat-memory.test.ts +480 -0
- package/src/clawchat-memory.ts +533 -0
- package/src/clawchat-metadata.test.ts +477 -0
- package/src/clawchat-metadata.ts +429 -0
- package/src/client.test.ts +169 -0
- package/src/client.ts +56 -0
- package/src/commands.test.ts +39 -0
- package/src/commands.ts +41 -0
- package/src/config.test.ts +344 -0
- package/src/config.ts +404 -0
- package/src/group-message-coalescer.test.ts +237 -0
- package/src/group-message-coalescer.ts +171 -0
- package/src/inbound.test.ts +508 -0
- package/src/inbound.ts +278 -0
- package/src/llm-context-debug.test.ts +55 -0
- package/src/llm-context-debug.ts +139 -0
- package/src/login.runtime.test.ts +737 -0
- package/src/login.runtime.ts +277 -0
- package/src/manifest.test.ts +352 -0
- package/src/media-runtime.test.ts +207 -0
- package/src/media-runtime.ts +152 -0
- package/src/message-mapper.test.ts +201 -0
- package/src/message-mapper.ts +174 -0
- package/src/mock-transport.test.ts +35 -0
- package/src/mock-transport.ts +38 -0
- package/src/outbound.test.ts +1269 -0
- package/src/outbound.ts +803 -0
- package/src/plugin-entry.test.ts +38 -0
- package/src/plugin-prompts.test.ts +94 -0
- package/src/plugin-prompts.ts +107 -0
- package/src/profile-prompt.test.ts +274 -0
- package/src/profile-prompt.ts +351 -0
- package/src/profile-sync.test.ts +539 -0
- package/src/profile-sync.ts +191 -0
- package/src/prompt-injection.test.ts +39 -0
- package/src/prompt-injection.ts +45 -0
- package/src/protocol-types.test.ts +69 -0
- package/src/protocol-types.ts +296 -0
- package/src/protocol-types.typecheck.ts +89 -0
- package/src/protocol.test.ts +39 -0
- package/src/protocol.ts +42 -0
- package/src/reply-dispatcher.test.ts +1324 -0
- package/src/reply-dispatcher.ts +555 -0
- package/src/runtime.test.ts +4719 -0
- package/src/runtime.ts +1493 -0
- package/src/scripts.test.ts +85 -0
- package/src/storage.test.ts +560 -0
- package/src/storage.ts +807 -0
- package/src/terminal-send.test.ts +81 -0
- package/src/terminal-send.ts +56 -0
- package/src/tools-schema.ts +337 -0
- package/src/tools.test.ts +933 -0
- package/src/tools.ts +1185 -0
- package/src/ws-alignment.test.ts +103 -0
- package/src/ws-alignment.ts +275 -0
- package/src/ws-client.test.ts +1217 -0
- package/src/ws-client.ts +662 -0
- package/src/ws-log.test.ts +32 -0
- package/src/ws-log.ts +31 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
function formatTurnTime(timestamp) {
|
|
2
|
+
if (!Number.isFinite(timestamp))
|
|
3
|
+
return "unknown-time";
|
|
4
|
+
const time = new Date(timestamp);
|
|
5
|
+
if (Number.isNaN(time.getTime()))
|
|
6
|
+
return "unknown-time";
|
|
7
|
+
return time.toISOString();
|
|
8
|
+
}
|
|
9
|
+
function formatSenderRelation(turn) {
|
|
10
|
+
return turn.senderRelation || "peer_user";
|
|
11
|
+
}
|
|
12
|
+
function formatSenderProfileType(turn) {
|
|
13
|
+
if (turn.senderProfileType)
|
|
14
|
+
return turn.senderProfileType;
|
|
15
|
+
const relation = formatSenderRelation(turn);
|
|
16
|
+
return relation === "self_agent" || relation === "peer_agent" ? "agent" : "user";
|
|
17
|
+
}
|
|
18
|
+
function formatMessageBody(rawBody) {
|
|
19
|
+
return rawBody || "(empty message)";
|
|
20
|
+
}
|
|
21
|
+
function formatField(value) {
|
|
22
|
+
return value.replace(/\\/g, "\\\\").replace(/\r/g, "\\r").replace(/\n/g, "\\n");
|
|
23
|
+
}
|
|
24
|
+
function formatMentionedUsers(turn) {
|
|
25
|
+
const mentionedUsers = turn.mentionedUsers && turn.mentionedUsers.length > 0
|
|
26
|
+
? turn.mentionedUsers
|
|
27
|
+
: turn.mentionedUserIds.map((id) => ({ id }));
|
|
28
|
+
if (mentionedUsers.length === 0)
|
|
29
|
+
return "-";
|
|
30
|
+
return mentionedUsers.map((mention) => {
|
|
31
|
+
const id = formatField(mention.id);
|
|
32
|
+
const display = mention.display?.trim();
|
|
33
|
+
return display ? `${id}(${formatField(display)})` : id;
|
|
34
|
+
}).join(",");
|
|
35
|
+
}
|
|
36
|
+
export function formatCoalescedGroupBody(turns, timing = { idleSeconds: 10, maxWaitSeconds: 30 }) {
|
|
37
|
+
const header = `ClawChat group batch (${turns.length} ${turns.length === 1 ? "message" : "messages"}, ${timing.idleSeconds}s idle, ${timing.maxWaitSeconds}s max):`;
|
|
38
|
+
return [
|
|
39
|
+
header,
|
|
40
|
+
turns.map((turn) => {
|
|
41
|
+
const senderName = turn.senderNickName || turn.senderId;
|
|
42
|
+
const senderIsAgentOwner = turn.senderIsOwner ?? formatSenderRelation(turn) === "owner";
|
|
43
|
+
return [
|
|
44
|
+
"[message]",
|
|
45
|
+
`sender_id: ${formatField(turn.senderId)}`,
|
|
46
|
+
`sender_name: ${formatField(senderName)}`,
|
|
47
|
+
`sender_profile_type: ${formatField(formatSenderProfileType(turn))}`,
|
|
48
|
+
`sender_is_agent_owner: ${senderIsAgentOwner ? "true" : "false"}`,
|
|
49
|
+
`sender_is_group_owner: ${turn.senderIsGroupOwner ? "true" : "false"}`,
|
|
50
|
+
`mentions_current_agent: ${turn.wasMentioned ? "true" : "false"}`,
|
|
51
|
+
`mentioned_users: ${formatMentionedUsers(turn)}`,
|
|
52
|
+
"text:",
|
|
53
|
+
formatMessageBody(turn.rawBody),
|
|
54
|
+
].join("\n");
|
|
55
|
+
}).join("\n\n"),
|
|
56
|
+
].join("\n");
|
|
57
|
+
}
|
|
58
|
+
export function mergeGroupTurns(turns, timing = { idleSeconds: 10, maxWaitSeconds: 30 }) {
|
|
59
|
+
if (turns.length === 0)
|
|
60
|
+
throw new Error("cannot merge empty group turn batch");
|
|
61
|
+
const latest = turns[turns.length - 1];
|
|
62
|
+
return {
|
|
63
|
+
...latest,
|
|
64
|
+
rawBody: formatCoalescedGroupBody(turns, timing),
|
|
65
|
+
mediaItems: turns.flatMap((turn) => turn.mediaItems),
|
|
66
|
+
wasMentioned: turns.some((turn) => turn.wasMentioned),
|
|
67
|
+
mentionedUserIds: Array.from(new Set(turns.flatMap((turn) => turn.mentionedUserIds))),
|
|
68
|
+
mentionedUsers: Array.from(new Map(turns.flatMap((turn) => turn.mentionedUsers ?? turn.mentionedUserIds.map((id) => ({ id })))
|
|
69
|
+
.map((mention) => [mention.id, mention])).values()),
|
|
70
|
+
coalescedGroupBatch: true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function createGroupMessageCoalescer(params) {
|
|
74
|
+
const pending = new Map();
|
|
75
|
+
const timing = {
|
|
76
|
+
idleSeconds: Math.round(params.idleMs / 1000),
|
|
77
|
+
maxWaitSeconds: Math.round(params.maxWaitMs / 1000),
|
|
78
|
+
};
|
|
79
|
+
const flush = (chatId) => {
|
|
80
|
+
const batch = pending.get(chatId);
|
|
81
|
+
if (!batch)
|
|
82
|
+
return;
|
|
83
|
+
pending.delete(chatId);
|
|
84
|
+
clearTimeout(batch.idleTimer);
|
|
85
|
+
clearTimeout(batch.maxWaitTimer);
|
|
86
|
+
void params.dispatch(mergeGroupTurns(batch.turns, timing)).catch((error) => {
|
|
87
|
+
params.onError?.(error);
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
enqueue(turn) {
|
|
92
|
+
const chatId = turn.peer.id;
|
|
93
|
+
const existing = pending.get(chatId);
|
|
94
|
+
if (existing) {
|
|
95
|
+
existing.turns.push(turn);
|
|
96
|
+
clearTimeout(existing.idleTimer);
|
|
97
|
+
existing.idleTimer = setTimeout(() => flush(chatId), params.idleMs);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const idleTimer = setTimeout(() => flush(chatId), params.idleMs);
|
|
101
|
+
const maxWaitTimer = setTimeout(() => flush(chatId), params.maxWaitMs);
|
|
102
|
+
pending.set(chatId, { turns: [turn], idleTimer, maxWaitTimer });
|
|
103
|
+
},
|
|
104
|
+
flushNow(chatId) {
|
|
105
|
+
flush(chatId);
|
|
106
|
+
},
|
|
107
|
+
cancelAll() {
|
|
108
|
+
for (const [chatId, batch] of pending) {
|
|
109
|
+
clearTimeout(batch.idleTimer);
|
|
110
|
+
clearTimeout(batch.maxWaitTimer);
|
|
111
|
+
params.onDrop?.(chatId, batch.turns.length);
|
|
112
|
+
}
|
|
113
|
+
pending.clear();
|
|
114
|
+
},
|
|
115
|
+
pendingCount(chatId) {
|
|
116
|
+
return pending.get(chatId)?.turns.length ?? 0;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { EVENT, } from "./protocol-types.js";
|
|
2
|
+
import { effectiveGroupMode } from "./config.js";
|
|
3
|
+
import { extractMediaFragments, fragmentsToText } from "./message-mapper.js";
|
|
4
|
+
import { hasRenderableText, isInboundMessagePayload } from "./protocol.js";
|
|
5
|
+
function normalizeSender(sender) {
|
|
6
|
+
if (!sender || typeof sender !== "object")
|
|
7
|
+
return null;
|
|
8
|
+
const s = sender;
|
|
9
|
+
const id = typeof s.id === "string" ? s.id : "";
|
|
10
|
+
if (!id)
|
|
11
|
+
return null;
|
|
12
|
+
const nickName = typeof s.nick_name === "string" ? s.nick_name : id;
|
|
13
|
+
const profileType = s.type === "agent" || s.type === "user" ? s.type : null;
|
|
14
|
+
return { id, nickName, profileType };
|
|
15
|
+
}
|
|
16
|
+
function requireChatId(envelope) {
|
|
17
|
+
const chatId = envelope.chat_id;
|
|
18
|
+
return typeof chatId === "string" && chatId.trim() ? chatId : null;
|
|
19
|
+
}
|
|
20
|
+
function mentionIdFromRecord(record) {
|
|
21
|
+
const userId = record.user_id ?? record.userId ?? record.id;
|
|
22
|
+
return typeof userId === "string" && userId.length > 0 ? userId : undefined;
|
|
23
|
+
}
|
|
24
|
+
function mentionDisplayFromRecord(record) {
|
|
25
|
+
const display = record.display ?? record.label ?? record.name ?? record.nick_name ?? record.nickname;
|
|
26
|
+
return typeof display === "string" && display.trim() ? display.trim().replace(/^@/, "") : undefined;
|
|
27
|
+
}
|
|
28
|
+
function extractMentionUsers(fragments) {
|
|
29
|
+
return fragments
|
|
30
|
+
.filter((fragment) => fragment.kind === "mention")
|
|
31
|
+
.map((fragment) => {
|
|
32
|
+
const id = mentionIdFromRecord(fragment);
|
|
33
|
+
if (!id)
|
|
34
|
+
return null;
|
|
35
|
+
const display = mentionDisplayFromRecord(fragment);
|
|
36
|
+
return display ? { id, display } : { id };
|
|
37
|
+
})
|
|
38
|
+
.filter((mention) => mention !== null);
|
|
39
|
+
}
|
|
40
|
+
function normalizeMentionUsers(mentions) {
|
|
41
|
+
return mentions
|
|
42
|
+
.map((mention) => {
|
|
43
|
+
if (typeof mention === "string" && mention.length > 0)
|
|
44
|
+
return { id: mention };
|
|
45
|
+
if (mention && typeof mention === "object") {
|
|
46
|
+
const record = mention;
|
|
47
|
+
const id = mentionIdFromRecord(record);
|
|
48
|
+
if (!id)
|
|
49
|
+
return null;
|
|
50
|
+
const display = mentionDisplayFromRecord(record);
|
|
51
|
+
return display ? { id, display } : { id };
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
})
|
|
55
|
+
.filter((mention) => mention !== null);
|
|
56
|
+
}
|
|
57
|
+
function mergeMentionUsers(...sources) {
|
|
58
|
+
const merged = new Map();
|
|
59
|
+
for (const source of sources) {
|
|
60
|
+
for (const mention of source) {
|
|
61
|
+
const existing = merged.get(mention.id);
|
|
62
|
+
if (!existing || (!existing.display && mention.display)) {
|
|
63
|
+
merged.set(mention.id, mention);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return Array.from(merged.values());
|
|
68
|
+
}
|
|
69
|
+
function mentionedUserIds(mentions) {
|
|
70
|
+
return mentions.map((mention) => mention.id);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Exported for direct unit testing. Direct chats always count as addressed;
|
|
74
|
+
* group chats require a mention unless config opts into all group messages.
|
|
75
|
+
*/
|
|
76
|
+
export function detectMention(params) {
|
|
77
|
+
if (params.chatType === "direct")
|
|
78
|
+
return true;
|
|
79
|
+
return mentionedUserIds(normalizeMentionUsers(params.mentions)).includes(params.userId);
|
|
80
|
+
}
|
|
81
|
+
export async function dispatchOpenclawClawlingInbound(params) {
|
|
82
|
+
const { envelope, account, log } = params;
|
|
83
|
+
const isMaterializedMessage = envelope.event === EVENT.MESSAGE_SEND || envelope.event === EVENT.MESSAGE_REPLY;
|
|
84
|
+
if (!isMaterializedMessage) {
|
|
85
|
+
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw skip non-business event=${envelope.event} trace=${envelope.trace_id}`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (isMaterializedMessage && !isInboundMessagePayload(envelope.payload)) {
|
|
89
|
+
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw skip: invalid payload trace=${envelope.trace_id}`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const chatId = requireChatId(envelope);
|
|
93
|
+
if (!chatId) {
|
|
94
|
+
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw skip: missing chat_id trace=${envelope.trace_id}`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const payload = envelope.payload;
|
|
98
|
+
const message = payload.message;
|
|
99
|
+
const sender = normalizeSender(envelope.sender);
|
|
100
|
+
if (!sender) {
|
|
101
|
+
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw skip: missing sender trace=${envelope.trace_id}`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (account.userId && sender.id === account.userId) {
|
|
105
|
+
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw skip self-echo msg=${payload.message_id} user=${sender.id}`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const chatType = envelope.chat_type === "group" ? "group" : "direct";
|
|
109
|
+
const isGroup = chatType === "group";
|
|
110
|
+
if (isMaterializedMessage && payload.message_mode !== "normal") {
|
|
111
|
+
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw skip non-normal mode=${payload.message_mode}`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (!hasRenderableText(message)) {
|
|
115
|
+
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw skip empty msg=${payload.message_id}`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const fragmentMentionUsers = extractMentionUsers(message.body.fragments);
|
|
119
|
+
const contextMentionUsers = normalizeMentionUsers(message.context.mentions);
|
|
120
|
+
const mentionedUsers = mergeMentionUsers(fragmentMentionUsers, contextMentionUsers);
|
|
121
|
+
const mentionIds = mentionedUserIds(mentionedUsers);
|
|
122
|
+
const rawBody = fragmentsToText(message.body.fragments, {
|
|
123
|
+
mentionFallbackIds: mentionIds,
|
|
124
|
+
});
|
|
125
|
+
const mediaItems = extractMediaFragments(message.body.fragments);
|
|
126
|
+
const wasMentioned = detectMention({
|
|
127
|
+
mentions: mentionIds,
|
|
128
|
+
chatType,
|
|
129
|
+
userId: account.userId,
|
|
130
|
+
});
|
|
131
|
+
// Group trigger policy: in "mention" mode we only handle group messages
|
|
132
|
+
// that @-mention us; "all" listens open and processes every group msg.
|
|
133
|
+
// Direct chats are unaffected (detectMention returns true).
|
|
134
|
+
const groupMode = isGroup ? effectiveGroupMode(account, chatId) : account.groupMode;
|
|
135
|
+
if (isGroup && groupMode === "mention" && !wasMentioned) {
|
|
136
|
+
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw skip group (no mention) msg=${payload.message_id}`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw inbound event=${envelope.event} msg=${payload.message_id} from=${sender.id} text_len=${rawBody.length} mentioned=${wasMentioned}`);
|
|
140
|
+
const replyCtx = message.context.reply
|
|
141
|
+
? {
|
|
142
|
+
replyToMessageId: message.context.reply.reply_to_msg_id,
|
|
143
|
+
replyPreviewChatId: chatId,
|
|
144
|
+
replyPreviewSenderId: message.context.reply.reply_preview.id ?? "",
|
|
145
|
+
replyPreviewNickName: message.context.reply.reply_preview.nick_name ?? "",
|
|
146
|
+
replyPreviewText: fragmentsToText(message.context.reply.reply_preview.fragments),
|
|
147
|
+
}
|
|
148
|
+
: undefined;
|
|
149
|
+
await params.ingest({
|
|
150
|
+
channel: "clawchat-plugin-openclaw",
|
|
151
|
+
accountId: account.accountId,
|
|
152
|
+
peer: { kind: isGroup ? "group" : "direct", id: chatId },
|
|
153
|
+
senderId: sender.id,
|
|
154
|
+
senderNickName: sender.nickName,
|
|
155
|
+
...(sender.profileType ? { senderProfileType: sender.profileType } : {}),
|
|
156
|
+
rawBody,
|
|
157
|
+
messageId: payload.message_id,
|
|
158
|
+
traceId: envelope.trace_id,
|
|
159
|
+
timestamp: envelope.emitted_at,
|
|
160
|
+
wasMentioned,
|
|
161
|
+
mentionedUserIds: mentionIds,
|
|
162
|
+
mentionedUsers,
|
|
163
|
+
mediaItems,
|
|
164
|
+
...(replyCtx ? { replyCtx } : {}),
|
|
165
|
+
cfg: params.cfg,
|
|
166
|
+
runtime: params.runtime,
|
|
167
|
+
account,
|
|
168
|
+
envelope,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
function envEnabled(value) {
|
|
4
|
+
return ["1", "true", "yes", "on"].includes(String(value ?? "").trim().toLowerCase());
|
|
5
|
+
}
|
|
6
|
+
function safeId(value) {
|
|
7
|
+
const text = String(value ?? "unknown").replace(/[^A-Za-z0-9_.-]+/g, "-");
|
|
8
|
+
return text.length > 0 ? text.slice(0, 80) : "unknown";
|
|
9
|
+
}
|
|
10
|
+
export function createOpenclawLlmContextDebug(params = {}) {
|
|
11
|
+
const env = params.env ?? process.env;
|
|
12
|
+
const enabled = envEnabled(env.CLAWCHAT_LLM_CONTEXT_DEBUG);
|
|
13
|
+
const snapshotRoot = path.resolve(env.CLAWCHAT_LLM_CONTEXT_SNAPSHOT_DIR || params.defaultSnapshotRoot || ".clawchat-llm-context-debug");
|
|
14
|
+
return {
|
|
15
|
+
enabled,
|
|
16
|
+
captureFullInput: envEnabled(env.CLAWCHAT_LLM_CONTEXT_CAPTURE_FULL_INPUT),
|
|
17
|
+
captureOutput: envEnabled(env.CLAWCHAT_LLM_CONTEXT_CAPTURE_OUTPUT),
|
|
18
|
+
reloadPrompts: envEnabled(env.CLAWCHAT_LLM_CONTEXT_RELOAD_PROMPTS),
|
|
19
|
+
snapshotRoot,
|
|
20
|
+
writeSnapshot(snapshot) {
|
|
21
|
+
if (!enabled)
|
|
22
|
+
return null;
|
|
23
|
+
const createdAt = new Date().toISOString();
|
|
24
|
+
const targetRoot = path.join(snapshotRoot, "openclaw");
|
|
25
|
+
const runsRoot = path.join(targetRoot, "runs");
|
|
26
|
+
fs.mkdirSync(runsRoot, { recursive: true });
|
|
27
|
+
const messageId = safeId(snapshot.trace.messageId);
|
|
28
|
+
const traceId = safeId(snapshot.trace.traceId);
|
|
29
|
+
const filePath = path.join(runsRoot, `${createdAt.replace(/[:.]/g, "-")}-${messageId}-${traceId}.json`);
|
|
30
|
+
const body = {
|
|
31
|
+
schemaVersion: 1,
|
|
32
|
+
source: "openclaw",
|
|
33
|
+
visibility: snapshot.visibility,
|
|
34
|
+
createdAt,
|
|
35
|
+
trace: snapshot.trace,
|
|
36
|
+
context: {
|
|
37
|
+
promptParts: snapshot.context?.promptParts ?? [],
|
|
38
|
+
tools: snapshot.context?.tools ?? [],
|
|
39
|
+
skills: snapshot.context?.skills ?? [],
|
|
40
|
+
},
|
|
41
|
+
input: {
|
|
42
|
+
injectedPrompt: snapshot.input.injectedPrompt ?? "",
|
|
43
|
+
eventText: snapshot.input.eventText ?? "",
|
|
44
|
+
fullLlmInput: snapshot.input.fullLlmInput ?? null,
|
|
45
|
+
sections: snapshot.input.sections ?? [],
|
|
46
|
+
},
|
|
47
|
+
output: {
|
|
48
|
+
rawModelOutput: snapshot.output?.rawModelOutput ?? null,
|
|
49
|
+
streamChunks: snapshot.output?.streamChunks ?? [],
|
|
50
|
+
toolCalls: snapshot.output?.toolCalls ?? [],
|
|
51
|
+
toolResults: snapshot.output?.toolResults ?? [],
|
|
52
|
+
finalAssistantText: snapshot.output?.finalAssistantText ?? null,
|
|
53
|
+
adapterFilteredText: snapshot.output?.adapterFilteredText ?? null,
|
|
54
|
+
outboundClawChatMessage: snapshot.output?.outboundClawChatMessage ?? null,
|
|
55
|
+
suppressed: snapshot.output?.suppressed ?? false,
|
|
56
|
+
suppressionReason: snapshot.output?.suppressionReason ?? null,
|
|
57
|
+
},
|
|
58
|
+
warnings: snapshot.warnings ?? [],
|
|
59
|
+
};
|
|
60
|
+
const text = `${JSON.stringify(body, null, 2)}\n`;
|
|
61
|
+
fs.writeFileSync(filePath, text, "utf8");
|
|
62
|
+
fs.writeFileSync(path.join(targetRoot, "latest.json"), text, "utf8");
|
|
63
|
+
return filePath;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export const openclawLlmContextDebug = {
|
|
68
|
+
get enabled() {
|
|
69
|
+
return createOpenclawLlmContextDebug().enabled;
|
|
70
|
+
},
|
|
71
|
+
get captureFullInput() {
|
|
72
|
+
return createOpenclawLlmContextDebug().captureFullInput;
|
|
73
|
+
},
|
|
74
|
+
get captureOutput() {
|
|
75
|
+
return createOpenclawLlmContextDebug().captureOutput;
|
|
76
|
+
},
|
|
77
|
+
get reloadPrompts() {
|
|
78
|
+
return createOpenclawLlmContextDebug().reloadPrompts;
|
|
79
|
+
},
|
|
80
|
+
get snapshotRoot() {
|
|
81
|
+
return createOpenclawLlmContextDebug().snapshotRoot;
|
|
82
|
+
},
|
|
83
|
+
writeSnapshot(snapshot) {
|
|
84
|
+
return createOpenclawLlmContextDebug().writeSnapshot(snapshot);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { createOpenclawClawlingApiClient } from "./api-client.js";
|
|
3
|
+
import { ClawlingApiError } from "./api-types.js";
|
|
4
|
+
import { CHANNEL_ID, mergeOpenclawClawchatRuntimePluginActivation, mergeOpenclawClawchatToolAllow, resolveOpenclawClawlingAccount, } from "./config.js";
|
|
5
|
+
import { getClawChatStore } from "./storage.js";
|
|
6
|
+
/**
|
|
7
|
+
* Platform tag sent to `/v1/agents/connect`. Identifies the host of this
|
|
8
|
+
* agent runtime — openclaw's bundled clawchat channel.
|
|
9
|
+
*/
|
|
10
|
+
export const AGENTS_CONNECT_PLATFORM = "openclaw";
|
|
11
|
+
/**
|
|
12
|
+
* Agent type tag sent to `/v1/agents/connect`. The clawchat channel is
|
|
13
|
+
* always a bot; humans don't log in through this flow.
|
|
14
|
+
*/
|
|
15
|
+
export const AGENTS_CONNECT_TYPE = "clawbot";
|
|
16
|
+
/**
|
|
17
|
+
* Prompt the operator for an invite code.
|
|
18
|
+
*
|
|
19
|
+
* The prompt text is emitted via `runtime.log` so it flows through the
|
|
20
|
+
* same openclaw logging pipeline every other channel plugin uses (no
|
|
21
|
+
* clack frame, no raw-mode takeover, no TTY detection). Input is read
|
|
22
|
+
* from stdin with `node:readline` — Enter-to-submit is plain language in
|
|
23
|
+
* the prompt so any upstream LLM / orchestrator reading the log stream
|
|
24
|
+
* knows a newline is expected, and the behavior is identical under a
|
|
25
|
+
* TTY, piped stdin, or a test harness.
|
|
26
|
+
*/
|
|
27
|
+
async function promptInviteCodeFromStdin(runtime) {
|
|
28
|
+
runtime.log("Please enter your ClawChat invite code (press Enter to submit):");
|
|
29
|
+
let rl;
|
|
30
|
+
try {
|
|
31
|
+
rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
32
|
+
const answer = await rl.question("> ");
|
|
33
|
+
return answer.trim();
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
rl?.close();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function buildLoginConfig(cfg, result) {
|
|
40
|
+
const channels = (cfg.channels ?? {});
|
|
41
|
+
const existing = (channels[CHANNEL_ID] ?? {});
|
|
42
|
+
const groupMode = existing.groupMode === "mention" || existing.groupMode === "all"
|
|
43
|
+
? existing.groupMode
|
|
44
|
+
: "all";
|
|
45
|
+
const groupCommandMode = existing.groupCommandMode === "all" || existing.groupCommandMode === "off"
|
|
46
|
+
? existing.groupCommandMode
|
|
47
|
+
: "owner";
|
|
48
|
+
const nextSection = {
|
|
49
|
+
...existing,
|
|
50
|
+
enabled: true,
|
|
51
|
+
groupMode,
|
|
52
|
+
groupCommandMode,
|
|
53
|
+
token: result.access_token,
|
|
54
|
+
...(result.agent.id ? { agentId: result.agent.id } : {}),
|
|
55
|
+
userId: result.agent.user_id,
|
|
56
|
+
ownerUserId: result.agent.owner_id,
|
|
57
|
+
};
|
|
58
|
+
if (result.refresh_token) {
|
|
59
|
+
nextSection.refreshToken = result.refresh_token;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
delete nextSection.refreshToken;
|
|
63
|
+
}
|
|
64
|
+
return mergeOpenclawClawchatRuntimePluginActivation(mergeOpenclawClawchatToolAllow({
|
|
65
|
+
...cfg,
|
|
66
|
+
channels: { ...channels, [CHANNEL_ID]: nextSection },
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
async function persistLoginConfig(params, result) {
|
|
70
|
+
if (params.mutateConfigFile) {
|
|
71
|
+
params.runtime.log(`Persisting ClawChat credentials and plugin activation for userId=${result.agent.user_id} ownerUserId=${result.agent.owner_id} with Gateway restart intent.`);
|
|
72
|
+
await params.mutateConfigFile({
|
|
73
|
+
afterWrite: {
|
|
74
|
+
mode: "restart",
|
|
75
|
+
reason: "clawchat-plugin-openclaw credentials changed",
|
|
76
|
+
},
|
|
77
|
+
mutate(draft) {
|
|
78
|
+
Object.assign(draft, buildLoginConfig(draft, result));
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
params.runtime.log(`ClawChat credentials and plugin activation persisted for userId=${result.agent.user_id} ownerUserId=${result.agent.owner_id}.`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (params.persistConfig) {
|
|
85
|
+
params.runtime.log(`Persisting ClawChat credentials and plugin activation for userId=${result.agent.user_id} ownerUserId=${result.agent.owner_id}.`);
|
|
86
|
+
await params.persistConfig(buildLoginConfig(params.cfg, result));
|
|
87
|
+
params.runtime.log(`ClawChat credentials and plugin activation persisted for userId=${result.agent.user_id} ownerUserId=${result.agent.owner_id}.`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
throw new Error("clawchat-plugin-openclaw: mutateConfigFile is required to persist login credentials");
|
|
91
|
+
}
|
|
92
|
+
function requireConnectString(value, fieldName) {
|
|
93
|
+
if (typeof value !== "string") {
|
|
94
|
+
throw new Error(`agents/connect response missing required fields (${fieldName})`);
|
|
95
|
+
}
|
|
96
|
+
const trimmed = value.trim();
|
|
97
|
+
if (!trimmed) {
|
|
98
|
+
throw new Error(`agents/connect response missing required fields (${fieldName})`);
|
|
99
|
+
}
|
|
100
|
+
return trimmed;
|
|
101
|
+
}
|
|
102
|
+
function readOptionalConnectString(value, fieldName) {
|
|
103
|
+
if (value == null) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
return requireConnectString(value, fieldName);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Run the invite-code credential exchange used by `/clawchat-activate`,
|
|
110
|
+
* `openclaw channels add --channel clawchat-plugin-openclaw --token <invite-code>`,
|
|
111
|
+
* and `openclaw channels login --channel clawchat-plugin-openclaw`:
|
|
112
|
+
* 1. Read the existing channel section; require `baseUrl` to be set so we
|
|
113
|
+
* know which server to hit.
|
|
114
|
+
* 2. Prompt the user for an invite code on stdin.
|
|
115
|
+
* 3. POST it to `${baseUrl}/v1/agents/connect`.
|
|
116
|
+
* 4. Write the returned `websocket_url` / `token` / `user_id` back into
|
|
117
|
+
* the config so subsequent Gateway runs pick them up.
|
|
118
|
+
*
|
|
119
|
+
* Errors surface with clear messages (missing baseUrl, empty invite,
|
|
120
|
+
* server-side rejection) so the caller can relay them to the operator.
|
|
121
|
+
*/
|
|
122
|
+
export async function runOpenclawClawlingLogin(params) {
|
|
123
|
+
const { cfg, runtime } = params;
|
|
124
|
+
// `resolveOpenclawClawlingAccount` falls back to the built-in
|
|
125
|
+
// `DEFAULT_BASE_URL` / `DEFAULT_WEBSOCKET_URL` when the operator has not
|
|
126
|
+
// overridden them, so login works without a prior `openclaw channels setup --channel clawchat-plugin-openclaw`.
|
|
127
|
+
const account = resolveOpenclawClawlingAccount(cfg);
|
|
128
|
+
const inviteCode = (await (params.readInviteCode ?? (() => promptInviteCodeFromStdin(runtime)))()).trim();
|
|
129
|
+
if (!inviteCode) {
|
|
130
|
+
throw new Error("Login aborted: invite code is required.");
|
|
131
|
+
}
|
|
132
|
+
const apiClient = (params.apiClientFactory ?? createOpenclawClawlingApiClient)({
|
|
133
|
+
baseUrl: account.baseUrl,
|
|
134
|
+
// Pre-login we may not have a token yet. Send the current one (or empty)
|
|
135
|
+
// — the server should accept an unauthenticated invite-code exchange.
|
|
136
|
+
token: account.token || "",
|
|
137
|
+
});
|
|
138
|
+
runtime.log("Verifying invite code …");
|
|
139
|
+
let result;
|
|
140
|
+
try {
|
|
141
|
+
result = await apiClient.agentsConnect({
|
|
142
|
+
code: inviteCode,
|
|
143
|
+
platform: AGENTS_CONNECT_PLATFORM,
|
|
144
|
+
type: AGENTS_CONNECT_TYPE,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
if (err instanceof ClawlingApiError) {
|
|
149
|
+
throw new Error(`agents/connect failed (${err.kind}): ${err.message}`);
|
|
150
|
+
}
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
const accessToken = requireConnectString(result?.access_token, "access_token");
|
|
154
|
+
const agentUserId = requireConnectString(result?.agent?.user_id, "agent.user_id");
|
|
155
|
+
const ownerUserId = requireConnectString(result?.agent?.owner_id, "agent.owner_id");
|
|
156
|
+
const agentId = readOptionalConnectString(result?.agent?.id, "agent.id");
|
|
157
|
+
let conversationId = null;
|
|
158
|
+
if (result?.conversation != null) {
|
|
159
|
+
conversationId = requireConnectString(result.conversation.id, "conversation.id");
|
|
160
|
+
}
|
|
161
|
+
const normalizedResult = {
|
|
162
|
+
...result,
|
|
163
|
+
access_token: accessToken,
|
|
164
|
+
refresh_token: typeof result?.refresh_token === "string" ? result.refresh_token.trim() : "",
|
|
165
|
+
agent: {
|
|
166
|
+
...result.agent,
|
|
167
|
+
...(agentId ? { id: agentId } : {}),
|
|
168
|
+
owner_id: ownerUserId,
|
|
169
|
+
user_id: agentUserId,
|
|
170
|
+
},
|
|
171
|
+
...(conversationId
|
|
172
|
+
? {
|
|
173
|
+
conversation: {
|
|
174
|
+
...result.conversation,
|
|
175
|
+
id: conversationId,
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
: {}),
|
|
179
|
+
};
|
|
180
|
+
runtime.log(`Updating config: channels.${CHANNEL_ID}.token=[REDACTED] agentId=${normalizedResult.agent.id || "-"} userId=${normalizedResult.agent.user_id} ownerUserId=${normalizedResult.agent.owner_id}${normalizedResult.refresh_token ? " refreshToken=[REDACTED]" : ""} plugins.entries.${CHANNEL_ID}.enabled=true plugins.allow+=${CHANNEL_ID} …`);
|
|
181
|
+
await persistLoginConfig(params, normalizedResult);
|
|
182
|
+
try {
|
|
183
|
+
const store = params.store ??
|
|
184
|
+
getClawChatStore({
|
|
185
|
+
...(params.dbPath ? { dbPath: params.dbPath } : {}),
|
|
186
|
+
log: { error: runtime.log },
|
|
187
|
+
});
|
|
188
|
+
store.upsertActivation({
|
|
189
|
+
platform: "openclaw",
|
|
190
|
+
accountId: account.accountId,
|
|
191
|
+
userId: normalizedResult.agent.user_id,
|
|
192
|
+
ownerUserId: normalizedResult.agent.owner_id,
|
|
193
|
+
accessToken: normalizedResult.access_token,
|
|
194
|
+
refreshToken: normalizedResult.refresh_token || null,
|
|
195
|
+
conversationId: normalizedResult.conversation?.id ?? null,
|
|
196
|
+
loginMethod: "login",
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
runtime.log("clawchat-plugin-openclaw sqlite activation persistence failed; login continues.");
|
|
201
|
+
}
|
|
202
|
+
runtime.log(`Config file updated.`);
|
|
203
|
+
runtime.log(`clawchat-plugin-openclaw login succeeded (user_id=${normalizedResult.agent.user_id}, owner_user_id=${normalizedResult.agent.owner_id}, nickname=${normalizedResult.agent.nickname || "-"}).`);
|
|
204
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { buildOutboundMediaLoadOptions, } from "openclaw/plugin-sdk/media-runtime";
|
|
2
|
+
export function inferMediaKindFromMime(mime) {
|
|
3
|
+
if (!mime)
|
|
4
|
+
return "file";
|
|
5
|
+
if (mime.startsWith("image/"))
|
|
6
|
+
return "image";
|
|
7
|
+
if (mime.startsWith("audio/"))
|
|
8
|
+
return "audio";
|
|
9
|
+
if (mime.startsWith("video/"))
|
|
10
|
+
return "video";
|
|
11
|
+
return "file";
|
|
12
|
+
}
|
|
13
|
+
const DEFAULT_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
|
|
14
|
+
/**
|
|
15
|
+
* Fetch each remote URL via the shared media runtime, persist to a local
|
|
16
|
+
* cache, and return the list of local paths.
|
|
17
|
+
*
|
|
18
|
+
* Failed items are logged at info level and dropped; the remaining items
|
|
19
|
+
* still resolve so a single bad URL doesn't blow up the whole inbound turn.
|
|
20
|
+
*/
|
|
21
|
+
export async function fetchInboundMedia(items, ctx) {
|
|
22
|
+
if (items.length === 0)
|
|
23
|
+
return [];
|
|
24
|
+
const maxBytes = ctx.maxBytes ?? DEFAULT_MEDIA_MAX_BYTES;
|
|
25
|
+
const paths = [];
|
|
26
|
+
for (const item of items) {
|
|
27
|
+
try {
|
|
28
|
+
const fetched = await ctx.runtime.channel.media.fetchRemoteMedia({
|
|
29
|
+
url: item.url,
|
|
30
|
+
maxBytes,
|
|
31
|
+
});
|
|
32
|
+
const saved = await ctx.runtime.channel.media.saveMediaBuffer(fetched.buffer, fetched.contentType ?? item.mime, "clawchat-plugin-openclaw-inbound", maxBytes, item.name ?? fetched.fileName);
|
|
33
|
+
paths.push(saved.path);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
ctx.log?.info?.(`clawchat-plugin-openclaw inbound media skipped: ${item.url} (${err instanceof Error ? err.message : String(err)})`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return paths;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Upload each URL (remote or local path) to /media/upload via the api
|
|
43
|
+
* client and return a fragment ready to splice into `body.fragments`.
|
|
44
|
+
*
|
|
45
|
+
* Uses the host runtime's `runtime.media.loadWebMedia`, so local-root
|
|
46
|
+
* enforcement and media-loading policy stay aligned with the current
|
|
47
|
+
* OpenClaw runtime instead of a directly imported helper.
|
|
48
|
+
*
|
|
49
|
+
* Single-upload failures log at error and are dropped; the remaining
|
|
50
|
+
* fragments still come back so a partially-failing batch still sends the
|
|
51
|
+
* working media.
|
|
52
|
+
*/
|
|
53
|
+
export async function uploadOutboundMedia(urls, ctx) {
|
|
54
|
+
if (urls.length === 0)
|
|
55
|
+
return [];
|
|
56
|
+
const maxBytes = ctx.maxBytes ?? DEFAULT_MEDIA_MAX_BYTES;
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const url of urls) {
|
|
59
|
+
try {
|
|
60
|
+
const loaded = await ctx.runtime.media.loadWebMedia(url, buildOutboundMediaLoadOptions({
|
|
61
|
+
maxBytes,
|
|
62
|
+
...(ctx.mediaAccess ? { mediaAccess: ctx.mediaAccess } : {}),
|
|
63
|
+
...(ctx.mediaLocalRoots ? { mediaLocalRoots: ctx.mediaLocalRoots } : {}),
|
|
64
|
+
...(ctx.mediaReadFile ? { mediaReadFile: ctx.mediaReadFile } : {}),
|
|
65
|
+
}));
|
|
66
|
+
const uploaded = await ctx.apiClient.uploadMedia({
|
|
67
|
+
buffer: loaded.buffer,
|
|
68
|
+
filename: loaded.fileName ?? "upload.bin",
|
|
69
|
+
mime: loaded.contentType,
|
|
70
|
+
});
|
|
71
|
+
const fragment = {
|
|
72
|
+
kind: uploaded.kind,
|
|
73
|
+
url: uploaded.url,
|
|
74
|
+
name: uploaded.name,
|
|
75
|
+
mime: uploaded.mime,
|
|
76
|
+
size: uploaded.size,
|
|
77
|
+
};
|
|
78
|
+
out.push(fragment);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
ctx.log?.error?.(`clawchat-plugin-openclaw outbound media upload failed: ${url} (${err instanceof Error ? err.message : String(err)})`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|