@botcord/daemon 0.1.1 → 0.2.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,322 @@
1
+ /**
2
+ * Loop-risk guard — detects patterns that suggest two daemon-hosted agents
3
+ * are stuck echoing each other (or that a conversation has naturally
4
+ * wound down but both sides keep sending courtesy acks). When triggered,
5
+ * `buildLoopRiskPrompt()` returns an injected hint that encourages the
6
+ * agent to reply with `NO_REPLY` unless it has something substantive to
7
+ * add.
8
+ *
9
+ * Ported from `plugin/src/loop-risk.ts` with one structural change: plugin
10
+ * has OpenClaw's message transcript available (`messages: unknown[]`) so
11
+ * it can reconstruct historical user turns on demand. Daemon does not —
12
+ * Claude Code owns the transcript, not daemon. So daemon records inbound
13
+ * texts in a module-level map the same way plugin records outbound ones.
14
+ *
15
+ * Detectors:
16
+ * - high_turn_rate — many user↔assistant alternations in a short window
17
+ * - short_ack_tail — the last two inbound texts are acks / closure phrases
18
+ * - repeated_outbound — recent outbound replies are highly similar
19
+ */
20
+
21
+ interface Sample {
22
+ text: string;
23
+ normalized: string;
24
+ timestamp: number;
25
+ }
26
+
27
+ export type LoopRiskReason = {
28
+ id: "high_turn_rate" | "short_ack_tail" | "repeated_outbound";
29
+ summary: string;
30
+ };
31
+
32
+ export interface LoopRiskEvaluation {
33
+ reasons: LoopRiskReason[];
34
+ }
35
+
36
+ // Module-level state. Keys are caller-supplied session identifiers
37
+ // (daemon uses `${accountId}:${conversationId}:${threadId ?? ""}`).
38
+ const inboundBySession = new Map<string, Sample[]>();
39
+ const outboundBySession = new Map<string, Sample[]>();
40
+
41
+ // --- Tunables ---------------------------------------------------------
42
+
43
+ const TURN_WINDOW_MS = 2 * 60_000;
44
+ const TURN_THRESHOLD = 8;
45
+ const ALTERNATION_THRESHOLD = 6;
46
+ const MIN_TURNS_PER_SIDE = 3;
47
+
48
+ const SAMPLE_MAX_AGE_MS = 10 * 60_000;
49
+ const MAX_TRACKED_PER_SIDE = 6;
50
+
51
+ const SHORT_ACK_MAX_CHARS = 48;
52
+ const MIN_REPEAT_TEXT_CHARS = 6;
53
+ const OUTBOUND_SIMILARITY_THRESHOLD = 0.88;
54
+
55
+ // --- Ack / closure phrase lists (mirrors plugin) ----------------------
56
+
57
+ const ENGLISH_ACK_OR_CLOSURE = new Set([
58
+ "ok", "okay", "got it", "thanks", "thank you", "noted", "understood",
59
+ "sounds good", "sgtm", "roger", "copy", "will do", "all good",
60
+ "no worries", "bye", "goodbye", "see you", "talk later",
61
+ ]);
62
+
63
+ const CHINESE_ACK_OR_CLOSURE = new Set([
64
+ "收到", "好的", "好", "行", "嗯", "嗯嗯", "明白", "明白了", "知道了",
65
+ "谢谢", "谢谢你", "感谢", "辛苦了", "先这样", "回头聊", "有需要再说",
66
+ "没问题", "了解", "好嘞",
67
+ ]);
68
+
69
+ // --- Text normalization -----------------------------------------------
70
+
71
+ /**
72
+ * Strip the `[BotCord Message] | …` header and `<agent-message>` / hint
73
+ * wrappers the user-turn composer adds around the raw inbound text. Leaves
74
+ * the plain body so similarity / ack detection operates on actual content.
75
+ * Kept in sync with `turn-text.ts` output shape.
76
+ */
77
+ export function stripBotCordPromptScaffolding(text: string): string {
78
+ const filtered = text
79
+ .split(/\r?\n/)
80
+ .map((line) => line.trim())
81
+ .filter((line) => {
82
+ if (!line) return false;
83
+ if (line.startsWith("[BotCord Message]")) return false;
84
+ if (line.startsWith("[BotCord Notification]")) return false;
85
+ if (line.startsWith("[Room Rule]")) return false;
86
+ if (line.startsWith("[In group chats, do NOT reply")) return false;
87
+ if (line.startsWith("[If the conversation has naturally concluded")) return false;
88
+ if (line.startsWith("[You received a contact request")) return false;
89
+ if (line.includes('reply with exactly "NO_REPLY"')) return false;
90
+ if (line.startsWith("<agent-message")) return false;
91
+ if (line === "</agent-message>") return false;
92
+ if (line.startsWith("<human-message")) return false;
93
+ if (line === "</human-message>") return false;
94
+ return true;
95
+ });
96
+ return filtered.join("\n").trim();
97
+ }
98
+
99
+ export function normalizeLoopText(text: string): string {
100
+ return stripBotCordPromptScaffolding(text)
101
+ .toLowerCase()
102
+ .replace(/https?:\/\/\S+/gu, " ")
103
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
104
+ .replace(/\s+/gu, " ")
105
+ .trim();
106
+ }
107
+
108
+ function isShortAckOrClosure(text: string): boolean {
109
+ const n = normalizeLoopText(text);
110
+ if (!n || n.length > SHORT_ACK_MAX_CHARS) return false;
111
+ return ENGLISH_ACK_OR_CLOSURE.has(n) || CHINESE_ACK_OR_CLOSURE.has(n);
112
+ }
113
+
114
+ // --- Similarity -------------------------------------------------------
115
+
116
+ function trigramSet(text: string): Set<string> {
117
+ if (text.length <= 3) return new Set([text]);
118
+ const grams = new Set<string>();
119
+ for (let i = 0; i <= text.length - 3; i++) grams.add(text.slice(i, i + 3));
120
+ return grams;
121
+ }
122
+
123
+ function jaccardSimilarity(a: string, b: string): number {
124
+ if (!a || !b) return 0;
125
+ if (a === b) return 1;
126
+ const A = trigramSet(a);
127
+ const B = trigramSet(b);
128
+ let inter = 0;
129
+ for (const g of A) if (B.has(g)) inter++;
130
+ const union = A.size + B.size - inter;
131
+ return union === 0 ? 0 : inter / union;
132
+ }
133
+
134
+ function areOutboundTextsHighlySimilar(a: string, b: string): boolean {
135
+ if (!a || !b) return false;
136
+ if (a === b) return true;
137
+ if (a.length < MIN_REPEAT_TEXT_CHARS || b.length < MIN_REPEAT_TEXT_CHARS) return false;
138
+ return jaccardSimilarity(a, b) >= OUTBOUND_SIMILARITY_THRESHOLD;
139
+ }
140
+
141
+ // --- Sample store -----------------------------------------------------
142
+
143
+ function prune(map: Map<string, Sample[]>, sessionKey: string, now: number): Sample[] {
144
+ const existing = map.get(sessionKey) ?? [];
145
+ const next = existing.filter((s) => now - s.timestamp <= SAMPLE_MAX_AGE_MS);
146
+ if (next.length === 0) {
147
+ map.delete(sessionKey);
148
+ return [];
149
+ }
150
+ map.set(sessionKey, next);
151
+ return next;
152
+ }
153
+
154
+ function record(
155
+ map: Map<string, Sample[]>,
156
+ sessionKey: string,
157
+ sample: Sample,
158
+ ): void {
159
+ const kept = prune(map, sessionKey, sample.timestamp);
160
+ const next = [...kept, sample].slice(-MAX_TRACKED_PER_SIDE);
161
+ map.set(sessionKey, next);
162
+ }
163
+
164
+ export function recordInboundText(params: {
165
+ sessionKey?: string;
166
+ text?: unknown;
167
+ timestamp?: number;
168
+ }): void {
169
+ const sessionKey = params.sessionKey?.trim();
170
+ const raw = typeof params.text === "string" ? params.text.trim() : "";
171
+ if (!sessionKey || !raw) return;
172
+ const normalized = normalizeLoopText(raw);
173
+ if (!normalized) return;
174
+ record(inboundBySession, sessionKey, {
175
+ text: raw,
176
+ normalized,
177
+ timestamp: params.timestamp ?? Date.now(),
178
+ });
179
+ }
180
+
181
+ export function recordOutboundText(params: {
182
+ sessionKey?: string;
183
+ text?: unknown;
184
+ timestamp?: number;
185
+ }): void {
186
+ const sessionKey = params.sessionKey?.trim();
187
+ const raw = typeof params.text === "string" ? params.text.trim() : "";
188
+ if (!sessionKey || !raw) return;
189
+ const normalized = normalizeLoopText(raw);
190
+ if (!normalized) return;
191
+ record(outboundBySession, sessionKey, {
192
+ text: raw,
193
+ normalized,
194
+ timestamp: params.timestamp ?? Date.now(),
195
+ });
196
+ }
197
+
198
+ export function clearLoopRiskSession(sessionKey?: string): void {
199
+ if (!sessionKey) return;
200
+ inboundBySession.delete(sessionKey);
201
+ outboundBySession.delete(sessionKey);
202
+ }
203
+
204
+ export function resetLoopRiskStateForTests(): void {
205
+ inboundBySession.clear();
206
+ outboundBySession.clear();
207
+ }
208
+
209
+ // --- Detectors --------------------------------------------------------
210
+
211
+ function detectHighTurnRate(inbound: Sample[], outbound: Sample[], now: number):
212
+ LoopRiskReason | undefined {
213
+ const timeline: Array<{ role: "user" | "assistant"; timestamp: number }> = [];
214
+ for (const s of inbound) {
215
+ if (now - s.timestamp <= TURN_WINDOW_MS) timeline.push({ role: "user", timestamp: s.timestamp });
216
+ }
217
+ for (const s of outbound) {
218
+ if (now - s.timestamp <= TURN_WINDOW_MS) timeline.push({ role: "assistant", timestamp: s.timestamp });
219
+ }
220
+ if (timeline.length < TURN_THRESHOLD) return undefined;
221
+ timeline.sort((a, b) => a.timestamp - b.timestamp);
222
+
223
+ let userTurns = 0;
224
+ let assistantTurns = 0;
225
+ let alternations = 0;
226
+ for (let i = 0; i < timeline.length; i++) {
227
+ if (timeline[i]!.role === "user") userTurns++;
228
+ else assistantTurns++;
229
+ if (i > 0 && timeline[i]!.role !== timeline[i - 1]!.role) alternations++;
230
+ }
231
+
232
+ if (
233
+ userTurns >= MIN_TURNS_PER_SIDE &&
234
+ assistantTurns >= MIN_TURNS_PER_SIDE &&
235
+ alternations >= ALTERNATION_THRESHOLD
236
+ ) {
237
+ return {
238
+ id: "high_turn_rate",
239
+ summary: `same session shows ${timeline.length} user/assistant turns within ${Math.round(TURN_WINDOW_MS / 1000)}s`,
240
+ };
241
+ }
242
+ return undefined;
243
+ }
244
+
245
+ function detectShortAckTail(inbound: Sample[]): LoopRiskReason | undefined {
246
+ const tail = inbound.slice(-2);
247
+ if (tail.length < 2) return undefined;
248
+ if (tail.every((s) => isShortAckOrClosure(s.text))) {
249
+ return {
250
+ id: "short_ack_tail",
251
+ summary: "the last two inbound user messages are short acknowledgements or closure phrases",
252
+ };
253
+ }
254
+ return undefined;
255
+ }
256
+
257
+ function detectRepeatedOutbound(outbound: Sample[]): LoopRiskReason | undefined {
258
+ const recent = outbound.slice(-3);
259
+ if (recent.length < 2) return undefined;
260
+ const last = recent[recent.length - 1];
261
+ if (!last) return undefined;
262
+ const previous = recent.slice(0, -1);
263
+ const exact = previous.filter((s) => s.normalized === last.normalized).length;
264
+ const similar = previous.filter((s) =>
265
+ areOutboundTextsHighlySimilar(s.normalized, last.normalized),
266
+ ).length;
267
+ if (exact >= 1 || (recent.length >= 3 && similar >= 2)) {
268
+ return {
269
+ id: "repeated_outbound",
270
+ summary: "recent outbound texts in this session are highly similar",
271
+ };
272
+ }
273
+ return undefined;
274
+ }
275
+
276
+ export function evaluateLoopRisk(params: {
277
+ sessionKey?: string;
278
+ now?: number;
279
+ }): LoopRiskEvaluation {
280
+ const now = params.now ?? Date.now();
281
+ if (!params.sessionKey) return { reasons: [] };
282
+ const inbound = prune(inboundBySession, params.sessionKey, now);
283
+ const outbound = prune(outboundBySession, params.sessionKey, now);
284
+ const reasons = [
285
+ detectHighTurnRate(inbound, outbound, now),
286
+ detectShortAckTail(inbound),
287
+ detectRepeatedOutbound(outbound),
288
+ ].filter((r): r is LoopRiskReason => Boolean(r));
289
+ return { reasons };
290
+ }
291
+
292
+ /** Build the injected system-context hint, or `null` if no risk detected. */
293
+ export function buildLoopRiskPrompt(params: {
294
+ sessionKey?: string;
295
+ now?: number;
296
+ }): string | null {
297
+ const evaluation = evaluateLoopRisk(params);
298
+ if (evaluation.reasons.length === 0) return null;
299
+ return [
300
+ "[BotCord loop-risk check]",
301
+ "Observed signals:",
302
+ ...evaluation.reasons.map((r) => `- ${r.summary}`),
303
+ "",
304
+ "Before sending any BotCord reply, verify that it adds new information, concrete progress, a blocking question, or a final result/error.",
305
+ 'If it does not, reply with exactly "NO_REPLY" and nothing else.',
306
+ "Do not send courtesy-only acknowledgements or mirrored sign-offs.",
307
+ ].join("\n");
308
+ }
309
+
310
+ /**
311
+ * Derive a loop-risk session key from a gateway inbound message or
312
+ * outbound reply. Keyed on (accountId, conversationId, threadId) so a
313
+ * DM and a group thread under the same agent don't share state.
314
+ */
315
+ export function loopRiskSessionKey(params: {
316
+ accountId: string;
317
+ conversationId: string;
318
+ threadId?: string | null;
319
+ }): string {
320
+ const thread = params.threadId ?? "";
321
+ return `${params.accountId}:${params.conversationId}:${thread}`;
322
+ }
@@ -69,6 +69,12 @@ export interface SystemContextDeps {
69
69
  * is skipped.
70
70
  */
71
71
  roomContextBuilder?: RoomStaticContextBuilder;
72
+ /**
73
+ * Optional per-turn loop-risk check. Returns a warning block when the
74
+ * session shows signs of agent-to-agent echo or courtesy loops. Sync
75
+ * + cheap — consulted every turn even when roomContextBuilder is absent.
76
+ */
77
+ loopRiskBuilder?: (message: GatewayInboundMessage) => string | null;
72
78
  }
