@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.
@@ -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
+ }
@@ -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
- livewareSampleSupervisor.stop();
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
- senderIsGroupOwner: turn.senderIsGroupOwner ?? false,
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,
@@ -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.15.2";
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`. */
@@ -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: "Image URL for the moment. Upload local files first; do not pass local paths.",
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
- return jsonResponse(outcome);
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. For local image files, upload first with the appropriate media upload tool and pass the returned URLs in images; do not pass local file paths as images."),
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
- return await withGatedClient(accountId, (c) => c.createMoment({
1114
- ...(text !== undefined ? { text } : {}),
1115
- ...(images !== undefined ? { images } : {}),
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("Replace the WHOLE system prompt (behavior) of one of the owner's other agents. " +
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("Replace the WHOLE system prompt (description) of a group the owner administers. " +
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 () => {