@botcord/daemon 0.2.78 → 0.2.80

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 (61) 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-singleton.d.ts +13 -0
  7. package/dist/daemon-singleton.js +68 -0
  8. package/dist/daemon.js +21 -6
  9. package/dist/gateway/channels/botcord.d.ts +1 -0
  10. package/dist/gateway/channels/botcord.js +62 -17
  11. package/dist/gateway/channels/login-session.d.ts +12 -0
  12. package/dist/gateway/channels/login-session.js +20 -2
  13. package/dist/gateway/channels/sanitize.d.ts +5 -18
  14. package/dist/gateway/channels/sanitize.js +5 -54
  15. package/dist/gateway/channels/text-split.d.ts +5 -11
  16. package/dist/gateway/channels/text-split.js +5 -31
  17. package/dist/gateway/dispatcher.d.ts +7 -1
  18. package/dist/gateway/dispatcher.js +88 -8
  19. package/dist/gateway/gateway.d.ts +16 -1
  20. package/dist/gateway/gateway.js +21 -0
  21. package/dist/gateway/policy-resolver.js +17 -9
  22. package/dist/gateway/runtimes/deepseek-tui.js +56 -13
  23. package/dist/gateway/types.d.ts +12 -57
  24. package/dist/gateway-control.js +18 -9
  25. package/dist/index.js +8 -3
  26. package/dist/provision.d.ts +7 -3
  27. package/dist/provision.js +115 -8
  28. package/dist/room-recovery-context.d.ts +11 -0
  29. package/dist/room-recovery-context.js +97 -0
  30. package/dist/status-render.d.ts +4 -0
  31. package/dist/status-render.js +14 -1
  32. package/package.json +2 -2
  33. package/src/__tests__/attention-policy-fetcher.test.ts +67 -0
  34. package/src/__tests__/cloud-gateway-runtime.test.ts +127 -0
  35. package/src/__tests__/daemon-singleton.test.ts +32 -0
  36. package/src/__tests__/gateway-control.test.ts +136 -0
  37. package/src/__tests__/policy-resolver.test.ts +20 -0
  38. package/src/__tests__/provision.test.ts +65 -0
  39. package/src/__tests__/status-render.test.ts +23 -0
  40. package/src/attention-policy-fetcher.ts +87 -0
  41. package/src/cloud-daemon.ts +8 -0
  42. package/src/cloud-gateway-runtime.ts +171 -0
  43. package/src/daemon-singleton.ts +85 -0
  44. package/src/daemon.ts +23 -6
  45. package/src/gateway/__tests__/botcord-channel.test.ts +211 -5
  46. package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +263 -0
  47. package/src/gateway/__tests__/dispatcher.test.ts +56 -0
  48. package/src/gateway/channels/botcord.ts +69 -17
  49. package/src/gateway/channels/login-session.ts +20 -2
  50. package/src/gateway/channels/sanitize.ts +8 -66
  51. package/src/gateway/channels/text-split.ts +5 -27
  52. package/src/gateway/dispatcher.ts +123 -27
  53. package/src/gateway/gateway.ts +29 -0
  54. package/src/gateway/policy-resolver.ts +20 -9
  55. package/src/gateway/runtimes/deepseek-tui.ts +63 -13
  56. package/src/gateway/types.ts +31 -59
  57. package/src/gateway-control.ts +21 -9
  58. package/src/index.ts +9 -2
  59. package/src/provision.ts +133 -7
  60. package/src/room-recovery-context.ts +131 -0
  61. package/src/status-render.ts +14 -1
