@clawling/clawchat-plugin-openclaw 2026.9.26-3 → 2026.10.7-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.
@@ -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. OpenClaw itself has no such
13
- * convention; these are carried because other agent runtimes define them, so a
14
- * model may fall back to one instead of the `clawchat:` form. They are ordinary
15
- * English words — substring-matching them would swallow prose such as "there is
16
- * no reply from the server".
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 mentions = params.mentions ?? [];
595
- const textFragments = text ? textToFragments(text) : [];
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: "normal",
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
- mode = "send";
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: "normal",
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,