@botcord/daemon 0.2.78 → 0.2.79

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.
Files changed (50) hide show
  1. package/dist/attention-policy-fetcher.d.ts +14 -0
  2. package/dist/attention-policy-fetcher.js +59 -0
  3. package/dist/cloud-daemon.js +8 -0
  4. package/dist/cloud-gateway-runtime.d.ts +29 -0
  5. package/dist/cloud-gateway-runtime.js +122 -0
  6. package/dist/daemon.js +21 -6
  7. package/dist/gateway/channels/botcord.js +29 -9
  8. package/dist/gateway/channels/login-session.d.ts +12 -0
  9. package/dist/gateway/channels/login-session.js +20 -2
  10. package/dist/gateway/channels/sanitize.d.ts +5 -18
  11. package/dist/gateway/channels/sanitize.js +5 -54
  12. package/dist/gateway/channels/text-split.d.ts +5 -11
  13. package/dist/gateway/channels/text-split.js +5 -31
  14. package/dist/gateway/dispatcher.d.ts +7 -1
  15. package/dist/gateway/dispatcher.js +88 -8
  16. package/dist/gateway/gateway.d.ts +16 -1
  17. package/dist/gateway/gateway.js +21 -0
  18. package/dist/gateway/policy-resolver.js +17 -9
  19. package/dist/gateway/runtimes/deepseek-tui.js +31 -12
  20. package/dist/gateway/types.d.ts +12 -57
  21. package/dist/gateway-control.js +18 -9
  22. package/dist/provision.d.ts +7 -3
  23. package/dist/provision.js +115 -8
  24. package/dist/room-recovery-context.d.ts +11 -0
  25. package/dist/room-recovery-context.js +97 -0
  26. package/package.json +2 -2
  27. package/src/__tests__/attention-policy-fetcher.test.ts +67 -0
  28. package/src/__tests__/cloud-gateway-runtime.test.ts +127 -0
  29. package/src/__tests__/gateway-control.test.ts +136 -0
  30. package/src/__tests__/policy-resolver.test.ts +20 -0
  31. package/src/__tests__/provision.test.ts +65 -0
  32. package/src/attention-policy-fetcher.ts +87 -0
  33. package/src/cloud-daemon.ts +8 -0
  34. package/src/cloud-gateway-runtime.ts +171 -0
  35. package/src/daemon.ts +23 -6
  36. package/src/gateway/__tests__/botcord-channel.test.ts +97 -0
  37. package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +177 -0
  38. package/src/gateway/__tests__/dispatcher.test.ts +56 -0
  39. package/src/gateway/channels/botcord.ts +32 -8
  40. package/src/gateway/channels/login-session.ts +20 -2
  41. package/src/gateway/channels/sanitize.ts +8 -66
  42. package/src/gateway/channels/text-split.ts +5 -27
  43. package/src/gateway/dispatcher.ts +123 -27
  44. package/src/gateway/gateway.ts +29 -0
  45. package/src/gateway/policy-resolver.ts +20 -9
  46. package/src/gateway/runtimes/deepseek-tui.ts +37 -12
  47. package/src/gateway/types.ts +31 -59
  48. package/src/gateway-control.ts +21 -9
  49. package/src/provision.ts +133 -7
  50. package/src/room-recovery-context.ts +131 -0