@@ -0,0 +1,127 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { RUNTIME_FRAME_TYPES, type GatewayInboundFrame } from "@botcord/protocol-core";
6
+
7
+ import { handleCloudGatewayRuntimeInbound } from "../cloud-gateway-runtime.js";
8
+ import { Gateway, type ChannelAdapter } from "../gateway/index.js";
9
+
10
+ describe("cloud gateway runtime inbound", () => {
11
+ let tmpDir: string;
12
+
13
+ beforeEach(() => {
14
+ tmpDir = mkdtempSync(path.join(os.tmpdir(), "cloud-gateway-runtime-"));
15
+ });
16
+
17
+ afterEach(() => {
18
+ rmSync(tmpDir, { recursive: true, force: true });
19
+ });
20
+
21
+ it("injects a gateway_inbound frame and captures the runtime reply", async () => {
22
+ const gateway = new Gateway({
23
+ config: {
24
+ channels: [],
25
+ defaultRoute: { runtime: "fake", cwd: tmpDir },
26
+ },
27
+ sessionStorePath: path.join(tmpDir, "sessions.json"),
28
+ createChannel: (cfg) => stubChannel(cfg.id, cfg.type, cfg.accountId),
29
+ createRuntime: () => ({
30
+ id: "fake",
31
+ async run() {
32
+ return { text: "hello from runtime", newSessionId: "sess_1" };
33
+ },
34
+ }),
35
+ transcriptEnabled: false,
36
+ });
37
+ await gateway.start();
38
+
39
+ const frame: GatewayInboundFrame = {
40
+ type: RUNTIME_FRAME_TYPES.GATEWAY_INBOUND,
41
+ event_id: "evt_1",
42
+ gateway_id: "gw_tg_1",
43
+ agent_id: "ag_1",
44
+ provider: "telegram",
45
+ message: {
46
+ id: "telegram:1:2",
47
+ channel: "gw_tg_1",
48
+ accountId: "ag_1",
49
+ conversation: { id: "telegram:user:1", kind: "direct" },
50
+ sender: { id: "telegram:user:1", kind: "user" },
51
+ text: "hi",
52
+ replyTo: null,
53
+ mentioned: true,
54
+ receivedAt: Date.now(),
55
+ trace: { id: "telegram:1:2", streamable: false },
56
+ },
57
+ };
58
+
59
+ const result = await handleCloudGatewayRuntimeInbound(gateway, frame);
60
+ await gateway.stop("test");
61
+
62
+ expect(result.accepted).toBe(true);
63
+ expect(result.eventId).toBe("evt_1");
64
+ expect(result.gatewayId).toBe("gw_tg_1");
65
+ expect(result.conversationId).toBe("telegram:user:1");
66
+ expect(result.outbound?.finalText).toBe("hello from runtime");
67
+ });
68
+
69
+ it("rejects frames outside the token scope", async () => {
70
+ const gateway = new Gateway({
71
+ config: {
72
+ channels: [],
73
+ defaultRoute: { runtime: "fake", cwd: tmpDir },
74
+ },
75
+ sessionStorePath: path.join(tmpDir, "sessions.json"),
76
+ createChannel: (cfg) => stubChannel(cfg.id, cfg.type, cfg.accountId),
77
+ createRuntime: () => ({
78
+ id: "fake",
79
+ async run() {
80
+ return { text: "unused", newSessionId: "sess_1" };
81
+ },
82
+ }),
83
+ transcriptEnabled: false,
84
+ });
85
+
86
+ const result = await handleCloudGatewayRuntimeInbound(gateway, {
87
+ type: RUNTIME_FRAME_TYPES.GATEWAY_INBOUND,
88
+ event_id: "evt_bad",
89
+ gateway_id: "gw_tg_1",
90
+ agent_id: "ag_1",
91
+ provider: "telegram",
92
+ message: {
93
+ id: "telegram:1:2",
94
+ channel: "gw_other",
95
+ accountId: "ag_1",
96
+ conversation: { id: "telegram:user:1", kind: "direct" },
97
+ sender: { id: "telegram:user:1", kind: "user" },
98
+ text: "hi",
99
+ replyTo: null,
100
+ mentioned: true,
101
+ receivedAt: Date.now(),
102
+ },
103
+ });
104
+
105
+ expect(result.accepted).toBe(false);
106
+ expect(result.error?.code).toBe("channel_mismatch");
107
+ });
108
+ });
109
+
110
+ function stubChannel(id: string, type: string, accountId: string): ChannelAdapter {
111
+ return {
112
+ id,
113
+ type,
114
+ async start() {
115
+ return undefined;
116
+ },
117
+ async stop() {
118
+ return undefined;
119
+ },
120
+ async send() {
121
+ return {};
122
+ },
123
+ status() {
124
+ return { channel: id, accountId, running: true };
125
+ },
126
+ };
127
+ }
@@ -5,8 +5,10 @@ import path from "node:path";
5
5
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
6
  import {
7
7
  ensureNoOtherDaemonFromPidFile,
8
+ parseDaemonProcesses,
8
9
  readPid,
9
10
  removePidFile,
11
+ stopOtherDaemonProcessesForRestart,
10
12
  stopDaemonFromPidFileForRestart,
11
13
  writeCurrentPid,
12
14
  } from "../daemon-singleton.js";
@@ -73,6 +75,36 @@ describe("daemon singleton pid helpers", () => {
73
75
 
74
76
  expect(readPid(pidPath)).toBeNull();
75
77
  });