73
79
 
74
80
  function safeReadWorkingMemory(agentId: string) {
@@ -122,10 +128,27 @@ export function createDaemonSystemContextBuilder(
122
128
  return filtered.length > 0 ? filtered.join("\n\n") : undefined;
123
129
  };
124
130
 
131
+ const runLoopRisk = (message: GatewayInboundMessage): string | null => {
132
+ if (!deps.loopRiskBuilder) return null;
133
+ try {
134
+ return deps.loopRiskBuilder(message);
135
+ } catch (err) {
136
+ log.warn("system-context: loopRiskBuilder threw — skipping loop-risk block", {
137
+ agentId: deps.agentId,
138
+ roomId: message.conversation.id,
139
+ err: err instanceof Error ? err.message : String(err),
140
+ });
141
+ return null;
142
+ }
143
+ };
144
+
125
145
  if (!deps.roomContextBuilder) {
126
146
  const syncBuilder = (message: GatewayInboundMessage): string | undefined => {
127
147
  const { ownerScene, memory, digest } = gatherSyncBlocks(message);
128
- return assemble([ownerScene, memory, digest]);
148
+ // Loop-risk sits at the end so its "reply NO_REPLY unless…" guidance
149
+ // is the last thing the model sees before the user turn body.
150
+ const loopRisk = runLoopRisk(message);
151
+ return assemble([ownerScene, memory, digest, loopRisk]);
129
152
  };
130
153
  // Compile-time witness that the narrower sync signature still satisfies
131
154
  // `SystemContextBuilder` (which allows async). Prevents the two contracts
@@ -154,7 +177,8 @@ export function createDaemonSystemContextBuilder(
154
177
  err: err instanceof Error ? err.message : String(err),
155
178
  });
156
179
  }