@@ -0,0 +1,14 @@
1
+ import type { DaemonAttentionPolicy } from "./gateway/policy-resolver.js";
2
+ export interface AttentionPolicyFetcherOptions {
3
+ credentialPathByAgentId: Map<string, string>;
4
+ defaultCredentialsPath?: string;
5
+ hubBaseUrl?: string;
6
+ log?: {
7
+ warn: (msg: string, meta?: Record<string, unknown>) => void;
8
+ };
9
+ }
10
+ export type AttentionPolicyFetcher = (args: {
11
+ agentId: string;
12
+ roomId?: string | null;
13
+ }) => Promise<DaemonAttentionPolicy | undefined>;
14
+ export declare function createAttentionPolicyFetcher(opts: AttentionPolicyFetcherOptions): AttentionPolicyFetcher;
@@ -0,0 +1,59 @@
1
+ import { BotCordClient, defaultCredentialsFile, loadStoredCredentials, updateCredentialsToken, } from "@botcord/protocol-core";
2
+ export function createAttentionPolicyFetcher(opts) {
3
+ const clients = new Map();
4
+ function getClient(agentId) {
5
+ const existing = clients.get(agentId);
6
+ if (existing)
7
+ return existing.client;
8
+ const credentialsPath = opts.credentialPathByAgentId.get(agentId) ??
9
+ opts.defaultCredentialsPath ??
10
+ defaultCredentialsFile(agentId);
11
+ try {
12
+ const creds = loadStoredCredentials(credentialsPath);
13
+ const client = new BotCordClient({
14
+ hubUrl: opts.hubBaseUrl ?? creds.hubUrl,
15
+ agentId: creds.agentId,
16
+ keyId: creds.keyId,
17
+ privateKey: creds.privateKey,
18
+ ...(creds.token ? { token: creds.token } : {}),
19
+ ...(creds.tokenExpiresAt !== undefined
20
+ ? { tokenExpiresAt: creds.tokenExpiresAt }
21
+ : {}),
22
+ });
23
+ client.onTokenRefresh = (token, expiresAt) => {
24
+ try {
25
+ updateCredentialsToken(credentialsPath, token, expiresAt);
26
+ }
27
+ catch {
28
+ // Persistence failures are non-fatal; the next refresh retries.
29
+ }
30
+ };
31
+ clients.set(agentId, { client, credentialsPath });
32
+ return client;
33
+ }
34
+ catch (err) {
35
+ opts.log?.warn("daemon.attention-policy.client-init-failed", {
36
+ agentId,
37
+ credentialsPath,
38
+ error: err instanceof Error ? err.message : String(err),
39
+ });
40
+ return null;
41
+ }
42
+ }
43
+ return async ({ agentId, roomId }) => {
44
+ const client = getClient(agentId);
45
+ if (!client)
46
+ return undefined;
47
+ try {
48
+ return await client.getAttentionPolicy({ roomId });
49
+ }
50
+ catch (err) {
51
+ opts.log?.warn("daemon.attention-policy.fetch-failed", {
52
+ agentId,
53
+ roomId: roomId ?? null,
54
+ error: err instanceof Error ? err.message : String(err),
55
+ });
56
+ return undefined;
57
+ }
58
+ };
59
+ }
@@ -23,6 +23,7 @@ import { createDaemonSystemContextBuilder } from "./system-context.js";
23
23
  import { readWorkingMemorySnapshot } from "./working-memory.js";
24
24
  import { createRoomStaticContextBuilder } from "./room-context.js";
25
25
  import { createRoomContextFetcher } from "./room-context-fetcher.js";
26
+ import { createRecentRoomMessagesRecoveryBuilder } from "./room-recovery-context.js";
26
27
  import { composeBotCordUserTurn } from "./turn-text.js";
27
28
  import { PolicyResolver } from "./gateway/policy-resolver.js";
28
29
  import { scanMention } from "./mention-scan.js";
@@ -92,6 +93,12 @@ export async function startCloudDaemon(opts) {
92
93
  fetchRoomInfo: roomContextFetcher,
93
94
  log: logger,
94
95
  });
96
+ const buildRuntimeRecoveryContext = createRecentRoomMessagesRecoveryBuilder({
97
+ credentialPathByAgentId,
98
+ hubBaseUrl: cloudCfg.hubUrl,
99
+ limit: 20,
100
+ log: logger,
101
+ });
95
102
  const scBuilders = new Map();
