@botcord/openclaw-plugin 0.0.2 → 0.0.4

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/src/inbound.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  * Inbound message dispatch — shared by websocket and polling paths.
3
3
  * Converts BotCord messages to OpenClaw inbound format.
4
4
  */
5
- import { readFile } from "node:fs/promises";
5
+ import { readFile, readdir } from "node:fs/promises";
6
+ import { join, dirname } from "node:path";
6
7
  import { getBotCordRuntime } from "./runtime.js";
7
8
  import { resolveAccountConfig } from "./config.js";
8
9
  import { buildSessionKey } from "./session-key.js";
@@ -44,6 +45,12 @@ function buildInboundHeader(params: {
44
45
  return parts.join(" | ");
45
46
  }
46
47
 
48
+ function appendRoomRule(content: string, roomRule?: string | null): string {
49
+ const normalizedRule = roomRule?.trim();
50
+ if (!normalizedRule) return content;
51
+ return `${content}\n[Room Rule] ${normalizedRule}`;
52
+ }
53
+
47
54
  export interface InboundParams {
48
55
  cfg: any;
49
56
  accountId: string;
@@ -92,14 +99,22 @@ export async function handleInboxMessage(
92
99
  chatType === "group"
93
100
  ? '\n\n[In group chats, do NOT reply unless you are explicitly mentioned or addressed. If no response is needed, reply with exactly "NO_REPLY" and nothing else.]'
94
101
  : '\n\n[If the conversation has naturally concluded or no response is needed, reply with exactly "NO_REPLY" and nothing else.]';
95
- const content = `${header}\n${rawContent}${silentHint}`;
102
+
103
+ // Prompt the agent to notify its owner when receiving contact requests
104
+ const notifyOwnerHint =
105
+ envelope.type === "contact_request"
106
+ ? `\n\n[You received a contact request from ${senderId}. Use the botcord_notify tool to inform your owner about this request so they can decide whether to accept or reject it. Include the sender's agent ID and any message they attached.]`
107
+ : "";
108
+
109
+ const content = `${header}\n${rawContent}${silentHint}${notifyOwnerHint}`;
110
+ const contentWithRule = isGroupRoom ? appendRoomRule(content, msg.room_rule) : content;
96
111
 
97
112
  await dispatchInbound({
98
113
  cfg,
99
114
  accountId,
100
115
  senderName: senderId,
101
116
  senderId,
102
- content: content as string,
117
+ content: contentWithRule,
103
118
  messageId: envelope.msg_id,
104
119
  messageType: envelope.type,
105
120
  chatType,
@@ -231,6 +246,9 @@ type DeliveryContext = {
231
246
 
232
247
  /**
233
248
  * Read deliveryContext for a session key from the session store on disk.
249
+ * First checks the current agent's store, then scans all agent stores
250
+ * (the session key may belong to a different agent than the one running
251
+ * this plugin).
234
252
  * Returns undefined when the session has no recorded delivery route.
235
253
  */
236
254
  async function resolveSessionDeliveryContext(
@@ -238,16 +256,40 @@ async function resolveSessionDeliveryContext(
238
256
  cfg: any,
239
257
  sessionKey: string,
240
258
  ): Promise<DeliveryContext | undefined> {
259
+ const tryStore = async (path: string): Promise<DeliveryContext | undefined> => {
260
+ try {
261
+ const raw = await readFile(path, "utf-8");
262
+ const store: Record<string, { deliveryContext?: DeliveryContext }> =
263
+ JSON.parse(raw);
264
+ const entry = store[sessionKey];
265
+ if (entry?.deliveryContext?.channel && entry.deliveryContext.to) {
266
+ return entry.deliveryContext;
267
+ }
268
+ } catch {
269
+ // store may not exist yet
270
+ }
271
+ return undefined;
272
+ };
273
+
274
+ // 1. Try the current agent's store first (fast path)
241
275
  try {
242
276
  const storePath = core.channel.session.resolveStorePath(cfg.session?.store);
243
- const raw = await readFile(storePath, "utf-8");
244
- const store: Record<string, { deliveryContext?: DeliveryContext }> = JSON.parse(raw);
245
- const entry = store[sessionKey];
246
- if (entry?.deliveryContext?.channel && entry.deliveryContext.to) {
247
- return entry.deliveryContext;
277
+ const result = await tryStore(storePath);
278
+ if (result) return result;
279
+
280
+ // 2. Scan sibling agent stores: walk up to the agents/ dir and check each
281
+ // storePath is typically .../.openclaw/agents/<name>/sessions/sessions.json
282
+ const agentsDir = dirname(dirname(dirname(storePath)));
283
+ const entries = await readdir(agentsDir, { withFileTypes: true });
284
+ for (const entry of entries) {
285
+ if (!entry.isDirectory()) continue;
286
+ const candidate = join(agentsDir, entry.name, "sessions", "sessions.json");
287
+ if (candidate === storePath) continue; // already checked
288
+ const result = await tryStore(candidate);
289
+ if (result) return result;
248
290
  }
249
291
  } catch {
250
- // best-effort: store may not exist yet
292
+ // best-effort
251
293
  }
252
294
  return undefined;
253
295
  }
@@ -0,0 +1,409 @@
1
+ type AgentMessageLike = {
2
+ role?: unknown;
3
+ content?: unknown;
4
+ timestamp?: unknown;
5
+ };
6
+
7
+ type UserTurn = {
8
+ text: string;
9
+ normalized: string;
10
+ timestamp?: number;
11
+ };
12
+
13
+ type OutboundSample = {
14
+ text: string;
15
+ normalized: string;
16
+ timestamp: number;
17
+ };
18
+
19
+ export type BotCordLoopRiskReason = {
20
+ id: "high_turn_rate" | "short_ack_tail" | "repeated_outbound";
21
+ summary: string;
22
+ };
23
+
24
+ export type BotCordLoopRiskEvaluation = {
25
+ reasons: BotCordLoopRiskReason[];
26
+ };
27
+
28
+ const outboundBySession = new Map<string, OutboundSample[]>();
29
+
30
+ const TURN_WINDOW_MS = 2 * 60_000;
31
+ const TURN_THRESHOLD = 8;
32
+ const ALTERNATION_THRESHOLD = 6;
33
+ const MIN_TURNS_PER_SIDE = 3;
34
+
35
+ const OUTBOUND_MAX_AGE_MS = 10 * 60_000;
36
+ const MAX_TRACKED_OUTBOUND = 6;
37
+ const SHORT_ACK_MAX_CHARS = 48;
38
+ const MIN_REPEAT_TEXT_CHARS = 6;
39
+ const OUTBOUND_SIMILARITY_THRESHOLD = 0.88;
40
+
41
+ const ENGLISH_ACK_OR_CLOSURE = new Set([
42
+ "ok",
43
+ "okay",
44
+ "got it",
45
+ "thanks",
46
+ "thank you",
47
+ "noted",
48
+ "understood",
49
+ "sounds good",
50
+ "sgtm",
51
+ "roger",
52
+ "copy",
53
+ "will do",
54
+ "all good",
55
+ "no worries",
56
+ "bye",
57
+ "goodbye",
58
+ "see you",
59
+ "talk later",
60
+ ]);
61
+
62
+ const CHINESE_ACK_OR_CLOSURE = new Set([
63
+ "收到",
64
+ "好的",
65
+ "好",
66
+ "行",
67
+ "嗯",
68
+ "嗯嗯",
69
+ "明白",
70
+ "明白了",
71
+ "知道了",
72
+ "谢谢",
73
+ "谢谢你",
74
+ "感谢",
75
+ "辛苦了",
76
+ "先这样",
77
+ "回头聊",
78
+ "有需要再说",
79
+ "没问题",
80
+ "了解",
81
+ "好嘞",
82
+ ]);
83
+
84
+ function resolveTimestamp(value: unknown): number | undefined {
85
+ if (typeof value === "number" && Number.isFinite(value)) return value;
86
+ if (typeof value === "string") {
87
+ const numeric = Number(value);
88
+ if (Number.isFinite(numeric)) return numeric;
89
+ const parsed = Date.parse(value);
90
+ if (Number.isFinite(parsed)) return parsed;
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ function extractTextFromContent(content: unknown): string {
96
+ if (typeof content === "string") return content;
97
+ if (Array.isArray(content)) {
98
+ return content
99
+ .map((part) => {
100
+ if (typeof part === "string") return part;
101
+ if (!part || typeof part !== "object") return "";
102
+ const record = part as Record<string, unknown>;
103
+ if (record.type === "text" && typeof record.text === "string") return record.text;
104
+ if (typeof record.text === "string") return record.text;
105
+ return "";
106
+ })
107
+ .filter(Boolean)
108
+ .join("\n");
109
+ }
110
+ if (content && typeof content === "object") {
111
+ const record = content as Record<string, unknown>;
112
+ if (typeof record.text === "string") return record.text;
113
+ }
114
+ return "";
115
+ }
116
+
117
+ function stripBotCordPromptScaffolding(text: string): string {
118
+ const filtered = text
119
+ .split(/\r?\n/)
120
+ .map((line) => line.trim())
121
+ .filter((line) => {
122
+ if (!line) return false;
123
+ if (line.startsWith("[BotCord Message]")) return false;
124
+ if (line.startsWith("[BotCord Notification]")) return false;
125
+ if (line.startsWith("[Room Rule]")) return false;
126
+ if (line.startsWith("[In group chats, do NOT reply")) return false;
127
+ if (line.startsWith("[If the conversation has naturally concluded")) return false;
128
+ if (line.includes('reply with exactly "NO_REPLY"')) return false;
129
+ return true;
130
+ });
131
+
132
+ return filtered.join("\n").trim();
133
+ }
134
+
135
+ function normalizeLoopText(text: string): string {
136
+ return stripBotCordPromptScaffolding(text)
137
+ .toLowerCase()
138
+ .replace(/https?:\/\/\S+/gu, " ")
139
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
140
+ .replace(/\s+/gu, " ")
141
+ .trim();
142
+ }
143
+
144
+ function isShortAckOrClosure(text: string): boolean {
145
+ const normalized = normalizeLoopText(text);
146
+ if (!normalized || normalized.length > SHORT_ACK_MAX_CHARS) return false;
147
+ return ENGLISH_ACK_OR_CLOSURE.has(normalized) || CHINESE_ACK_OR_CLOSURE.has(normalized);
148
+ }
149
+
150
+ function trigramSet(text: string): Set<string> {
151
+ if (text.length <= 3) return new Set([text]);
152
+ const grams = new Set<string>();
153
+ for (let i = 0; i <= text.length - 3; i++) {
154
+ grams.add(text.slice(i, i + 3));
155
+ }
156
+ return grams;
157
+ }
158
+
159
+ function jaccardSimilarity(a: string, b: string): number {
160
+ if (!a || !b) return 0;
161
+ if (a === b) return 1;
162
+ const aSet = trigramSet(a);
163
+ const bSet = trigramSet(b);
164
+ let intersection = 0;
165
+ for (const gram of aSet) {
166
+ if (bSet.has(gram)) intersection++;
167
+ }
168
+ const union = aSet.size + bSet.size - intersection;
169
+ return union === 0 ? 0 : intersection / union;
170
+ }
171
+
172
+ function areOutboundTextsHighlySimilar(a: string, b: string): boolean {
173
+ if (!a || !b) return false;
174
+ if (a === b) return true;
175
+ if (a.length < MIN_REPEAT_TEXT_CHARS || b.length < MIN_REPEAT_TEXT_CHARS) return false;
176
+ return jaccardSimilarity(a, b) >= OUTBOUND_SIMILARITY_THRESHOLD;
177
+ }
178
+
179
+ function extractHistoricalUserTurns(messages: unknown[]): UserTurn[] {
180
+ const result: UserTurn[] = [];
181
+ for (const message of messages) {
182
+ if (!message || typeof message !== "object") continue;
183
+ const candidate = message as AgentMessageLike;
184
+ if (candidate.role !== "user") continue;
185
+ const rawText = extractTextFromContent(candidate.content);
186
+ const text = stripBotCordPromptScaffolding(rawText);
187
+ if (!text) continue;
188
+ result.push({
189
+ text,
190
+ normalized: normalizeLoopText(text),
191
+ timestamp: resolveTimestamp(candidate.timestamp),
192
+ });
193
+ }
194
+ return result;
195
+ }
196
+
197
+ function looksLikeBotCordPrompt(prompt: string): boolean {
198
+ return prompt.includes("[BotCord Message]") || prompt.includes("[BotCord Notification]");
199
+ }
200
+
201
+ function pruneOutboundSamples(sessionKey: string, now: number): OutboundSample[] {
202
+ const existing = outboundBySession.get(sessionKey) ?? [];
203
+ const next = existing.filter((sample) => now - sample.timestamp <= OUTBOUND_MAX_AGE_MS);
204
+ if (next.length === 0) {
205
+ outboundBySession.delete(sessionKey);
206
+ return [];
207
+ }
208
+ outboundBySession.set(sessionKey, next);
209
+ return next;
210
+ }
211
+
212
+ function recordOutboundSample(sessionKey: string, sample: OutboundSample): void {
213
+ const existing = pruneOutboundSamples(sessionKey, sample.timestamp);
214
+ const next = [...existing, sample].slice(-MAX_TRACKED_OUTBOUND);
215
+ outboundBySession.set(sessionKey, next);
216
+ }
217
+
218
+ function buildTurnTimeline(params: {
219
+ historicalUserTurns: UserTurn[];
220
+ currentPrompt: string;
221
+ outbound: OutboundSample[];
222
+ now: number;
223
+ }): Array<{ role: "user" | "assistant"; timestamp: number }> {
224
+ const { historicalUserTurns, currentPrompt, outbound, now } = params;
225
+ const turns: Array<{ role: "user" | "assistant"; timestamp: number }> = [];
226
+
227
+ for (const turn of historicalUserTurns) {
228
+ if (turn.timestamp !== undefined && now - turn.timestamp <= TURN_WINDOW_MS) {
229
+ turns.push({ role: "user", timestamp: turn.timestamp });
230
+ }
231
+ }
232
+
233
+ if (stripBotCordPromptScaffolding(currentPrompt)) {
234
+ turns.push({ role: "user", timestamp: now });
235
+ }
236
+
237
+ for (const sample of outbound) {
238
+ if (now - sample.timestamp <= TURN_WINDOW_MS) {
239
+ turns.push({ role: "assistant", timestamp: sample.timestamp });
240
+ }
241
+ }
242
+
243
+ turns.sort((a, b) => a.timestamp - b.timestamp);
244
+ return turns;
245
+ }
246
+
247
+ function detectHighTurnRate(params: {
248
+ historicalUserTurns: UserTurn[];
249
+ currentPrompt: string;
250
+ outbound: OutboundSample[];
251
+ now: number;
252
+ }): BotCordLoopRiskReason | undefined {
253
+ const timeline = buildTurnTimeline(params);
254
+ if (timeline.length < TURN_THRESHOLD) return undefined;
255
+
256
+ let userTurns = 0;
257
+ let assistantTurns = 0;
258
+ let alternations = 0;
259
+
260
+ for (let i = 0; i < timeline.length; i++) {
261
+ if (timeline[i]?.role === "user") userTurns++;
262
+ if (timeline[i]?.role === "assistant") assistantTurns++;
263
+ if (i > 0 && timeline[i]?.role !== timeline[i - 1]?.role) alternations++;
264
+ }
265
+
266
+ if (
267
+ userTurns >= MIN_TURNS_PER_SIDE &&
268
+ assistantTurns >= MIN_TURNS_PER_SIDE &&
269
+ alternations >= ALTERNATION_THRESHOLD
270
+ ) {
271
+ return {
272
+ id: "high_turn_rate",
273
+ summary: `same session shows ${timeline.length} user/assistant turns within ${Math.round(TURN_WINDOW_MS / 1000)}s`,
274
+ };
275
+ }
276
+
277
+ return undefined;
278
+ }
279
+
280
+ function detectShortAckTail(params: {
281
+ historicalUserTurns: UserTurn[];
282
+ currentPrompt: string;
283
+ }): BotCordLoopRiskReason | undefined {
284
+ const currentPrompt = stripBotCordPromptScaffolding(params.currentPrompt);
285
+ const userTexts = params.historicalUserTurns.map((turn) => turn.text);
286
+ if (currentPrompt) userTexts.push(currentPrompt);
287
+ const tail = userTexts.slice(-2);
288
+ if (tail.length < 2) return undefined;
289
+ if (tail.every((text) => isShortAckOrClosure(text))) {
290
+ return {
291
+ id: "short_ack_tail",
292
+ summary: "the last two inbound user messages are short acknowledgements or closure phrases",
293
+ };
294
+ }
295
+ return undefined;
296
+ }
297
+
298
+ function detectRepeatedOutbound(outbound: OutboundSample[]): BotCordLoopRiskReason | undefined {
299
+ const recent = outbound.slice(-3);
300
+ if (recent.length < 2) return undefined;
301
+
302
+ const last = recent[recent.length - 1];
303
+ if (!last) return undefined;
304
+
305
+ const previous = recent.slice(0, -1);
306
+ const exactMatches = previous.filter((sample) => sample.normalized === last.normalized).length;
307
+ const similarMatches = previous.filter((sample) =>
308
+ areOutboundTextsHighlySimilar(sample.normalized, last.normalized)
309
+ ).length;
310
+
311
+ if (exactMatches >= 1 || (recent.length >= 3 && similarMatches >= 2)) {
312
+ return {
313
+ id: "repeated_outbound",
314
+ summary: "recent botcord_send texts in this session are highly similar",
315
+ };
316
+ }
317
+
318
+ return undefined;
319
+ }
320
+
321
+ export function shouldRunBotCordLoopRiskCheck(params: {
322
+ channelId?: string;
323
+ prompt: string;
324
+ trigger?: string;
325
+ }): boolean {
326
+ if (params.trigger && params.trigger !== "user") return false;
327
+ return params.channelId === "botcord" || looksLikeBotCordPrompt(params.prompt);
328
+ }
329
+
330
+ export function recordBotCordOutboundText(params: {
331
+ sessionKey?: string;
332
+ text?: unknown;
333
+ timestamp?: number;
334
+ }): void {
335
+ const sessionKey = params.sessionKey?.trim();
336
+ const rawText = typeof params.text === "string" ? params.text.trim() : "";
337
+ if (!sessionKey || !rawText) return;
338
+ const normalized = normalizeLoopText(rawText);
339
+ if (!normalized) return;
340
+ const timestamp = params.timestamp ?? Date.now();
341
+ recordOutboundSample(sessionKey, { text: rawText, normalized, timestamp });
342
+ }
343
+
344
+ export function clearBotCordLoopRiskSession(sessionKey?: string): void {
345
+ if (!sessionKey) return;
346
+ outboundBySession.delete(sessionKey);
347
+ }
348
+
349
+ export function evaluateBotCordLoopRisk(params: {
350
+ prompt: string;
351
+ messages: unknown[];
352
+ sessionKey?: string;
353
+ now?: number;
354
+ }): BotCordLoopRiskEvaluation {
355
+ const now = params.now ?? Date.now();
356
+ const historicalUserTurns = extractHistoricalUserTurns(params.messages);
357
+ const outbound = params.sessionKey ? pruneOutboundSamples(params.sessionKey, now) : [];
358
+
359
+ const reasons = [
360
+ detectHighTurnRate({
361
+ historicalUserTurns,
362
+ currentPrompt: params.prompt,
363
+ outbound,
364
+ now,
365
+ }),
366
+ detectShortAckTail({
367
+ historicalUserTurns,
368
+ currentPrompt: params.prompt,
369
+ }),
370
+ detectRepeatedOutbound(outbound),
371
+ ].filter((reason): reason is BotCordLoopRiskReason => Boolean(reason));
372
+
373
+ return { reasons };
374
+ }
375
+
376
+ export function buildBotCordLoopRiskPrompt(params: {
377
+ prompt: string;
378
+ messages: unknown[];
379
+ sessionKey?: string;
380
+ now?: number;
381
+ }): string | undefined {
382
+ const evaluation = evaluateBotCordLoopRisk(params);
383
+ if (evaluation.reasons.length === 0) return undefined;
384
+
385
+ const lines = [
386
+ "[BotCord loop-risk check]",
387
+ "Observed signals:",
388
+ ...evaluation.reasons.map((reason) => `- ${reason.summary}`),
389
+ "",
390
+ "Before sending any BotCord reply, verify that it adds new information, concrete progress, a blocking question, or a final result/error.",
391
+ 'If it does not, reply with exactly "NO_REPLY" and nothing else.',
392
+ "Do not send courtesy-only acknowledgements or mirrored sign-offs.",
393
+ ];
394
+
395
+ return lines.join("\n");
396
+ }
397
+
398
+ export function didBotCordSendSucceed(result: unknown, error?: string): boolean {
399
+ if (error) return false;
400
+ if (!result || typeof result !== "object") return true;
401
+ const record = result as Record<string, unknown>;
402
+ if (record.ok === true) return true;
403
+ if (typeof record.error === "string" && record.error.trim()) return false;
404
+ return true;
405
+ }
406
+
407
+ export function resetBotCordLoopRiskStateForTests(): void {
408
+ outboundBySession.clear();
409
+ }
@@ -40,6 +40,10 @@ export function createRoomsTool() {
40
40
  type: "string" as const,
41
41
  description: "Room description — for create, update",
42
42
  },
43
+ rule: {
44
+ type: "string" as const,
45
+ description: "Room rule/instructions — for create, update",
46
+ },
43
47
  visibility: {
44
48
  type: "string" as const,
45
49
  enum: ["private", "public"],
@@ -94,6 +98,7 @@ export function createRoomsTool() {
94
98
  return await client.createRoom({
95
99
  name: args.name,
96
100
  description: args.description,
101
+ rule: args.rule,
97
102
  visibility: args.visibility || "private",
98
103
  join_policy: args.join_policy,
99
104
  default_send: args.default_send,
@@ -111,6 +116,7 @@ export function createRoomsTool() {
111
116
  return await client.updateRoom(args.room_id, {
112
117
  name: args.name,
113
118
  description: args.description,
119
+ rule: args.rule,
114
120
  visibility: args.visibility,
115
121
  join_policy: args.join_policy,
116
122
  default_send: args.default_send,