157
- return assemble([ownerScene, memory, roomBlock, digest]);
180
+ const loopRisk = runLoopRisk(message);
181
+ return assemble([ownerScene, memory, roomBlock, digest, loopRisk]);
158
182
  };
159
183
  const _typecheck: SystemContextBuilder = asyncBuilder;
160
184
  void _typecheck;
package/src/turn-text.ts CHANGED
@@ -34,6 +34,19 @@ const DIRECT_HINT =
34
34
  '[If the conversation has naturally concluded or no response is needed, ' +
35
35
  'reply with exactly "NO_REPLY" and nothing else.]';
36
36
 
37
+ /**
38
+ * Read the BotCord envelope type from a raw inbound message. Returns
39
+ * `undefined` when the message didn't come from the BotCord channel or the
40
+ * raw shape is unexpected — callers treat that the same as "message".
41
+ */
42
+ function readEnvelopeType(raw: unknown): string | undefined {
43
+ if (!raw || typeof raw !== "object") return undefined;
44
+ const env = (raw as { envelope?: unknown }).envelope;
45
+ if (!env || typeof env !== "object") return undefined;
46
+ const t = (env as { type?: unknown }).type;
47
+ return typeof t === "string" ? t : undefined;
48
+ }
49
+
37
50
  /**
38
51
  * Compose the user-turn text for a BotCord inbound message.
39
52
  *
@@ -82,12 +95,29 @@ export function composeBotCordUserTurn(msg: GatewayInboundMessage): string {
82
95
 
83
96
  const hint = isGroup ? GROUP_HINT : DIRECT_HINT;
84
97
 
85
- return [
98
+ // Contact-request envelopes travel through the same "inbound message"
99
+ // path as regular messages, but carry an additional expectation: the
100
+ // agent should surface the request to its owner rather than auto-accept
101
+ // or auto-reject. Mirrors `plugin/src/inbound.ts` §handleA2ASingle.
102
+ const isContactRequest = readEnvelopeType(msg.raw) === "contact_request";
103
+ const contactRequestHint = isContactRequest
104
+ ? "[You received a contact request from " +
105
+ sanitizedSenderLabel +
106
+ ". Use the botcord_notify tool to inform your owner about this request so " +
107
+ "they can decide whether to accept or reject it. Include the sender's " +
108
+ "agent ID and any message they attached.]"
109
+ : null;
110
+
111
+ const lines: string[] = [
86
112
  headerFields.join(" | "),
87
113
  `<${tag} sender="${sanitizedSenderLabel}" sender_kind="${senderKindAttr}">`,
88
114
  trimmed,
89
115
  `</${tag}>`,
90
116
  "",
91
117
  hint,
92
- ].join("\n");
118
+ ];
119
+ if (contactRequestHint) {
120
+ lines.push("", contactRequestHint);
121
+ }
122
+ return lines.join("\n");
93
123
  }