96
103
  const buildSystemContext = (message) => {
97
104
  const b = scBuilders.get(message.accountId);
@@ -180,6 +187,7 @@ export async function startCloudDaemon(opts) {
180
187
  turnTimeoutMs: DEFAULT_TURN_TIMEOUT_MS,
181
188
  buildSystemContext,
182
189
  buildMemoryContext,
190
+ buildRuntimeRecoveryContext,
183
191
  onInbound,
184
192
  onTurnComplete,
185
193
  composeUserTurn: composeBotCordUserTurn,
@@ -0,0 +1,29 @@
1
+ import { type GatewayInboundFrame } from "@botcord/protocol-core";
2
+ import type { Gateway, GatewayLogger } from "./gateway/index.js";
3
+ export interface CloudGatewayRuntimeResult {
4
+ accepted: boolean;
5
+ eventId: string;
6
+ gatewayId: string;
7
+ agentId: string;
8
+ conversationId: string;
9
+ turnId: string;
10
+ outbound?: {
11
+ finalText: string;
12
+ providerMessageId?: string | null;
13
+ };
14
+ error?: {
15
+ code: string;
16
+ message: string;
17
+ };
18
+ }
19
+ /**
20
+ * Execute one ingress-originated runtime frame through the normal Gateway
21
+ * dispatcher while keeping provider I/O outside the cloud sandbox.
22
+ *
23
+ * The temporary channel is scoped to this call and implements only the
24
+ * dispatcher-facing send/status surface. Its send() method captures the
25
+ * final runtime reply; the Hub relay converts that into
26
+ * gateway_outbound_complete for gateway-ingress, which then calls the real
27
+ * provider API.
28
+ */
29
+ export declare function handleCloudGatewayRuntimeInbound(gateway: Gateway, frame: GatewayInboundFrame, log?: GatewayLogger): Promise<CloudGatewayRuntimeResult>;
@@ -0,0 +1,122 @@
1
+ import { RUNTIME_FRAME_TYPES, } from "@botcord/protocol-core";
2
+ /**
3
+ * Execute one ingress-originated runtime frame through the normal Gateway
4
+ * dispatcher while keeping provider I/O outside the cloud sandbox.
5
+ *
6
+ * The temporary channel is scoped to this call and implements only the
7
+ * dispatcher-facing send/status surface. Its send() method captures the
8
+ * final runtime reply; the Hub relay converts that into
9
+ * gateway_outbound_complete for gateway-ingress, which then calls the real
10
+ * provider API.
11
+ */
12
+ export async function handleCloudGatewayRuntimeInbound(gateway, frame, log) {
13
+ if (frame.type !== RUNTIME_FRAME_TYPES.GATEWAY_INBOUND) {
14
+ return rejected(frame, "bad_frame_type", `unsupported frame type "${frame.type}"`);
15
+ }
16
+ if (!frame.gateway_id || !frame.agent_id || !frame.event_id) {
17
+ return rejected(frame, "bad_frame", "gateway_id, agent_id and event_id are required");
18
+ }
19
+ if (frame.message.accountId !== frame.agent_id) {
20
+ return rejected(frame, "account_mismatch", "message.accountId does not match frame.agent_id");
21
+ }
22
+ if (frame.message.channel !== frame.gateway_id) {
23
+ return rejected(frame, "channel_mismatch", "message.channel does not match frame.gateway_id");
24
+ }
25
+ let accepted = false;
26
+ let outboundText = null;
27
+ let providerMessageId;
28
+ const channel = createRuntimeRelayChannel({
29
+ id: frame.gateway_id,
30
+ provider: frame.provider,
31
+ accountId: frame.agent_id,
32
+ onSend: async (ctx) => {
33
+ outboundText = ctx.message.text ?? "";
34
+ providerMessageId = ctx.message.traceId ?? null;
35
+ return { providerMessageId };
36
+ },
37
+ });
38
+ const message = {
39
+ ...frame.message,
40
+ raw: {
41
+ source_type: "cloud_gateway_ingress",
42
+ provider: frame.provider,
43
+ event_id: frame.event_id,
44
+ gateway_id: frame.gateway_id,
45
+ },
46
+ };
47
+ try {
48
+ await gateway.injectInboundThrough(message, channel, {
49
+ accept: async () => {
50
+ accepted = true;
51
+ },
52
+ });
53
+ }
54
+ catch (err) {
55
+ const message = err instanceof Error ? err.message : String(err);
56
+ log?.warn("cloud gateway runtime dispatch failed", {
57
+ eventId: frame.event_id,
58
+ gatewayId: frame.gateway_id,
59
+ agentId: frame.agent_id,
60
+ error: message,
61
+ });
62
+ return rejected(frame, "dispatch_failed", message);
63
+ }
64
+ return {
65
+ accepted,
66
+ eventId: frame.event_id,
67
+ gatewayId: frame.gateway_id,
68
+ agentId: frame.agent_id,
69
+ conversationId: frame.message.conversation.id,
70
+ turnId: `turn_${frame.event_id}`,
71
+ ...(outboundText !== null
72
+ ? {
73
+ outbound: {
74
+ finalText: outboundText,
75
+ providerMessageId: providerMessageId ?? null,
76
+ },
77
+ }
78
+ : {}),
79
+ ...(!accepted
80
+ ? { error: { code: "not_accepted", message: "dispatcher did not accept inbound" } }
81
+ : {}),
82
+ };
83
+ }
84
+ function createRuntimeRelayChannel(opts) {
85
+ let lastSendAt;
86
+ return {
87
+ id: opts.id,
88
+ type: opts.provider,
89
+ async start() {
90
+ return undefined;
91
+ },
92
+ async stop() {
93
+ return undefined;
94
+ },
95
+ async send(ctx) {
96
+ lastSendAt = Date.now();
97
+ return opts.onSend(ctx);
98
+ },
99
+ status() {
100
+ return {
101
+ channel: opts.id,
102
+ accountId: opts.accountId,
103
+ running: true,
104
+ connected: true,
105
+ authorized: true,
106
+ provider: opts.provider,
107
+ ...(lastSendAt ? { lastSendAt } : {}),
108
+ };
109
+ },
110
+ };
111
+ }
112
+ function rejected(frame, code, message) {
113
+ return {
114
+ accepted: false,
115
+ eventId: frame.event_id ?? "",
116
+ gatewayId: frame.gateway_id ?? "",
117
+ agentId: frame.agent_id ?? "",
118
+ conversationId: frame.message?.conversation.id ?? "",
119
+ turnId: frame.event_id ? `turn_${frame.event_id}` : "turn_unknown",
120
+ error: { code, message },
121
+ };
122
+ }
package/dist/daemon.js CHANGED
@@ -14,12 +14,14 @@ import { createDaemonSystemContextBuilder } from "./system-context.js";
14
14
  import { readWorkingMemorySnapshot } from "./working-memory.js";
15
15
  import { createRoomStaticContextBuilder } from "./room-context.js";
16
16
  import { createRoomContextFetcher } from "./room-context-fetcher.js";
17
+ import { createRecentRoomMessagesRecoveryBuilder } from "./room-recovery-context.js";
17
18
  import { buildLoopRiskPrompt, loopRiskSessionKey, recordInboundText as recordLoopRiskInbound, recordOutboundText as recordLoopRiskOutbound, } from "./loop-risk.js";
18
19
  import { composeBotCordUserTurn } from "./turn-text.js";
19
20
  import { UserAuthManager } from "./user-auth.js";
20
21
  import { PolicyResolver } from "./gateway/policy-resolver.js";
21
22
  import { scanMention } from "./mention-scan.js";
22
23
  import { createDiagnosticBundle, uploadDiagnosticBundle } from "./diagnostics.js";
24
+ import { createAttentionPolicyFetcher } from "./attention-policy-fetcher.js";
23
25
  /**
24
26
  * Default hard cap for a single runtime turn. Long-running coding/research
25
27
  * tasks routinely exceed 10 minutes, so daemon-hosted agents get a larger
@@ -233,6 +235,13 @@ export async function startDaemon(opts) {
233
235
  fetchRoomInfo: roomContextFetcher,
234
236
  log: logger,
235
237
  });
238
+ const buildRuntimeRecoveryContext = createRecentRoomMessagesRecoveryBuilder({
239
+ credentialPathByAgentId,
240
+ ...(opts.credentialsPath ? { defaultCredentialsPath: opts.credentialsPath } : {}),
241
+ ...(opts.hubBaseUrl ? { hubBaseUrl: opts.hubBaseUrl } : {}),
242
+ limit: 20,
243
+ log: logger,
244
+ });
236
245
  const scBuilders = new Map();
237
246
  const loopRiskBuilder = (msg) => buildLoopRiskPrompt({
238
247
  sessionKey: loopRiskSessionKey({
@@ -296,13 +305,18 @@ export async function startDaemon(opts) {
296
305
  text: out.text,
297
306
  });
298
307
  };
299
- // Per-agent attention policy cache (PR3, design §4.2 / §5). Seeded from
300
- // the optional `defaultAttention` / `attentionKeywords` carried by
301
- // `provision_agent`, refreshed in-place by the `policy_updated` control
302
- // frame. PR2 will plug per-room overrides into `fetchEffective`; PR3
303
- // leaves it absent so the resolver collapses to per-agent state.
308
+ // Per-agent attention policy cache (design §4.2 / §5). It is seeded from
309
+ // `provision_agent` / `policy_updated` frames when available and falls back
310
+ // to Hub on cold misses, so daemon restarts preserve dashboard policy.
311
+ const fetchAttentionPolicy = createAttentionPolicyFetcher({
312
+ credentialPathByAgentId,
313
+ defaultCredentialsPath: opts.credentialsPath,
314
+ hubBaseUrl: opts.hubBaseUrl,
315
+ log: logger,
316
+ });
304
317
  const policyResolver = new PolicyResolver({
305
- fetchGlobal: async (_agentId) => undefined,
318
+ fetchGlobal: async (agentId) => fetchAttentionPolicy({ agentId, roomId: null }),
319
+ fetchEffective: async (agentId, roomId) => fetchAttentionPolicy({ agentId, roomId }),
306
320
  });
307
321
  // Display-name lookup for the mention text-fallback. Populated from boot
308
322
  // credentials; multi-agent daemons can reuse the same map via accountId.
@@ -374,6 +388,7 @@ export async function startDaemon(opts) {
374
388
  turnTimeoutMs: DEFAULT_TURN_TIMEOUT_MS,
375
389
  buildSystemContext,
376
390
  buildMemoryContext,
391
+ buildRuntimeRecoveryContext,
377
392
  onInbound,
378
393
  onOutbound,
379
394
  onRuntimeCircuitBreakerChange: pushLiveRuntimeSnapshot,
@@ -842,7 +842,7 @@ function normalizeBlockForHub(block, seq) {
842
842
  // Claude Code: {type:"assistant", message:{content:[{type:"text",text}]}}
843
843
  // Codex: {type:"item.completed", item:{type:"agent_message", text}}
844
844
  // DeepSeek: {event:"message.delta", payload:{content}} or
845
- // {event:"item.delta", payload:{payload:{kind:"agent_message", delta}}}
845
+ // {event:"item.delta", payload:{kind:"agent_message", delta}}
846
846
  let text = "";
847
847
  const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
848
848
  for (const c of contents) {
@@ -856,9 +856,13 @@ function normalizeBlockForHub(block, seq) {
856
856
  }
857
857
  if (!text &&
858
858
  raw?.event === "item.delta" &&
859
- raw?.payload?.payload?.kind === "agent_message" &&
860
- typeof raw?.payload?.payload?.delta === "string") {
861
- text = raw.payload.payload.delta;
859
+ (raw?.payload?.kind === "agent_message" || raw?.payload?.payload?.kind === "agent_message")) {
860
+ text =
861
+ typeof raw?.payload?.delta === "string"
862
+ ? raw.payload.delta
863
+ : typeof raw?.payload?.payload?.delta === "string"
864
+ ? raw.payload.payload.delta
865
+ : "";
862
866
  }
863
867
  return { kind: "assistant", seq, payload: { text } };
864
868
  }
@@ -1048,8 +1052,12 @@ function extractDeepseekToolCall(raw) {
1048
1052
  status: stringField(payload, "status") ?? stringField(tool, "status"),
1049
1053
  };
1050
1054
  }
1051
- if (payload.event === "item.started") {
1052
- const inner = payload.payload && typeof payload.payload === "object" ? payload.payload : {};
1055
+ if (raw?.event === "item.started" || payload.event === "item.started") {
1056
+ const inner = raw?.event === "item.started"
1057
+ ? payload
1058
+ : payload.payload && typeof payload.payload === "object"
1059
+ ? payload.payload
1060
+ : {};
1053
1061
  const item = inner.item && typeof inner.item === "object" ? inner.item : undefined;
1054
1062
  const tool = inner.tool && typeof inner.tool === "object" ? inner.tool : item?.tool;
1055
1063
  return {
@@ -1082,8 +1090,15 @@ function extractDeepseekToolResult(raw) {
1082
1090
  id: stringField(payload, "id"),
1083
1091
  };
1084
1092
  }
1085
- if (payload.event === "item.completed" || payload.event === "item.failed") {
1086
- const inner = payload.payload && typeof payload.payload === "object" ? payload.payload : {};
1093
+ if (raw?.event === "item.completed" ||
1094
+ raw?.event === "item.failed" ||
1095
+ payload.event === "item.completed" ||
1096
+ payload.event === "item.failed") {
1097
+ const inner = raw?.event === "item.completed" || raw?.event === "item.failed"
1098
+ ? payload
1099
+ : payload.payload && typeof payload.payload === "object"
1100
+ ? payload.payload
1101
+ : {};
1087
1102
  const item = inner.item && typeof inner.item === "object" ? inner.item : undefined;
1088
1103
  const result = item?.output ??
1089
1104
  item?.result ??
@@ -1112,7 +1127,12 @@ function formatBlockDetails(raw) {
1112
1127
  : typeof r.message === "string" ? r.message
1113
1128
  : typeof r.summary === "string" ? r.summary
1114
1129
  : typeof r.label === "string" ? r.label
1115
- : "";
1130
+ : typeof r.payload?.delta === "string" ? r.payload.delta
1131
+ : typeof r.payload?.item?.detail === "string" ? r.payload.item.detail
1132
+ : typeof r.payload?.item?.summary === "string" ? r.payload.item.summary
1133
+ : typeof r.payload?.payload?.item?.detail === "string" ? r.payload.payload.item.detail
1134
+ : typeof r.payload?.payload?.item?.summary === "string" ? r.payload.payload.item.summary
1135
+ : "";
1116
1136
  if (direct)
1117
1137
  return direct;
1118
1138
  const contentText = extractContentText(r.content ?? r.message?.content ?? r.params?.update?.content);
@@ -60,6 +60,18 @@ export declare class LoginSessionStore {
60
60
  create(input: Omit<LoginSession, "expiresAt"> & {
61
61
  expiresAt?: number;
62
62
  }): LoginSession;
63
+ /**
64
+ * Distinguish whether `loginId` is unknown to the store ("missing") vs
65
+ * known-but-past-TTL ("expired"). When the entry is expired this also
66
+ * evicts it from the internal map so callers do not need to follow up
67
+ * with a separate `delete`. Use this when the caller wants to surface
68
+ * a precise error code to the user; prefer `get` when a single nullable
69
+ * result is enough.
70
+ */
71
+ resolve(loginId: string): {
72
+ state: "live" | "expired" | "missing";
73
+ session?: LoginSession;
74
+ };
63
75
  /** Get a non-expired session by id, or `null` when missing/expired. */
64
76
  get(loginId: string): LoginSession | null;
65
77
  /**
@@ -35,10 +35,28 @@ export class LoginSessionStore {
35
35
  this.sessions.set(session.loginId, session);
36
36
  return session;
37
37
  }
38
+ /**
39
+ * Distinguish whether `loginId` is unknown to the store ("missing") vs
40
+ * known-but-past-TTL ("expired"). When the entry is expired this also
41
+ * evicts it from the internal map so callers do not need to follow up
42
+ * with a separate `delete`. Use this when the caller wants to surface
43
+ * a precise error code to the user; prefer `get` when a single nullable
44
+ * result is enough.
45
+ */
46
+ resolve(loginId) {
47
+ const s = this.sessions.get(loginId);
48
+ if (!s)
49
+ return { state: "missing" };
50
+ if (s.expiresAt <= this.now()) {
51
+ this.sessions.delete(loginId);
52
+ return { state: "expired" };
53
+ }
54
+ return { state: "live", session: s };
55
+ }
38
56
  /** Get a non-expired session by id, or `null` when missing/expired. */
39
57
  get(loginId) {
40
- this.sweep();
41
- return this.sessions.get(loginId) ?? null;
58
+ const { state, session } = this.resolve(loginId);
59
+ return state === "live" && session ? session : null;
42
60
  }
43
61
  /**
44
62
  * Apply a partial patch to the session in place. No-op when the session
@@ -1,20 +1,7 @@
1
1
  /**
2
- * Sanitize untrusted inbound content before handing it off to a local runtime.
3
- *
4
- * Copied from `packages/daemon/src/sanitize.ts` so the gateway channel adapter
5
- * does not depend back on the daemon package. Keep these two files in sync —
6
- * any new structural marker added in one place should be mirrored in the other.
7
- *
8
- * Neutralizes:
9
- * - BotCord structural markers the channel itself emits (so peers can't forge them).
10
- * - Common LLM prompt-injection patterns (<system>, [INST], <<SYS>>, <|im_start|>, etc.).
11
- * - Wrapper XML tags the channel uses to frame inbound content
12
- * (<agent-message>, <human-message>, <room-rule>).
2
+ * Thin re-export `sanitizeUntrustedContent` / `sanitizeSenderName` live
3
+ * in `@botcord/protocol-core` so the daemon channel adapters and the
4
+ * `gateway-ingress` provider adapters use one canonical implementation.
5
+ * Existing imports of this module keep working unchanged.
13
6
  */
14
- export declare function sanitizeUntrustedContent(text: string): string;
15
- /**
16
- * Sanitize a sender label so it's safe to embed inside
17
- * `<agent-message sender="...">`. Must not contain newlines, structural
18
- * markers, or characters that could break the XML attribute boundary.
19
- */
20
- export declare function sanitizeSenderName(name: string): string;
7
+ export { sanitizeUntrustedContent, sanitizeSenderName, } from "@botcord/protocol-core";
@@ -1,56 +1,7 @@
1
1
  /**
2
- * Sanitize untrusted inbound content before handing it off to a local runtime.
3
- *
4
- * Copied from `packages/daemon/src/sanitize.ts` so the gateway channel adapter
5
- * does not depend back on the daemon package. Keep these two files in sync —
6
- * any new structural marker added in one place should be mirrored in the other.
7
- *
8
- * Neutralizes:
9
- * - BotCord structural markers the channel itself emits (so peers can't forge them).
10
- * - Common LLM prompt-injection patterns (<system>, [INST], <<SYS>>, <|im_start|>, etc.).
11
- * - Wrapper XML tags the channel uses to frame inbound content
12
- * (<agent-message>, <human-message>, <room-rule>).
2
+ * Thin re-export `sanitizeUntrustedContent` / `sanitizeSenderName` live
3
+ * in `@botcord/protocol-core` so the daemon channel adapters and the
4
+ * `gateway-ingress` provider adapters use one canonical implementation.
5
+ * Existing imports of this module keep working unchanged.
13
6
  */
14
- export function sanitizeUntrustedContent(text) {
15
- let s = text;
16
- s = s.replace(/<\/?a[\s]*g[\s]*e[\s]*n[\s]*t[\s]*-[\s]*m[\s]*e[\s]*s[\s]*s[\s]*a[\s]*g[\s]*e[\s\S]*?>/gi, "[⚠ stripped: agent-message tag]");
17
- s = s.replace(/<\/?h[\s]*u[\s]*m[\s]*a[\s]*n[\s]*-[\s]*m[\s]*e[\s]*s[\s]*s[\s]*a[\s]*g[\s]*e[\s\S]*?>/gi, "[⚠ stripped: human-message tag]");
18
- s = s.replace(/<\/?r[\s]*o[\s]*o[\s]*m[\s]*-[\s]*r[\s]*u[\s]*l[\s]*e[\s\S]*?>/gi, "[⚠ stripped: room-rule tag]");
19
- return s
20
- .split(/\r?\n/)
21
- .map((line) => {
22
- let l = line;
23
- l = l.replace(/^\[(BotCord (?:Message|Notification))\]/i, "[⚠ fake: $1]");
24
- l = l.replace(/^\[Room Rule\]/i, "[⚠ fake: Room Rule]");
25
- l = l.replace(/^\[房间规则\]/i, "[⚠ fake: 房间规则]");
26
- l = l.replace(/^\[系统提示\]/i, "[⚠ fake: 系统提示]");
27
- l = l.replace(/^\[BotCord\s+([^\]\r\n]+)\]/i, (_m, label) => {
28
- const head = String(label).split(":")[0].trim() || String(label).trim();
29
- return `[⚠ fake: BotCord ${head}]`;
30
- });
31
- l = l.replace(/^\[(System|SYSTEM|Assistant|ASSISTANT|User|USER)\]/, "[⚠ fake: $1]");
32
- l = l.replace(/<\/?\s*system(?:-reminder)?\s*>/gi, "[⚠ stripped: system tag]");
33
- l = l.replace(/<\|im_start\|>/gi, "[⚠ stripped: im_start]");
34
- l = l.replace(/<\|im_end\|>/gi, "[⚠ stripped: im_end]");
35
- l = l.replace(/\[\/?INST\]/gi, "[⚠ stripped: INST]");
36
- l = l.replace(/<<\/?SYS>>/gi, "[⚠ stripped: SYS]");
37
- l = l.replace(/<\s*\/?\|(?:system|user|assistant)\|?\s*>/gi, "[⚠ stripped: role tag]");
38
- return l;
39
- })
40
- .join("\n");
41
- }
42
- /**
43
- * Sanitize a sender label so it's safe to embed inside
44
- * `<agent-message sender="...">`. Must not contain newlines, structural
45
- * markers, or characters that could break the XML attribute boundary.
46
- */
47
- export function sanitizeSenderName(name) {
48
- return name
49
- .replace(/[\n\r]/g, " ")
50
- .replace(/\[/g, "⟦")
51
- .replace(/\]/g, "⟧")
52
- .replace(/"/g, "'")
53
- .replace(/</g, "<")
54
- .replace(/>/g, ">")
55
- .slice(0, 100);
56
- }
7
+ export { sanitizeUntrustedContent, sanitizeSenderName, } from "@botcord/protocol-core";
@@ -1,13 +1,7 @@
1
1
  /**
2
- * Split a long message into chunks <= `limit` characters each. Prefers to cut
3
- * on newline boundaries so multi-paragraph replies don't fragment mid-line.
4
- *
5
- * Shared by third-party channel adapters (Telegram, WeChat) which both have a
6
- * per-message size cap from upstream and no native streaming. WeChat caller
7
- * passes a smaller `limit` (~1800), Telegram a larger one (~4000, since the
8
- * raw Telegram limit is 4096).
9
- *
10
- * Empty input returns `[""]` so callers can iterate uniformly without a length
11
- * check.
2
+ * Thin re-export `splitText` lives in `@botcord/protocol-core` so the
3
+ * daemon channel adapters and the `gateway-ingress` provider adapters use
4
+ * one canonical implementation. Existing imports of this module keep
5
+ * working unchanged.
12
6
  */
13
- export declare function splitText(text: string, limit: number): string[];
7
+ export { splitText } from "@botcord/protocol-core";
@@ -1,33 +1,7 @@
1
1
  /**
2
- * Split a long message into chunks <= `limit` characters each. Prefers to cut
3
- * on newline boundaries so multi-paragraph replies don't fragment mid-line.
4
- *
5
- * Shared by third-party channel adapters (Telegram, WeChat) which both have a
6
- * per-message size cap from upstream and no native streaming. WeChat caller
7
- * passes a smaller `limit` (~1800), Telegram a larger one (~4000, since the
8
- * raw Telegram limit is 4096).
9
- *
10
- * Empty input returns `[""]` so callers can iterate uniformly without a length
11
- * check.
2
+ * Thin re-export `splitText` lives in `@botcord/protocol-core` so the
3
+ * daemon channel adapters and the `gateway-ingress` provider adapters use
4
+ * one canonical implementation. Existing imports of this module keep
5
+ * working unchanged.
12
6
  */
13
- export function splitText(text, limit) {
14
- if (limit <= 0)
15
- return [text];
16
- if (text.length === 0)
17
- return [""];
18
- if (text.length <= limit)
19
- return [text];
20
- const out = [];
21
- let remaining = text;
22
- while (remaining.length > limit) {
23
- let cut = remaining.lastIndexOf("\n", limit);
24
- if (cut <= 0)
25
- cut = limit;
26
- out.push(remaining.slice(0, cut));
27
- // Drop the leading newline so the next chunk doesn't start with a blank line.
28
- remaining = remaining.slice(cut).replace(/^\n/, "");
29
- }
30
- if (remaining.length > 0)
31
- out.push(remaining);
32
- return out;
33
- }
7
+ export { splitText } from "@botcord/protocol-core";
@@ -1,7 +1,7 @@
1
1
  import type { GatewayLogger } from "./log.js";
2
2
  import { type SessionStore } from "./session-store.js";
3
3
  import { type TranscriptWriter } from "./transcript.js";
4
- import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayInboundMessage, GatewayRoute, InboundObserver, MemoryContextBuilder, OutboundObserver, RuntimeAdapter, RuntimeRunResult, RuntimeCircuitBreakerSnapshot, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
4
+ import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayInboundMessage, GatewayRoute, InboundObserver, MemoryContextBuilder, OutboundObserver, RuntimeAdapter, RuntimeRecoveryContextBuilder, RuntimeRunResult, RuntimeCircuitBreakerSnapshot, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
5
5
  /** Factory signature for building a runtime adapter at turn dispatch time. */
6
6
  export type RuntimeFactory = (runtimeId: string, extraArgs?: string[]) => RuntimeAdapter;
7
7
  /** Constructor options for `Dispatcher`. */
@@ -33,6 +33,11 @@ export interface DispatcherOptions {
33
33
  * keep following stale memory.
34
34
  */
35
35
  buildMemoryContext?: MemoryContextBuilder;
36
+ /**
37
+ * Optional hook that returns recent room context for a fresh-session retry
38
+ * after a runtime resume session becomes unrecoverable.
39
+ */
40
+ buildRuntimeRecoveryContext?: RuntimeRecoveryContextBuilder;
36
41
  /**
37
42
  * Optional side-effect hook invoked after ack, before the turn runs.
38
43
  * Intended for bookkeeping (e.g. activity tracking). Errors are logged
@@ -116,6 +121,7 @@ export declare class Dispatcher {
116
121
  private readonly runtimeAuthFailureCooldownMs;
117
122
  private readonly buildSystemContext?;
118
123
  private readonly buildMemoryContext?;
124
+ private readonly buildRuntimeRecoveryContext?;
119
125
  private readonly onInbound?;
120
126
  private readonly onOutbound?;
121
127
  private readonly onTurnComplete?;