@clawling/clawchat-plugin-openclaw 2026.10.7-1 → 2026.10.8-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/clawchat-metadata.js +10 -2
- package/dist/src/framework-error-copy.js +172 -0
- package/dist/src/liveware-sample.js +106 -19
- package/dist/src/permission-result.js +116 -27
- package/dist/src/profile-prompt.js +17 -2
- package/dist/src/reply-dispatcher.js +92 -10
- package/dist/src/reply-stream.js +14 -12
- package/dist/src/runtime.js +145 -2
- package/dist/src/skill-update.js +1 -1
- package/dist/src/tools-schema.js +1 -1
- package/dist/src/tools.js +90 -8
- package/package.json +3 -8
- package/skills/clawchat-core/SKILL.md +12 -3
- package/skills/clawchat-orchestration/SKILL.md +2 -1
- package/skills/clawchat-set-greeting/SKILL.md +5 -4
- package/skills/manifest.json +25 -25
- package/src/clawchat-metadata.ts +9 -2
- package/src/framework-error-copy.ts +185 -0
- package/src/liveware-sample.ts +106 -19
- package/src/permission-result.ts +134 -30
- package/src/profile-prompt.ts +21 -2
- package/src/reply-dispatcher.ts +105 -8
- package/src/reply-stream.ts +20 -15
- package/src/runtime.ts +143 -3
- package/src/skill-update.ts +1 -1
- package/src/tools-schema.ts +1 -1
- package/src/tools.ts +99 -8
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { interactiveReplyToPresentation, renderMessagePresentationFallbackText, } from "openclaw/plugin-sdk/interactive-runtime";
|
|
2
2
|
import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
|
|
3
|
+
import { classifyFrameworkError, formatFrameworkErrorForOwner, isFrameworkErrorReply, } from "./framework-error-copy.js";
|
|
3
4
|
import { createOpenclawClawlingApiClient } from "./api-client.js";
|
|
4
5
|
import { effectiveOutputVisibility, } from "./config.js";
|
|
5
6
|
import { describeOutboundMediaShortfall, uploadOutboundMedia, } from "./media-runtime.js";
|
|
6
7
|
import { mintMessageId, sendOpenclawClawlingText, } from "./outbound.js";
|
|
7
8
|
import { isClawChatNoopResponseText } from "./profile-prompt.js";
|
|
8
9
|
import { consumeTerminalClawChatSend } from "./terminal-send.js";
|
|
9
|
-
import { createReplyStream } from "./reply-stream.js";
|
|
10
|
+
import { createReplyStream, streamableText } from "./reply-stream.js";
|
|
10
11
|
import { openclawLlmContextDebug } from "./llm-context-debug.js";
|
|
11
12
|
/**
|
|
12
13
|
* Re-send cadence for `typing.update {is_typing:true}` while a reply is in
|
|
@@ -265,7 +266,7 @@ function formatApprovalSummary(payload) {
|
|
|
265
266
|
* as separate ClawChat messages.
|
|
266
267
|
*/
|
|
267
268
|
export function createOpenclawClawlingReplyDispatcher(options) {
|
|
268
|
-
const { cfg, runtime, account, client, target, replyCtx, inboundMessageId, store, resolveOwnerConversationId, log, } = options;
|
|
269
|
+
const { cfg, runtime, account, client, target, replyCtx, inboundMessageId, store, resolveOwnerConversationId, resolveOwnerLocale, log, } = options;
|
|
269
270
|
const isGroupTarget = target.chatType === "group";
|
|
270
271
|
const outputVisibility = effectiveOutputVisibility(account, target.chatId, target.chatType);
|
|
271
272
|
const splitFullOutput = outputVisibility === "full";
|
|
@@ -344,7 +345,10 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
344
345
|
// send, or an idle run with nothing delivered fails it. Host partials are snapshots of
|
|
345
346
|
// the current assistant message that may be rewritten, so a snapshot that no
|
|
346
347
|
// longer extends the preview fails the old stream and starts a new one.
|
|
347
|
-
|
|
348
|
+
// Direct chats only, same as Hermes. In a group the server's merged stream
|
|
349
|
+
// copy carries no @, and a receiver that dedupes by message_id keeps
|
|
350
|
+
// whichever copy lands first, so an @-only agent would never wake up.
|
|
351
|
+
const streamEnabled = account.streamReplies === true && !isGroupTarget;
|
|
348
352
|
let replyStream = null;
|
|
349
353
|
const failStream = (reason) => {
|
|
350
354
|
replyStream?.fail(reason);
|
|
@@ -355,11 +359,19 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
355
359
|
replyStream = null;
|
|
356
360
|
return id;
|
|
357
361
|
};
|
|
362
|
+
// Latest host snapshot of the current assistant message, including any tail
|
|
363
|
+
// the add throttle held back, so a boundary can close the preview whole.
|
|
364
|
+
let streamPartialText = "";
|
|
365
|
+
// Texts already delivered as their own message at a message boundary; a
|
|
366
|
+
// final that only repeats one of them is not sent again.
|
|
367
|
+
const finalizedPreviewTexts = new Set();
|
|
358
368
|
const onStreamPartial = (text) => {
|
|
359
369
|
if (runDone || terminalReplySuppressed)
|
|
360
370
|
return;
|
|
361
|
-
|
|
362
|
-
|
|
371
|
+
// A snapshot that no longer extends the preview without a message
|
|
372
|
+
// boundary is a host rewrite: the stream stops growing and the final
|
|
373
|
+
// replaces it in place (same id). It is never retracted.
|
|
374
|
+
streamPartialText = text;
|
|
363
375
|
replyStream ??= createReplyStream({
|
|
364
376
|
client,
|
|
365
377
|
chatId: target.chatId,
|
|
@@ -555,6 +567,36 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
555
567
|
log,
|
|
556
568
|
});
|
|
557
569
|
};
|
|
570
|
+
// ----- Stream message boundary -----------------------------------------
|
|
571
|
+
//
|
|
572
|
+
// A new assistant message (after a tool call or a thinking block) means the
|
|
573
|
+
// previewed one is complete. Close it as its own message — `message.done` +
|
|
574
|
+
// `message.reply` under the same id — instead of letting the next snapshot
|
|
575
|
+
// supersede it, so text the recipient already saw stays. A preview still
|
|
576
|
+
// held back (never shown) is just dropped.
|
|
577
|
+
const finalizeStreamAtBoundary = async (reason) => {
|
|
578
|
+
if (!replyStream)
|
|
579
|
+
return;
|
|
580
|
+
if (!replyStream.isOpen()) {
|
|
581
|
+
replyStream = null;
|
|
582
|
+
streamPartialText = "";
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const decision = streamableText(streamPartialText);
|
|
586
|
+
const text = decision.kind === "ok"
|
|
587
|
+
? decision.text
|
|
588
|
+
: decision.kind === "poisoned"
|
|
589
|
+
? streamPartialText
|
|
590
|
+
: replyStream.shownText();
|
|
591
|
+
streamPartialText = "";
|
|
592
|
+
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw stream boundary reason=${reason} text_len=${text.length} to=${target.chatId}`);
|
|
593
|
+
const result = await sendStatic(text, [], [], { recordMessage: true });
|
|
594
|
+
if (result)
|
|
595
|
+
finalizedPreviewTexts.add(text.trim());
|
|
596
|
+
// Whatever sendStatic did (sent, suppressed as no-reply, skipped after a
|
|
597
|
+
// terminal tool send), the stream is closed now.
|
|
598
|
+
replyStream = null;
|
|
599
|
+
};
|
|
558
600
|
// ----- Static send ------------------------------------------------------
|
|
559
601
|
const sendStatic = async (text, mediaFragments = [], richFragments = [], options = {}) => {
|
|
560
602
|
const messageMode = options.messageMode ?? "normal";
|
|
@@ -753,7 +795,16 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
753
795
|
});
|
|
754
796
|
return;
|
|
755
797
|
}
|
|
756
|
-
|
|
798
|
+
// OpenClaw's own failure copy (isError, or a leading ⚠️) is English
|
|
799
|
+
// and often operator-facing; lead it with an owner-language sentence
|
|
800
|
+
// and keep the original at the end.
|
|
801
|
+
const deliveredText = isFrameworkErrorReply(payload, text)
|
|
802
|
+
? formatFrameworkErrorForOwner(text, resolveOwnerLocale?.() ?? null)
|
|
803
|
+
: text;
|
|
804
|
+
if (deliveredText !== text) {
|
|
805
|
+
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw final framework error localized category=${classifyFrameworkError(text)}`);
|
|
806
|
+
}
|
|
807
|
+
const finalText = richFragment && account.richInteractions ? mergeFinalText("") : mergeFinalText(deliveredText);
|
|
757
808
|
const finalUrls = mergeFinalUrls(urls);
|
|
758
809
|
if (isClawChatNoopResponseText(finalText) &&
|
|
759
810
|
!richFragment &&
|
|
@@ -762,6 +813,13 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
762
813
|
failStream("no-reply");
|
|
763
814
|
return;
|
|
764
815
|
}
|
|
816
|
+
if (!replyStream?.isOpen() &&
|
|
817
|
+
finalizedPreviewTexts.has(finalText.trim()) &&
|
|
818
|
+
!richFragment &&
|
|
819
|
+
finalUrls.length === 0) {
|
|
820
|
+
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw final skipped: already delivered at a message boundary`);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
765
823
|
const mediaFragments = await uploadMediaUrls(finalUrls);
|
|
766
824
|
const result = await sendStatic(finalText, mediaFragments, richFragment && account.richInteractions ? [richFragment] : [], { recordMessage: true });
|
|
767
825
|
if (result?.messageId)
|
|
@@ -786,16 +844,31 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
786
844
|
return;
|
|
787
845
|
const fallbackText = bufferedOutputText.trim();
|
|
788
846
|
const fallbackUrls = bufferedOutputUrls.slice();
|
|
789
|
-
if (!fallbackText && fallbackUrls.length === 0)
|
|
847
|
+
if (!fallbackText && fallbackUrls.length === 0) {
|
|
848
|
+
// No final came, but a preview was shown: keep it as the reply
|
|
849
|
+
// rather than retracting text the recipient already read.
|
|
850
|
+
if (replyStream?.isOpen())
|
|
851
|
+
await finalizeStreamAtBoundary("idle");
|
|
790
852
|
return;
|
|
853
|
+
}
|
|
791
854
|
const mediaFragments = await uploadMediaUrls(fallbackUrls);
|
|
792
855
|
const result = await sendStatic(fallbackText, mediaFragments, [], { recordMessage: true });
|
|
793
856
|
if (result?.messageId)
|
|
794
857
|
recordThinkingIfLinked(result.messageId);
|
|
795
858
|
}
|
|
796
859
|
finally {
|
|
797
|
-
//
|
|
798
|
-
|
|
860
|
+
// Safety net: nothing above claimed or closed an open stream (e.g. the
|
|
861
|
+
// finalize send threw). It must not linger open, and text already
|
|
862
|
+
// shown must not vanish: close it with what was shown (the server
|
|
863
|
+
// materializes that from message.done). Only a preview whose snapshot
|
|
864
|
+
// turned out to be a no-reply is retracted.
|
|
865
|
+
if (replyStream?.isOpen()) {
|
|
866
|
+
if (streamableText(streamPartialText).kind === "poisoned")
|
|
867
|
+
failStream("no-reply");
|
|
868
|
+
else
|
|
869
|
+
claimStreamMessageId(replyStream.shownText());
|
|
870
|
+
}
|
|
871
|
+
replyStream = null;
|
|
799
872
|
}
|
|
800
873
|
},
|
|
801
874
|
onCleanup: () => {
|
|
@@ -830,13 +903,22 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
830
903
|
reasoningText = reasoningText ? `${reasoningText}\n${trimmed}` : trimmed;
|
|
831
904
|
}
|
|
832
905
|
: undefined,
|
|
833
|
-
onToolStart: splitFullOutput
|
|
906
|
+
onToolStart: splitFullOutput || streamEnabled
|
|
834
907
|
? async (payload) => {
|
|
908
|
+
if (streamEnabled)
|
|
909
|
+
await finalizeStreamAtBoundary("tool-start");
|
|
910
|
+
if (!splitFullOutput)
|
|
911
|
+
return;
|
|
835
912
|
if (consumeTerminalSend("tool-start"))
|
|
836
913
|
return;
|
|
837
914
|
await emitProcessSegment(formatToolStartSummary(payload));
|
|
838
915
|
}
|
|
839
916
|
: undefined,
|
|
917
|
+
onAssistantMessageStart: streamEnabled
|
|
918
|
+
? async () => {
|
|
919
|
+
await finalizeStreamAtBoundary("assistant-message-start");
|
|
920
|
+
}
|
|
921
|
+
: undefined,
|
|
840
922
|
onToolResult: splitFullOutput
|
|
841
923
|
? async (payload) => {
|
|
842
924
|
if (consumeTerminalSend("tool-result"))
|
package/dist/src/reply-stream.js
CHANGED
|
@@ -11,12 +11,13 @@
|
|
|
11
11
|
* and the server materializes its merged reply from the stream. `finish`
|
|
12
12
|
* therefore takes the final text, sends whatever tail of it the throttle held
|
|
13
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)
|
|
15
|
-
* the final
|
|
14
|
+
* extend what was shown cannot be appended (§8.2): `message.done` then closes
|
|
15
|
+
* with the shown text and the final reply, under the same id, replaces it in
|
|
16
|
+
* place, so text the recipient already saw never vanishes.
|
|
16
17
|
*
|
|
17
|
-
* or, when the
|
|
18
|
-
*
|
|
19
|
-
* which
|
|
18
|
+
* or, when the preview must not stay (a no-reply, or the reply went out
|
|
19
|
+
* through a ClawChat tool instead): message.created(M) → … → message.failed(M),
|
|
20
|
+
* which retracts the preview — the recipient keeps nothing of it.
|
|
20
21
|
*
|
|
21
22
|
* Streaming frames get no ack (§8.5) and no server-side mention repair, so the
|
|
22
23
|
* stream only ever carries plain text; mentions, media and rich fragments ride
|
|
@@ -184,23 +185,24 @@ export function createReplyStream(options) {
|
|
|
184
185
|
return null;
|
|
185
186
|
}
|
|
186
187
|
// Trailing whitespace a partial carried may be trimmed off the final.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
return null;
|
|
190
|
-
}
|
|
191
|
-
if (finalText.length > sent.length && finalText.startsWith(sent)) {
|
|
188
|
+
const extendsShown = finalText.startsWith(sent.trimEnd());
|
|
189
|
+
if (extendsShown && finalText.length > sent.length && finalText.startsWith(sent)) {
|
|
192
190
|
// Past the throttle: this is the last frame before done.
|
|
193
191
|
sendAdd(finalText, now());
|
|
194
192
|
}
|
|
193
|
+
// A final that rewrites the preview cannot be appended (§8.2), but the
|
|
194
|
+
// shown text must not vanish: close the stream with what was shown and
|
|
195
|
+
// let the final reply under this same id replace it in place.
|
|
196
|
+
const doneText = extendsShown ? finalText : sent;
|
|
195
197
|
closed = true;
|
|
196
198
|
const at = now();
|
|
197
199
|
emit(EVENT.MESSAGE_DONE, {
|
|
198
200
|
message_id: messageId,
|
|
199
|
-
fragments: [{ kind: "text", text:
|
|
201
|
+
fragments: [{ kind: "text", text: doneText }],
|
|
200
202
|
streaming: streamingState("done", at),
|
|
201
203
|
completed_at: at,
|
|
202
204
|
});
|
|
203
|
-
options.log?.info?.(`${prefix} stream done msg=${messageId} text_len=${finalText.length}`);
|
|
205
|
+
options.log?.info?.(`${prefix} stream done msg=${messageId} text_len=${finalText.length}${extendsShown ? "" : " replaced_in_place=true"}`);
|
|
204
206
|
return messageId;
|
|
205
207
|
},
|
|
206
208
|
fail,
|
package/dist/src/runtime.js
CHANGED
|
@@ -859,6 +859,10 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
859
859
|
// `sendOwnerSkillMessage` below) — both live in this same function closure, so
|
|
860
860
|
// the forward reference here just resolves to the fresh value at call time.
|
|
861
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;
|
|
862
866
|
if (store) {
|
|
863
867
|
const stateDirForSample = runtime.state?.resolveStateDir?.();
|
|
864
868
|
const livewareSampleDeps = {
|
|
@@ -901,6 +905,7 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
901
905
|
livewareSampleSupervisors.set(abortSignal, supervisorsForSignal);
|
|
902
906
|
const supervisorKey = `openclaw:${accountId}`;
|
|
903
907
|
const runningSupervisor = supervisorsForSignal.get(supervisorKey);
|
|
908
|
+
livewareSampleForShutdown = runningSupervisor ?? null;
|
|
904
909
|
if (runningSupervisor) {
|
|
905
910
|
// A gateway re-entry (refresh/backoff reconnect), not a fresh channel start:
|
|
906
911
|
// hand the live supervisor this closure's token + REST client + notify path
|
|
@@ -910,9 +915,12 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
910
915
|
}
|
|
911
916
|
else {
|
|
912
917
|
const livewareSampleSupervisor = new LivewareSampleSupervisor(livewareSampleDeps);
|
|
918
|
+
livewareSampleForShutdown = livewareSampleSupervisor;
|
|
913
919
|
supervisorsForSignal.set(supervisorKey, livewareSampleSupervisor);
|
|
914
920
|
abortSignal.addEventListener("abort", () => {
|
|
915
|
-
|
|
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();
|
|
916
924
|
if (supervisorsForSignal.get(supervisorKey) === livewareSampleSupervisor) {
|
|
917
925
|
supervisorsForSignal.delete(supervisorKey);
|
|
918
926
|
}
|
|
@@ -1188,6 +1196,25 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
1188
1196
|
const persistOwnerProfile = upsertOwnerProfile
|
|
1189
1197
|
? makeOwnerProfilePersister({ upsertOwnerProfile }, { accountId })
|
|
1190
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
|
+
};
|
|
1191
1218
|
const refreshAgentBehavior = async (options) => {
|
|
1192
1219
|
try {
|
|
1193
1220
|
await pullOwnerMetadata({
|
|
@@ -2305,6 +2332,9 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2305
2332
|
if (type === "friend.added") {
|
|
2306
2333
|
scheduleFriendGreeting(payload);
|
|
2307
2334
|
}
|
|
2335
|
+
if (type === "friend.profile_updated") {
|
|
2336
|
+
refreshOwnerProfileOnUpdate(payload?.entity_id);
|
|
2337
|
+
}
|
|
2308
2338
|
}
|
|
2309
2339
|
}
|
|
2310
2340
|
});
|
|
@@ -2409,6 +2439,8 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2409
2439
|
? groupFile.metadata.group_owner_nickname ?? metadata?.nickname ?? userId
|
|
2410
2440
|
: metadata?.nickname ?? userId;
|
|
2411
2441
|
const profileType = metadata?.profile_type ?? (userId === account.userId ? "agent" : "user");
|
|
2442
|
+
if (name !== userId)
|
|
2443
|
+
knownNicknames.set(userId, name);
|
|
2412
2444
|
participants.push({ id: userId, name, profileType, isAgentOwner, isGroupOwner });
|
|
2413
2445
|
}
|
|
2414
2446
|
return participants;
|
|
@@ -2444,6 +2476,7 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2444
2476
|
basePrompt: turn.peer.kind === "group" ? getClawChatGroupPrompt() : getClawChatUserPrompt(),
|
|
2445
2477
|
...promptMetadata,
|
|
2446
2478
|
ownerIdFallback: account.ownerUserId,
|
|
2479
|
+
currentAgentIdFallback: account.userId,
|
|
2447
2480
|
groupParticipants,
|
|
2448
2481
|
turn: {
|
|
2449
2482
|
chatType: promptChatType,
|
|
@@ -2463,9 +2496,105 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2463
2496
|
},
|
|
2464
2497
|
});
|
|
2465
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
|
+
// The wire carries no sender type, so a batch message from another agent
|
|
2556
|
+
// (Hermes, a second OpenClaw…) would read as a person. users/<id>.md holds
|
|
2557
|
+
// the backend's user type; this agent is always an agent.
|
|
2558
|
+
const typeCache = new Map();
|
|
2559
|
+
const lookupProfileType = async (userId) => {
|
|
2560
|
+
if (userId === account.userId)
|
|
2561
|
+
return "agent";
|
|
2562
|
+
if (!typeCache.has(userId)) {
|
|
2563
|
+
let profileType = null;
|
|
2564
|
+
try {
|
|
2565
|
+
const userFile = await readClawChatMemoryFile(memoryRoot, { targetType: "user", targetId: userId });
|
|
2566
|
+
profileType = userFile.exists && userFile.metadata.profile_type ? userFile.metadata.profile_type : null;
|
|
2567
|
+
}
|
|
2568
|
+
catch {
|
|
2569
|
+
profileType = null;
|
|
2570
|
+
}
|
|
2571
|
+
typeCache.set(userId, profileType);
|
|
2572
|
+
}
|
|
2573
|
+
return typeCache.get(userId) ?? null;
|
|
2574
|
+
};
|
|
2575
|
+
const groupMessages = turn.groupMessages
|
|
2576
|
+
? await Promise.all(turn.groupMessages.map(async (message) => {
|
|
2577
|
+
const named = isBareId(message.senderName, message.senderId)
|
|
2578
|
+
? { ...message, senderName: (await lookup(message.senderId)) ?? message.senderName }
|
|
2579
|
+
: message;
|
|
2580
|
+
const profileType = await lookupProfileType(message.senderId);
|
|
2581
|
+
if (!profileType || profileType === named.senderProfileType)
|
|
2582
|
+
return named;
|
|
2583
|
+
const senderRelation = profileType === "agent" && named.senderRelation === "peer_user"
|
|
2584
|
+
? "peer_agent"
|
|
2585
|
+
: named.senderRelation;
|
|
2586
|
+
return { ...named, senderProfileType: profileType, senderRelation };
|
|
2587
|
+
}))
|
|
2588
|
+
: turn.groupMessages;
|
|
2589
|
+
return { ...turn, senderNickName, ...(groupMessages ? { groupMessages } : {}) };
|
|
2590
|
+
};
|
|
2466
2591
|
const resolveSenderNickNameForTurn = (turn) => {
|
|
2467
2592
|
if (turn.senderNickName && turn.senderNickName !== turn.senderId)
|
|
2468
2593
|
return turn.senderNickName;
|
|
2594
|
+
// Synchronous on purpose: an await here would reorder same-group frames.
|
|
2595
|
+
const known = turn.peer.kind === "group" ? knownNicknames.get(turn.senderId) : undefined;
|
|
2596
|
+
if (known)
|
|
2597
|
+
return known;
|
|
2469
2598
|
return turn.senderNickName || turn.senderId;
|
|
2470
2599
|
};
|
|
2471
2600
|
const resolveSenderBatchIdentityForTurn = (turn) => {
|
|
@@ -2480,7 +2609,9 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2480
2609
|
senderProfileType: turn.senderProfileType ??
|
|
2481
2610
|
(senderRelation === "self_agent" || senderRelation === "peer_agent" ? "agent" : "user"),
|
|
2482
2611
|
senderIsOwner: senderRelation === "owner",
|
|
2483
|
-
|
|
2612
|
+
// Left undefined when the frame does not say, so the prompt falls back
|
|
2613
|
+
// to the group metadata's group_owner_id instead of a forced false.
|
|
2614
|
+
...(turn.senderIsGroupOwner !== undefined ? { senderIsGroupOwner: turn.senderIsGroupOwner } : {}),
|
|
2484
2615
|
};
|
|
2485
2616
|
};
|
|
2486
2617
|
const claimInboundTurn = (turn) => {
|
|
@@ -2550,6 +2681,7 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2550
2681
|
catch (err) {
|
|
2551
2682
|
log?.error?.(`[${accountId}] clawchat-plugin-openclaw message metadata refresh failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2552
2683
|
}
|
|
2684
|
+
turn = await withCachedGroupSenderNames(turn, memoryRoot);
|
|
2553
2685
|
const conversationTarget = `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`;
|
|
2554
2686
|
const turnPrompt = await buildPromptForTurn(turn, memoryRoot);
|
|
2555
2687
|
const senderIsOwner = turn.senderIsOwner === true || turn.senderId === account.ownerUserId;
|
|
@@ -2680,6 +2812,7 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2680
2812
|
runtime,
|
|
2681
2813
|
account,
|
|
2682
2814
|
client,
|
|
2815
|
+
resolveOwnerLocale: () => store?.getOwnerProfile?.({ platform: "openclaw", accountId })?.locale ?? null,
|
|
2683
2816
|
target: { chatId: turn.peer.id, chatType: turn.peer.kind },
|
|
2684
2817
|
...(replyCtx ? { replyCtx } : {}),
|
|
2685
2818
|
inboundMessageId: turn.messageId,
|
|
@@ -3270,6 +3403,16 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
3270
3403
|
closeReason: "client close",
|
|
3271
3404
|
});
|
|
3272
3405
|
client.close();
|
|
3406
|
+
if (livewareSampleForShutdown) {
|
|
3407
|
+
// SIGTERM, then SIGKILL after KILL_GRACE_MS (3 s): inside the host's
|
|
3408
|
+
// 5 s wait for this task, so a stuck child no longer outlives the stop.
|
|
3409
|
+
try {
|
|
3410
|
+
await livewareSampleForShutdown.stop();
|
|
3411
|
+
}
|
|
3412
|
+
catch (err) {
|
|
3413
|
+
log?.error?.(`[${accountId}] clawchat-plugin-openclaw liveware-sample stop failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
3414
|
+
}
|
|
3415
|
+
}
|
|
3273
3416
|
setStatus({
|
|
3274
3417
|
...getStatus(),
|
|
3275
3418
|
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
|
};
|