@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
package/src/liveware-sample.ts
CHANGED
|
@@ -252,10 +252,28 @@ export async function downloadLivewareSample(opts: {
|
|
|
252
252
|
export type SpawnLike = typeof nodeSpawn;
|
|
253
253
|
export type ExecFileLike = typeof nodeExecFile;
|
|
254
254
|
|
|
255
|
+
/** How long a SIGTERM'd liveware process tree gets before SIGKILL. */
|
|
256
|
+
export const KILL_GRACE_MS = 3_000;
|
|
257
|
+
const KILL_POLL_MS = 50;
|
|
258
|
+
|
|
259
|
+
export interface KillProcessTreeOptions {
|
|
260
|
+
/** SIGTERM → SIGKILL grace period (default {@link KILL_GRACE_MS}). */
|
|
261
|
+
graceMs?: number;
|
|
262
|
+
/** `process.kill` seam for tests. Negative pids address a process group. */
|
|
263
|
+
killFn?: (pid: number, signal: NodeJS.Signals | 0) => void;
|
|
264
|
+
}
|
|
265
|
+
|
|
255
266
|
/**
|
|
256
|
-
* Terminate a child and everything it spawned
|
|
267
|
+
* Terminate a child and everything it spawned; resolves once the tree is gone
|
|
268
|
+
* (POSIX) or the kill has been issued (Windows).
|
|
257
269
|
*
|
|
258
|
-
* POSIX:
|
|
270
|
+
* POSIX: the liveware children are spawned `detached` (see
|
|
271
|
+
* {@link childSpawnOptions}), so each leads its own process group and
|
|
272
|
+
* `kill(-pid)` reaches every grandchild the liveware agent started. SIGTERM
|
|
273
|
+
* first; anything still alive after `graceMs` gets SIGKILL — a child that
|
|
274
|
+
* ignores SIGTERM used to keep the gateway's stop (and systemd's restart)
|
|
275
|
+
* waiting for the full unit timeout. A child that is not a group leader (no
|
|
276
|
+
* pid, or spawned without `detached`) is signalled directly instead.
|
|
259
277
|
*
|
|
260
278
|
* Windows: there are no signals. `child.kill()` maps to TerminateProcess on
|
|
261
279
|
* that one process, so any grandchild the liveware agent spawned survives —
|
|
@@ -263,21 +281,73 @@ export type ExecFileLike = typeof nodeExecFile;
|
|
|
263
281
|
* which then fails to bind. `taskkill /T /F` takes the whole tree down.
|
|
264
282
|
* Fire-and-forget: we never wait on it, and a failure (already dead, no such
|
|
265
283
|
* pid) falls through to the ordinary kill().
|
|
284
|
+
*
|
|
285
|
+
* The first signal is sent synchronously, before the returned promise is
|
|
286
|
+
* first awaited, so fire-and-forget callers keep their old behaviour.
|
|
266
287
|
*/
|
|
267
288
|
export function killProcessTree(
|
|
268
289
|
child: ChildProcess | null | undefined,
|
|
269
290
|
execFileFn: ExecFileLike = nodeExecFile,
|
|
270
|
-
|
|
271
|
-
|
|
291
|
+
opts: KillProcessTreeOptions = {},
|
|
292
|
+
): Promise<void> {
|
|
293
|
+
if (!child) return Promise.resolve();
|
|
272
294
|
if (process.platform === "win32" && typeof child.pid === "number") {
|
|
273
295
|
try {
|
|
274
296
|
execFileFn("taskkill", ["/pid", String(child.pid), "/T", "/F"], () => {});
|
|
275
|
-
return;
|
|
297
|
+
return Promise.resolve();
|
|
276
298
|
} catch {
|
|
277
299
|
// taskkill missing or spawn refused — fall back to the plain kill below.
|
|
278
300
|
}
|
|
279
301
|
}
|
|
280
|
-
|
|
302
|
+
if (process.platform === "win32") {
|
|
303
|
+
try { child.kill("SIGTERM"); } catch { /* already dead */ }
|
|
304
|
+
return Promise.resolve();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const killFn = opts.killFn ?? ((pid: number, signal: NodeJS.Signals | 0) => { process.kill(pid, signal); });
|
|
308
|
+
const graceMs = opts.graceMs ?? KILL_GRACE_MS;
|
|
309
|
+
const pid = typeof child.pid === "number" && child.pid > 0 ? child.pid : null;
|
|
310
|
+
let exited = child.exitCode != null || child.signalCode != null;
|
|
311
|
+
const onExit = () => { exited = true; };
|
|
312
|
+
child.once("exit", onExit);
|
|
313
|
+
|
|
314
|
+
const groupAlive = (): boolean => {
|
|
315
|
+
if (pid === null) return false;
|
|
316
|
+
try { killFn(-pid, 0); return true; } catch { return false; }
|
|
317
|
+
};
|
|
318
|
+
const signalTree = (signal: NodeJS.Signals): void => {
|
|
319
|
+
let reachedGroup = false;
|
|
320
|
+
if (pid !== null) {
|
|
321
|
+
try { killFn(-pid, signal); reachedGroup = true; } catch { /* not a group leader, or gone */ }
|
|
322
|
+
}
|
|
323
|
+
if (!reachedGroup && !exited) {
|
|
324
|
+
try { child.kill(signal); } catch { /* already dead */ }
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
const treeAlive = () => !exited || groupAlive();
|
|
328
|
+
|
|
329
|
+
signalTree("SIGTERM");
|
|
330
|
+
return (async () => {
|
|
331
|
+
try {
|
|
332
|
+
const deadline = Date.now() + graceMs;
|
|
333
|
+
while (treeAlive() && Date.now() < deadline) {
|
|
334
|
+
await new Promise((r) => setTimeout(r, Math.min(KILL_POLL_MS, Math.max(1, deadline - Date.now()))));
|
|
335
|
+
}
|
|
336
|
+
if (treeAlive()) signalTree("SIGKILL");
|
|
337
|
+
} finally {
|
|
338
|
+
child.removeListener("exit", onExit);
|
|
339
|
+
}
|
|
340
|
+
})();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Spawn options shared by the long-lived liveware children. POSIX: `detached`
|
|
345
|
+
* makes the child a process-group leader so {@link killProcessTree} can reach
|
|
346
|
+
* its grandchildren with `kill(-pid)`. Windows keeps the old non-detached
|
|
347
|
+
* spawn (detached would open a console window there) and relies on taskkill.
|
|
348
|
+
*/
|
|
349
|
+
function childSpawnOptions(): { detached?: boolean } {
|
|
350
|
+
return process.platform === "win32" ? {} : { detached: true };
|
|
281
351
|
}
|
|
282
352
|
|
|
283
353
|
const SERVER_START_TIMEOUT_MS = 10_000;
|
|
@@ -302,7 +372,7 @@ function waitForOutput(
|
|
|
302
372
|
child.stderr?.removeListener("data", onData);
|
|
303
373
|
child.removeListener("exit", onExit);
|
|
304
374
|
if (err) {
|
|
305
|
-
killProcessTree(child);
|
|
375
|
+
void killProcessTree(child);
|
|
306
376
|
reject(err);
|
|
307
377
|
} else {
|
|
308
378
|
resolve(value as string);
|
|
@@ -380,7 +450,7 @@ export async function startSampleServer(opts: {
|
|
|
380
450
|
const spawnFn = opts.spawnFn ?? nodeSpawn;
|
|
381
451
|
const args = [path.join(opts.appDir, "server.mjs"), "--dir", opts.appDir, "--port", String(opts.port)];
|
|
382
452
|
if (opts.agentUserId) args.push("--agent-id", opts.agentUserId);
|
|
383
|
-
const child = spawnFn(process.execPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
453
|
+
const child = spawnFn(process.execPath, args, { stdio: ["ignore", "pipe", "pipe"], ...childSpawnOptions() });
|
|
384
454
|
const line = await waitForOutput(
|
|
385
455
|
child,
|
|
386
456
|
(acc) => acc.split("\n").find((l) => l.trim().startsWith('{"port"')) ?? null,
|
|
@@ -473,7 +543,7 @@ export async function startTunnelAgent(opts: {
|
|
|
473
543
|
const child = spawnFn(
|
|
474
544
|
opts.livewarePath,
|
|
475
545
|
["agent"],
|
|
476
|
-
{ stdio: ["ignore", "pipe", "pipe"], ...(opts.env ? { env: opts.env } : {}) },
|
|
546
|
+
{ stdio: ["ignore", "pipe", "pipe"], ...childSpawnOptions(), ...(opts.env ? { env: opts.env } : {}) },
|
|
477
547
|
);
|
|
478
548
|
await waitForOutput(
|
|
479
549
|
child,
|
|
@@ -721,6 +791,8 @@ export type LivewareSampleSupervisorDeps = {
|
|
|
721
791
|
ref?: string;
|
|
722
792
|
spawnFn?: SpawnLike;
|
|
723
793
|
execFileFn?: ExecFileLike;
|
|
794
|
+
/** SIGTERM → SIGKILL grace on stop (default {@link KILL_GRACE_MS}); a test seam. */
|
|
795
|
+
killGraceMs?: number;
|
|
724
796
|
/** Private HOME for the liveware CLI (`livewareCliHomeDir(stateDir, accountId)`),
|
|
725
797
|
* so two instances on one host — or two accounts in one gateway — do not
|
|
726
798
|
* share `$HOME/.clawling`. Absent → the CLI inherits the ambient HOME, which
|
|
@@ -803,6 +875,8 @@ export class LivewareSampleSupervisor {
|
|
|
803
875
|
private serverChild: ChildProcess | null = null;
|
|
804
876
|
private tunnelChild: ChildProcess | null = null;
|
|
805
877
|
private stopped = false;
|
|
878
|
+
/** The one shutdown wait, shared by every stop() caller. */
|
|
879
|
+
private stopping: Promise<void> | null = null;
|
|
806
880
|
/** True while a startAttempt chain (bootstrap/relaunch) is running; read by
|
|
807
881
|
* isIdle() so adoptDeps never starts a second concurrent flow. */
|
|
808
882
|
private launchInFlight = false;
|
|
@@ -874,7 +948,7 @@ export class LivewareSampleSupervisor {
|
|
|
874
948
|
} catch (err) {
|
|
875
949
|
// A mid-flow failure (e.g. registerApp rejected) may have left children
|
|
876
950
|
// running without a persisted row — never leak orphan processes.
|
|
877
|
-
this.killChildren();
|
|
951
|
+
void this.killChildren();
|
|
878
952
|
const delayMs = START_RETRY_DELAYS_MS[attempt];
|
|
879
953
|
if (delayMs === undefined || this.stopped) {
|
|
880
954
|
deps.log?.warn?.(
|
|
@@ -891,20 +965,30 @@ export class LivewareSampleSupervisor {
|
|
|
891
965
|
}
|
|
892
966
|
}
|
|
893
967
|
|
|
894
|
-
|
|
968
|
+
/**
|
|
969
|
+
* Stop for good and resolve once both children (and their process groups)
|
|
970
|
+
* are gone — SIGTERM, then SIGKILL after `killGraceMs`. Idempotent: the abort
|
|
971
|
+
* listener and the gateway's own shutdown both call it and await one wait.
|
|
972
|
+
*/
|
|
973
|
+
stop(): Promise<void> {
|
|
974
|
+
if (this.stopping) return this.stopping;
|
|
895
975
|
this.stopped = true;
|
|
896
976
|
for (const t of this.timers) clearTimeout(t);
|
|
897
977
|
this.timers.clear();
|
|
898
|
-
this.killChildren();
|
|
978
|
+
this.stopping = this.killChildren();
|
|
979
|
+
return this.stopping;
|
|
899
980
|
}
|
|
900
981
|
|
|
901
|
-
private killChildren(): void {
|
|
982
|
+
private killChildren(): Promise<void> {
|
|
902
983
|
this.generation += 1; // invalidate pending exit handlers before killing
|
|
903
|
-
|
|
904
|
-
killProcessTree(child, this.deps.execFileFn
|
|
905
|
-
|
|
984
|
+
const kills = [this.serverChild, this.tunnelChild].map((child) =>
|
|
985
|
+
killProcessTree(child, this.deps.execFileFn, {
|
|
986
|
+
...(this.deps.killGraceMs !== undefined ? { graceMs: this.deps.killGraceMs } : {}),
|
|
987
|
+
}),
|
|
988
|
+
);
|
|
906
989
|
this.serverChild = null;
|
|
907
990
|
this.tunnelChild = null;
|
|
991
|
+
return Promise.all(kills).then(() => undefined);
|
|
908
992
|
}
|
|
909
993
|
|
|
910
994
|
private schedule(fn: () => void, delayMs: number): void {
|
|
@@ -919,7 +1003,10 @@ export class LivewareSampleSupervisor {
|
|
|
919
1003
|
* after stop() ran would otherwise never be killed. */
|
|
920
1004
|
private bailIfStopped(): boolean {
|
|
921
1005
|
if (!this.stopped) return false;
|
|
922
|
-
this
|
|
1006
|
+
// Fold this late kill into the shared shutdown wait so a stop() caller
|
|
1007
|
+
// that awaits after this point also waits for it.
|
|
1008
|
+
const late = this.killChildren();
|
|
1009
|
+
this.stopping = Promise.all([this.stopping, late]).then(() => undefined);
|
|
923
1010
|
return true;
|
|
924
1011
|
}
|
|
925
1012
|
|
|
@@ -1151,7 +1238,7 @@ export class LivewareSampleSupervisor {
|
|
|
1151
1238
|
const gen = ++this.generation;
|
|
1152
1239
|
const onChildExit = (): void => {
|
|
1153
1240
|
if (this.stopped || gen !== this.generation) return;
|
|
1154
|
-
this.killChildren();
|
|
1241
|
+
void this.killChildren();
|
|
1155
1242
|
const now = Date.now();
|
|
1156
1243
|
this.restartTimes = this.restartTimes.filter((t) => now - t < RESTART_WINDOW_MS);
|
|
1157
1244
|
if (this.restartTimes.length >= MAX_RESTARTS_PER_WINDOW) {
|
|
@@ -1181,7 +1268,7 @@ export class LivewareSampleSupervisor {
|
|
|
1181
1268
|
.catch((err) => {
|
|
1182
1269
|
// A partially-completed relaunch may have spawned children before
|
|
1183
1270
|
// failing — never leave them running unwatched.
|
|
1184
|
-
this.killChildren();
|
|
1271
|
+
void this.killChildren();
|
|
1185
1272
|
this.deps.log?.warn?.(`liveware-sample: relaunch failed: ${String(err)}`);
|
|
1186
1273
|
this.deps.store.updateLivewareSampleStatus({
|
|
1187
1274
|
platform: this.deps.platform, accountId: this.deps.accountId,
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text "@name" → structured mention, applied to outbound group messages just
|
|
3
|
+
* before they are sent.
|
|
4
|
+
*
|
|
5
|
+
* A model naturally writes "@Anne please check" in a group reply. Sent as plain
|
|
6
|
+
* text, that wakes nobody: the mentioned agent's mention-only gate never opens
|
|
7
|
+
* and clients draw no highlight. This module recognises roster names after `@`
|
|
8
|
+
* and splits the text into text / mention fragments (protocol §7.1 / §10.2);
|
|
9
|
+
* the caller sends the fragments in `body.fragments` and the same mentions in
|
|
10
|
+
* `context.mentions`.
|
|
11
|
+
*
|
|
12
|
+
* Rules (shared with the reference ClawChat client so both sides agree):
|
|
13
|
+
* - `@` opens a mention unless the character before it belongs to an email
|
|
14
|
+
* local part (ASCII alnum, `.`, `_`, `%`, `+`, `-`), so an email address is not a
|
|
15
|
+
* mention but "让@大Q看" (CJK glued to the `@`) is.
|
|
16
|
+
* - Longest roster name wins; at equal length an exact-case match beats an
|
|
17
|
+
* ASCII-case-folded one; a tie between two different users selects nobody.
|
|
18
|
+
* - A name ending in an ASCII alnum must not be followed by one ("@Bean" does
|
|
19
|
+
* not match inside "@Beanstalk"); CJK names need no right boundary.
|
|
20
|
+
* - Self and the `all` sentinel are never produced from text: auto-waking a
|
|
21
|
+
* whole room is an echo-storm starter and stays an explicit tool action.
|
|
22
|
+
* - Anything not recognised stays plain text. This step must never block the
|
|
23
|
+
* message itself.
|
|
24
|
+
*
|
|
25
|
+
* Pure functions plus a per-account roster-resolver registry (the runtime
|
|
26
|
+
* registers one that reads the cached group metadata).
|
|
27
|
+
*/
|
|
28
|
+
import type { Fragment, MentionFragment } from "./protocol-types.ts";
|
|
29
|
+
|
|
30
|
+
/** The `@everyone` sentinel user id; never auto-linked from text. */
|
|
31
|
+
export const MENTION_ALL_SENTINEL = "all";
|
|
32
|
+
|
|
33
|
+
export interface MentionRosterMember {
|
|
34
|
+
userId: string;
|
|
35
|
+
/** Display name the model sees for this member. */
|
|
36
|
+
name: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const AT = 0x40;
|
|
40
|
+
|
|
41
|
+
function isAsciiAlnum(c: number): boolean {
|
|
42
|
+
return (c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isEmailLocal(c: number): boolean {
|
|
46
|
+
return isAsciiAlnum(c) || c === 0x2e || c === 0x5f || c === 0x25 || c === 0x2b || c === 0x2d;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function openBoundary(text: string, at: number): boolean {
|
|
50
|
+
if (at === 0) return true;
|
|
51
|
+
return !isEmailLocal(text.charCodeAt(at - 1));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function lowerAscii(c: number): number {
|
|
55
|
+
return c >= 0x41 && c <= 0x5a ? c + 0x20 : c;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function matches(text: string, start: number, name: string, fold: boolean): boolean {
|
|
59
|
+
const end = start + name.length;
|
|
60
|
+
if (end > text.length) return false;
|
|
61
|
+
for (let k = 0; k < name.length; k += 1) {
|
|
62
|
+
let a = text.charCodeAt(start + k);
|
|
63
|
+
let b = name.charCodeAt(k);
|
|
64
|
+
if (fold) {
|
|
65
|
+
a = lowerAscii(a);
|
|
66
|
+
b = lowerAscii(b);
|
|
67
|
+
}
|
|
68
|
+
if (a !== b) return false;
|
|
69
|
+
}
|
|
70
|
+
if (end === text.length) return true;
|
|
71
|
+
return !(isAsciiAlnum(name.charCodeAt(name.length - 1)) && isAsciiAlnum(text.charCodeAt(end)));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function matchAt(text: string, start: number, targets: MentionRosterMember[]): MentionRosterMember | null {
|
|
75
|
+
let best: MentionRosterMember | null = null;
|
|
76
|
+
let bestExact = false;
|
|
77
|
+
let ambiguous = false;
|
|
78
|
+
for (const t of targets) {
|
|
79
|
+
const exact = matches(text, start, t.name, false);
|
|
80
|
+
const hit = exact || matches(text, start, t.name, true);
|
|
81
|
+
if (!hit) continue;
|
|
82
|
+
if (
|
|
83
|
+
!best ||
|
|
84
|
+
t.name.length > best.name.length ||
|
|
85
|
+
(t.name.length === best.name.length && exact && !bestExact)
|
|
86
|
+
) {
|
|
87
|
+
best = t;
|
|
88
|
+
bestExact = exact;
|
|
89
|
+
ambiguous = false;
|
|
90
|
+
} else if (t.name.length === best.name.length && exact === bestExact && t.userId !== best.userId) {
|
|
91
|
+
ambiguous = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return ambiguous ? null : best;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function targetsOf(roster: MentionRosterMember[], ownUserId: string): MentionRosterMember[] {
|
|
98
|
+
const out: MentionRosterMember[] = [];
|
|
99
|
+
for (const m of roster) {
|
|
100
|
+
const name = typeof m.name === "string" ? m.name.trim() : "";
|
|
101
|
+
const userId = typeof m.userId === "string" ? m.userId.trim() : "";
|
|
102
|
+
if (!name || !userId) continue;
|
|
103
|
+
if (userId === ownUserId) continue;
|
|
104
|
+
if (userId === MENTION_ALL_SENTINEL) continue;
|
|
105
|
+
out.push({ userId, name });
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Whether `text` has an `@` worth fetching the group roster for. */
|
|
111
|
+
export function hasMentionCandidate(text: string): boolean {
|
|
112
|
+
for (let i = 0; i < text.length - 1; i += 1) {
|
|
113
|
+
if (text.charCodeAt(i) === AT && openBoundary(text, i)) return true;
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Split `text` into text / mention fragments using `roster`. The `@` is
|
|
120
|
+
* consumed by the mention fragment (`display` is the bare name; clients
|
|
121
|
+
* render `@display`). With no hit the result is a single text fragment
|
|
122
|
+
* (or `[]` for empty text).
|
|
123
|
+
*/
|
|
124
|
+
export function autolinkMentions(
|
|
125
|
+
text: string,
|
|
126
|
+
roster: MentionRosterMember[],
|
|
127
|
+
options: { ownUserId: string },
|
|
128
|
+
): Fragment[] {
|
|
129
|
+
if (!text) return [];
|
|
130
|
+
const targets = targetsOf(roster, options.ownUserId);
|
|
131
|
+
if (targets.length === 0) return [{ kind: "text", text }];
|
|
132
|
+
const out: Fragment[] = [];
|
|
133
|
+
let cursor = 0;
|
|
134
|
+
let i = 0;
|
|
135
|
+
while (i < text.length) {
|
|
136
|
+
if (text.charCodeAt(i) !== AT || !openBoundary(text, i)) {
|
|
137
|
+
i += 1;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const hit = matchAt(text, i + 1, targets);
|
|
141
|
+
if (!hit) {
|
|
142
|
+
i += 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (i > cursor) out.push({ kind: "text", text: text.slice(cursor, i) });
|
|
146
|
+
out.push({ kind: "mention", user_id: hit.userId, display: hit.name });
|
|
147
|
+
i += 1 + hit.name.length;
|
|
148
|
+
cursor = i;
|
|
149
|
+
}
|
|
150
|
+
if (cursor < text.length) out.push({ kind: "text", text: text.slice(cursor) });
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The `context.mentions` half: mention fragments de-duplicated by user id. */
|
|
155
|
+
export function mentionsIn(fragments: Fragment[]): MentionFragment[] {
|
|
156
|
+
const seen = new Set<string>();
|
|
157
|
+
const out: MentionFragment[] = [];
|
|
158
|
+
for (const f of fragments) {
|
|
159
|
+
if (f.kind !== "mention") continue;
|
|
160
|
+
const id = typeof f.user_id === "string" ? f.user_id : "";
|
|
161
|
+
if (!id || seen.has(id)) continue;
|
|
162
|
+
seen.add(id);
|
|
163
|
+
out.push({ kind: "mention", user_id: id, ...(f.display ? { display: f.display } : {}) });
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export type MentionRosterResolver = (groupId: string) => Promise<MentionRosterMember[]>;
|
|
169
|
+
|
|
170
|
+
const rosterResolvers = new Map<string, MentionRosterResolver>();
|
|
171
|
+
|
|
172
|
+
/** Register the roster source for one account; returns an unregister function. */
|
|
173
|
+
export function registerMentionRosterResolver(
|
|
174
|
+
accountId: string,
|
|
175
|
+
resolver: MentionRosterResolver,
|
|
176
|
+
): () => void {
|
|
177
|
+
rosterResolvers.set(accountId, resolver);
|
|
178
|
+
return () => {
|
|
179
|
+
if (rosterResolvers.get(accountId) === resolver) rosterResolvers.delete(accountId);
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Best-effort roster lookup: any failure or missing resolver yields `[]`. */
|
|
184
|
+
export async function resolveMentionRoster(
|
|
185
|
+
accountId: string,
|
|
186
|
+
groupId: string,
|
|
187
|
+
): Promise<MentionRosterMember[]> {
|
|
188
|
+
const resolver = rosterResolvers.get(accountId);
|
|
189
|
+
if (!resolver) return [];
|
|
190
|
+
try {
|
|
191
|
+
const roster = await resolver(groupId);
|
|
192
|
+
return Array.isArray(roster) ? roster : [];
|
|
193
|
+
} catch {
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function clearMentionRosterResolversForTest(): void {
|
|
199
|
+
rosterResolvers.clear();
|
|
200
|
+
}
|
package/src/no-reply.ts
CHANGED
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
* tolerated for free and must not be written into the pattern itself.
|
|
10
10
|
*
|
|
11
11
|
* RULE B — bare runtime silence markers (`NO_REPLY` / `[SILENT]` / `SILENT` /
|
|
12
|
-
* `NO REPLY`), matched as a WHOLE STRING ONLY.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* `NO REPLY` / `HEARTBEAT_OK`), matched as a WHOLE STRING ONLY. These are
|
|
13
|
+
* carried because agent runtimes define them, so a model may fall back to one
|
|
14
|
+
* instead of the `clawchat:` form; `HEARTBEAT_OK` is the OpenClaw host's
|
|
15
|
+
* heartbeat acknowledgement, which some host versions let through as a reply.
|
|
16
|
+
* They are ordinary words — substring-matching them would swallow prose such as
|
|
17
|
+
* "there is no reply from the server".
|
|
17
18
|
*
|
|
18
19
|
* This module MUST stay a literal mirror of the Hermes plugin's equivalent
|
|
19
20
|
* module. When one side changes, change the other in the same breath.
|
|
@@ -38,7 +39,7 @@ const DECORATION_CLOSE = "[/>\\]}`*_~]*";
|
|
|
38
39
|
|
|
39
40
|
const STRIP_RE = new RegExp(DECORATION_OPEN + CORE + DECORATION_CLOSE, "gi");
|
|
40
41
|
|
|
41
|
-
const HOST_MARKERS = new Set(["[SILENT]", "SILENT", "NO_REPLY", "NO REPLY"]);
|
|
42
|
+
const HOST_MARKERS = new Set(["[SILENT]", "SILENT", "NO_REPLY", "NO REPLY", "HEARTBEAT_OK"]);
|
|
42
43
|
const HOST_MARKER_MAX_LEN = 64;
|
|
43
44
|
|
|
44
45
|
// General_Category=Punctuation, matching the host's `unicodedata.category()`
|
package/src/outbound.ts
CHANGED
|
@@ -21,6 +21,13 @@ import {
|
|
|
21
21
|
uploadOutboundMedia,
|
|
22
22
|
type ClawlingMediaFragment,
|
|
23
23
|
} from "./media-runtime.ts";
|
|
24
|
+
import {
|
|
25
|
+
autolinkMentions,
|
|
26
|
+
hasMentionCandidate,
|
|
27
|
+
mentionsIn,
|
|
28
|
+
resolveMentionRoster,
|
|
29
|
+
type MentionRosterMember,
|
|
30
|
+
} from "./mention-autolink.ts";
|
|
24
31
|
import { isClawChatNoopResponseText } from "./profile-prompt.ts";
|
|
25
32
|
import { stripNoReplyTokens } from "./no-reply.ts";
|
|
26
33
|
import {
|
|
@@ -64,9 +71,29 @@ export interface SendParams {
|
|
|
64
71
|
mediaFragments?: ClawlingMediaFragment[];
|
|
65
72
|
mentions?: MentionFragment[];
|
|
66
73
|
messageId?: string;
|
|
74
|
+
/**
|
|
75
|
+
* Protocol §7.5 `payload.message_mode`. `"thinking"` marks process output
|
|
76
|
+
* (tool progress, runtime notices, reasoning) that agents skip as input and
|
|
77
|
+
* clients may fold; everything else is `"normal"` (the default).
|
|
78
|
+
*/
|
|
79
|
+
messageMode?: OutboundMessageMode;
|
|
80
|
+
/**
|
|
81
|
+
* Group roster for text "@name" autolinking. When omitted, a group send
|
|
82
|
+
* whose text contains a candidate `@` looks the roster up via the
|
|
83
|
+
* per-account resolver the runtime registers (`mention-autolink.ts`).
|
|
84
|
+
*/
|
|
85
|
+
mentionRoster?: MentionRosterMember[];
|
|
86
|
+
/**
|
|
87
|
+
* This send is the final of a reply stream and reuses its message_id. It
|
|
88
|
+
* goes out as `message.reply` (protocol §8.4 finalize-reply pattern) even
|
|
89
|
+
* when it quotes nothing; the reply context is unchanged.
|
|
90
|
+
*/
|
|
91
|
+
finalizesStream?: boolean;
|
|
67
92
|
log?: LogSink;
|
|
68
93
|
}
|
|
69
94
|
|
|
95
|
+
export type OutboundMessageMode = "normal" | "thinking";
|
|
96
|
+
|
|
70
97
|
export interface SendResult {
|
|
71
98
|
messageId: string;
|
|
72
99
|
acceptedAt: number;
|
|
@@ -591,8 +618,31 @@ export async function sendOpenclawClawlingText(params: SendParams): Promise<Send
|
|
|
591
618
|
return null;
|
|
592
619
|
}
|
|
593
620
|
|
|
594
|
-
const
|
|
595
|
-
|
|
621
|
+
const messageMode: OutboundMessageMode = params.messageMode ?? "normal";
|
|
622
|
+
let mentions = params.mentions ?? [];
|
|
623
|
+
let textFragments = text ? textToFragments(text) : [];
|
|
624
|
+
// Text "@name" → structured mention (group, normal messages only). Process
|
|
625
|
+
// (thinking) output must not wake anyone. Best-effort: an unavailable roster
|
|
626
|
+
// leaves the text plain and never blocks the send.
|
|
627
|
+
if (
|
|
628
|
+
textFragments.length > 0
|
|
629
|
+
&& params.to.chatType === "group"
|
|
630
|
+
&& messageMode === "normal"
|
|
631
|
+
&& hasMentionCandidate(text)
|
|
632
|
+
) {
|
|
633
|
+
const roster = params.mentionRoster
|
|
634
|
+
?? await resolveMentionRoster(params.account.accountId, params.to.chatId);
|
|
635
|
+
const linked = autolinkMentions(text, roster, { ownUserId: params.account.userId ?? "" });
|
|
636
|
+
const linkedMentions = mentionsIn(linked);
|
|
637
|
+
if (linkedMentions.length > 0) {
|
|
638
|
+
textFragments = linked;
|
|
639
|
+
const seen = new Set(mentions.map((m) => m.user_id).filter(Boolean));
|
|
640
|
+
mentions = [...mentions, ...linkedMentions.filter((m) => !seen.has(m.user_id))];
|
|
641
|
+
params.log?.info?.(
|
|
642
|
+
`[${params.account.accountId}] clawchat-plugin-openclaw outbound text mentions linked count=${linkedMentions.length} to=${params.to.chatId}`,
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
596
646
|
// Each MediaItem object is structurally compatible
|
|
597
647
|
// with one of the local narrow Fragment members (ImageFragment / FileFragment /
|
|
598
648
|
// AudioFragment / VideoFragment) based on its runtime `kind`. The wide local
|
|
@@ -617,7 +667,7 @@ export async function sendOpenclawClawlingText(params: SendParams): Promise<Send
|
|
|
617
667
|
mode = "reply";
|
|
618
668
|
const payload = {
|
|
619
669
|
message_id: messageId,
|
|
620
|
-
message_mode:
|
|
670
|
+
message_mode: messageMode,
|
|
621
671
|
message: {
|
|
622
672
|
body: { fragments },
|
|
623
673
|
context: {
|
|
@@ -642,7 +692,8 @@ export async function sendOpenclawClawlingText(params: SendParams): Promise<Send
|
|
|
642
692
|
...(params.log ? { log: params.log } : {}),
|
|
643
693
|
});
|
|
644
694
|
} else {
|
|
645
|
-
|
|
695
|
+
// A stream's final is a message.reply (§8.4) whether or not it quotes.
|
|
696
|
+
mode = params.finalizesStream ? "reply" : "send";
|
|
646
697
|
const reply = params.replyCtx
|
|
647
698
|
? {
|
|
648
699
|
reply_to_msg_id: params.replyCtx.replyToMessageId,
|
|
@@ -651,7 +702,7 @@ export async function sendOpenclawClawlingText(params: SendParams): Promise<Send
|
|
|
651
702
|
: null;
|
|
652
703
|
const payload = {
|
|
653
704
|
message_id: messageId,
|
|
654
|
-
message_mode:
|
|
705
|
+
message_mode: messageMode,
|
|
655
706
|
message: {
|
|
656
707
|
body: { fragments },
|
|
657
708
|
context: { mentions, reply },
|
|
@@ -660,7 +711,7 @@ export async function sendOpenclawClawlingText(params: SendParams): Promise<Send
|
|
|
660
711
|
ack = await sendAlignedAckableEnvelope({
|
|
661
712
|
client: params.client,
|
|
662
713
|
account: params.account,
|
|
663
|
-
eventName: "message.send",
|
|
714
|
+
eventName: mode === "reply" ? "message.reply" : "message.send",
|
|
664
715
|
chatId: params.to.chatId,
|
|
665
716
|
payload,
|
|
666
717
|
...(params.log ? { log: params.log } : {}),
|
|
@@ -672,7 +723,7 @@ export async function sendOpenclawClawlingText(params: SendParams): Promise<Send
|
|
|
672
723
|
);
|
|
673
724
|
}
|
|
674
725
|
params.log?.info?.(
|
|
675
|
-
`[${params.account.accountId}] clawchat-plugin-openclaw outbound mode=${mode} msg=${ack.payload.message_id} text_len=${text.length} media=${mediaFragments.length} trace=${ack.trace_id}`,
|
|
726
|
+
`[${params.account.accountId}] clawchat-plugin-openclaw outbound mode=${mode} message_mode=${messageMode} msg=${ack.payload.message_id} text_len=${text.length} media=${mediaFragments.length} trace=${ack.trace_id}`,
|
|
676
727
|
);
|
|
677
728
|
return {
|
|
678
729
|
messageId: ack.payload.message_id,
|