78
+
79
+ it("finds botcord daemon start commands in ps output", () => {
80
+ const out = parseDaemonProcesses(
81
+ [
82
+ " 111 node /Users/me/.botcord/daemon/node_modules/.bin/botcord-daemon start --foreground",
83
+ " 222 node /opt/botcord/daemon/dist/index.js start --foreground",
84
+ " 333 node /tmp/other.js",
85
+ ` ${process.pid} node /Users/me/.botcord/daemon/node_modules/.bin/botcord-daemon start --foreground`,
86
+ ].join("\n"),
87
+ process.pid,
88
+ );
89
+
90
+ expect(out.map((p) => p.pid)).toEqual([111, 222]);
91
+ });
92
+
93
+ it("terminates extra daemon processes discovered outside the pid file", async () => {
94
+ const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
95
+ stdio: "ignore",
96
+ });
97
+ children.push(child);
98
+ await waitForPid(child);
99
+
100
+ await stopOtherDaemonProcessesForRestart({
101
+ currentPid: process.pid,
102
+ processes: [{ pid: child.pid!, command: "node /opt/botcord/daemon/dist/index.js start --foreground" }],
103
+ });
104
+
105
+ await waitForExit(child);
106
+ expect(child.exitCode === null && child.signalCode === null).toBe(false);
107
+ });
76
108
  });
77
109
 
78
110
  async function waitForPid(child: ChildProcess): Promise<void> {
@@ -733,3 +733,139 @@ describe("W4: handleLoginStatus accountId ownership check", () => {
733
733
  expect(ack.error?.code).toBe("bad_params");
734
734
  });
735
735
  });
736
+
737
+ describe("login_missing vs login_expired", () => {
738
+ it("wechat upsert with an unknown loginId returns login_missing", async () => {
739
+ const gw = makeFakeGateway();
740
+ const { io } = makeConfigIO(baseCfg());
741
+ const sessions = new LoginSessionStore();
742
+ const ctrl = createGatewayControl({
743
+ gateway: gw as any,
744
+ configIO: io,
745
+ loginSessions: sessions,
746
+ });
747
+
748
+ const ack = await ctrl.handleUpsert({
749
+ id: uniqId("wx_missing"),
750
+ type: "wechat",
751
+ accountId: "ag_alice",
752
+ enabled: true,
753
+ loginId: "wxl_never_created",
754
+ });
755
+
756
+ expect(ack.ok).toBe(false);
757
+ expect(ack.error?.code).toBe("login_missing");
758
+ expect(gw.addChannel).not.toHaveBeenCalled();
759
+ });
760
+
761
+ it("feishu upsert with an unknown loginId returns login_missing", async () => {
762
+ const gw = makeFakeGateway();
763
+ const { io } = makeConfigIO(baseCfg());
764
+ const sessions = new LoginSessionStore();
765
+ const ctrl = createGatewayControl({
766
+ gateway: gw as any,
767
+ configIO: io,
768
+ loginSessions: sessions,
769
+ });
770
+
771
+ const ack = await ctrl.handleUpsert({
772
+ id: uniqId("fs_missing"),
773
+ type: "feishu",
774
+ accountId: "ag_alice",
775
+ enabled: true,
776
+ loginId: "fsl_never_created",
777
+ });
778
+
779
+ expect(ack.ok).toBe(false);
780
+ expect(ack.error?.code).toBe("login_missing");
781
+ expect(gw.addChannel).not.toHaveBeenCalled();
782
+ });
783
+
784
+ it("recent_senders with an unknown loginId returns login_missing", async () => {
785
+ const gw = makeFakeGateway();
786
+ const { io } = makeConfigIO(baseCfg());
787
+ const sessions = new LoginSessionStore();
788
+ const ctrl = createGatewayControl({
789
+ gateway: gw as any,
790
+ configIO: io,
791
+ loginSessions: sessions,
792
+ });
793
+
794
+ const ack = await ctrl.handleRecentSenders({
795
+ provider: "wechat",
796
+ loginId: "wxl_never_created",
797
+ accountId: "ag_alice",
798
+ });
799
+
800
+ expect(ack.ok).toBe(false);
801
+ expect(ack.error?.code).toBe("login_missing");
802
+ });
803
+
804
+ it("wechat upsert with a TTL-expired loginId returns login_expired", async () => {
805
+ const gw = makeFakeGateway();
806
+ const { io } = makeConfigIO(baseCfg());
807
+ let nowMs = 1_000_000;
808
+ const sessions = new LoginSessionStore({ now: () => nowMs, ttlMs: 60_000 });
809
+ sessions.create({
810
+ loginId: "wxl_aged",
811
+ accountId: "ag_alice",
812
+ provider: "wechat",
813
+ qrcode: "QR",
814
+ baseUrl: "https://ilinkai.weixin.qq.com",
815
+ botToken: "wechat-bot-token-aged",
816
+ });
817
+ nowMs += 120_000;
818
+
819
+ const ctrl = createGatewayControl({
820
+ gateway: gw as any,
821
+ configIO: io,
822
+ loginSessions: sessions,
823
+ });
824
+
825
+ const ack = await ctrl.handleUpsert({
826
+ id: uniqId("wx_expired"),
827
+ type: "wechat",
828
+ accountId: "ag_alice",
829
+ enabled: true,
830
+ loginId: "wxl_aged",
831
+ });
832
+
833
+ expect(ack.ok).toBe(false);
834
+ expect(ack.error?.code).toBe("login_expired");
835
+ // resolve() also evicts — a follow-up call should now report missing.
836
+ expect(sessions.resolve("wxl_aged").state).toBe("missing");
837
+ });
838
+
839
+ it("feishu upsert with a TTL-expired loginId returns login_expired", async () => {
840
+ const gw = makeFakeGateway();
841
+ const { io } = makeConfigIO(baseCfg());
842
+ let nowMs = 2_000_000;
843
+ const sessions = new LoginSessionStore({ now: () => nowMs, ttlMs: 60_000 });
844
+ sessions.create({
845
+ loginId: "fsl_aged",
846
+ accountId: "ag_alice",
847
+ provider: "feishu",
848
+ appId: "cli_xxx",
849
+ appSecret: "feishu-secret-aged",
850
+ domain: "feishu",
851
+ });
852
+ nowMs += 120_000;
853
+
854
+ const ctrl = createGatewayControl({
855
+ gateway: gw as any,
856
+ configIO: io,
857
+ loginSessions: sessions,
858
+ });
859
+
860
+ const ack = await ctrl.handleUpsert({
861
+ id: uniqId("fs_expired"),
862
+ type: "feishu",
863
+ accountId: "ag_alice",
864
+ enabled: true,
865
+ loginId: "fsl_aged",
866
+ });
867
+
868
+ expect(ack.ok).toBe(false);
869
+ expect(ack.error?.code).toBe("login_expired");
870
+ });
871
+ });
@@ -65,6 +65,26 @@ describe("PolicyResolver", () => {
65
65
  expect(p.mode).toBe("always");
66
66
  });
