@botcord/daemon 0.1.1 → 0.2.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/daemon-config-map.js +4 -3
- package/dist/daemon.js +35 -1
- package/dist/gateway/channels/botcord.d.ts +11 -0
- package/dist/gateway/channels/botcord.js +86 -6
- package/dist/gateway/dispatcher.d.ts +8 -1
- package/dist/gateway/dispatcher.js +14 -0
- package/dist/gateway/gateway.d.ts +6 -1
- package/dist/gateway/gateway.js +1 -0
- package/dist/gateway/router.d.ts +8 -3
- package/dist/gateway/router.js +14 -4
- package/dist/gateway/types.d.ts +6 -0
- package/dist/loop-risk.d.ts +65 -0
- package/dist/loop-risk.js +286 -0
- package/dist/system-context.d.ts +6 -0
- package/dist/system-context.js +21 -2
- package/dist/turn-text.js +120 -3
- package/package.json +2 -2
- package/src/__tests__/loop-risk.test.ts +172 -0
- package/src/__tests__/system-context.test.ts +35 -0
- package/src/__tests__/turn-text.test.ts +143 -0
- package/src/daemon-config-map.ts +4 -3
- package/src/daemon.ts +42 -1
- package/src/gateway/__tests__/botcord-channel.test.ts +89 -0
- package/src/gateway/__tests__/dispatcher.test.ts +40 -0
- package/src/gateway/__tests__/router.test.ts +27 -1
- package/src/gateway/channels/botcord.ts +102 -6
- package/src/gateway/dispatcher.ts +20 -0
- package/src/gateway/gateway.ts +7 -0
- package/src/gateway/router.ts +18 -4
- package/src/gateway/types.ts +9 -0
- package/src/loop-risk.ts +322 -0
- package/src/system-context.ts +26 -2
- package/src/turn-text.ts +151 -3
|
@@ -0,0 +1,286 @@
|
|
|
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
|
+
// Module-level state. Keys are caller-supplied session identifiers
|
|
21
|
+
// (daemon uses `${accountId}:${conversationId}:${threadId ?? ""}`).
|
|
22
|
+
const inboundBySession = new Map();
|
|
23
|
+
const outboundBySession = new Map();
|
|
24
|
+
// --- Tunables ---------------------------------------------------------
|
|
25
|
+
const TURN_WINDOW_MS = 2 * 60_000;
|
|
26
|
+
const TURN_THRESHOLD = 8;
|
|
27
|
+
const ALTERNATION_THRESHOLD = 6;
|
|
28
|
+
const MIN_TURNS_PER_SIDE = 3;
|
|
29
|
+
const SAMPLE_MAX_AGE_MS = 10 * 60_000;
|
|
30
|
+
const MAX_TRACKED_PER_SIDE = 6;
|
|
31
|
+
const SHORT_ACK_MAX_CHARS = 48;
|
|
32
|
+
const MIN_REPEAT_TEXT_CHARS = 6;
|
|
33
|
+
const OUTBOUND_SIMILARITY_THRESHOLD = 0.88;
|
|
34
|
+
// --- Ack / closure phrase lists (mirrors plugin) ----------------------
|
|
35
|
+
const ENGLISH_ACK_OR_CLOSURE = new Set([
|
|
36
|
+
"ok", "okay", "got it", "thanks", "thank you", "noted", "understood",
|
|
37
|
+
"sounds good", "sgtm", "roger", "copy", "will do", "all good",
|
|
38
|
+
"no worries", "bye", "goodbye", "see you", "talk later",
|
|
39
|
+
]);
|
|
40
|
+
const CHINESE_ACK_OR_CLOSURE = new Set([
|
|
41
|
+
"收到", "好的", "好", "行", "嗯", "嗯嗯", "明白", "明白了", "知道了",
|
|
42
|
+
"谢谢", "谢谢你", "感谢", "辛苦了", "先这样", "回头聊", "有需要再说",
|
|
43
|
+
"没问题", "了解", "好嘞",
|
|
44
|
+
]);
|
|
45
|
+
// --- Text normalization -----------------------------------------------
|
|
46
|
+
/**
|
|
47
|
+
* Strip the `[BotCord Message] | …` header and `<agent-message>` / hint
|
|
48
|
+
* wrappers the user-turn composer adds around the raw inbound text. Leaves
|
|
49
|
+
* the plain body so similarity / ack detection operates on actual content.
|
|
50
|
+
* Kept in sync with `turn-text.ts` output shape.
|
|
51
|
+
*/
|
|
52
|
+
export function stripBotCordPromptScaffolding(text) {
|
|
53
|
+
const filtered = text
|
|
54
|
+
.split(/\r?\n/)
|
|
55
|
+
.map((line) => line.trim())
|
|
56
|
+
.filter((line) => {
|
|
57
|
+
if (!line)
|
|
58
|
+
return false;
|
|
59
|
+
if (line.startsWith("[BotCord Message]"))
|
|
60
|
+
return false;
|
|
61
|
+
if (line.startsWith("[BotCord Notification]"))
|
|
62
|
+
return false;
|
|
63
|
+
if (line.startsWith("[Room Rule]"))
|
|
64
|
+
return false;
|
|
65
|
+
if (line.startsWith("[In group chats, do NOT reply"))
|
|
66
|
+
return false;
|
|
67
|
+
if (line.startsWith("[If the conversation has naturally concluded"))
|
|
68
|
+
return false;
|
|
69
|
+
if (line.startsWith("[You received a contact request"))
|
|
70
|
+
return false;
|
|
71
|
+
if (line.includes('reply with exactly "NO_REPLY"'))
|
|
72
|
+
return false;
|
|
73
|
+
if (line.startsWith("<agent-message"))
|
|
74
|
+
return false;
|
|
75
|
+
if (line === "</agent-message>")
|
|
76
|
+
return false;
|
|
77
|
+
if (line.startsWith("<human-message"))
|
|
78
|
+
return false;
|
|
79
|
+
if (line === "</human-message>")
|
|
80
|
+
return false;
|
|
81
|
+
return true;
|
|
82
|
+
});
|
|
83
|
+
return filtered.join("\n").trim();
|
|
84
|
+
}
|
|
85
|
+
export function normalizeLoopText(text) {
|
|
86
|
+
return stripBotCordPromptScaffolding(text)
|
|
87
|
+
.toLowerCase()
|
|
88
|
+
.replace(/https?:\/\/\S+/gu, " ")
|
|
89
|
+
.replace(/[^\p{L}\p{N}\s]/gu, " ")
|
|
90
|
+
.replace(/\s+/gu, " ")
|
|
91
|
+
.trim();
|
|
92
|
+
}
|
|
93
|
+
function isShortAckOrClosure(text) {
|
|
94
|
+
const n = normalizeLoopText(text);
|
|
95
|
+
if (!n || n.length > SHORT_ACK_MAX_CHARS)
|
|
96
|
+
return false;
|
|
97
|
+
return ENGLISH_ACK_OR_CLOSURE.has(n) || CHINESE_ACK_OR_CLOSURE.has(n);
|
|
98
|
+
}
|
|
99
|
+
// --- Similarity -------------------------------------------------------
|
|
100
|
+
function trigramSet(text) {
|
|
101
|
+
if (text.length <= 3)
|
|
102
|
+
return new Set([text]);
|
|
103
|
+
const grams = new Set();
|
|
104
|
+
for (let i = 0; i <= text.length - 3; i++)
|
|
105
|
+
grams.add(text.slice(i, i + 3));
|
|
106
|
+
return grams;
|
|
107
|
+
}
|
|
108
|
+
function jaccardSimilarity(a, b) {
|
|
109
|
+
if (!a || !b)
|
|
110
|
+
return 0;
|
|
111
|
+
if (a === b)
|
|
112
|
+
return 1;
|
|
113
|
+
const A = trigramSet(a);
|
|
114
|
+
const B = trigramSet(b);
|
|
115
|
+
let inter = 0;
|
|
116
|
+
for (const g of A)
|
|
117
|
+
if (B.has(g))
|
|
118
|
+
inter++;
|
|
119
|
+
const union = A.size + B.size - inter;
|
|
120
|
+
return union === 0 ? 0 : inter / union;
|
|
121
|
+
}
|
|
122
|
+
function areOutboundTextsHighlySimilar(a, b) {
|
|
123
|
+
if (!a || !b)
|
|
124
|
+
return false;
|
|
125
|
+
if (a === b)
|
|
126
|
+
return true;
|
|
127
|
+
if (a.length < MIN_REPEAT_TEXT_CHARS || b.length < MIN_REPEAT_TEXT_CHARS)
|
|
128
|
+
return false;
|
|
129
|
+
return jaccardSimilarity(a, b) >= OUTBOUND_SIMILARITY_THRESHOLD;
|
|
130
|
+
}
|
|
131
|
+
// --- Sample store -----------------------------------------------------
|
|
132
|
+
function prune(map, sessionKey, now) {
|
|
133
|
+
const existing = map.get(sessionKey) ?? [];
|
|
134
|
+
const next = existing.filter((s) => now - s.timestamp <= SAMPLE_MAX_AGE_MS);
|
|
135
|
+
if (next.length === 0) {
|
|
136
|
+
map.delete(sessionKey);
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
map.set(sessionKey, next);
|
|
140
|
+
return next;
|
|
141
|
+
}
|
|
142
|
+
function record(map, sessionKey, sample) {
|
|
143
|
+
const kept = prune(map, sessionKey, sample.timestamp);
|
|
144
|
+
const next = [...kept, sample].slice(-MAX_TRACKED_PER_SIDE);
|
|
145
|
+
map.set(sessionKey, next);
|
|
146
|
+
}
|
|
147
|
+
export function recordInboundText(params) {
|
|
148
|
+
const sessionKey = params.sessionKey?.trim();
|
|
149
|
+
const raw = typeof params.text === "string" ? params.text.trim() : "";
|
|
150
|
+
if (!sessionKey || !raw)
|
|
151
|
+
return;
|
|
152
|
+
const normalized = normalizeLoopText(raw);
|
|
153
|
+
if (!normalized)
|
|
154
|
+
return;
|
|
155
|
+
record(inboundBySession, sessionKey, {
|
|
156
|
+
text: raw,
|
|
157
|
+
normalized,
|
|
158
|
+
timestamp: params.timestamp ?? Date.now(),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
export function recordOutboundText(params) {
|
|
162
|
+
const sessionKey = params.sessionKey?.trim();
|
|
163
|
+
const raw = typeof params.text === "string" ? params.text.trim() : "";
|
|
164
|
+
if (!sessionKey || !raw)
|
|
165
|
+
return;
|
|
166
|
+
const normalized = normalizeLoopText(raw);
|
|
167
|
+
if (!normalized)
|
|
168
|
+
return;
|
|
169
|
+
record(outboundBySession, sessionKey, {
|
|
170
|
+
text: raw,
|
|
171
|
+
normalized,
|
|
172
|
+
timestamp: params.timestamp ?? Date.now(),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
export function clearLoopRiskSession(sessionKey) {
|
|
176
|
+
if (!sessionKey)
|
|
177
|
+
return;
|
|
178
|
+
inboundBySession.delete(sessionKey);
|
|
179
|
+
outboundBySession.delete(sessionKey);
|
|
180
|
+
}
|
|
181
|
+
export function resetLoopRiskStateForTests() {
|
|
182
|
+
inboundBySession.clear();
|
|
183
|
+
outboundBySession.clear();
|
|
184
|
+
}
|
|
185
|
+
// --- Detectors --------------------------------------------------------
|
|
186
|
+
function detectHighTurnRate(inbound, outbound, now) {
|
|
187
|
+
const timeline = [];
|
|
188
|
+
for (const s of inbound) {
|
|
189
|
+
if (now - s.timestamp <= TURN_WINDOW_MS)
|
|
190
|
+
timeline.push({ role: "user", timestamp: s.timestamp });
|
|
191
|
+
}
|
|
192
|
+
for (const s of outbound) {
|
|
193
|
+
if (now - s.timestamp <= TURN_WINDOW_MS)
|
|
194
|
+
timeline.push({ role: "assistant", timestamp: s.timestamp });
|
|
195
|
+
}
|
|
196
|
+
if (timeline.length < TURN_THRESHOLD)
|
|
197
|
+
return undefined;
|
|
198
|
+
timeline.sort((a, b) => a.timestamp - b.timestamp);
|
|
199
|
+
let userTurns = 0;
|
|
200
|
+
let assistantTurns = 0;
|
|
201
|
+
let alternations = 0;
|
|
202
|
+
for (let i = 0; i < timeline.length; i++) {
|
|
203
|
+
if (timeline[i].role === "user")
|
|
204
|
+
userTurns++;
|
|
205
|
+
else
|
|
206
|
+
assistantTurns++;
|
|
207
|
+
if (i > 0 && timeline[i].role !== timeline[i - 1].role)
|
|
208
|
+
alternations++;
|
|
209
|
+
}
|
|
210
|
+
if (userTurns >= MIN_TURNS_PER_SIDE &&
|
|
211
|
+
assistantTurns >= MIN_TURNS_PER_SIDE &&
|
|
212
|
+
alternations >= ALTERNATION_THRESHOLD) {
|
|
213
|
+
return {
|
|
214
|
+
id: "high_turn_rate",
|
|
215
|
+
summary: `same session shows ${timeline.length} user/assistant turns within ${Math.round(TURN_WINDOW_MS / 1000)}s`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
function detectShortAckTail(inbound) {
|
|
221
|
+
const tail = inbound.slice(-2);
|
|
222
|
+
if (tail.length < 2)
|
|
223
|
+
return undefined;
|
|
224
|
+
if (tail.every((s) => isShortAckOrClosure(s.text))) {
|
|
225
|
+
return {
|
|
226
|
+
id: "short_ack_tail",
|
|
227
|
+
summary: "the last two inbound user messages are short acknowledgements or closure phrases",
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
function detectRepeatedOutbound(outbound) {
|
|
233
|
+
const recent = outbound.slice(-3);
|
|
234
|
+
if (recent.length < 2)
|
|
235
|
+
return undefined;
|
|
236
|
+
const last = recent[recent.length - 1];
|
|
237
|
+
if (!last)
|
|
238
|
+
return undefined;
|
|
239
|
+
const previous = recent.slice(0, -1);
|
|
240
|
+
const exact = previous.filter((s) => s.normalized === last.normalized).length;
|
|
241
|
+
const similar = previous.filter((s) => areOutboundTextsHighlySimilar(s.normalized, last.normalized)).length;
|
|
242
|
+
if (exact >= 1 || (recent.length >= 3 && similar >= 2)) {
|
|
243
|
+
return {
|
|
244
|
+
id: "repeated_outbound",
|
|
245
|
+
summary: "recent outbound texts in this session are highly similar",
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
export function evaluateLoopRisk(params) {
|
|
251
|
+
const now = params.now ?? Date.now();
|
|
252
|
+
if (!params.sessionKey)
|
|
253
|
+
return { reasons: [] };
|
|
254
|
+
const inbound = prune(inboundBySession, params.sessionKey, now);
|
|
255
|
+
const outbound = prune(outboundBySession, params.sessionKey, now);
|
|
256
|
+
const reasons = [
|
|
257
|
+
detectHighTurnRate(inbound, outbound, now),
|
|
258
|
+
detectShortAckTail(inbound),
|
|
259
|
+
detectRepeatedOutbound(outbound),
|
|
260
|
+
].filter((r) => Boolean(r));
|
|
261
|
+
return { reasons };
|
|
262
|
+
}
|
|
263
|
+
/** Build the injected system-context hint, or `null` if no risk detected. */
|
|
264
|
+
export function buildLoopRiskPrompt(params) {
|
|
265
|
+
const evaluation = evaluateLoopRisk(params);
|
|
266
|
+
if (evaluation.reasons.length === 0)
|
|
267
|
+
return null;
|
|
268
|
+
return [
|
|
269
|
+
"[BotCord loop-risk check]",
|
|
270
|
+
"Observed signals:",
|
|
271
|
+
...evaluation.reasons.map((r) => `- ${r.summary}`),
|
|
272
|
+
"",
|
|
273
|
+
"Before sending any BotCord reply, verify that it adds new information, concrete progress, a blocking question, or a final result/error.",
|
|
274
|
+
'If it does not, reply with exactly "NO_REPLY" and nothing else.',
|
|
275
|
+
"Do not send courtesy-only acknowledgements or mirrored sign-offs.",
|
|
276
|
+
].join("\n");
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Derive a loop-risk session key from a gateway inbound message or
|
|
280
|
+
* outbound reply. Keyed on (accountId, conversationId, threadId) so a
|
|
281
|
+
* DM and a group thread under the same agent don't share state.
|
|
282
|
+
*/
|
|
283
|
+
export function loopRiskSessionKey(params) {
|
|
284
|
+
const thread = params.threadId ?? "";
|
|
285
|
+
return `${params.accountId}:${params.conversationId}:${thread}`;
|
|
286
|
+
}
|
package/dist/system-context.d.ts
CHANGED
|
@@ -46,6 +46,12 @@ export interface SystemContextDeps {
|
|
|
46
46
|
* is skipped.
|
|
47
47
|
*/
|
|
48
48
|
roomContextBuilder?: RoomStaticContextBuilder;
|
|
49
|
+
/**
|
|
50
|
+
* Optional per-turn loop-risk check. Returns a warning block when the
|
|
51
|
+
* session shows signs of agent-to-agent echo or courtesy loops. Sync
|
|
52
|
+
* + cheap — consulted every turn even when roomContextBuilder is absent.
|
|
53
|
+
*/
|
|
54
|
+
loopRiskBuilder?: (message: GatewayInboundMessage) => string | null;
|
|
49
55
|
}
|
|
50
56
|
/**
|
|
51
57
|
* Build a {@link SystemContextBuilder} for the gateway dispatcher.
|
package/dist/system-context.js
CHANGED
|
@@ -53,10 +53,28 @@ export function createDaemonSystemContextBuilder(deps) {
|
|
|
53
53
|
const filtered = parts.filter((p) => typeof p === "string" && p.length > 0);
|
|
54
54
|
return filtered.length > 0 ? filtered.join("\n\n") : undefined;
|
|
55
55
|
};
|
|
56
|
+
const runLoopRisk = (message) => {
|
|
57
|
+
if (!deps.loopRiskBuilder)
|
|
58
|
+
return null;
|
|
59
|
+
try {
|
|
60
|
+
return deps.loopRiskBuilder(message);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
log.warn("system-context: loopRiskBuilder threw — skipping loop-risk block", {
|
|
64
|
+
agentId: deps.agentId,
|
|
65
|
+
roomId: message.conversation.id,
|
|
66
|
+
err: err instanceof Error ? err.message : String(err),
|
|
67
|
+
});
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
56
71
|
if (!deps.roomContextBuilder) {
|
|
57
72
|
const syncBuilder = (message) => {
|
|
58
73
|
const { ownerScene, memory, digest } = gatherSyncBlocks(message);
|
|
59
|
-
|
|
74
|
+
// Loop-risk sits at the end so its "reply NO_REPLY unless…" guidance
|
|
75
|
+
// is the last thing the model sees before the user turn body.
|
|
76
|
+
const loopRisk = runLoopRisk(message);
|
|
77
|
+
return assemble([ownerScene, memory, digest, loopRisk]);
|
|
60
78
|
};
|
|
61
79
|
// Compile-time witness that the narrower sync signature still satisfies
|
|
62
80
|
// `SystemContextBuilder` (which allows async). Prevents the two contracts
|
|
@@ -83,7 +101,8 @@ export function createDaemonSystemContextBuilder(deps) {
|
|
|
83
101
|
err: err instanceof Error ? err.message : String(err),
|
|
84
102
|
});
|
|
85
103
|
}
|
|
86
|
-
|
|
104
|
+
const loopRisk = runLoopRisk(message);
|
|
105
|
+
return assemble([ownerScene, memory, roomBlock, digest, loopRisk]);
|
|
87
106
|
};
|
|
88
107
|
const _typecheck = asyncBuilder;
|
|
89
108
|
void _typecheck;
|
package/dist/turn-text.js
CHANGED
|
@@ -1,9 +1,56 @@
|
|
|
1
|
-
import { sanitizeSenderName } from "./gateway/index.js";
|
|
1
|
+
import { sanitizeSenderName, sanitizeUntrustedContent } from "./gateway/index.js";
|
|
2
2
|
import { classifyActivitySender } from "./sender-classify.js";
|
|
3
3
|
const GROUP_HINT = '[In group chats, do NOT reply unless you are explicitly mentioned or addressed. ' +
|
|
4
4
|
'If no response is needed, reply with exactly "NO_REPLY" and nothing else.]';
|
|
5
5
|
const DIRECT_HINT = '[If the conversation has naturally concluded or no response is needed, ' +
|
|
6
6
|
'reply with exactly "NO_REPLY" and nothing else.]';
|
|
7
|
+
/**
|
|
8
|
+
* Read the BotCord envelope type from a raw inbound message. Returns
|
|
9
|
+
* `undefined` when the message didn't come from the BotCord channel or the
|
|
10
|
+
* raw shape is unexpected — callers treat that the same as "message".
|
|
11
|
+
*/
|
|
12
|
+
function readEnvelopeType(raw) {
|
|
13
|
+
if (!raw || typeof raw !== "object")
|
|
14
|
+
return undefined;
|
|
15
|
+
const env = raw.envelope;
|
|
16
|
+
if (!env || typeof env !== "object")
|
|
17
|
+
return undefined;
|
|
18
|
+
const t = env.type;
|
|
19
|
+
return typeof t === "string" ? t : undefined;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Read the `raw.batch` array emitted by the BotCord channel when inbox
|
|
23
|
+
* drain groups multiple messages for the same `(room, topic)`. Returns the
|
|
24
|
+
* list when present and well-shaped, else null. Single-message envelopes
|
|
25
|
+
* have no `batch` field and fall through to the single-message path.
|
|
26
|
+
*/
|
|
27
|
+
function readBatch(raw) {
|
|
28
|
+
if (!raw || typeof raw !== "object")
|
|
29
|
+
return null;
|
|
30
|
+
const b = raw.batch;
|
|
31
|
+
if (!Array.isArray(b) || b.length < 2)
|
|
32
|
+
return null;
|
|
33
|
+
return b;
|
|
34
|
+
}
|
|
35
|
+
function entryFromLabel(e) {
|
|
36
|
+
const envType = typeof e.envelope?.type === "string" ? e.envelope.type : undefined;
|
|
37
|
+
const isHuman = e.source_type === "dashboard_human_room" ||
|
|
38
|
+
(typeof e.envelope?.from === "string" && e.envelope.from.startsWith("hu_"));
|
|
39
|
+
const fromId = typeof e.envelope?.from === "string" ? e.envelope.from : "unknown";
|
|
40
|
+
const label = isHuman
|
|
41
|
+
? typeof e.source_user_name === "string" && e.source_user_name
|
|
42
|
+
? e.source_user_name
|
|
43
|
+
: "User"
|
|
44
|
+
: fromId;
|
|
45
|
+
return { label, kind: isHuman ? "human" : "agent", envelopeType: envType };
|
|
46
|
+
}
|
|
47
|
+
function entryText(e) {
|
|
48
|
+
if (typeof e.text === "string")
|
|
49
|
+
return e.text;
|
|
50
|
+
if (typeof e.envelope?.payload?.text === "string")
|
|
51
|
+
return e.envelope.payload.text;
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
7
54
|
/**
|
|
8
55
|
* Compose the user-turn text for a BotCord inbound message.
|
|
9
56
|
*
|
|
@@ -23,6 +70,10 @@ export function composeBotCordUserTurn(msg) {
|
|
|
23
70
|
// system-context handles context; wrapping here would just add noise.
|
|
24
71
|
if (sender.kind === "owner")
|
|
25
72
|
return trimmed;
|
|
73
|
+
const batch = readBatch(msg.raw);
|
|
74
|
+
if (batch) {
|
|
75
|
+
return composeBatchedTurn(msg, batch);
|
|
76
|
+
}
|
|
26
77
|
const conversation = msg.conversation;
|
|
27
78
|
const isGroup = conversation.kind === "group";
|
|
28
79
|
const roomTitle = typeof conversation.title === "string" ? conversation.title : undefined;
|
|
@@ -46,12 +97,78 @@ export function composeBotCordUserTurn(msg) {
|
|
|
46
97
|
const tag = sender.kind === "human" ? "human-message" : "agent-message";
|
|
47
98
|
const senderKindAttr = sender.kind === "human" ? "human" : "agent";
|
|
48
99
|
const hint = isGroup ? GROUP_HINT : DIRECT_HINT;
|
|
49
|
-
|
|
100
|
+
// Contact-request envelopes travel through the same "inbound message"
|
|
101
|
+
// path as regular messages, but carry an additional expectation: the
|
|
102
|
+
// agent should surface the request to its owner rather than auto-accept
|
|
103
|
+
// or auto-reject. Mirrors `plugin/src/inbound.ts` §handleA2ASingle.
|
|
104
|
+
const isContactRequest = readEnvelopeType(msg.raw) === "contact_request";
|
|
105
|
+
const contactRequestHint = isContactRequest
|
|
106
|
+
? "[You received a contact request from " +
|
|
107
|
+
sanitizedSenderLabel +
|
|
108
|
+
". Use the botcord_notify tool to inform your owner about this request so " +
|
|
109
|
+
"they can decide whether to accept or reject it. Include the sender's " +
|
|
110
|
+
"agent ID and any message they attached.]"
|
|
111
|
+
: null;
|
|
112
|
+
const lines = [
|
|
50
113
|
headerFields.join(" | "),
|
|
51
114
|
`<${tag} sender="${sanitizedSenderLabel}" sender_kind="${senderKindAttr}">`,
|
|
52
115
|
trimmed,
|
|
53
116
|
`</${tag}>`,
|
|
54
117
|
"",
|
|
55
118
|
hint,
|
|
56
|
-
]
|
|
119
|
+
];
|
|
120
|
+
if (contactRequestHint) {
|
|
121
|
+
lines.push("", contactRequestHint);
|
|
122
|
+
}
|
|
123
|
+
return lines.join("\n");
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Render a batched turn (≥2 messages from the same room/topic folded into
|
|
127
|
+
* one envelope by `botcord.ts:normalizeInboxBatch`). Mirrors plugin's
|
|
128
|
+
* `handleA2AGroup` output shape so Claude Code sees the same prompt
|
|
129
|
+
* whether driven by OpenClaw or by daemon.
|
|
130
|
+
*/
|
|
131
|
+
function composeBatchedTurn(msg, batch) {
|
|
132
|
+
const conversation = msg.conversation;
|
|
133
|
+
const isGroup = conversation.kind === "group";
|
|
134
|
+
const roomTitle = typeof conversation.title === "string" ? conversation.title : undefined;
|
|
135
|
+
const header = [
|
|
136
|
+
`[BotCord Messages (${batch.length} new)]`,
|
|
137
|
+
`to: ${msg.accountId}`,
|
|
138
|
+
];
|
|
139
|
+
if (isGroup && roomTitle) {
|
|
140
|
+
const safeRoom = sanitizeSenderName(roomTitle.replace(/[\r\n]+/g, " "));
|
|
141
|
+
header.push(`room: ${safeRoom}`);
|
|
142
|
+
}
|
|
143
|
+
if (msg.mentioned) {
|
|
144
|
+
header.push("mentioned: true");
|
|
145
|
+
}
|
|
146
|
+
const blocks = [];
|
|
147
|
+
const contactRequestSenders = [];
|
|
148
|
+
for (const entry of batch) {
|
|
149
|
+
const { label, kind, envelopeType } = entryFromLabel(entry);
|
|
150
|
+
const safeLabel = sanitizeSenderName(label);
|
|
151
|
+
const raw = entryText(entry);
|
|
152
|
+
// Owner-trust bypass is handled at the outer level — by the time we
|
|
153
|
+
// reach a batched turn the sender classifier has already returned
|
|
154
|
+
// non-owner. Still sanitize defensively.
|
|
155
|
+
const safeBody = sanitizeUntrustedContent(raw);
|
|
156
|
+
const tag = kind === "human" ? "human-message" : "agent-message";
|
|
157
|
+
blocks.push(`<${tag} sender="${safeLabel}" sender_kind="${kind}">\n${safeBody}\n</${tag}>`);
|
|
158
|
+
if (envelopeType === "contact_request") {
|
|
159
|
+
contactRequestSenders.push(safeLabel);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const hint = isGroup ? GROUP_HINT : DIRECT_HINT;
|
|
163
|
+
const lines = [header.join(" | "), blocks.join("\n"), "", hint];
|
|
164
|
+
if (contactRequestSenders.length > 0) {
|
|
165
|
+
// Dedup + list — multiple distinct senders show as "A, B".
|
|
166
|
+
const unique = Array.from(new Set(contactRequestSenders));
|
|
167
|
+
lines.push("", "[You received a contact request from " +
|
|
168
|
+
unique.join(", ") +
|
|
169
|
+
". Use the botcord_notify tool to inform your owner about this request so " +
|
|
170
|
+
"they can decide whether to accept or reject it. Include the sender's " +
|
|
171
|
+
"agent ID and any message they attached.]");
|
|
172
|
+
}
|
|
173
|
+
return lines.join("\n");
|
|
57
174
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botcord/daemon",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"access": "public"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@botcord/protocol-core": "^0.
|
|
30
|
+
"@botcord/protocol-core": "^0.2.0",
|
|
31
31
|
"ws": "^8.18.0"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildLoopRiskPrompt,
|
|
4
|
+
clearLoopRiskSession,
|
|
5
|
+
evaluateLoopRisk,
|
|
6
|
+
loopRiskSessionKey,
|
|
7
|
+
recordInboundText,
|
|
8
|
+
recordOutboundText,
|
|
9
|
+
resetLoopRiskStateForTests,
|
|
10
|
+
stripBotCordPromptScaffolding,
|
|
11
|
+
} from "../loop-risk.js";
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
resetLoopRiskStateForTests();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("stripBotCordPromptScaffolding", () => {
|
|
18
|
+
it("removes headers, hints, and wrapper tags but keeps the actual body", () => {
|
|
19
|
+
const wrapped = [
|
|
20
|
+
"[BotCord Message] | from: ag_alice | to: ag_me | room: Team",
|
|
21
|
+
'<agent-message sender="ag_alice" sender_kind="agent">',
|
|
22
|
+
"hello world",
|
|
23
|
+
"</agent-message>",
|
|
24
|
+
"",
|
|
25
|
+
'[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.]',
|
|
26
|
+
].join("\n");
|
|
27
|
+
expect(stripBotCordPromptScaffolding(wrapped)).toBe("hello world");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("leaves plain text untouched", () => {
|
|
31
|
+
expect(stripBotCordPromptScaffolding("just a line")).toBe("just a line");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("evaluateLoopRisk", () => {
|
|
36
|
+
const key = "ag_me:rm_team:";
|
|
37
|
+
|
|
38
|
+
it("returns no reasons for an unknown session", () => {
|
|
39
|
+
expect(evaluateLoopRisk({ sessionKey: "unknown" })).toEqual({ reasons: [] });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("flags short_ack_tail when the last two inbound messages are both acks", () => {
|
|
43
|
+
const now = 1_700_000_000_000;
|
|
44
|
+
recordInboundText({ sessionKey: key, text: "Let's ship it", timestamp: now - 3000 });
|
|
45
|
+
recordInboundText({ sessionKey: key, text: "Thanks!", timestamp: now - 2000 });
|
|
46
|
+
recordInboundText({ sessionKey: key, text: "好的", timestamp: now - 1000 });
|
|
47
|
+
const out = evaluateLoopRisk({ sessionKey: key, now });
|
|
48
|
+
expect(out.reasons.some((r) => r.id === "short_ack_tail")).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("does NOT flag short_ack_tail when only one message is an ack", () => {
|
|
52
|
+
const now = 1_700_000_000_000;
|
|
53
|
+
recordInboundText({ sessionKey: key, text: "Can you help with X?", timestamp: now - 2000 });
|
|
54
|
+
recordInboundText({ sessionKey: key, text: "OK", timestamp: now - 1000 });
|
|
55
|
+
const out = evaluateLoopRisk({ sessionKey: key, now });
|
|
56
|
+
expect(out.reasons.some((r) => r.id === "short_ack_tail")).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("flags repeated_outbound when the last outbound reply matches the previous one exactly", () => {
|
|
60
|
+
const now = 1_700_000_000_000;
|
|
61
|
+
recordOutboundText({ sessionKey: key, text: "Got it, thanks!", timestamp: now - 2000 });
|
|
62
|
+
recordOutboundText({ sessionKey: key, text: "Got it, thanks!", timestamp: now - 1000 });
|
|
63
|
+
const out = evaluateLoopRisk({ sessionKey: key, now });
|
|
64
|
+
expect(out.reasons.some((r) => r.id === "repeated_outbound")).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("flags repeated_outbound on high trigram similarity (>= 0.88)", () => {
|
|
68
|
+
const now = 1_700_000_000_000;
|
|
69
|
+
const a = "Sounds good, I'll take care of that shortly.";
|
|
70
|
+
const b = "Sounds good, I'll take care of that shortly!";
|
|
71
|
+
const c = "Sounds good, I'll take care of that shortly :)";
|
|
72
|
+
recordOutboundText({ sessionKey: key, text: a, timestamp: now - 3000 });
|
|
73
|
+
recordOutboundText({ sessionKey: key, text: b, timestamp: now - 2000 });
|
|
74
|
+
recordOutboundText({ sessionKey: key, text: c, timestamp: now - 1000 });
|
|
75
|
+
const out = evaluateLoopRisk({ sessionKey: key, now });
|
|
76
|
+
expect(out.reasons.some((r) => r.id === "repeated_outbound")).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("flags high_turn_rate on rapid user↔assistant alternation", () => {
|
|
80
|
+
const now = 1_700_000_000_000;
|
|
81
|
+
// 8 turns over the last 60s, tightly alternating.
|
|
82
|
+
const base = now - 60_000;
|
|
83
|
+
for (let i = 0; i < 4; i++) {
|
|
84
|
+
recordInboundText({
|
|
85
|
+
sessionKey: key,
|
|
86
|
+
text: `inbound ${i}`,
|
|
87
|
+
timestamp: base + i * 14_000,
|
|
88
|
+
});
|
|
89
|
+
recordOutboundText({
|
|
90
|
+
sessionKey: key,
|
|
91
|
+
text: `outbound ${i}`,
|
|
92
|
+
timestamp: base + i * 14_000 + 7_000,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
const out = evaluateLoopRisk({ sessionKey: key, now });
|
|
96
|
+
expect(out.reasons.some((r) => r.id === "high_turn_rate")).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("does NOT flag high_turn_rate when the turns are spread out beyond the 2-minute window", () => {
|
|
100
|
+
const now = 1_700_000_000_000;
|
|
101
|
+
const base = now - 5 * 60_000;
|
|
102
|
+
for (let i = 0; i < 4; i++) {
|
|
103
|
+
recordInboundText({
|
|
104
|
+
sessionKey: key,
|
|
105
|
+
text: `in ${i}`,
|
|
106
|
+
timestamp: base + i * 30_000,
|
|
107
|
+
});
|
|
108
|
+
recordOutboundText({
|
|
109
|
+
sessionKey: key,
|
|
110
|
+
text: `out ${i}`,
|
|
111
|
+
timestamp: base + i * 30_000 + 1000,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
const out = evaluateLoopRisk({ sessionKey: key, now });
|
|
115
|
+
expect(out.reasons.some((r) => r.id === "high_turn_rate")).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("prunes samples older than the 10-minute max age", () => {
|
|
119
|
+
const now = 1_700_000_000_000;
|
|
120
|
+
recordOutboundText({ sessionKey: key, text: "ancient", timestamp: now - 20 * 60_000 });
|
|
121
|
+
recordOutboundText({ sessionKey: key, text: "ancient", timestamp: now - 15 * 60_000 });
|
|
122
|
+
// Same text immediately before now would trigger repeated_outbound if the
|
|
123
|
+
// old samples survived; they should be pruned, so no flag fires.
|
|
124
|
+
recordOutboundText({ sessionKey: key, text: "ancient", timestamp: now });
|
|
125
|
+
const out = evaluateLoopRisk({ sessionKey: key, now });
|
|
126
|
+
expect(out.reasons.some((r) => r.id === "repeated_outbound")).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("clearLoopRiskSession drops all state for the given key", () => {
|
|
130
|
+
const now = 1_700_000_000_000;
|
|
131
|
+
recordOutboundText({ sessionKey: key, text: "same", timestamp: now - 1000 });
|
|
132
|
+
recordOutboundText({ sessionKey: key, text: "same", timestamp: now });
|
|
133
|
+
clearLoopRiskSession(key);
|
|
134
|
+
expect(evaluateLoopRisk({ sessionKey: key, now }).reasons).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("buildLoopRiskPrompt", () => {
|
|
139
|
+
const key = "ag_me:rm_x:";
|
|
140
|
+
|
|
141
|
+
it("returns null when no risk is detected", () => {
|
|
142
|
+
expect(buildLoopRiskPrompt({ sessionKey: key })).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("renders the full prompt block when a risk fires", () => {
|
|
146
|
+
const now = 1_700_000_000_000;
|
|
147
|
+
recordOutboundText({ sessionKey: key, text: "same outbound", timestamp: now - 1000 });
|
|
148
|
+
recordOutboundText({ sessionKey: key, text: "same outbound", timestamp: now });
|
|
149
|
+
const out = buildLoopRiskPrompt({ sessionKey: key, now });
|
|
150
|
+
expect(out).toContain("[BotCord loop-risk check]");
|
|
151
|
+
expect(out).toContain("Observed signals:");
|
|
152
|
+
expect(out).toContain("recent outbound texts in this session are highly similar");
|
|
153
|
+
expect(out).toContain('reply with exactly "NO_REPLY"');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("loopRiskSessionKey", () => {
|
|
158
|
+
it("includes threadId when present", () => {
|
|
159
|
+
expect(
|
|
160
|
+
loopRiskSessionKey({ accountId: "ag_me", conversationId: "rm_1", threadId: "tp_a" }),
|
|
161
|
+
).toBe("ag_me:rm_1:tp_a");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("uses empty string for threadId when null/undefined", () => {
|
|
165
|
+
expect(loopRiskSessionKey({ accountId: "ag_me", conversationId: "rm_1" })).toBe(
|
|
166
|
+
"ag_me:rm_1:",
|
|
167
|
+
);
|
|
168
|
+
expect(
|
|
169
|
+
loopRiskSessionKey({ accountId: "ag_me", conversationId: "rm_1", threadId: null }),
|
|
170
|
+
).toBe("ag_me:rm_1:");
|
|
171
|
+
});
|
|
172
|
+
});
|