@clawling/clawchat-plugin-openclaw 2026.9.26-3 → 2026.10.7-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/config.js +3 -0
- package/dist/src/framework-error-copy.js +172 -0
- package/dist/src/liveware-sample.js +106 -19
- package/dist/src/mention-autolink.js +155 -0
- package/dist/src/no-reply.js +7 -6
- package/dist/src/outbound.js +28 -7
- package/dist/src/permission-result.js +116 -27
- package/dist/src/profile-prompt.js +17 -2
- package/dist/src/reply-dispatcher.js +145 -31
- package/dist/src/reply-stream.js +208 -0
- package/dist/src/runtime.js +135 -2
- package/dist/src/skill-update.js +1 -1
- package/dist/src/tools-schema.js +1 -1
- package/dist/src/tools.js +105 -12
- package/openclaw.plugin.json +4 -0
- package/package.json +3 -8
- package/skills/clawchat-core/SKILL.md +12 -3
- package/skills/clawchat-liveware-sample/SKILL.md +3 -2
- package/skills/clawchat-orchestration/SKILL.md +30 -1
- package/skills/clawchat-set-greeting/SKILL.md +5 -4
- package/skills/manifest.json +31 -31
- package/src/config.ts +7 -0
- package/src/framework-error-copy.ts +185 -0
- package/src/liveware-sample.ts +106 -19
- package/src/mention-autolink.ts +200 -0
- package/src/no-reply.ts +7 -6
- package/src/outbound.ts +58 -7
- package/src/permission-result.ts +134 -30
- package/src/profile-prompt.ts +21 -2
- package/src/reply-dispatcher.ts +163 -28
- package/src/reply-stream.ts +244 -0
- package/src/runtime.ts +136 -3
- package/src/skill-update.ts +1 -1
- package/src/tools-schema.ts +1 -1
- package/src/tools.ts +112 -10
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound reply streaming (protocol §8) for one assistant message.
|
|
3
|
+
*
|
|
4
|
+
* Lifecycle on the wire (§8.4 finalize-reply pattern):
|
|
5
|
+
*
|
|
6
|
+
* message.created(M) → message.add(M, seq 0..n)* → message.done(M)
|
|
7
|
+
* → message.reply (message_id = M, the polished final)
|
|
8
|
+
*
|
|
9
|
+
* `message.done` carries the FINAL text, not the last partial: adds are
|
|
10
|
+
* throttled, so the last one that went out can be well short of the answer,
|
|
11
|
+
* and the server materializes its merged reply from the stream. `finish`
|
|
12
|
+
* therefore takes the final text, sends whatever tail of it the throttle held
|
|
13
|
+
* back as one last add, and closes with the whole text. A final that does not
|
|
14
|
+
* extend what was shown cannot be appended (§8.2) — the stream is failed and
|
|
15
|
+
* the final goes out under its own id.
|
|
16
|
+
*
|
|
17
|
+
* or, when the reply turns out not to be sendable (no-reply token, run
|
|
18
|
+
* aborted, nothing delivered): message.created(M) → … → message.failed(M),
|
|
19
|
+
* which aborts the stream without materializing anything.
|
|
20
|
+
*
|
|
21
|
+
* Streaming frames get no ack (§8.5) and no server-side mention repair, so the
|
|
22
|
+
* stream only ever carries plain text; mentions, media and rich fragments ride
|
|
23
|
+
* on the final materialized message, which reuses M so the stored row collapses
|
|
24
|
+
* to the final reply.
|
|
25
|
+
*
|
|
26
|
+
* No-reply guard. A partial must never show something that later turns out to
|
|
27
|
+
* be a no-reply: nothing is opened until the text is at least
|
|
28
|
+
* STREAM_HOLD_CHARS long and is not a no-reply / silence marker; a trailing
|
|
29
|
+
* fragment that could be the start of a no-reply token is held back; and if a
|
|
30
|
+
* token shows up later the stream stops growing and is failed at finalize.
|
|
31
|
+
*/
|
|
32
|
+
import { EVENT } from "./protocol-types.js";
|
|
33
|
+
import { isClawChatNoopResponseText } from "./profile-prompt.js";
|
|
34
|
+
import { isNoReplyTokenPrefix } from "./no-reply.js";
|
|
35
|
+
/** Minimum visible (trimmed) length before a stream is opened. */
|
|
36
|
+
export const STREAM_HOLD_CHARS = 32;
|
|
37
|
+
/** Minimum spacing between two `message.add` frames for one stream. */
|
|
38
|
+
export const STREAM_MIN_ADD_INTERVAL_MS = 250;
|
|
39
|
+
/** How far back from the end a partial no-reply token is looked for. */
|
|
40
|
+
const TOKEN_TAIL_WINDOW = 40;
|
|
41
|
+
/**
|
|
42
|
+
* Decide how much of a cumulative partial `text` may be shown.
|
|
43
|
+
* - `poisoned`: the text is (or contains) a no-reply token; never show it.
|
|
44
|
+
* - `hold`: too short to be sure yet.
|
|
45
|
+
* - `ok`: `text` with any trailing possible-token prefix cut off.
|
|
46
|
+
*/
|
|
47
|
+
export function streamableText(text) {
|
|
48
|
+
if (isClawChatNoopResponseText(text))
|
|
49
|
+
return { kind: "poisoned" };
|
|
50
|
+
let safe = text;
|
|
51
|
+
for (let i = Math.max(0, text.length - TOKEN_TAIL_WINDOW); i < text.length; i += 1) {
|
|
52
|
+
const ch = text[i];
|
|
53
|
+
if (/\s/.test(ch))
|
|
54
|
+
continue;
|
|
55
|
+
if (isNoReplyTokenPrefix(text.slice(i))) {
|
|
56
|
+
safe = text.slice(0, i);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (safe.trim().length < STREAM_HOLD_CHARS)
|
|
61
|
+
return { kind: "hold" };
|
|
62
|
+
return { kind: "ok", text: safe };
|
|
63
|
+
}
|
|
64
|
+
export function createReplyStream(options) {
|
|
65
|
+
const now = options.now ?? Date.now;
|
|
66
|
+
const prefix = options.logPrefix ?? "clawchat-plugin-openclaw";
|
|
67
|
+
let messageId = null;
|
|
68
|
+
let closed = false;
|
|
69
|
+
let poisoned = false;
|
|
70
|
+
let diverged = false;
|
|
71
|
+
let sent = "";
|
|
72
|
+
let sequence = -1;
|
|
73
|
+
let lastAddAt = 0;
|
|
74
|
+
let startedAt = 0;
|
|
75
|
+
const canSend = () => {
|
|
76
|
+
const client = options.client;
|
|
77
|
+
if (client.transportState !== undefined && client.transportState !== "open")
|
|
78
|
+
return false;
|
|
79
|
+
if (client.state !== undefined && client.state !== "connected")
|
|
80
|
+
return false;
|
|
81
|
+
return true;
|
|
82
|
+
};
|
|
83
|
+
const emit = (event, payload) => {
|
|
84
|
+
try {
|
|
85
|
+
options.client.emitRaw(event, payload, { chat_id: options.chatId });
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
options.log?.error?.(`${prefix} stream ${event} failed msg=${messageId}: ${String(error)}`);
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const streamingState = (status, completedAt) => ({
|
|
94
|
+
status,
|
|
95
|
+
sequence: Math.max(sequence, 0),
|
|
96
|
+
mutation_policy: "append_text_only",
|
|
97
|
+
started_at: startedAt,
|
|
98
|
+
completed_at: completedAt,
|
|
99
|
+
});
|
|
100
|
+
const sendAdd = (next, at) => {
|
|
101
|
+
sequence += 1;
|
|
102
|
+
const ok = emit(EVENT.MESSAGE_ADD, {
|
|
103
|
+
message_id: messageId,
|
|
104
|
+
sequence,
|
|
105
|
+
mutation: { type: "append", target_fragment_index: 0 },
|
|
106
|
+
fragments: [{ kind: "text", text: next, delta: next.slice(sent.length) }],
|
|
107
|
+
streaming: streamingState("streaming", null),
|
|
108
|
+
added_at: at,
|
|
109
|
+
});
|
|
110
|
+
if (!ok) {
|
|
111
|
+
sequence -= 1;
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
sent = next;
|
|
115
|
+
lastAddAt = at;
|
|
116
|
+
return true;
|
|
117
|
+
};
|
|
118
|
+
const fail = (reason) => {
|
|
119
|
+
if (!messageId || closed)
|
|
120
|
+
return;
|
|
121
|
+
closed = true;
|
|
122
|
+
const at = now();
|
|
123
|
+
emit(EVENT.MESSAGE_FAILED, {
|
|
124
|
+
message_id: messageId,
|
|
125
|
+
fragments: [{ kind: "text", text: sent }],
|
|
126
|
+
streaming: streamingState("failed", at),
|
|
127
|
+
completed_at: at,
|
|
128
|
+
});
|
|
129
|
+
options.log?.info?.(`${prefix} stream failed msg=${messageId} reason=${reason}`);
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
update(text) {
|
|
133
|
+
if (closed || poisoned || diverged)
|
|
134
|
+
return;
|
|
135
|
+
const decision = streamableText(text);
|
|
136
|
+
if (decision.kind === "poisoned") {
|
|
137
|
+
poisoned = true;
|
|
138
|
+
options.log?.info?.(`${prefix} stream halted: no-reply token in partial msg=${messageId ?? "-"}`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (decision.kind === "hold")
|
|
142
|
+
return;
|
|
143
|
+
const next = decision.text;
|
|
144
|
+
if (messageId && !next.startsWith(sent)) {
|
|
145
|
+
// §8.2 delta invariant cannot hold for a rewrite; stop growing the
|
|
146
|
+
// stream and let the final message (same id) carry the real text.
|
|
147
|
+
diverged = true;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (next === sent)
|
|
151
|
+
return;
|
|
152
|
+
if (!canSend())
|
|
153
|
+
return;
|
|
154
|
+
if (!messageId) {
|
|
155
|
+
const id = options.mintMessageId();
|
|
156
|
+
startedAt = now();
|
|
157
|
+
if (!emit(EVENT.MESSAGE_CREATED, { message_id: id, message_mode: "normal" }))
|
|
158
|
+
return;
|
|
159
|
+
messageId = id;
|
|
160
|
+
options.log?.info?.(`${prefix} stream opened msg=${id} to=${options.chatId}`);
|
|
161
|
+
}
|
|
162
|
+
else if (now() - lastAddAt < STREAM_MIN_ADD_INTERVAL_MS) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
sendAdd(next, now());
|
|
166
|
+
},
|
|
167
|
+
diverges(text) {
|
|
168
|
+
if (!messageId || closed)
|
|
169
|
+
return false;
|
|
170
|
+
const decision = streamableText(text);
|
|
171
|
+
return decision.kind === "ok" && !decision.text.startsWith(sent);
|
|
172
|
+
},
|
|
173
|
+
isOpen() {
|
|
174
|
+
return messageId !== null && !closed;
|
|
175
|
+
},
|
|
176
|
+
shownText() {
|
|
177
|
+
return sent;
|
|
178
|
+
},
|
|
179
|
+
finish(finalText) {
|
|
180
|
+
if (!messageId || closed)
|
|
181
|
+
return null;
|
|
182
|
+
if (streamableText(finalText).kind === "poisoned") {
|
|
183
|
+
fail("no-reply");
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
// Trailing whitespace a partial carried may be trimmed off the final.
|
|
187
|
+
if (!finalText.startsWith(sent.trimEnd())) {
|
|
188
|
+
fail("superseded");
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
if (finalText.length > sent.length && finalText.startsWith(sent)) {
|
|
192
|
+
// Past the throttle: this is the last frame before done.
|
|
193
|
+
sendAdd(finalText, now());
|
|
194
|
+
}
|
|
195
|
+
closed = true;
|
|
196
|
+
const at = now();
|
|
197
|
+
emit(EVENT.MESSAGE_DONE, {
|
|
198
|
+
message_id: messageId,
|
|
199
|
+
fragments: [{ kind: "text", text: finalText }],
|
|
200
|
+
streaming: streamingState("done", at),
|
|
201
|
+
completed_at: at,
|
|
202
|
+
});
|
|
203
|
+
options.log?.info?.(`${prefix} stream done msg=${messageId} text_len=${finalText.length}`);
|
|
204
|
+
return messageId;
|
|
205
|
+
},
|
|
206
|
+
fail,
|
|
207
|
+
};
|
|
208
|
+
}
|
package/dist/src/runtime.js
CHANGED
|
@@ -33,6 +33,7 @@ import { loadClawChatPromptMetadata, renderClawChatProfilePrompt, resolveSenderR
|
|
|
33
33
|
import { refreshGroupProfile, syncFirstSeenClawChatProfiles } from "./profile-sync.js";
|
|
34
34
|
import { pullGroupMetadata, pullOwnerMetadata } from "./clawchat-metadata.js";
|
|
35
35
|
import { deleteClawChatMemoryFile, readClawChatMemoryFile } from "./clawchat-memory.js";
|
|
36
|
+
import { registerMentionRosterResolver } from "./mention-autolink.js";
|
|
36
37
|
import { clearClawChatPromptInjectionForSession, stageClawChatPromptInjection, } from "./prompt-injection.js";
|
|
37
38
|
import { createGroupMessageCoalescer } from "./group-message-coalescer.js";
|
|
38
39
|
import { openclawLlmContextDebug } from "./llm-context-debug.js";
|
|
@@ -858,6 +859,10 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
858
859
|
// `sendOwnerSkillMessage` below) — both live in this same function closure, so
|
|
859
860
|
// the forward reference here just resolves to the fresh value at call time.
|
|
860
861
|
let livewareSampleNotify = async () => false;
|
|
862
|
+
// The supervisor this gateway call shares (new or adopted). The abort cleanup
|
|
863
|
+
// below awaits its stop() so the host's channel stop (which waits on this
|
|
864
|
+
// task) does not return while liveware children are still being killed.
|
|
865
|
+
let livewareSampleForShutdown = null;
|
|
861
866
|
if (store) {
|
|
862
867
|
const stateDirForSample = runtime.state?.resolveStateDir?.();
|
|
863
868
|
const livewareSampleDeps = {
|
|
@@ -900,6 +905,7 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
900
905
|
livewareSampleSupervisors.set(abortSignal, supervisorsForSignal);
|
|
901
906
|
const supervisorKey = `openclaw:${accountId}`;
|
|
902
907
|
const runningSupervisor = supervisorsForSignal.get(supervisorKey);
|
|
908
|
+
livewareSampleForShutdown = runningSupervisor ?? null;
|
|
903
909
|
if (runningSupervisor) {
|
|
904
910
|
// A gateway re-entry (refresh/backoff reconnect), not a fresh channel start:
|
|
905
911
|
// hand the live supervisor this closure's token + REST client + notify path
|
|
@@ -909,9 +915,12 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
909
915
|
}
|
|
910
916
|
else {
|
|
911
917
|
const livewareSampleSupervisor = new LivewareSampleSupervisor(livewareSampleDeps);
|
|
918
|
+
livewareSampleForShutdown = livewareSampleSupervisor;
|
|
912
919
|
supervisorsForSignal.set(supervisorKey, livewareSampleSupervisor);
|
|
913
920
|
abortSignal.addEventListener("abort", () => {
|
|
914
|
-
|
|
921
|
+
// Starts the SIGTERM → SIGKILL teardown at once; the gateway's own
|
|
922
|
+
// abort cleanup awaits the same (idempotent) stop() promise.
|
|
923
|
+
void livewareSampleSupervisor.stop();
|
|
915
924
|
if (supervisorsForSignal.get(supervisorKey) === livewareSampleSupervisor) {
|
|
916
925
|
supervisorsForSignal.delete(supervisorKey);
|
|
917
926
|
}
|
|
@@ -1187,6 +1196,25 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
1187
1196
|
const persistOwnerProfile = upsertOwnerProfile
|
|
1188
1197
|
? makeOwnerProfilePersister({ upsertOwnerProfile }, { accountId })
|
|
1189
1198
|
: undefined;
|
|
1199
|
+
// The owner_profile row is where owner-language copy reads its locale
|
|
1200
|
+
// (framework errors, greetings, liveware intros). It was only refreshed on
|
|
1201
|
+
// owner metadata pulls, so an owner who switched app language kept getting
|
|
1202
|
+
// the old one. friend.profile_updated also reaches the agent when the
|
|
1203
|
+
// owner edits their profile; refetch just the owner row then.
|
|
1204
|
+
const refreshOwnerProfileOnUpdate = (entityId) => {
|
|
1205
|
+
const subject = typeof entityId === "string" ? entityId.trim() : "";
|
|
1206
|
+
if (!persistOwnerProfile || !subject || subject !== account.ownerUserId)
|
|
1207
|
+
return;
|
|
1208
|
+
void (async () => {
|
|
1209
|
+
try {
|
|
1210
|
+
const owner = await getConversationApiClient().getAgentOwner();
|
|
1211
|
+
persistOwnerProfile(owner);
|
|
1212
|
+
}
|
|
1213
|
+
catch (err) {
|
|
1214
|
+
log?.error?.(`[${accountId}] clawchat-plugin-openclaw owner profile refresh failed source=friend.profile_updated: ${err instanceof Error ? err.message : String(err)}`);
|
|
1215
|
+
}
|
|
1216
|
+
})();
|
|
1217
|
+
};
|
|
1190
1218
|
const refreshAgentBehavior = async (options) => {
|
|
1191
1219
|
try {
|
|
1192
1220
|
await pullOwnerMetadata({
|
|
@@ -2304,6 +2332,9 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2304
2332
|
if (type === "friend.added") {
|
|
2305
2333
|
scheduleFriendGreeting(payload);
|
|
2306
2334
|
}
|
|
2335
|
+
if (type === "friend.profile_updated") {
|
|
2336
|
+
refreshOwnerProfileOnUpdate(payload?.entity_id);
|
|
2337
|
+
}
|
|
2307
2338
|
}
|
|
2308
2339
|
}
|
|
2309
2340
|
});
|
|
@@ -2408,6 +2439,8 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2408
2439
|
? groupFile.metadata.group_owner_nickname ?? metadata?.nickname ?? userId
|
|
2409
2440
|
: metadata?.nickname ?? userId;
|
|
2410
2441
|
const profileType = metadata?.profile_type ?? (userId === account.userId ? "agent" : "user");
|
|
2442
|
+
if (name !== userId)
|
|
2443
|
+
knownNicknames.set(userId, name);
|
|
2411
2444
|
participants.push({ id: userId, name, profileType, isAgentOwner, isGroupOwner });
|
|
2412
2445
|
}
|
|
2413
2446
|
return participants;
|
|
@@ -2443,6 +2476,7 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2443
2476
|
basePrompt: turn.peer.kind === "group" ? getClawChatGroupPrompt() : getClawChatUserPrompt(),
|
|
2444
2477
|
...promptMetadata,
|
|
2445
2478
|
ownerIdFallback: account.ownerUserId,
|
|
2479
|
+
currentAgentIdFallback: account.userId,
|
|
2446
2480
|
groupParticipants,
|
|
2447
2481
|
turn: {
|
|
2448
2482
|
chatType: promptChatType,
|
|
@@ -2462,9 +2496,76 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2462
2496
|
},
|
|
2463
2497
|
});
|
|
2464
2498
|
};
|
|
2499
|
+
// Group frames often carry the sender's id as nick_name. Prefer the nickname
|
|
2500
|
+
// already cached in ClawChat memory (owner.md for the owner and this agent,
|
|
2501
|
+
// users/<id>.md for everyone else, the group's group_owner_nickname for its
|
|
2502
|
+
// owner); keep the id when nothing is cached.
|
|
2503
|
+
const cachedNicknameFor = async (memoryRoot, userId) => {
|
|
2504
|
+
try {
|
|
2505
|
+
if (userId === account.ownerUserId || userId === account.userId) {
|
|
2506
|
+
const ownerFile = await readClawChatMemoryFile(memoryRoot, { targetType: "owner", targetId: "owner" });
|
|
2507
|
+
const nickname = userId === account.userId
|
|
2508
|
+
? ownerFile.metadata.agent_nickname
|
|
2509
|
+
: ownerFile.metadata.agent_owner_nickname;
|
|
2510
|
+
if (nickname)
|
|
2511
|
+
return nickname;
|
|
2512
|
+
}
|
|
2513
|
+
const userFile = await readClawChatMemoryFile(memoryRoot, { targetType: "user", targetId: userId });
|
|
2514
|
+
return userFile.exists && userFile.metadata.nickname ? userFile.metadata.nickname : null;
|
|
2515
|
+
}
|
|
2516
|
+
catch {
|
|
2517
|
+
return null;
|
|
2518
|
+
}
|
|
2519
|
+
};
|
|
2520
|
+
// Nicknames seen while building group prompts, so ingest can label a
|
|
2521
|
+
// coalesced batch's transcript ("Alice:" rather than "usr_…:") without
|
|
2522
|
+
// touching disk.
|
|
2523
|
+
const knownNicknames = new Map();
|
|
2524
|
+
const withCachedGroupSenderNames = async (turn, memoryRoot) => {
|
|
2525
|
+
if (turn.peer.kind !== "group" || !memoryRoot)
|
|
2526
|
+
return turn;
|
|
2527
|
+
const isBareId = (name, id) => !name || name === id;
|
|
2528
|
+
const cache = new Map();
|
|
2529
|
+
let groupOwner = null;
|
|
2530
|
+
const lookup = async (userId) => {
|
|
2531
|
+
if (!cache.has(userId)) {
|
|
2532
|
+
let nickname = await cachedNicknameFor(memoryRoot, userId);
|
|
2533
|
+
if (!nickname) {
|
|
2534
|
+
if (!groupOwner) {
|
|
2535
|
+
try {
|
|
2536
|
+
const groupFile = await readClawChatMemoryFile(memoryRoot, { targetType: "group", targetId: turn.peer.id });
|
|
2537
|
+
groupOwner = { id: groupFile.metadata.group_owner_id, nickname: groupFile.metadata.group_owner_nickname };
|
|
2538
|
+
}
|
|
2539
|
+
catch {
|
|
2540
|
+
groupOwner = {};
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
if (groupOwner.id === userId && groupOwner.nickname)
|
|
2544
|
+
nickname = groupOwner.nickname;
|
|
2545
|
+
}
|
|
2546
|
+
cache.set(userId, nickname);
|
|
2547
|
+
if (nickname)
|
|
2548
|
+
knownNicknames.set(userId, nickname);
|
|
2549
|
+
}
|
|
2550
|
+
return cache.get(userId) ?? null;
|
|
2551
|
+
};
|
|
2552
|
+
const senderNickName = isBareId(turn.senderNickName, turn.senderId)
|
|
2553
|
+
? (await lookup(turn.senderId)) ?? turn.senderNickName
|
|
2554
|
+
: turn.senderNickName;
|
|
2555
|
+
const groupMessages = turn.groupMessages
|
|
2556
|
+
? await Promise.all(turn.groupMessages.map(async (message) => isBareId(message.senderName, message.senderId)
|
|
2557
|
+
? { ...message, senderName: (await lookup(message.senderId)) ?? message.senderName }
|
|
2558
|
+
: message))
|
|
2559
|
+
: turn.groupMessages;
|
|
2560
|
+
return { ...turn, senderNickName, ...(groupMessages ? { groupMessages } : {}) };
|
|
2561
|
+
};
|
|
2465
2562
|
const resolveSenderNickNameForTurn = (turn) => {
|
|
2466
2563
|
if (turn.senderNickName && turn.senderNickName !== turn.senderId)
|
|
2467
2564
|
return turn.senderNickName;
|
|
2565
|
+
// Synchronous on purpose: an await here would reorder same-group frames.
|
|
2566
|
+
const known = turn.peer.kind === "group" ? knownNicknames.get(turn.senderId) : undefined;
|
|
2567
|
+
if (known)
|
|
2568
|
+
return known;
|
|
2468
2569
|
return turn.senderNickName || turn.senderId;
|
|
2469
2570
|
};
|
|
2470
2571
|
const resolveSenderBatchIdentityForTurn = (turn) => {
|
|
@@ -2479,7 +2580,9 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2479
2580
|
senderProfileType: turn.senderProfileType ??
|
|
2480
2581
|
(senderRelation === "self_agent" || senderRelation === "peer_agent" ? "agent" : "user"),
|
|
2481
2582
|
senderIsOwner: senderRelation === "owner",
|
|
2482
|
-
|
|
2583
|
+
// Left undefined when the frame does not say, so the prompt falls back
|
|
2584
|
+
// to the group metadata's group_owner_id instead of a forced false.
|
|
2585
|
+
...(turn.senderIsGroupOwner !== undefined ? { senderIsGroupOwner: turn.senderIsGroupOwner } : {}),
|
|
2483
2586
|
};
|
|
2484
2587
|
};
|
|
2485
2588
|
const claimInboundTurn = (turn) => {
|
|
@@ -2549,6 +2652,7 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2549
2652
|
catch (err) {
|
|
2550
2653
|
log?.error?.(`[${accountId}] clawchat-plugin-openclaw message metadata refresh failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2551
2654
|
}
|
|
2655
|
+
turn = await withCachedGroupSenderNames(turn, memoryRoot);
|
|
2552
2656
|
const conversationTarget = `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`;
|
|
2553
2657
|
const turnPrompt = await buildPromptForTurn(turn, memoryRoot);
|
|
2554
2658
|
const senderIsOwner = turn.senderIsOwner === true || turn.senderId === account.ownerUserId;
|
|
@@ -2679,6 +2783,7 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2679
2783
|
runtime,
|
|
2680
2784
|
account,
|
|
2681
2785
|
client,
|
|
2786
|
+
resolveOwnerLocale: () => store?.getOwnerProfile?.({ platform: "openclaw", accountId })?.locale ?? null,
|
|
2682
2787
|
target: { chatId: turn.peer.id, chatType: turn.peer.kind },
|
|
2683
2788
|
...(replyCtx ? { replyCtx } : {}),
|
|
2684
2789
|
inboundMessageId: turn.messageId,
|
|
@@ -3229,6 +3334,23 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
3229
3334
|
return;
|
|
3230
3335
|
}
|
|
3231
3336
|
activeClients.set(accountId, client);
|
|
3337
|
+
// Text "@name" autolinking on outbound group sends reads the same cached
|
|
3338
|
+
// roster (participant ids + nicknames) the group prompt shows the model.
|
|
3339
|
+
const unregisterMentionRoster = registerMentionRosterResolver(accountId, async (groupId) => {
|
|
3340
|
+
const memoryRoot = resolveMemoryRootForPeer({ kind: "group", id: groupId });
|
|
3341
|
+
if (!memoryRoot)
|
|
3342
|
+
return [];
|
|
3343
|
+
let ownerMetadata = null;
|
|
3344
|
+
try {
|
|
3345
|
+
const ownerFile = await readClawChatMemoryFile(memoryRoot, { targetType: "owner", targetId: "owner" });
|
|
3346
|
+
ownerMetadata = ownerFile.exists ? ownerFile.metadata : null;
|
|
3347
|
+
}
|
|
3348
|
+
catch {
|
|
3349
|
+
ownerMetadata = null;
|
|
3350
|
+
}
|
|
3351
|
+
const participants = await loadGroupParticipantsForPrompt({ memoryRoot, groupId, ownerMetadata });
|
|
3352
|
+
return participants.map((participant) => ({ userId: participant.id, name: participant.name }));
|
|
3353
|
+
});
|
|
3232
3354
|
log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime active client registered`);
|
|
3233
3355
|
setStatus({
|
|
3234
3356
|
...getStatus(),
|
|
@@ -3240,6 +3362,7 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
3240
3362
|
await waitUntilAbort(abortSignal, async () => {
|
|
3241
3363
|
log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime abort received; closing client`);
|
|
3242
3364
|
activeClients.delete(accountId);
|
|
3365
|
+
unregisterMentionRoster();
|
|
3243
3366
|
closingForAbort = true;
|
|
3244
3367
|
clearBootstrapRetry();
|
|
3245
3368
|
// §A.1 — stop the proactive refresh timer on shutdown.
|
|
@@ -3251,6 +3374,16 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
3251
3374
|
closeReason: "client close",
|
|
3252
3375
|
});
|
|
3253
3376
|
client.close();
|
|
3377
|
+
if (livewareSampleForShutdown) {
|
|
3378
|
+
// SIGTERM, then SIGKILL after KILL_GRACE_MS (3 s): inside the host's
|
|
3379
|
+
// 5 s wait for this task, so a stuck child no longer outlives the stop.
|
|
3380
|
+
try {
|
|
3381
|
+
await livewareSampleForShutdown.stop();
|
|
3382
|
+
}
|
|
3383
|
+
catch (err) {
|
|
3384
|
+
log?.error?.(`[${accountId}] clawchat-plugin-openclaw liveware-sample stop failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3254
3387
|
setStatus({
|
|
3255
3388
|
...getStatus(),
|
|
3256
3389
|
connected: false,
|
package/dist/src/skill-update.js
CHANGED
|
@@ -66,7 +66,7 @@ export const OFFICIAL_SKILLS_BASE = "https://raw.githubusercontent.com/clawling/
|
|
|
66
66
|
* in the install-cli repo, bump this constant, ship it. `liveware-sample.ts`
|
|
67
67
|
* imports the same ref, so the `livewares` tree at that tag is pinned too.
|
|
68
68
|
*/
|
|
69
|
-
export const DEFAULT_SKILLS_REF = "skills-v1.
|
|
69
|
+
export const DEFAULT_SKILLS_REF = "skills-v1.17.0";
|
|
70
70
|
/** Refuse to treat an absurdly large response as a skill file (defence in depth). */
|
|
71
71
|
export const MAX_SKILL_BYTES = 256 * 1024;
|
|
72
72
|
/** This adapter's host target inside `skills/manifest.json`. */
|
package/dist/src/tools-schema.js
CHANGED
|
@@ -205,7 +205,7 @@ export const ClawchatCreateMomentSchema = Type.Object({
|
|
|
205
205
|
description: "Moment text. At least one of text or images is required.",
|
|
206
206
|
})),
|
|
207
207
|
images: Type.Optional(Type.Array(Type.String({
|
|
208
|
-
description: "
|
|
208
|
+
description: "An http(s) image URL, or an absolute local image path (the tool uploads it and uses the hosted URL).",
|
|
209
209
|
}))),
|
|
210
210
|
});
|
|
211
211
|
export const ClawchatDeleteMomentSchema = Type.Object({
|
package/dist/src/tools.js
CHANGED
|
@@ -28,6 +28,15 @@ function extractRequestId(err) {
|
|
|
28
28
|
}
|
|
29
29
|
return undefined;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* What the agent must do with the later `permission_result` receipt
|
|
33
|
+
* (agent-protocol §2.8): on approval the SERVER executes the gated call, so a
|
|
34
|
+
* second call would duplicate it or raise a fresh card. Only `approved_retry`
|
|
35
|
+
* (reads, which are never replayed) asks for exactly one more call.
|
|
36
|
+
*/
|
|
37
|
+
const PERMISSION_RECEIPT_RULE = "When the result arrives: outcome \"approved\" (or \"auto_allowed\") means the server has already done it for you — do not call this tool again; " +
|
|
38
|
+
"only outcome \"approved_retry\" means call this same tool once more with the same arguments. " +
|
|
39
|
+
"Anything the server produced (a new id, a code, read data) comes in that message's Result line.";
|
|
31
40
|
function permissionGateResult(err) {
|
|
32
41
|
const code = err.meta?.code;
|
|
33
42
|
const requestId = extractRequestId(err);
|
|
@@ -38,7 +47,8 @@ function permissionGateResult(err) {
|
|
|
38
47
|
"This operation requires the owner's approval and has been submitted for review" +
|
|
39
48
|
(requestId ? ` (request_id=${requestId})` : "") +
|
|
40
49
|
". It has NOT failed. Do not retry — the result will arrive later as a normal " +
|
|
41
|
-
"chat message; wait for it instead of calling this tool again."
|
|
50
|
+
"chat message; wait for it instead of calling this tool again. " +
|
|
51
|
+
PERMISSION_RECEIPT_RULE;
|
|
42
52
|
status = "pending";
|
|
43
53
|
}
|
|
44
54
|
else {
|
|
@@ -120,6 +130,46 @@ function inferMimeFromPath(filePath) {
|
|
|
120
130
|
const ext = path.extname(filePath).toLowerCase();
|
|
121
131
|
return MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
122
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Classify `clawchat_create_moment` image entries: http(s) URLs pass through;
|
|
135
|
+
* an existing absolute local regular file (≤ 20MB) is marked for upload; any
|
|
136
|
+
* other value is a validation error naming the offending index.
|
|
137
|
+
*/
|
|
138
|
+
function planMomentImages(images) {
|
|
139
|
+
const entries = [];
|
|
140
|
+
for (const [i, raw] of images.entries()) {
|
|
141
|
+
const where = `clawchat-plugin-openclaw: images[${i}]`;
|
|
142
|
+
const value = typeof raw === "string" ? raw.trim() : "";
|
|
143
|
+
if (!value) {
|
|
144
|
+
return { ok: false, message: `${where} must be an http(s) image URL or an absolute local image path` };
|
|
145
|
+
}
|
|
146
|
+
if (/^https?:\/\/\S+$/i.test(value)) {
|
|
147
|
+
entries.push({ kind: "url", url: value });
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (!path.isAbsolute(value)) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
message: `${where} (${JSON.stringify(value)}) is neither an http(s) URL nor an absolute local file path; no moment was created`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
let stat;
|
|
157
|
+
try {
|
|
158
|
+
stat = fs.statSync(value);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return { ok: false, message: `${where}: local file ${value} does not exist; no moment was created` };
|
|
162
|
+
}
|
|
163
|
+
if (!stat.isFile()) {
|
|
164
|
+
return { ok: false, message: `${where}: ${value} is not a regular file; no moment was created` };
|
|
165
|
+
}
|
|
166
|
+
if (stat.size > MAX_UPLOAD_BYTES) {
|
|
167
|
+
return { ok: false, message: `${where}: ${value} is too large (${stat.size} bytes; max 20MB)` };
|
|
168
|
+
}
|
|
169
|
+
entries.push({ kind: "local", path: value });
|
|
170
|
+
}
|
|
171
|
+
return { ok: true, entries };
|
|
172
|
+
}
|
|
123
173
|
function validateMentionMessageParams(params) {
|
|
124
174
|
if (!params || typeof params !== "object") {
|
|
125
175
|
return { ok: false, message: "clawchat-plugin-openclaw: params are required" };
|
|
@@ -285,6 +335,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
285
335
|
ok: true,
|
|
286
336
|
client: createOpenclawClawlingApiClient({
|
|
287
337
|
baseUrl: acct.baseUrl,
|
|
338
|
+
mediaBaseUrl: acct.mediaBaseUrl,
|
|
288
339
|
token: acct.token,
|
|
289
340
|
userId: acct.userId,
|
|
290
341
|
}),
|
|
@@ -329,8 +380,16 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
329
380
|
? rawData
|
|
330
381
|
: {};
|
|
331
382
|
const outcome = mapGateOutcome(code, gateData);
|
|
332
|
-
if (outcome !== null)
|
|
333
|
-
|
|
383
|
+
if (outcome !== null) {
|
|
384
|
+
// `next_step` is added here, not in mapGateOutcome: that mapping
|
|
385
|
+
// is pinned byte-for-byte against the Hermes parity fixtures.
|
|
386
|
+
return jsonResponse(outcome.status === "pending_owner_approval"
|
|
387
|
+
? {
|
|
388
|
+
...outcome,
|
|
389
|
+
next_step: "Waiting for the owner's approval; do not call this tool again now. " + PERMISSION_RECEIPT_RULE,
|
|
390
|
+
}
|
|
391
|
+
: outcome);
|
|
392
|
+
}
|
|
334
393
|
}
|
|
335
394
|
return apiError(err);
|
|
336
395
|
}
|
|
@@ -1100,7 +1159,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
1100
1159
|
label: "Create ClawChat Moment",
|
|
1101
1160
|
description: toolDescription("Create a new ClawChat moment/dynamic for the configured ClawChat account. " +
|
|
1102
1161
|
"TRIGGER - invoke when the user asks to publish, post, or send a ClawChat moment/dynamic, such as \"post a ClawChat moment saying ...\", \"publish a dynamic ...\", or \"send a friend moment ...\". " +
|
|
1103
|
-
"At least one of text or images must be present.
|
|
1162
|
+
"At least one of text or images must be present. Each images entry is either an http(s) image URL (used as-is) or an absolute local image path (max 20MB) — for a local path this tool uploads it to ClawChat media itself and uses the hosted URL, so no separate upload step is needed. Any other value (relative path, file:// or data: URI, missing file) is rejected and no moment is created."),
|
|
1104
1163
|
parameters: ClawchatCreateMomentSchema,
|
|
1105
1164
|
async execute(_callId, params) {
|
|
1106
1165
|
return await recordClawchatToolCall(accountId, "clawchat_create_moment", params, async () => {
|
|
@@ -1110,10 +1169,33 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
1110
1169
|
if (!text && (!images || images.length === 0)) {
|
|
1111
1170
|
return validationError("clawchat-plugin-openclaw: at least one of text or images is required");
|
|
1112
1171
|
}
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1172
|
+
// Validate every entry before touching the network, so a bad value
|
|
1173
|
+
// never leaves a half-uploaded batch or a moment with a broken image.
|
|
1174
|
+
const plan = images !== undefined ? planMomentImages(images) : { ok: true, entries: [] };
|
|
1175
|
+
if (!plan.ok)
|
|
1176
|
+
return validationError(plan.message);
|
|
1177
|
+
return await withGatedClient(accountId, async (c) => {
|
|
1178
|
+
const resolved = [];
|
|
1179
|
+
for (const entry of plan.entries) {
|
|
1180
|
+
if (entry.kind === "url") {
|
|
1181
|
+
resolved.push(entry.url);
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
const uploaded = await c.uploadMedia({
|
|
1185
|
+
buffer: fs.readFileSync(entry.path),
|
|
1186
|
+
filename: path.basename(entry.path),
|
|
1187
|
+
mime: inferMimeFromPath(entry.path),
|
|
1188
|
+
});
|
|
1189
|
+
if (uploaded.kind !== "image") {
|
|
1190
|
+
throw new Error(`clawchat-plugin-openclaw: ${entry.path} is not an image (media service classified it as ${uploaded.kind}); no moment was created`);
|
|
1191
|
+
}
|
|
1192
|
+
resolved.push(uploaded.url);
|
|
1193
|
+
}
|
|
1194
|
+
return await c.createMoment({
|
|
1195
|
+
...(text !== undefined ? { text } : {}),
|
|
1196
|
+
...(images !== undefined ? { images: resolved } : {}),
|
|
1197
|
+
});
|
|
1198
|
+
});
|
|
1117
1199
|
});
|
|
1118
1200
|
},
|
|
1119
1201
|
};
|
|
@@ -1449,11 +1531,18 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
1449
1531
|
return {
|
|
1450
1532
|
name: "clawchat_orchestrate_set_agent_behavior",
|
|
1451
1533
|
label: "Rewrite A Sibling's System Prompt",
|
|
1452
|
-
description: toolDescription("
|
|
1534
|
+
description: toolDescription("Stage/room speaking rules go in the group description, not here; for a stage, append the stage sentence below to off-desktop members (clawchat-orchestration skill). " +
|
|
1535
|
+
"Replace the WHOLE system prompt (behavior) of one of the owner's other agents. " +
|
|
1453
1536
|
"TRIGGER — invoke when the owner asks to change how another agent behaves, talks, or what it is for. " +
|
|
1454
1537
|
"This REPLACES the entire field — call clawchat_orchestrate_get_agent first, edit the text you got back, and send the full new value. " +
|
|
1455
1538
|
"Sending a fragment deletes everything else and the owner cannot recover it. Max 3000 runes. " +
|
|
1456
|
-
"Nickname and bio cannot be changed here."
|
|
1539
|
+
"Nickname and bio cannot be changed here. " +
|
|
1540
|
+
"Behavior follows an agent into every room, so one room's rules never go here — they go in that group's description (clawchat_orchestrate_set_group_prompt). " +
|
|
1541
|
+
"A stage (a group whose description has its agents chime in freely) adds only one line here, the same for every room: " +
|
|
1542
|
+
"give each member that the ClawChat desktop app does not run on the owner's computer — Hermes and OpenClaw plugin agents among them — this sentence, verbatim, " +
|
|
1543
|
+
"appended without deleting anything and without naming the room: " +
|
|
1544
|
+
"\"In a group whose description makes it a stage, you are one of the players: pick up the other characters' lines without waiting to be called.\" " +
|
|
1545
|
+
"Older plugin versions rank the group description below their own reply rules; this line is what lets them speak on a stage."),
|
|
1457
1546
|
parameters: ClawchatOrchSetAgentBehaviorSchema,
|
|
1458
1547
|
async execute(_callId, params) {
|
|
1459
1548
|
return await recordClawchatToolCall(accountId, "clawchat_orchestrate_set_agent_behavior", params, async () => {
|
|
@@ -1497,11 +1586,15 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
1497
1586
|
return {
|
|
1498
1587
|
name: "clawchat_orchestrate_set_group_prompt",
|
|
1499
1588
|
label: "Rewrite A Group's System Prompt",
|
|
1500
|
-
description: toolDescription("
|
|
1589
|
+
description: toolDescription("Stage/room speaking rules go here, in the group description; for a stage, also append the stage sentence to off-desktop members' behavior (clawchat-orchestration skill). " +
|
|
1590
|
+
"Replace the WHOLE system prompt (description) of a group the owner administers. " +
|
|
1501
1591
|
"TRIGGER — invoke when the owner asks to change how a group behaves as a whole. " +
|
|
1502
1592
|
"This REPLACES the entire field — call clawchat_orchestrate_get_group first and send the full new value. " +
|
|
1503
1593
|
"Sending a fragment deletes everything else and the owner cannot recover it. Max 3000 runes. " +
|
|
1504
|
-
"The group title cannot be changed here."
|
|
1594
|
+
"The group title cannot be changed here. " +
|
|
1595
|
+
"A room where agents should chime in freely, a stage, says so in its description, and its own rules go nowhere else: behavior follows an agent into every room. " +
|
|
1596
|
+
"A stage also needs one room-independent line in the behavior of each member the ClawChat desktop app does not run on the owner's computer, appended verbatim with clawchat_orchestrate_set_agent_behavior: " +
|
|
1597
|
+
"\"In a group whose description makes it a stage, you are one of the players: pick up the other characters' lines without waiting to be called.\""),
|
|
1505
1598
|
parameters: ClawchatOrchSetGroupPromptSchema,
|
|
1506
1599
|
async execute(_callId, params) {
|
|
1507
1600
|
return await recordClawchatToolCall(accountId, "clawchat_orchestrate_set_group_prompt", params, async () => {
|