67
67
 
68
+ it.each(["telegram:user:42", "wechat:user:alice", "feishu:user:ou_alice"])(
69
+ "forces third-party direct chat %s to mode=always",
70
+ async (conversationId) => {
71
+ const resolver = new PolicyResolver({ fetchGlobal: async () => undefined });
72
+ resolver.put("ag_a", null, { mode: "mention_only", keywords: [] });
73
+ const p = await resolver.resolve("ag_a", conversationId);
74
+ expect(p.mode).toBe("always");
75
+ },
76
+ );
77
+
78
+ it.each(["telegram:group:-1001", "feishu:chat:oc_team"])(
79
+ "does not force third-party group chat %s to mode=always",
80
+ async (conversationId) => {
81
+ const resolver = new PolicyResolver({ fetchGlobal: async () => undefined });
82
+ resolver.put("ag_a", null, { mode: "mention_only", keywords: [] });
83
+ const p = await resolver.resolve("ag_a", conversationId);
84
+ expect(p.mode).toBe("mention_only");
85
+ },
86
+ );
87
+
68
88
  it("falls back to defaults when fetch throws", async () => {
69
89
  const resolver = new PolicyResolver({
70
90
  fetchGlobal: async () => {
@@ -1736,6 +1736,71 @@ describe("update_agent handler", () => {
1736
1736
  });
1737
1737
  });
1738
1738
 
1739
+ it("updates runtime selectors in credentials and managed route", async () => {
1740
+ await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
1741
+ mockState.cfg = {
1742
+ defaultRoute: {
1743
+ adapter: "codex",
1744
+ cwd: tmp,
1745
+ extraArgs: ["--skip-git-repo-check"],
1746
+ },
1747
+ routes: [],
1748
+ streamBlocks: true,
1749
+ };
1750
+ const credDir = nodePath.join(tmp, ".botcord", "credentials");
1751
+ fs.mkdirSync(credDir, { recursive: true });
1752
+ fs.writeFileSync(
1753
+ nodePath.join(credDir, "ag_runtime.json"),
1754
+ JSON.stringify({
1755
+ version: 1,
1756
+ hubUrl: "https://hub.example",
1757
+ agentId: "ag_runtime",
1758
+ keyId: "k_runtime",
1759
+ privateKey: Buffer.alloc(32, 24).toString("base64"),
1760
+ savedAt: new Date().toISOString(),
1761
+ }),
1762
+ );
1763
+
1764
+ const gw = makeFakeGateway();
1765
+ const provisioner = createProvisioner({
1766
+ gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
1767
+ });
1768
+ const ack = await provisioner({
1769
+ id: "req_update_runtime",
1770
+ type: CONTROL_FRAME_TYPES.UPDATE_AGENT,
1771
+ params: {
1772
+ agentId: "ag_runtime",
1773
+ runtime: "codex",
1774
+ runtimeModel: "gpt-5.2",
1775
+ reasoningEffort: "high",
1776
+ thinking: true,
1777
+ },
1778
+ });
1779
+
1780
+ expect(ack.ok).toBe(true);
1781
+ expect((ack.result as { changed: boolean }).changed).toBe(true);
1782
+
1783
+ const saved = JSON.parse(
1784
+ fs.readFileSync(nodePath.join(credDir, "ag_runtime.json"), "utf8"),
1785
+ ) as Record<string, unknown>;
1786
+ expect(saved.runtime).toBe("codex");
1787
+ expect(saved.runtimeModel).toBe("gpt-5.2");
1788
+ expect(saved.reasoningEffort).toBe("high");
1789
+ expect(saved.thinking).toBe(true);
1790
+ expect(gw.listManagedRoutes()[0]).toMatchObject({
1791
+ match: { accountId: "ag_runtime" },
1792
+ runtime: "codex",
1793
+ extraArgs: [
1794
+ "--skip-git-repo-check",
1795
+ "--model",
1796
+ "gpt-5.2",
1797
+ "-c",
1798
+ 'model_reasoning_effort="high"',
1799
+ ],
1800
+ });
1801
+ });
1802
+ });
1803
+
1739
1804
  it("rejects update_agent without agentId", async () => {
1740
1805
  const gw = makeFakeGateway();
1741
1806
  const provisioner = createProvisioner({
@@ -18,6 +18,18 @@ describe("renderStatus", () => {
18
18
  expect(out).toContain("stopped");
19
19
  });
20
20
 
21
+ it("reports daemon processes even when the pid file is missing", () => {
22
+ const out = renderStatus({
23
+ pid: null,
24
+ alive: false,
25
+ daemonProcesses: [{ pid: 1342 }, { pid: 1527 }],
26
+ });
27
+
28
+ expect(out).toContain("daemon: no pid file");
29
+ expect(out).toContain("warning: 2 daemon processes detected without pid file");
30
+ expect(out).toContain("pids: 1342, 1527");
31
+ });
32
+
21
33
  it("prints pid + agent + config when only PID state is known (no snapshot)", () => {
22
34
  const out = renderStatus(
23
35
  {
@@ -95,6 +107,17 @@ describe("renderStatus", () => {
95
107
  expect(out).toContain("agent: ag_solo");
96
108
  });
97
109
 
110
+ it("warns when extra daemon processes are detected", () => {
111
+ const out = renderStatus({
112
+ pid: 42,
113
+ alive: true,
114
+ daemonProcesses: [{ pid: 1001 }, { pid: 1002 }],
115
+ });
116
+
117
+ expect(out).toContain("warning: 2 additional daemon processes detected");
118
+ expect(out).toContain("extra pids: 1001, 1002");
119
+ });
120
+
98
121
  it("surfaces ⚠ stale when snapshotAgeMs exceeds the threshold", () => {
99
122
  const out = renderStatus(
100
123
  {
@@ -0,0 +1,87 @@
1
+ import {
2
+ BotCordClient,
3
+ defaultCredentialsFile,
4
+ loadStoredCredentials,
5
+ updateCredentialsToken,
6
+ } from "@botcord/protocol-core";
7
+ import type { DaemonAttentionPolicy } from "./gateway/policy-resolver.js";
8
+
9
+ interface CachedClient {
10
+ client: BotCordClient;
11
+ credentialsPath: string;
12
+ }
13
+
14
+ export interface AttentionPolicyFetcherOptions {
15
+ credentialPathByAgentId: Map<string, string>;
16
+ defaultCredentialsPath?: string;
17
+ hubBaseUrl?: string;
18
+ log?: {
19
+ warn: (msg: string, meta?: Record<string, unknown>) => void;
20
+ };
21
+ }
22
+
23
+ export type AttentionPolicyFetcher = (args: {
24
+ agentId: string;
25
+ roomId?: string | null;
26
+ }) => Promise<DaemonAttentionPolicy | undefined>;
27
+
28
+ export function createAttentionPolicyFetcher(
29
+ opts: AttentionPolicyFetcherOptions,
30
+ ): AttentionPolicyFetcher {
31
+ const clients = new Map<string, CachedClient>();
32
+
33
+ function getClient(agentId: string): BotCordClient | null {
34
+ const existing = clients.get(agentId);
35
+ if (existing) return existing.client;
36
+
37
+ const credentialsPath =
38
+ opts.credentialPathByAgentId.get(agentId) ??
39
+ opts.defaultCredentialsPath ??
40
+ defaultCredentialsFile(agentId);
41
+
42
+ try {
43
+ const creds = loadStoredCredentials(credentialsPath);
44
+ const client = new BotCordClient({
45
+ hubUrl: opts.hubBaseUrl ?? creds.hubUrl,
46
+ agentId: creds.agentId,
47
+ keyId: creds.keyId,
48
+ privateKey: creds.privateKey,
49
+ ...(creds.token ? { token: creds.token } : {}),
50
+ ...(creds.tokenExpiresAt !== undefined
51
+ ? { tokenExpiresAt: creds.tokenExpiresAt }
52
+ : {}),
53
+ });
54
+ client.onTokenRefresh = (token, expiresAt) => {
55
+ try {
56
+ updateCredentialsToken(credentialsPath, token, expiresAt);
57
+ } catch {
58
+ // Persistence failures are non-fatal; the next refresh retries.
59
+ }
60
+ };
61
+ clients.set(agentId, { client, credentialsPath });
62
+ return client;
63
+ } catch (err) {
64
+ opts.log?.warn("daemon.attention-policy.client-init-failed", {
65
+ agentId,
66
+ credentialsPath,
67
+ error: err instanceof Error ? err.message : String(err),
68
+ });
69
+ return null;
70
+ }
71
+ }
72
+
73
+ return async ({ agentId, roomId }) => {
74
+ const client = getClient(agentId);
75
+ if (!client) return undefined;
76
+ try {
77
+ return await client.getAttentionPolicy({ roomId });
78
+ } catch (err) {
79
+ opts.log?.warn("daemon.attention-policy.fetch-failed", {
80
+ agentId,
81
+ roomId: roomId ?? null,
82
+ error: err instanceof Error ? err.message : String(err),
83
+ });
84
+ return undefined;
85
+ }
86
+ };
87
+ }
@@ -32,6 +32,7 @@ import { createDaemonSystemContextBuilder } from "./system-context.js";
32
32
  import { readWorkingMemorySnapshot } from "./working-memory.js";
33
33
  import { createRoomStaticContextBuilder } from "./room-context.js";
34
34
  import { createRoomContextFetcher } from "./room-context-fetcher.js";
35
+ import { createRecentRoomMessagesRecoveryBuilder } from "./room-recovery-context.js";
35
36
  import { composeBotCordUserTurn } from "./turn-text.js";
36
37
  import { PolicyResolver, type DaemonAttentionPolicy } from "./gateway/policy-resolver.js";
37
38
  import { scanMention } from "./mention-scan.js";
@@ -143,6 +144,12 @@ export async function startCloudDaemon(
143
144
  fetchRoomInfo: roomContextFetcher,
144
145
  log: logger,
145
146
  });
147
+ const buildRuntimeRecoveryContext = createRecentRoomMessagesRecoveryBuilder({
148
+ credentialPathByAgentId,
149
+ hubBaseUrl: cloudCfg.hubUrl,
150
+ limit: 20,
151
+ log: logger,
152
+ });
146
153
 
147
154
  type PerAgentBuilder = (
148
155
  msg: GatewayInboundMessage,
@@ -258,6 +265,7 @@ export async function startCloudDaemon(
258
265
  turnTimeoutMs: DEFAULT_TURN_TIMEOUT_MS,
259
266
  buildSystemContext,
260
267
  buildMemoryContext,
268
+ buildRuntimeRecoveryContext,
261
269
  onInbound,
262
270
  onTurnComplete,
263
271
  composeUserTurn: composeBotCordUserTurn,