@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.
- package/dist/daemon.js +35 -1
- package/dist/gateway/channels/botcord.js +7 -1
- 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/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 +32 -2
- 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 +32 -0
- package/src/daemon.ts +42 -1
- package/src/gateway/__tests__/botcord-channel.test.ts +26 -0
- package/src/gateway/__tests__/dispatcher.test.ts +40 -0
- package/src/gateway/channels/botcord.ts +7 -1
- package/src/gateway/dispatcher.ts +20 -0
- package/src/gateway/gateway.ts +7 -0
- 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 +32 -2
package/dist/daemon.js
CHANGED
|
@@ -12,6 +12,7 @@ import { SnapshotWriter } from "./snapshot-writer.js";
|
|
|
12
12
|
import { createDaemonSystemContextBuilder } from "./system-context.js";
|
|
13
13
|
import { createRoomStaticContextBuilder } from "./room-context.js";
|
|
14
14
|
import { createRoomContextFetcher } from "./room-context-fetcher.js";
|
|
15
|
+
import { buildLoopRiskPrompt, loopRiskSessionKey, recordInboundText as recordLoopRiskInbound, recordOutboundText as recordLoopRiskOutbound, } from "./loop-risk.js";
|
|
15
16
|
import { composeBotCordUserTurn } from "./turn-text.js";
|
|
16
17
|
import { UserAuthManager } from "./user-auth.js";
|
|
17
18
|
/**
|
|
@@ -144,11 +145,19 @@ export async function startDaemon(opts) {
|
|
|
144
145
|
log: logger,
|
|
145
146
|
});
|
|
146
147
|
const scBuilders = new Map();
|
|
148
|
+
const loopRiskBuilder = (msg) => buildLoopRiskPrompt({
|
|
149
|
+
sessionKey: loopRiskSessionKey({
|
|
150
|
+
accountId: msg.accountId,
|
|
151
|
+
conversationId: msg.conversation.id,
|
|
152
|
+
threadId: msg.conversation.threadId ?? null,
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
147
155
|
for (const aid of agentIds) {
|
|
148
156
|
scBuilders.set(aid, createDaemonSystemContextBuilder({
|
|
149
157
|
agentId: aid,
|
|
150
158
|
activityTracker,
|
|
151
159
|
roomContextBuilder,
|
|
160
|
+
loopRiskBuilder,
|
|
152
161
|
}));
|
|
153
162
|
}
|
|
154
163
|
const buildSystemContext = (message) => {
|
|
@@ -169,10 +178,34 @@ export async function startDaemon(opts) {
|
|
|
169
178
|
// outside the system-context builder (option A) means the builder stays
|
|
170
179
|
// pure — a cleaner contract the gateway can also expose to non-daemon
|
|
171
180
|
// callers in the future.
|
|
172
|
-
const
|
|
181
|
+
const recordActivity = createActivityRecorder({
|
|
173
182
|
activityTracker,
|
|
174
183
|
...(agentIds[0] ? { fallbackAgentId: agentIds[0] } : {}),
|
|
175
184
|
});
|
|
185
|
+
const onInbound = (msg) => {
|
|
186
|
+
recordActivity(msg);
|
|
187
|
+
// Feed the loop-risk tracker with the sanitized inbound text so
|
|
188
|
+
// detectShortAckTail + detectHighTurnRate have a timeline.
|
|
189
|
+
recordLoopRiskInbound({
|
|
190
|
+
sessionKey: loopRiskSessionKey({
|
|
191
|
+
accountId: msg.accountId,
|
|
192
|
+
conversationId: msg.conversation.id,
|
|
193
|
+
threadId: msg.conversation.threadId ?? null,
|
|
194
|
+
}),
|
|
195
|
+
text: msg.text,
|
|
196
|
+
timestamp: msg.receivedAt,
|
|
197
|
+
});
|
|
198
|
+
};
|
|
199
|
+
const onOutbound = (out) => {
|
|
200
|
+
recordLoopRiskOutbound({
|
|
201
|
+
sessionKey: loopRiskSessionKey({
|
|
202
|
+
accountId: out.accountId,
|
|
203
|
+
conversationId: out.conversationId,
|
|
204
|
+
threadId: out.threadId ?? null,
|
|
205
|
+
}),
|
|
206
|
+
text: out.text,
|
|
207
|
+
});
|
|
208
|
+
};
|
|
176
209
|
const gateway = new Gateway({
|
|
177
210
|
config: gwConfig,
|
|
178
211
|
sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
|
|
@@ -190,6 +223,7 @@ export async function startDaemon(opts) {
|
|
|
190
223
|
turnTimeoutMs: DEFAULT_TURN_TIMEOUT_MS,
|
|
191
224
|
buildSystemContext,
|
|
192
225
|
onInbound,
|
|
226
|
+
onOutbound,
|
|
193
227
|
composeUserTurn: composeBotCordUserTurn,
|
|
194
228
|
});
|
|
195
229
|
logger.info("daemon starting", {
|
|
@@ -69,7 +69,13 @@ function normalizeInbox(msg, options) {
|
|
|
69
69
|
const env = msg.envelope;
|
|
70
70
|
if (!env)
|
|
71
71
|
return null;
|
|
72
|
-
|
|
72
|
+
// `message` is the normal conversational envelope; `contact_request` is
|
|
73
|
+
// a lightweight inbound asking the agent to notify its owner (the
|
|
74
|
+
// composer appends the notify-owner hint). All other envelope types
|
|
75
|
+
// (notification, system, contact_added/removed, …) are still filtered
|
|
76
|
+
// out here — they belong in a separate push-notification path that
|
|
77
|
+
// daemon does not yet implement.
|
|
78
|
+
if (env.type !== "message" && env.type !== "contact_request")
|
|
73
79
|
return null;
|
|
74
80
|
if (!msg.room_id)
|
|
75
81
|
return null;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { GatewayLogger } from "./log.js";
|
|
2
2
|
import { type SessionStore } from "./session-store.js";
|
|
3
|
-
import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayRoute, InboundObserver, RuntimeAdapter, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
|
|
3
|
+
import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayRoute, InboundObserver, OutboundObserver, RuntimeAdapter, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
|
|
4
4
|
/** Factory signature for building a runtime adapter at turn dispatch time. */
|
|
5
5
|
export type RuntimeFactory = (runtimeId: string, extraArgs?: string[]) => RuntimeAdapter;
|
|
6
6
|
/** Constructor options for `Dispatcher`. */
|
|
@@ -36,6 +36,12 @@ export interface DispatcherOptions {
|
|
|
36
36
|
* a fallback so a buggy composer cannot drop turns.
|
|
37
37
|
*/
|
|
38
38
|
composeUserTurn?: UserTurnBuilder;
|
|
39
|
+
/**
|
|
40
|
+
* Optional observer fired after each reply is dispatched. Intended for
|
|
41
|
+
* outbound bookkeeping such as loop-risk tracking. Errors are logged
|
|
42
|
+
* and suppressed so observer failures never break the turn.
|
|
43
|
+
*/
|
|
44
|
+
onOutbound?: OutboundObserver;
|
|
39
45
|
}
|
|
40
46
|
/**
|
|
41
47
|
* Gateway dispatcher: consumes `GatewayInboundEnvelope` and drives a runtime
|
|
@@ -56,6 +62,7 @@ export declare class Dispatcher {
|
|
|
56
62
|
private readonly turnTimeoutMs;
|
|
57
63
|
private readonly buildSystemContext?;
|
|
58
64
|
private readonly onInbound?;
|
|
65
|
+
private readonly onOutbound?;
|
|
59
66
|
private readonly composeUserTurn?;
|
|
60
67
|
private readonly managedRoutes?;
|
|
61
68
|
private readonly queues;
|
|
@@ -20,6 +20,7 @@ export class Dispatcher {
|
|
|
20
20
|
turnTimeoutMs;
|
|
21
21
|
buildSystemContext;
|
|
22
22
|
onInbound;
|
|
23
|
+
onOutbound;
|
|
23
24
|
composeUserTurn;
|
|
24
25
|
managedRoutes;
|
|
25
26
|
queues = new Map();
|
|
@@ -32,6 +33,7 @@ export class Dispatcher {
|
|
|
32
33
|
this.turnTimeoutMs = opts.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
|
|
33
34
|
this.buildSystemContext = opts.buildSystemContext;
|
|
34
35
|
this.onInbound = opts.onInbound;
|
|
36
|
+
this.onOutbound = opts.onOutbound;
|
|
35
37
|
this.composeUserTurn = opts.composeUserTurn;
|
|
36
38
|
this.managedRoutes = opts.managedRoutes;
|
|
37
39
|
}
|
|
@@ -414,6 +416,18 @@ export class Dispatcher {
|
|
|
414
416
|
conversationId: outbound.conversationId,
|
|
415
417
|
error: err instanceof Error ? err.message : String(err),
|
|
416
418
|
});
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (this.onOutbound) {
|
|
422
|
+
try {
|
|
423
|
+
await this.onOutbound(outbound);
|
|
424
|
+
}
|
|
425
|
+
catch (err) {
|
|
426
|
+
this.log.warn("dispatcher: onOutbound threw — continuing", {
|
|
427
|
+
conversationId: outbound.conversationId,
|
|
428
|
+
error: err instanceof Error ? err.message : String(err),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
417
431
|
}
|
|
418
432
|
}
|
|
419
433
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ChannelBackoffOptions } from "./channel-manager.js";
|
|
2
2
|
import { type RuntimeFactory } from "./dispatcher.js";
|
|
3
3
|
import { type GatewayLogger } from "./log.js";
|
|
4
|
-
import type { ChannelAdapter, GatewayChannelConfig, GatewayConfig, GatewayRoute, GatewayRuntimeSnapshot, InboundObserver, SystemContextBuilder, UserTurnBuilder } from "./types.js";
|
|
4
|
+
import type { ChannelAdapter, GatewayChannelConfig, GatewayConfig, GatewayRoute, GatewayRuntimeSnapshot, InboundObserver, OutboundObserver, SystemContextBuilder, UserTurnBuilder } from "./types.js";
|
|
5
5
|
/** Constructor options for `Gateway`. */
|
|
6
6
|
export interface GatewayBootOptions {
|
|
7
7
|
config: GatewayConfig;
|
|
@@ -30,6 +30,11 @@ export interface GatewayBootOptions {
|
|
|
30
30
|
* to the runtime. Forwarded to the dispatcher; see {@link UserTurnBuilder}.
|
|
31
31
|
*/
|
|
32
32
|
composeUserTurn?: UserTurnBuilder;
|
|
33
|
+
/**
|
|
34
|
+
* Optional observer fired after each reply is sent. Intended for outbound
|
|
35
|
+
* bookkeeping like loop-risk tracking.
|
|
36
|
+
*/
|
|
37
|
+
onOutbound?: OutboundObserver;
|
|
33
38
|
}
|
|
34
39
|
/**
|
|
35
40
|
* Top-level gateway bootstrap. Wires `ChannelManager` → `Dispatcher` →
|
package/dist/gateway/gateway.js
CHANGED
|
@@ -63,6 +63,7 @@ export class Gateway {
|
|
|
63
63
|
buildSystemContext: opts.buildSystemContext,
|
|
64
64
|
onInbound: opts.onInbound,
|
|
65
65
|
composeUserTurn: opts.composeUserTurn,
|
|
66
|
+
onOutbound: opts.onOutbound,
|
|
66
67
|
managedRoutes: this.managedRoutes,
|
|
67
68
|
});
|
|
68
69
|
this.channelManager = new ChannelManager({
|
package/dist/gateway/types.d.ts
CHANGED
|
@@ -110,6 +110,12 @@ export type InboundObserver = (message: GatewayInboundMessage) => Promise<void>
|
|
|
110
110
|
* a buggy composer never drops turns.
|
|
111
111
|
*/
|
|
112
112
|
export type UserTurnBuilder = (message: GatewayInboundMessage) => string;
|
|
113
|
+
/**
|
|
114
|
+
* Optional hook fired after the dispatcher dispatches a reply to a channel.
|
|
115
|
+
* Intended for outbound bookkeeping (loop-risk tracking, metrics). Errors
|
|
116
|
+
* are caught and logged so observer failures never break the turn.
|
|
117
|
+
*/
|
|
118
|
+
export type OutboundObserver = (message: GatewayOutboundMessage) => Promise<void> | void;
|
|
113
119
|
/** Outbound reply payload passed to `ChannelAdapter.send()`. */
|
|
114
120
|
export interface GatewayOutboundMessage {
|
|
115
121
|
channel: string;
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
export type LoopRiskReason = {
|
|
21
|
+
id: "high_turn_rate" | "short_ack_tail" | "repeated_outbound";
|
|
22
|
+
summary: string;
|
|
23
|
+
};
|
|
24
|
+
export interface LoopRiskEvaluation {
|
|
25
|
+
reasons: LoopRiskReason[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Strip the `[BotCord Message] | …` header and `<agent-message>` / hint
|
|
29
|
+
* wrappers the user-turn composer adds around the raw inbound text. Leaves
|
|
30
|
+
* the plain body so similarity / ack detection operates on actual content.
|
|
31
|
+
* Kept in sync with `turn-text.ts` output shape.
|
|
32
|
+
*/
|
|
33
|
+
export declare function stripBotCordPromptScaffolding(text: string): string;
|
|
34
|
+
export declare function normalizeLoopText(text: string): string;
|
|
35
|
+
export declare function recordInboundText(params: {
|
|
36
|
+
sessionKey?: string;
|
|
37
|
+
text?: unknown;
|
|
38
|
+
timestamp?: number;
|
|
39
|
+
}): void;
|
|
40
|
+
export declare function recordOutboundText(params: {
|
|
41
|
+
sessionKey?: string;
|
|
42
|
+
text?: unknown;
|
|
43
|
+
timestamp?: number;
|
|
44
|
+
}): void;
|
|
45
|
+
export declare function clearLoopRiskSession(sessionKey?: string): void;
|
|
46
|
+
export declare function resetLoopRiskStateForTests(): void;
|
|
47
|
+
export declare function evaluateLoopRisk(params: {
|
|
48
|
+
sessionKey?: string;
|
|
49
|
+
now?: number;
|
|
50
|
+
}): LoopRiskEvaluation;
|
|
51
|
+
/** Build the injected system-context hint, or `null` if no risk detected. */
|
|
52
|
+
export declare function buildLoopRiskPrompt(params: {
|
|
53
|
+
sessionKey?: string;
|
|
54
|
+
now?: number;
|
|
55
|
+
}): string | null;
|
|
56
|
+
/**
|
|
57
|
+
* Derive a loop-risk session key from a gateway inbound message or
|
|
58
|
+
* outbound reply. Keyed on (accountId, conversationId, threadId) so a
|
|
59
|
+
* DM and a group thread under the same agent don't share state.
|
|
60
|
+
*/
|
|
61
|
+
export declare function loopRiskSessionKey(params: {
|
|
62
|
+
accountId: string;
|
|
63
|
+
conversationId: string;
|
|
64
|
+
threadId?: string | null;
|
|
65
|
+
}): string;
|
|
@@ -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
|
@@ -4,6 +4,20 @@ const GROUP_HINT = '[In group chats, do NOT reply unless you are explicitly ment
|
|
|
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
|
+
}
|
|
7
21
|
/**
|
|
8
22
|
* Compose the user-turn text for a BotCord inbound message.
|
|
9
23
|
*
|
|
@@ -46,12 +60,28 @@ export function composeBotCordUserTurn(msg) {
|
|
|
46
60
|
const tag = sender.kind === "human" ? "human-message" : "agent-message";
|
|
47
61
|
const senderKindAttr = sender.kind === "human" ? "human" : "agent";
|
|
48
62
|
const hint = isGroup ? GROUP_HINT : DIRECT_HINT;
|
|
49
|
-
|
|
63
|
+
// Contact-request envelopes travel through the same "inbound message"
|
|
64
|
+
// path as regular messages, but carry an additional expectation: the
|
|
65
|
+
// agent should surface the request to its owner rather than auto-accept
|
|
66
|
+
// or auto-reject. Mirrors `plugin/src/inbound.ts` §handleA2ASingle.
|
|
67
|
+
const isContactRequest = readEnvelopeType(msg.raw) === "contact_request";
|
|
68
|
+
const contactRequestHint = isContactRequest
|
|
69
|
+
? "[You received a contact request from " +
|
|
70
|
+
sanitizedSenderLabel +
|
|
71
|
+
". Use the botcord_notify tool to inform your owner about this request so " +
|
|
72
|
+
"they can decide whether to accept or reject it. Include the sender's " +
|
|
73
|
+
"agent ID and any message they attached.]"
|
|
74
|
+
: null;
|
|
75
|
+
const lines = [
|
|
50
76
|
headerFields.join(" | "),
|
|
51
77
|
`<${tag} sender="${sanitizedSenderLabel}" sender_kind="${senderKindAttr}">`,
|
|
52
78
|
trimmed,
|
|
53
79
|
`</${tag}>`,
|
|
54
80
|
"",
|
|
55
81
|
hint,
|
|
56
|
-
]
|
|
82
|
+
];
|
|
83
|
+
if (contactRequestHint) {
|
|
84
|
+
lines.push("", contactRequestHint);
|
|
85
|
+
}
|
|
86
|
+
return lines.join("\n");
|
|
57
87
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botcord/daemon",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
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": {
|