@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.
@@ -113,9 +113,10 @@ export function toGatewayConfig(cfg, opts = {}) {
113
113
  const routes = (cfg.routes ?? []).map(mapRoute);
114
114
  // Synthesize a per-agent route for every bound agent and hand it to the
115
115
  // gateway via the managed-routes bucket (plan §10.1). User-authored
116
- // `cfg.routes[]` stay untouched so an explicit operator override still
117
- // wins on conflict the gateway matches `routes[] → managedRoutes →
118
- // defaultRoute` in that order.
116
+ // `cfg.routes[]` stay untouched. Match priority (see router.ts):
117
+ // `routes[] with explicit accountId managedRoutes other routes[] →
118
+ // defaultRoute`. Broad prefix/kind rules no longer clobber the agent's
119
+ // chosen runtime — only routes that name the agent by `accountId` do.
119
120
  const managedMap = buildManagedRoutes(agentIds, opts.agentRuntimes ?? {}, defaultRoute);
120
121
  return {
121
122
  channels,
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 onInbound = createActivityRecorder({
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", {
@@ -82,6 +82,17 @@ declare function normalizeInbox(msg: InboxMessage, options: {
82
82
  channelId: string;
83
83
  accountId: string;
84
84
  }): GatewayInboundMessage | null;
85
+ /**
86
+ * Shape of the `raw` field when the channel batches multiple messages into
87
+ * one envelope. Keeps the latest message's InboxMessage fields at top level
88
+ * so existing accesses (`raw.envelope.type`, `raw.source_type`, …) still
89
+ * work, and exposes the full list via `raw.batch`. `composeBotCordUserTurn`
90
+ * reads `raw.batch` to build one `<agent-message>` / `<human-message>` block
91
+ * per entry.
92
+ */
93
+ export interface BatchedInboxRaw extends InboxMessage {
94
+ batch: InboxMessage[];
95
+ }
85
96
  /**
86
97
  * Construct a BotCord channel adapter.
87
98
  *
@@ -69,7 +69,13 @@ function normalizeInbox(msg, options) {
69
69
  const env = msg.envelope;
70
70
  if (!env)
71
71
  return null;
72
- if (env.type !== "message")
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;
@@ -107,6 +113,50 @@ function normalizeInbox(msg, options) {
107
113
  trace: { id: msg.hub_msg_id, streamable },
108
114
  };
109
115
  }
116
+ /**
117
+ * Normalize a group of InboxMessages for the same `(room, topic)` into a
118
+ * single `GatewayInboundMessage`. The envelope carries the latest msg's
119
+ * metadata (routing, session key, trace) and a `raw.batch` array the
120
+ * composer uses to render per-sender blocks.
121
+ *
122
+ * `mentioned` is sticky: true if ANY message in the group is a mention.
123
+ * Returns null if no message in the group is normalizable on its own.
124
+ */
125
+ function normalizeInboxBatch(msgs, options) {
126
+ if (msgs.length === 0)
127
+ return null;
128
+ if (msgs.length === 1)
129
+ return normalizeInbox(msgs[0], options);
130
+ const latest = msgs[msgs.length - 1];
131
+ const base = normalizeInbox(latest, options);
132
+ if (!base)
133
+ return null;
134
+ // Fold sibling metadata into the base envelope. `text` is kept non-empty
135
+ // when at least one batched member has a body, so the dispatcher's empty-
136
+ // text skip rule doesn't drop the whole batch just because the latest
137
+ // envelope was e.g. a zero-payload contact_request.
138
+ const anyMentioned = msgs.some((m) => m.mentioned === true);
139
+ let representativeText = base.text ?? "";
140
+ if (!representativeText.trim()) {
141
+ for (let i = msgs.length - 1; i >= 0; i--) {
142
+ const m = msgs[i];
143
+ const candidate = m.text ??
144
+ (typeof m.envelope?.payload?.text === "string"
145
+ ? m.envelope.payload.text
146
+ : "");
147
+ if (candidate && candidate.trim()) {
148
+ representativeText = candidate;
149
+ break;
150
+ }
151
+ }
152
+ }
153
+ return {
154
+ ...base,
155
+ text: representativeText,
156
+ mentioned: anyMentioned,
157
+ raw: { ...latest, batch: msgs },
158
+ };
159
+ }
110
160
  /**
111
161
  * Construct a BotCord channel adapter.
112
162
  *
@@ -155,9 +205,14 @@ export function createBotCordChannel(options) {
155
205
  log.info("botcord inbox drained", { count: msgs.length });
156
206
  if (msgs.length === 0)
157
207
  return;
208
+ // First pass: ack duplicates/skipped messages so Hub stops requeueing,
209
+ // and collect eligible messages preserving poll order. Grouping by
210
+ // `(room_id, topic)` mirrors plugin's `handleInboxMessageBatch` — the
211
+ // same conversation thread folds into one turn so the agent sees all
212
+ // new messages at once instead of running N turns back-to-back.
213
+ const eligible = [];
158
214
  for (const msg of msgs) {
159
215
  if (!rememberSeen(msg.hub_msg_id)) {
160
- // Already emitted; ack again so Hub stops requeueing.
161
216
  try {
162
217
  await client.ackMessages([msg.hub_msg_id]);
163
218
  }
@@ -171,7 +226,6 @@ export function createBotCordChannel(options) {
171
226
  accountId: options.accountId,
172
227
  });
173
228
  if (!normalized) {
174
- // Not eligible (wrong type, missing room, etc.) — ack so it drops.
175
229
  try {
176
230
  await client.ackMessages([msg.hub_msg_id]);
177
231
  }
@@ -180,16 +234,42 @@ export function createBotCordChannel(options) {
180
234
  }
181
235
  continue;
182
236
  }
237
+ eligible.push(msg);
238
+ }
239
+ if (eligible.length === 0)
240
+ return;
241
+ // Group by `(room_id, topic)`. Insertion order is the poll order, so
242
+ // iterating the map yields groups with the same external chronology.
243
+ const groups = new Map();
244
+ for (const msg of eligible) {
245
+ const topic = msg.topic_id ?? msg.topic ?? "";
246
+ const key = `${msg.room_id ?? ""}:${topic}`;
247
+ const list = groups.get(key);
248
+ if (list)
249
+ list.push(msg);
250
+ else
251
+ groups.set(key, [msg]);
252
+ }
253
+ for (const group of groups.values()) {
254
+ const normalized = normalizeInboxBatch(group, {
255
+ channelId: options.id,
256
+ accountId: options.accountId,
257
+ });
258
+ if (!normalized)
259
+ continue;
260
+ const hubIds = group.map((m) => m.hub_msg_id);
183
261
  const envelope = {
184
262
  message: normalized,
185
263
  ack: {
186
264
  accept: async () => {
187
265
  try {
188
- await client.ackMessages([msg.hub_msg_id]);
266
+ // Ack the entire batch together so Hub never re-delivers any
267
+ // member of this turn if the agent succeeds on the group.
268
+ await client.ackMessages(hubIds);
189
269
  }
190
270
  catch (err) {
191
271
  log.warn("botcord ack failed — relying on seen-cache dedup", {
192
- hubMsgId: msg.hub_msg_id,
272
+ hubMsgIds: hubIds,
193
273
  err: String(err),
194
274
  });
195
275
  }
@@ -201,7 +281,7 @@ export function createBotCordChannel(options) {
201
281
  }
202
282
  catch (err) {
203
283
  log.error("botcord emit threw", {
204
- hubMsgId: msg.hub_msg_id,
284
+ hubMsgIds: hubIds,
205
285
  err: String(err),
206
286
  });
207
287
  }
@@ -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` →
@@ -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({
@@ -3,8 +3,13 @@ import type { GatewayConfig, GatewayInboundMessage, GatewayRoute, RouteMatch } f
3
3
  export declare function matchesRoute(message: GatewayInboundMessage, match: RouteMatch | undefined): boolean;
4
4
  /**
5
5
  * Picks the first matching route in priority order:
6
- * 1. `config.routes[]` (user-authored)
7
- * 2. `managedRoutes` (daemon-synthesized per-agent)
8
- * 3. `config.defaultRoute`
6
+ * 1. `config.routes[]` entries whose `match.accountId` names this message's
7
+ * accountId explicit operator override for a specific agent.
8
+ * 2. `managedRoutes` (daemon-synthesized per-agent, reflects the runtime
9
+ * the user picked when provisioning the agent). Broad user routes do
10
+ * NOT clobber this, because the agent's runtime is itself an explicit
11
+ * user choice — a catch-all prefix rule shouldn't silently downgrade it.
12
+ * 3. Remaining `config.routes[]` (broad prefix/kind/channel rules).
13
+ * 4. `config.defaultRoute`.
9
14
  */
10
15
  export declare function resolveRoute(message: GatewayInboundMessage, config: Pick<GatewayConfig, "defaultRoute" | "routes">, managedRoutes?: readonly GatewayRoute[]): GatewayRoute;
@@ -28,15 +28,21 @@ export function matchesRoute(message, match) {
28
28
  }
29
29
  /**
30
30
  * Picks the first matching route in priority order:
31
- * 1. `config.routes[]` (user-authored)
32
- * 2. `managedRoutes` (daemon-synthesized per-agent)
33
- * 3. `config.defaultRoute`
31
+ * 1. `config.routes[]` entries whose `match.accountId` names this message's
32
+ * accountId explicit operator override for a specific agent.
33
+ * 2. `managedRoutes` (daemon-synthesized per-agent, reflects the runtime
34
+ * the user picked when provisioning the agent). Broad user routes do
35
+ * NOT clobber this, because the agent's runtime is itself an explicit
36
+ * user choice — a catch-all prefix rule shouldn't silently downgrade it.
37
+ * 3. Remaining `config.routes[]` (broad prefix/kind/channel rules).
38
+ * 4. `config.defaultRoute`.
34
39
  */
35
40
  export function resolveRoute(message, config, managedRoutes) {
36
41
  const routes = config.routes ?? [];
37
42
  for (const route of routes) {
38
- if (matchesRoute(message, route.match))
43
+ if (route.match?.accountId === message.accountId && matchesRoute(message, route.match)) {
39
44
  return route;
45
+ }
40
46
  }
41
47
  if (managedRoutes) {
42
48
  for (const route of managedRoutes) {
@@ -44,5 +50,9 @@ export function resolveRoute(message, config, managedRoutes) {
44
50
  return route;
45
51
  }
46
52
  }
53
+ for (const route of routes) {
54
+ if (matchesRoute(message, route.match))
55
+ return route;
56
+ }
47
57
  return config.defaultRoute;
48
58
  }
@@ -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;