@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,244 @@
|
|
|
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.ts";
|
|
33
|
+
import { isClawChatNoopResponseText } from "./profile-prompt.ts";
|
|
34
|
+
import { isNoReplyTokenPrefix } from "./no-reply.ts";
|
|
35
|
+
import type { ClawlingChatClient } from "./ws-client.ts";
|
|
36
|
+
|
|
37
|
+
/** Minimum visible (trimmed) length before a stream is opened. */
|
|
38
|
+
export const STREAM_HOLD_CHARS = 32;
|
|
39
|
+
/** Minimum spacing between two `message.add` frames for one stream. */
|
|
40
|
+
export const STREAM_MIN_ADD_INTERVAL_MS = 250;
|
|
41
|
+
/** How far back from the end a partial no-reply token is looked for. */
|
|
42
|
+
const TOKEN_TAIL_WINDOW = 40;
|
|
43
|
+
|
|
44
|
+
export type StreamableText =
|
|
45
|
+
| { kind: "hold" }
|
|
46
|
+
| { kind: "poisoned" }
|
|
47
|
+
| { kind: "ok"; text: string };
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Decide how much of a cumulative partial `text` may be shown.
|
|
51
|
+
* - `poisoned`: the text is (or contains) a no-reply token; never show it.
|
|
52
|
+
* - `hold`: too short to be sure yet.
|
|
53
|
+
* - `ok`: `text` with any trailing possible-token prefix cut off.
|
|
54
|
+
*/
|
|
55
|
+
export function streamableText(text: string): StreamableText {
|
|
56
|
+
if (isClawChatNoopResponseText(text)) return { kind: "poisoned" };
|
|
57
|
+
let safe = text;
|
|
58
|
+
for (let i = Math.max(0, text.length - TOKEN_TAIL_WINDOW); i < text.length; i += 1) {
|
|
59
|
+
const ch = text[i]!;
|
|
60
|
+
if (/\s/.test(ch)) continue;
|
|
61
|
+
if (isNoReplyTokenPrefix(text.slice(i))) {
|
|
62
|
+
safe = text.slice(0, i);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (safe.trim().length < STREAM_HOLD_CHARS) return { kind: "hold" };
|
|
67
|
+
return { kind: "ok", text: safe };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ReplyStreamOptions {
|
|
71
|
+
client: ClawlingChatClient;
|
|
72
|
+
chatId: string;
|
|
73
|
+
mintMessageId: () => string;
|
|
74
|
+
now?: () => number;
|
|
75
|
+
log?: { info?: (m: string) => void; error?: (m: string) => void };
|
|
76
|
+
logPrefix?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ReplyStream {
|
|
80
|
+
/** Feed the cumulative text of the current assistant message. */
|
|
81
|
+
update(text: string): void;
|
|
82
|
+
/**
|
|
83
|
+
* True when `text` is showable but does not extend what this open stream
|
|
84
|
+
* already shows: a new assistant message or a host rewrite. The caller then
|
|
85
|
+
* fails this stream and starts a new one. Held / poisoned text never
|
|
86
|
+
* diverges (nothing new would be shown).
|
|
87
|
+
*/
|
|
88
|
+
diverges(text: string): boolean;
|
|
89
|
+
/** True once `message.created` went out and the stream is not closed. */
|
|
90
|
+
isOpen(): boolean;
|
|
91
|
+
/** Text the recipient currently sees for this stream. */
|
|
92
|
+
shownText(): string;
|
|
93
|
+
/**
|
|
94
|
+
* Close the stream with the final reply text: an unthrottled last
|
|
95
|
+
* `message.add` for any tail not yet shown, then `message.done` carrying
|
|
96
|
+
* `finalText`. Returns the message_id for the final materialized message to
|
|
97
|
+
* reuse. Returns null — the final then uses a fresh id — when no stream is
|
|
98
|
+
* open, or when `finalText` does not extend the shown text (or is a no-reply),
|
|
99
|
+
* in which case the stream is failed instead.
|
|
100
|
+
*/
|
|
101
|
+
finish(finalText: string): string | null;
|
|
102
|
+
/** Abort an open stream with `message.failed`. No-op when nothing is open. */
|
|
103
|
+
fail(reason: string): void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createReplyStream(options: ReplyStreamOptions): ReplyStream {
|
|
107
|
+
const now = options.now ?? Date.now;
|
|
108
|
+
const prefix = options.logPrefix ?? "clawchat-plugin-openclaw";
|
|
109
|
+
let messageId: string | null = null;
|
|
110
|
+
let closed = false;
|
|
111
|
+
let poisoned = false;
|
|
112
|
+
let diverged = false;
|
|
113
|
+
let sent = "";
|
|
114
|
+
let sequence = -1;
|
|
115
|
+
let lastAddAt = 0;
|
|
116
|
+
let startedAt = 0;
|
|
117
|
+
|
|
118
|
+
const canSend = (): boolean => {
|
|
119
|
+
const client = options.client as { transportState?: string; state?: string };
|
|
120
|
+
if (client.transportState !== undefined && client.transportState !== "open") return false;
|
|
121
|
+
if (client.state !== undefined && client.state !== "connected") return false;
|
|
122
|
+
return true;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const emit = (event: string, payload: Record<string, unknown>): boolean => {
|
|
126
|
+
try {
|
|
127
|
+
options.client.emitRaw(event, payload, { chat_id: options.chatId });
|
|
128
|
+
return true;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
options.log?.error?.(`${prefix} stream ${event} failed msg=${messageId}: ${String(error)}`);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const streamingState = (status: "streaming" | "done" | "failed", completedAt: number | null) => ({
|
|
136
|
+
status,
|
|
137
|
+
sequence: Math.max(sequence, 0),
|
|
138
|
+
mutation_policy: "append_text_only",
|
|
139
|
+
started_at: startedAt,
|
|
140
|
+
completed_at: completedAt,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const sendAdd = (next: string, at: number): boolean => {
|
|
144
|
+
sequence += 1;
|
|
145
|
+
const ok = emit(EVENT.MESSAGE_ADD, {
|
|
146
|
+
message_id: messageId,
|
|
147
|
+
sequence,
|
|
148
|
+
mutation: { type: "append", target_fragment_index: 0 },
|
|
149
|
+
fragments: [{ kind: "text", text: next, delta: next.slice(sent.length) }],
|
|
150
|
+
streaming: streamingState("streaming", null),
|
|
151
|
+
added_at: at,
|
|
152
|
+
});
|
|
153
|
+
if (!ok) {
|
|
154
|
+
sequence -= 1;
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
sent = next;
|
|
158
|
+
lastAddAt = at;
|
|
159
|
+
return true;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const fail = (reason: string): void => {
|
|
163
|
+
if (!messageId || closed) return;
|
|
164
|
+
closed = true;
|
|
165
|
+
const at = now();
|
|
166
|
+
emit(EVENT.MESSAGE_FAILED, {
|
|
167
|
+
message_id: messageId,
|
|
168
|
+
fragments: [{ kind: "text", text: sent }],
|
|
169
|
+
streaming: streamingState("failed", at),
|
|
170
|
+
completed_at: at,
|
|
171
|
+
});
|
|
172
|
+
options.log?.info?.(`${prefix} stream failed msg=${messageId} reason=${reason}`);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
update(text: string) {
|
|
177
|
+
if (closed || poisoned || diverged) return;
|
|
178
|
+
const decision = streamableText(text);
|
|
179
|
+
if (decision.kind === "poisoned") {
|
|
180
|
+
poisoned = true;
|
|
181
|
+
options.log?.info?.(`${prefix} stream halted: no-reply token in partial msg=${messageId ?? "-"}`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (decision.kind === "hold") return;
|
|
185
|
+
const next = decision.text;
|
|
186
|
+
if (messageId && !next.startsWith(sent)) {
|
|
187
|
+
// §8.2 delta invariant cannot hold for a rewrite; stop growing the
|
|
188
|
+
// stream and let the final message (same id) carry the real text.
|
|
189
|
+
diverged = true;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (next === sent) return;
|
|
193
|
+
if (!canSend()) return;
|
|
194
|
+
if (!messageId) {
|
|
195
|
+
const id = options.mintMessageId();
|
|
196
|
+
startedAt = now();
|
|
197
|
+
if (!emit(EVENT.MESSAGE_CREATED, { message_id: id, message_mode: "normal" })) return;
|
|
198
|
+
messageId = id;
|
|
199
|
+
options.log?.info?.(`${prefix} stream opened msg=${id} to=${options.chatId}`);
|
|
200
|
+
} else if (now() - lastAddAt < STREAM_MIN_ADD_INTERVAL_MS) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
sendAdd(next, now());
|
|
204
|
+
},
|
|
205
|
+
diverges(text: string) {
|
|
206
|
+
if (!messageId || closed) return false;
|
|
207
|
+
const decision = streamableText(text);
|
|
208
|
+
return decision.kind === "ok" && !decision.text.startsWith(sent);
|
|
209
|
+
},
|
|
210
|
+
isOpen() {
|
|
211
|
+
return messageId !== null && !closed;
|
|
212
|
+
},
|
|
213
|
+
shownText() {
|
|
214
|
+
return sent;
|
|
215
|
+
},
|
|
216
|
+
finish(finalText: string) {
|
|
217
|
+
if (!messageId || closed) return null;
|
|
218
|
+
if (streamableText(finalText).kind === "poisoned") {
|
|
219
|
+
fail("no-reply");
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
// Trailing whitespace a partial carried may be trimmed off the final.
|
|
223
|
+
if (!finalText.startsWith(sent.trimEnd())) {
|
|
224
|
+
fail("superseded");
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
if (finalText.length > sent.length && finalText.startsWith(sent)) {
|
|
228
|
+
// Past the throttle: this is the last frame before done.
|
|
229
|
+
sendAdd(finalText, now());
|
|
230
|
+
}
|
|
231
|
+
closed = true;
|
|
232
|
+
const at = now();
|
|
233
|
+
emit(EVENT.MESSAGE_DONE, {
|
|
234
|
+
message_id: messageId,
|
|
235
|
+
fragments: [{ kind: "text", text: finalText }],
|
|
236
|
+
streaming: streamingState("done", at),
|
|
237
|
+
completed_at: at,
|
|
238
|
+
});
|
|
239
|
+
options.log?.info?.(`${prefix} stream done msg=${messageId} text_len=${finalText.length}`);
|
|
240
|
+
return messageId;
|
|
241
|
+
},
|
|
242
|
+
fail,
|
|
243
|
+
};
|
|
244
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -96,6 +96,7 @@ import {
|
|
|
96
96
|
import { refreshGroupProfile, syncFirstSeenClawChatProfiles } from "./profile-sync.ts";
|
|
97
97
|
import { pullGroupMetadata, pullOwnerMetadata } from "./clawchat-metadata.ts";
|
|
98
98
|
import { deleteClawChatMemoryFile, readClawChatMemoryFile } from "./clawchat-memory.ts";
|
|
99
|
+
import { registerMentionRosterResolver } from "./mention-autolink.ts";
|
|
99
100
|
import {
|
|
100
101
|
clearClawChatPromptInjectionForSession,
|
|
101
102
|
stageClawChatPromptInjection,
|
|
@@ -1209,6 +1210,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1209
1210
|
// `sendOwnerSkillMessage` below) — both live in this same function closure, so
|
|
1210
1211
|
// the forward reference here just resolves to the fresh value at call time.
|
|
1211
1212
|
let livewareSampleNotify: (text: string) => Promise<boolean> = async () => false;
|
|
1213
|
+
// The supervisor this gateway call shares (new or adopted). The abort cleanup
|
|
1214
|
+
// below awaits its stop() so the host's channel stop (which waits on this
|
|
1215
|
+
// task) does not return while liveware children are still being killed.
|
|
1216
|
+
let livewareSampleForShutdown: LivewareSampleSupervisor | null = null;
|
|
1212
1217
|
if (store) {
|
|
1213
1218
|
const stateDirForSample = runtime.state?.resolveStateDir?.();
|
|
1214
1219
|
const livewareSampleDeps: LivewareSampleSupervisorDeps = {
|
|
@@ -1252,6 +1257,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1252
1257
|
livewareSampleSupervisors.set(abortSignal, supervisorsForSignal);
|
|
1253
1258
|
const supervisorKey = `openclaw:${accountId}`;
|
|
1254
1259
|
const runningSupervisor = supervisorsForSignal.get(supervisorKey);
|
|
1260
|
+
livewareSampleForShutdown = runningSupervisor ?? null;
|
|
1255
1261
|
if (runningSupervisor) {
|
|
1256
1262
|
// A gateway re-entry (refresh/backoff reconnect), not a fresh channel start:
|
|
1257
1263
|
// hand the live supervisor this closure's token + REST client + notify path
|
|
@@ -1262,9 +1268,12 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1262
1268
|
runningSupervisor.adoptDeps(livewareSampleDeps);
|
|
1263
1269
|
} else {
|
|
1264
1270
|
const livewareSampleSupervisor = new LivewareSampleSupervisor(livewareSampleDeps);
|
|
1271
|
+
livewareSampleForShutdown = livewareSampleSupervisor;
|
|
1265
1272
|
supervisorsForSignal.set(supervisorKey, livewareSampleSupervisor);
|
|
1266
1273
|
abortSignal.addEventListener("abort", () => {
|
|
1267
|
-
|
|
1274
|
+
// Starts the SIGTERM → SIGKILL teardown at once; the gateway's own
|
|
1275
|
+
// abort cleanup awaits the same (idempotent) stop() promise.
|
|
1276
|
+
void livewareSampleSupervisor.stop();
|
|
1268
1277
|
if (supervisorsForSignal.get(supervisorKey) === livewareSampleSupervisor) {
|
|
1269
1278
|
supervisorsForSignal.delete(supervisorKey);
|
|
1270
1279
|
}
|
|
@@ -1575,6 +1584,25 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1575
1584
|
const persistOwnerProfile = upsertOwnerProfile
|
|
1576
1585
|
? makeOwnerProfilePersister({ upsertOwnerProfile }, { accountId })
|
|
1577
1586
|
: undefined;
|
|
1587
|
+
// The owner_profile row is where owner-language copy reads its locale
|
|
1588
|
+
// (framework errors, greetings, liveware intros). It was only refreshed on
|
|
1589
|
+
// owner metadata pulls, so an owner who switched app language kept getting
|
|
1590
|
+
// the old one. friend.profile_updated also reaches the agent when the
|
|
1591
|
+
// owner edits their profile; refetch just the owner row then.
|
|
1592
|
+
const refreshOwnerProfileOnUpdate = (entityId: unknown): void => {
|
|
1593
|
+
const subject = typeof entityId === "string" ? entityId.trim() : "";
|
|
1594
|
+
if (!persistOwnerProfile || !subject || subject !== account.ownerUserId) return;
|
|
1595
|
+
void (async () => {
|
|
1596
|
+
try {
|
|
1597
|
+
const owner = await getConversationApiClient().getAgentOwner();
|
|
1598
|
+
persistOwnerProfile(owner as unknown as Record<string, unknown>);
|
|
1599
|
+
} catch (err) {
|
|
1600
|
+
log?.error?.(
|
|
1601
|
+
`[${accountId}] clawchat-plugin-openclaw owner profile refresh failed source=friend.profile_updated: ${err instanceof Error ? err.message : String(err)}`,
|
|
1602
|
+
);
|
|
1603
|
+
}
|
|
1604
|
+
})();
|
|
1605
|
+
};
|
|
1578
1606
|
const refreshAgentBehavior = async (options: {
|
|
1579
1607
|
metadataVersion?: number;
|
|
1580
1608
|
source: string;
|
|
@@ -2790,6 +2818,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
2790
2818
|
if (type === "friend.added") {
|
|
2791
2819
|
scheduleFriendGreeting(payload as Record<string, unknown> | undefined);
|
|
2792
2820
|
}
|
|
2821
|
+
if (type === "friend.profile_updated") {
|
|
2822
|
+
refreshOwnerProfileOnUpdate(payload?.entity_id);
|
|
2823
|
+
}
|
|
2793
2824
|
}
|
|
2794
2825
|
}
|
|
2795
2826
|
});
|
|
@@ -2902,6 +2933,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
2902
2933
|
? groupFile.metadata.group_owner_nickname ?? metadata?.nickname ?? userId
|
|
2903
2934
|
: metadata?.nickname ?? userId;
|
|
2904
2935
|
const profileType = metadata?.profile_type ?? (userId === account.userId ? "agent" : "user");
|
|
2936
|
+
if (name !== userId) knownNicknames.set(userId, name);
|
|
2905
2937
|
participants.push({ id: userId, name, profileType, isAgentOwner, isGroupOwner });
|
|
2906
2938
|
}
|
|
2907
2939
|
return participants;
|
|
@@ -2940,6 +2972,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
2940
2972
|
basePrompt: turn.peer.kind === "group" ? getClawChatGroupPrompt() : getClawChatUserPrompt(),
|
|
2941
2973
|
...promptMetadata,
|
|
2942
2974
|
ownerIdFallback: account.ownerUserId,
|
|
2975
|
+
currentAgentIdFallback: account.userId,
|
|
2943
2976
|
groupParticipants,
|
|
2944
2977
|
turn: {
|
|
2945
2978
|
chatType: promptChatType,
|
|
@@ -2960,8 +2993,76 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
2960
2993
|
});
|
|
2961
2994
|
};
|
|
2962
2995
|
|
|
2996
|
+
// Group frames often carry the sender's id as nick_name. Prefer the nickname
|
|
2997
|
+
// already cached in ClawChat memory (owner.md for the owner and this agent,
|
|
2998
|
+
// users/<id>.md for everyone else, the group's group_owner_nickname for its
|
|
2999
|
+
// owner); keep the id when nothing is cached.
|
|
3000
|
+
const cachedNicknameFor = async (memoryRoot: string, userId: string): Promise<string | null> => {
|
|
3001
|
+
try {
|
|
3002
|
+
if (userId === account.ownerUserId || userId === account.userId) {
|
|
3003
|
+
const ownerFile = await readClawChatMemoryFile(memoryRoot, { targetType: "owner", targetId: "owner" });
|
|
3004
|
+
const nickname = userId === account.userId
|
|
3005
|
+
? ownerFile.metadata.agent_nickname
|
|
3006
|
+
: ownerFile.metadata.agent_owner_nickname;
|
|
3007
|
+
if (nickname) return nickname;
|
|
3008
|
+
}
|
|
3009
|
+
const userFile = await readClawChatMemoryFile(memoryRoot, { targetType: "user", targetId: userId });
|
|
3010
|
+
return userFile.exists && userFile.metadata.nickname ? userFile.metadata.nickname : null;
|
|
3011
|
+
} catch {
|
|
3012
|
+
return null;
|
|
3013
|
+
}
|
|
3014
|
+
};
|
|
3015
|
+
|
|
3016
|
+
// Nicknames seen while building group prompts, so ingest can label a
|
|
3017
|
+
// coalesced batch's transcript ("Alice:" rather than "usr_…:") without
|
|
3018
|
+
// touching disk.
|
|
3019
|
+
const knownNicknames = new Map<string, string>();
|
|
3020
|
+
|
|
3021
|
+
const withCachedGroupSenderNames = async <T extends IngestTurnParams>(
|
|
3022
|
+
turn: T,
|
|
3023
|
+
memoryRoot: string | null,
|
|
3024
|
+
): Promise<T> => {
|
|
3025
|
+
if (turn.peer.kind !== "group" || !memoryRoot) return turn;
|
|
3026
|
+
const isBareId = (name: string | null | undefined, id: string): boolean => !name || name === id;
|
|
3027
|
+
const cache = new Map<string, string | null>();
|
|
3028
|
+
let groupOwner: { id?: string; nickname?: string } | null = null;
|
|
3029
|
+
const lookup = async (userId: string): Promise<string | null> => {
|
|
3030
|
+
if (!cache.has(userId)) {
|
|
3031
|
+
let nickname = await cachedNicknameFor(memoryRoot, userId);
|
|
3032
|
+
if (!nickname) {
|
|
3033
|
+
if (!groupOwner) {
|
|
3034
|
+
try {
|
|
3035
|
+
const groupFile = await readClawChatMemoryFile(memoryRoot, { targetType: "group", targetId: turn.peer.id });
|
|
3036
|
+
groupOwner = { id: groupFile.metadata.group_owner_id, nickname: groupFile.metadata.group_owner_nickname };
|
|
3037
|
+
} catch {
|
|
3038
|
+
groupOwner = {};
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
if (groupOwner.id === userId && groupOwner.nickname) nickname = groupOwner.nickname;
|
|
3042
|
+
}
|
|
3043
|
+
cache.set(userId, nickname);
|
|
3044
|
+
if (nickname) knownNicknames.set(userId, nickname);
|
|
3045
|
+
}
|
|
3046
|
+
return cache.get(userId) ?? null;
|
|
3047
|
+
};
|
|
3048
|
+
const senderNickName = isBareId(turn.senderNickName, turn.senderId)
|
|
3049
|
+
? (await lookup(turn.senderId)) ?? turn.senderNickName
|
|
3050
|
+
: turn.senderNickName;
|
|
3051
|
+
const groupMessages = turn.groupMessages
|
|
3052
|
+
? await Promise.all(turn.groupMessages.map(async (message) =>
|
|
3053
|
+
isBareId(message.senderName, message.senderId)
|
|
3054
|
+
? { ...message, senderName: (await lookup(message.senderId)) ?? message.senderName }
|
|
3055
|
+
: message
|
|
3056
|
+
))
|
|
3057
|
+
: turn.groupMessages;
|
|
3058
|
+
return { ...turn, senderNickName, ...(groupMessages ? { groupMessages } : {}) };
|
|
3059
|
+
};
|
|
3060
|
+
|
|
2963
3061
|
const resolveSenderNickNameForTurn = (turn: IngestTurnParams): string => {
|
|
2964
3062
|
if (turn.senderNickName && turn.senderNickName !== turn.senderId) return turn.senderNickName;
|
|
3063
|
+
// Synchronous on purpose: an await here would reorder same-group frames.
|
|
3064
|
+
const known = turn.peer.kind === "group" ? knownNicknames.get(turn.senderId) : undefined;
|
|
3065
|
+
if (known) return known;
|
|
2965
3066
|
return turn.senderNickName || turn.senderId;
|
|
2966
3067
|
};
|
|
2967
3068
|
|
|
@@ -2969,7 +3070,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
2969
3070
|
senderRelation: NonNullable<IngestTurnParams["senderRelation"]>;
|
|
2970
3071
|
senderProfileType: string;
|
|
2971
3072
|
senderIsOwner: boolean;
|
|
2972
|
-
senderIsGroupOwner
|
|
3073
|
+
senderIsGroupOwner?: boolean;
|
|
2973
3074
|
} => {
|
|
2974
3075
|
const senderRelation = resolveSenderRelation({
|
|
2975
3076
|
senderId: turn.senderId,
|
|
@@ -2982,7 +3083,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
2982
3083
|
senderProfileType: turn.senderProfileType ??
|
|
2983
3084
|
(senderRelation === "self_agent" || senderRelation === "peer_agent" ? "agent" : "user"),
|
|
2984
3085
|
senderIsOwner: senderRelation === "owner",
|
|
2985
|
-
|
|
3086
|
+
// Left undefined when the frame does not say, so the prompt falls back
|
|
3087
|
+
// to the group metadata's group_owner_id instead of a forced false.
|
|
3088
|
+
...(turn.senderIsGroupOwner !== undefined ? { senderIsGroupOwner: turn.senderIsGroupOwner } : {}),
|
|
2986
3089
|
};
|
|
2987
3090
|
};
|
|
2988
3091
|
|
|
@@ -3061,6 +3164,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3061
3164
|
`[${accountId}] clawchat-plugin-openclaw message metadata refresh failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
3062
3165
|
);
|
|
3063
3166
|
}
|
|
3167
|
+
turn = await withCachedGroupSenderNames(turn, memoryRoot);
|
|
3064
3168
|
const conversationTarget = `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`;
|
|
3065
3169
|
const turnPrompt = await buildPromptForTurn(turn, memoryRoot);
|
|
3066
3170
|
const senderIsOwner = turn.senderIsOwner === true || turn.senderId === account.ownerUserId;
|
|
@@ -3201,6 +3305,8 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3201
3305
|
runtime,
|
|
3202
3306
|
account,
|
|
3203
3307
|
client,
|
|
3308
|
+
resolveOwnerLocale: () =>
|
|
3309
|
+
store?.getOwnerProfile?.({ platform: "openclaw", accountId })?.locale ?? null,
|
|
3204
3310
|
target: { chatId: turn.peer.id, chatType: turn.peer.kind },
|
|
3205
3311
|
...(replyCtx ? { replyCtx } : {}),
|
|
3206
3312
|
inboundMessageId: turn.messageId,
|
|
@@ -3831,6 +3937,21 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3831
3937
|
return;
|
|
3832
3938
|
}
|
|
3833
3939
|
activeClients.set(accountId, client);
|
|
3940
|
+
// Text "@name" autolinking on outbound group sends reads the same cached
|
|
3941
|
+
// roster (participant ids + nicknames) the group prompt shows the model.
|
|
3942
|
+
const unregisterMentionRoster = registerMentionRosterResolver(accountId, async (groupId) => {
|
|
3943
|
+
const memoryRoot = resolveMemoryRootForPeer({ kind: "group", id: groupId });
|
|
3944
|
+
if (!memoryRoot) return [];
|
|
3945
|
+
let ownerMetadata: ClawChatPromptMetadata | null = null;
|
|
3946
|
+
try {
|
|
3947
|
+
const ownerFile = await readClawChatMemoryFile(memoryRoot, { targetType: "owner", targetId: "owner" });
|
|
3948
|
+
ownerMetadata = ownerFile.exists ? ownerFile.metadata : null;
|
|
3949
|
+
} catch {
|
|
3950
|
+
ownerMetadata = null;
|
|
3951
|
+
}
|
|
3952
|
+
const participants = await loadGroupParticipantsForPrompt({ memoryRoot, groupId, ownerMetadata });
|
|
3953
|
+
return participants.map((participant) => ({ userId: participant.id, name: participant.name }));
|
|
3954
|
+
});
|
|
3834
3955
|
log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime active client registered`);
|
|
3835
3956
|
setStatus({
|
|
3836
3957
|
...getStatus(),
|
|
@@ -3843,6 +3964,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3843
3964
|
await waitUntilAbort(abortSignal, async () => {
|
|
3844
3965
|
log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime abort received; closing client`);
|
|
3845
3966
|
activeClients.delete(accountId);
|
|
3967
|
+
unregisterMentionRoster();
|
|
3846
3968
|
closingForAbort = true;
|
|
3847
3969
|
clearBootstrapRetry();
|
|
3848
3970
|
// §A.1 — stop the proactive refresh timer on shutdown.
|
|
@@ -3854,6 +3976,17 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3854
3976
|
closeReason: "client close",
|
|
3855
3977
|
});
|
|
3856
3978
|
client.close();
|
|
3979
|
+
if (livewareSampleForShutdown) {
|
|
3980
|
+
// SIGTERM, then SIGKILL after KILL_GRACE_MS (3 s): inside the host's
|
|
3981
|
+
// 5 s wait for this task, so a stuck child no longer outlives the stop.
|
|
3982
|
+
try {
|
|
3983
|
+
await livewareSampleForShutdown.stop();
|
|
3984
|
+
} catch (err) {
|
|
3985
|
+
log?.error?.(
|
|
3986
|
+
`[${accountId}] clawchat-plugin-openclaw liveware-sample stop failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
3987
|
+
);
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
3857
3990
|
setStatus({
|
|
3858
3991
|
...getStatus(),
|
|
3859
3992
|
connected: false,
|
package/src/skill-update.ts
CHANGED
|
@@ -71,7 +71,7 @@ export const OFFICIAL_SKILLS_BASE =
|
|
|
71
71
|
* in the install-cli repo, bump this constant, ship it. `liveware-sample.ts`
|
|
72
72
|
* imports the same ref, so the `livewares` tree at that tag is pinned too.
|
|
73
73
|
*/
|
|
74
|
-
export const DEFAULT_SKILLS_REF = "skills-v1.
|
|
74
|
+
export const DEFAULT_SKILLS_REF = "skills-v1.17.0";
|
|
75
75
|
|
|
76
76
|
/** Refuse to treat an absurdly large response as a skill file (defence in depth). */
|
|
77
77
|
export const MAX_SKILL_BYTES = 256 * 1024;
|
package/src/tools-schema.ts
CHANGED
|
@@ -328,7 +328,7 @@ export const ClawchatCreateMomentSchema = Type.Object({
|
|
|
328
328
|
images: Type.Optional(
|
|
329
329
|
Type.Array(
|
|
330
330
|
Type.String({
|
|
331
|
-
description: "
|
|
331
|
+
description: "An http(s) image URL, or an absolute local image path (the tool uploads it and uses the hosted URL).",
|
|
332
332
|
}),
|
|
333
333
|
),
|
|
334
334
|
),
|