@botcord/daemon 0.2.68 → 0.2.70

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.
@@ -20,6 +20,7 @@ import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, read
20
20
  import { createRequire } from "node:module";
21
21
  import { homedir } from "node:os";
22
22
  import path from "node:path";
23
+ import { fileURLToPath } from "node:url";
23
24
  const require = createRequire(import.meta.url);
24
25
  // Accepted agent id pattern. Enforced at every path-builder entry so a
25
26
  // malicious / malformed agentId (e.g. "../../etc") cannot escape
@@ -357,8 +358,24 @@ export function ensureAgentHermesWorkspace(agentId, opts = {}) {
357
358
  * Seeded fresh per `ensureAgent*` call (force-overwrite) so daemon
358
359
  * upgrades propagate.
359
360
  */
360
- const BUNDLED_SKILLS = ["botcord", "botcord-user-guide"];
361
+ const BUNDLED_SKILLS = ["botcord", "botcord-user-guide", "botcord_memory"];
362
+ function resolveRepoCliSkillsRoot() {
363
+ let dir = path.dirname(fileURLToPath(import.meta.url));
364
+ for (let i = 0; i < 6; i += 1) {
365
+ const candidate = path.join(dir, "cli", "skills");
366
+ if (existsSync(candidate))
367
+ return candidate;
368
+ const parent = path.dirname(dir);
369
+ if (parent === dir)
370
+ break;
371
+ dir = parent;
372
+ }
373
+ return null;
374
+ }
361
375
  function resolveBundledCliSkillsRoot() {
376
+ const repoRoot = resolveRepoCliSkillsRoot();
377
+ if (repoRoot)
378
+ return repoRoot;
362
379
  try {
363
380
  const pkgJsonPath = require.resolve("@botcord/cli/package.json");
364
381
  const root = path.join(path.dirname(pkgJsonPath), "skills");
@@ -2,7 +2,7 @@ import { type ChannelBackoffOptions } from "./channel-manager.js";
2
2
  import { type RuntimeFactory } from "./dispatcher.js";
3
3
  import { type GatewayLogger } from "./log.js";
4
4
  import { type TranscriptWriter } from "./transcript.js";
5
- import type { ChannelAdapter, GatewayChannelConfig, GatewayConfig, GatewayInboundMessage, GatewayRoute, GatewayRuntimeSnapshot, InboundObserver, OutboundObserver, SystemContextBuilder, UserTurnBuilder } from "./types.js";
5
+ import type { ChannelAdapter, GatewayChannelConfig, GatewayConfig, GatewayInboundMessage, GatewayOutboundMessage, GatewayRoute, GatewayRuntimeSnapshot, InboundObserver, OutboundObserver, SystemContextBuilder, UserTurnBuilder } from "./types.js";
6
6
  /** Constructor options for `Gateway`. */
7
7
  export interface GatewayBootOptions {
8
8
  config: GatewayConfig;
@@ -124,4 +124,12 @@ export declare class Gateway {
124
124
  * routing, queueing, transcript, and runtime behavior as channel messages.
125
125
  */
126
126
  injectInbound(message: GatewayInboundMessage): Promise<void>;
127
+ /**
128
+ * Send a daemon-control initiated outbound message through a registered
129
+ * channel. Used by proactive third-party gateway sends where the runtime
130
+ * explicitly targets an external provider conversation.
131
+ */
132
+ sendOutbound(message: GatewayOutboundMessage): Promise<{
133
+ providerMessageId?: string | null;
134
+ }>;
127
135
  }
@@ -174,4 +174,16 @@ export class Gateway {
174
174
  async injectInbound(message) {
175
175
  await this.dispatcher.handle({ message });
176
176
  }
177
+ /**
178
+ * Send a daemon-control initiated outbound message through a registered
179
+ * channel. Used by proactive third-party gateway sends where the runtime
180
+ * explicitly targets an external provider conversation.
181
+ */
182
+ async sendOutbound(message) {
183
+ const channel = this.channelMap.get(message.channel);
184
+ if (!channel) {
185
+ throw new Error(`channel "${message.channel}" is not registered`);
186
+ }
187
+ return channel.send({ message, log: this.log });
188
+ }
177
189
  }
@@ -60,6 +60,13 @@ interface GatewayRecentSendersParams {
60
60
  accountId: string;
61
61
  timeoutSeconds?: number;
62
62
  }
63
+ interface GatewaySendParams {
64
+ agentId: string;
65
+ gatewayId: string;
66
+ conversationId: string;
67
+ text: string;
68
+ idempotencyKey?: string;
69
+ }
63
70
  export type { FetchLike };
64
71
  export interface GatewayControlContext {
65
72
  gateway: Gateway;
@@ -98,6 +105,7 @@ export declare function createGatewayControl(ctx: GatewayControlContext): {
98
105
  handleLoginStart: (params: GatewayLoginStartParams) => Promise<AckBody>;
99
106
  handleLoginStatus: (params: GatewayLoginStatusParams) => Promise<AckBody>;
100
107
  handleRecentSenders: (params: GatewayRecentSendersParams) => Promise<AckBody>;
108
+ handleSend: (params: GatewaySendParams) => Promise<AckBody>;
101
109
  /** Exposed for tests — direct access to the in-memory session map. */
102
110
  _sessions: LoginSessionStore;
103
111
  };
@@ -731,6 +731,78 @@ export function createGatewayControl(ctx) {
731
731
  };
732
732
  }
733
733
  }
734
+ // --- gateway_send -------------------------------------------------------
735
+ async function handleSend(params) {
736
+ if (!params.agentId || typeof params.agentId !== "string") {
737
+ return badParams("gateway_send: agentId is required");
738
+ }
739
+ if (!params.gatewayId || typeof params.gatewayId !== "string") {
740
+ return badParams("gateway_send: gatewayId is required");
741
+ }
742
+ if (!params.conversationId || typeof params.conversationId !== "string") {
743
+ return badParams("gateway_send: conversationId is required");
744
+ }
745
+ if (typeof params.text !== "string" || params.text.length === 0) {
746
+ return badParams("gateway_send: text is required");
747
+ }
748
+ const cfg = cfgIO.load();
749
+ const profile = (cfg.thirdPartyGateways ?? []).find((g) => g.id === params.gatewayId);
750
+ if (!profile) {
751
+ return {
752
+ ok: false,
753
+ error: { code: "unknown_gateway", message: `no gateway with id "${params.gatewayId}"` },
754
+ };
755
+ }
756
+ if (profile.accountId !== params.agentId) {
757
+ return {
758
+ ok: false,
759
+ error: { code: "account_mismatch", message: "gateway is bound to a different agent" },
760
+ };
761
+ }
762
+ if (profile.enabled === false) {
763
+ return {
764
+ ok: false,
765
+ error: { code: "gateway_disabled", message: "gateway is disabled" },
766
+ };
767
+ }
768
+ if (profile.type === "wechat") {
769
+ return {
770
+ ok: false,
771
+ error: { code: "unsupported_provider", message: "wechat gateway_send requires an inbound context_token and is not supported" },
772
+ };
773
+ }
774
+ const conversationErr = validateOutboundConversation(profile, params.conversationId);
775
+ if (conversationErr)
776
+ return conversationErr;
777
+ try {
778
+ const sendResult = await ctx.gateway.sendOutbound({
779
+ channel: params.gatewayId,
780
+ accountId: params.agentId,
781
+ conversationId: params.conversationId,
782
+ text: params.text,
783
+ traceId: `gateway-send:${params.idempotencyKey ?? Date.now()}`,
784
+ });
785
+ const result = {
786
+ gatewayId: params.gatewayId,
787
+ conversationId: params.conversationId,
788
+ providerMessageId: sendResult.providerMessageId ?? null,
789
+ };
790
+ return { ok: true, result };
791
+ }
792
+ catch (err) {
793
+ const message = err instanceof Error ? err.message : String(err);
794
+ daemonLog.warn("gateway_send failed", {
795
+ gatewayId: params.gatewayId,
796
+ accountId: params.agentId,
797
+ conversationId: params.conversationId,
798
+ error: message,
799
+ });
800
+ return {
801
+ ok: false,
802
+ error: { code: "send_failed", message },
803
+ };
804
+ }
805
+ }
734
806
  return {
735
807
  handleList,
736
808
  handleUpsert,
@@ -739,6 +811,7 @@ export function createGatewayControl(ctx) {
739
811
  handleLoginStart,
740
812
  handleLoginStatus,
741
813
  handleRecentSenders,
814
+ handleSend,
742
815
  /** Exposed for tests — direct access to the in-memory session map. */
743
816
  _sessions: sessions,
744
817
  };
@@ -761,6 +834,50 @@ function validateUpsertParams(p) {
761
834
  return "upsert_gateway: accountId is required";
762
835
  return null;
763
836
  }
837
+ function validateOutboundConversation(profile, conversationId) {
838
+ const chatId = chatIdFromConversation(profile.type, conversationId);
839
+ if (!chatId) {
840
+ return {
841
+ ok: false,
842
+ error: {
843
+ code: "bad_conversation",
844
+ message: `conversationId "${conversationId}" is not valid for ${profile.type}`,
845
+ },
846
+ };
847
+ }
848
+ const allowed = new Set((profile.allowedChatIds ?? []).map(String));
849
+ if (!allowed.has(chatId)) {
850
+ return {
851
+ ok: false,
852
+ error: {
853
+ code: "conversation_not_allowed",
854
+ message: "conversation is not in the gateway allowedChatIds list",
855
+ },
856
+ };
857
+ }
858
+ return null;
859
+ }
860
+ function chatIdFromConversation(provider, conversationId) {
861
+ if (provider === "telegram") {
862
+ if (conversationId.startsWith("telegram:user:")) {
863
+ return conversationId.slice("telegram:user:".length);
864
+ }
865
+ if (conversationId.startsWith("telegram:group:")) {
866
+ return conversationId.slice("telegram:group:".length);
867
+ }
868
+ return null;
869
+ }
870
+ if (provider === "feishu") {
871
+ if (conversationId.startsWith("feishu:user:")) {
872
+ return conversationId.slice("feishu:user:".length);
873
+ }
874
+ if (conversationId.startsWith("feishu:chat:")) {
875
+ return conversationId.slice("feishu:chat:".length);
876
+ }
877
+ return null;
878
+ }
879
+ return null;
880
+ }
764
881
  function annotateProfile(p, status) {
765
882
  return {
766
883
  id: p.id,
package/dist/loop-risk.js CHANGED
@@ -76,6 +76,12 @@ export function stripBotCordPromptScaffolding(text) {
76
76
  return false;
77
77
  if (line.startsWith("you do not reply to the group"))
78
78
  return false;
79
+ if (line.startsWith("Before replying NO_REPLY in a non-owner group room"))
80
+ return false;
81
+ if (line.startsWith("match a memory-backed monitoring rule"))
82
+ return false;
83
+ if (line.startsWith("or owner-approved workflow. If needed"))
84
+ return false;
79
85
  if (line.startsWith("[If the conversation has naturally concluded"))
80
86
  return false;
81
87
  if (line.startsWith("[You received a contact request"))
package/dist/provision.js CHANGED
@@ -255,6 +255,14 @@ export function createProvisioner(opts) {
255
255
  return v.ack;
256
256
  return gatewayControl.handleRecentSenders(v.params);
257
257
  }
258
+ case "gateway_send": {
259
+ const v = validateGatewayParams(frame.params, {
260
+ required: ["agentId", "gatewayId", "conversationId", "text"],
261
+ });
262
+ if (!v.ok)
263
+ return v.ack;
264
+ return gatewayControl.handleSend(v.params);
265
+ }
258
266
  case "list_agent_files": {
259
267
  const params = (frame.params ?? {});
260
268
  if (!params.agentId) {
@@ -344,6 +352,8 @@ async function handleWakeAgent(gateway, raw) {
344
352
  }
345
353
  const runId = params.run_id || params.runId || `wake-${Date.now()}`;
346
354
  const scheduleId = params.schedule_id || params.scheduleId;
355
+ const scheduledFor = params.scheduled_for || params.scheduledFor;
356
+ const dispatchedAt = params.dispatched_at || params.dispatchedAt;
347
357
  const dedupeKey = params.dedupe_key || params.dedupeKey;
348
358
  const conversationId = `rm_schedule_${agentId}`;
349
359
  const msg = {
@@ -365,6 +375,8 @@ async function handleWakeAgent(gateway, raw) {
365
375
  raw: {
366
376
  source_type: "botcord_schedule",
367
377
  schedule_id: scheduleId,
378
+ scheduled_for: scheduledFor,
379
+ dispatched_at: dispatchedAt,
368
380
  run_id: runId,
369
381
  dedupe_key: dedupeKey,
370
382
  },
package/dist/turn-text.js CHANGED
@@ -9,6 +9,10 @@ const GROUP_HINT = "[In group chats, do not send a message back to the current g
9
9
  "When a message matches an active monitoring rule, automation goal, working-memory task, " +
10
10
  "keyword, sender rule, or owner-approved workflow, perform the required action even if " +
11
11
  "you do not reply to the group.\n\n" +
12
+ "Before replying NO_REPLY in a non-owner group room, consider whether this message could " +
13
+ "match a memory-backed monitoring rule, automation goal, pending task, keyword, sender rule, " +
14
+ "or owner-approved workflow. If needed, use the botcord_memory skill to retrieve or update " +
15
+ "working memory.\n\n" +
12
16
  'If no group reply and no background action is needed, reply exactly "NO_REPLY".]';
13
17
  const DIRECT_HINT = '[If the conversation has naturally concluded or no response is needed, ' +
14
18
  'reply with exactly "NO_REPLY" and nothing else.]';
@@ -56,6 +60,31 @@ function appendConversationFields(fields, msg) {
56
60
  fields.push(`channel: ${sanitizeSenderName(msg.channel)}`);
57
61
  }
58
62
  }
63
+ function formatScheduleContext(raw) {
64
+ const r = raw && typeof raw === "object" ? raw : {};
65
+ if (r.source_type !== "botcord_schedule")
66
+ return [];
67
+ const fields = [];
68
+ if (typeof r.schedule_id === "string" && r.schedule_id) {
69
+ fields.push(`schedule_id: ${sanitizeSenderName(r.schedule_id)}`);
70
+ }
71
+ if (typeof r.scheduled_for === "string" && r.scheduled_for) {
72
+ fields.push(`scheduled_for: ${sanitizeSenderName(r.scheduled_for)}`);
73
+ }
74
+ if (typeof r.dispatched_at === "string" && r.dispatched_at) {
75
+ fields.push(`dispatched_at: ${sanitizeSenderName(r.dispatched_at)}`);
76
+ }
77
+ if (typeof r.run_id === "string" && r.run_id) {
78
+ fields.push(`run_id: ${sanitizeSenderName(r.run_id)}`);
79
+ }
80
+ return fields.length > 0
81
+ ? [
82
+ "[BotCord Schedule]",
83
+ "This turn was triggered by a proactive schedule.",
84
+ fields.join(" | "),
85
+ ]
86
+ : ["[BotCord Schedule]", "This turn was triggered by a proactive schedule."];
87
+ }
59
88
  /**
60
89
  * Read the `raw.batch` array emitted by the BotCord channel when inbox
61
90
  * drain groups multiple messages for the same `(room, topic)`. Returns the
@@ -179,6 +208,7 @@ export function composeBotCordUserTurn(msg) {
179
208
  : null;
180
209
  const lines = [
181
210
  headerFields.join(" | "),
211
+ ...formatScheduleContext(msg.raw),
182
212
  ...(isGroup ? formatRoomContext(msg.raw, { id: conversation.id, title: roomTitle }) : []),
183
213
  `<${tag} sender="${sanitizedSenderLabel}" sender_kind="${senderKindAttr}">`,
184
214
  trimmed,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.68",
3
+ "version": "0.2.70",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,7 @@
30
30
  "@botcord/cli": "^0.1.7",
31
31
  "@botcord/protocol-core": "^0.2.4",
32
32
  "@larksuiteoapi/node-sdk": "^1.63.1",
33
- "ws": "^8.18.0"
33
+ "ws": "^8.20.1"
34
34
  },
35
35
  "overrides": {
36
36
  "axios": "^1.15.2"
@@ -88,6 +88,7 @@ describe("ensureAgentWorkspace", () => {
88
88
  const skillsDir = path.join(agentWorkspaceDir("ag_skills"), ".claude", "skills");
89
89
  expect(existsSync(path.join(skillsDir, "botcord", "SKILL.md"))).toBe(true);
90
90
  expect(existsSync(path.join(skillsDir, "botcord-user-guide", "SKILL.md"))).toBe(true);
91
+ expect(existsSync(path.join(skillsDir, "botcord_memory", "SKILL.md"))).toBe(true);
91
92
  });
92
93
 
93
94
  it("re-seeds skills on a second call so daemon upgrades propagate", () => {
@@ -113,6 +114,7 @@ describe("ensureAgentWorkspace", () => {
113
114
  const skillsDir = path.join(agentCodexHomeDir("ag_codex_skills"), "skills");
114
115
  expect(existsSync(path.join(skillsDir, "botcord", "SKILL.md"))).toBe(true);
115
116
  expect(existsSync(path.join(skillsDir, "botcord-user-guide", "SKILL.md"))).toBe(true);
117
+ expect(existsSync(path.join(skillsDir, "botcord_memory", "SKILL.md"))).toBe(true);
116
118
  });
117
119
 
118
120
  it("re-seeds codex skills on subsequent ensureAgentCodexHome calls", () => {
@@ -137,6 +139,7 @@ describe("ensureAgentWorkspace", () => {
137
139
  const skillsDir = path.join(hermesHome, "skills");
138
140
  expect(existsSync(path.join(skillsDir, "botcord", "SKILL.md"))).toBe(true);
139
141
  expect(existsSync(path.join(skillsDir, "botcord-user-guide", "SKILL.md"))).toBe(true);
142
+ expect(existsSync(path.join(skillsDir, "botcord_memory", "SKILL.md"))).toBe(true);
140
143
  });
141
144
 
142
145
  it("re-seeds hermes skills on subsequent ensureAgentHermesWorkspace calls", () => {
@@ -164,6 +167,9 @@ describe("ensureAgentWorkspace", () => {
164
167
  expect(existsSync(path.join(profileHome, "skills", "botcord-user-guide", "SKILL.md"))).toBe(
165
168
  true,
166
169
  );
170
+ expect(existsSync(path.join(profileHome, "skills", "botcord_memory", "SKILL.md"))).toBe(
171
+ true,
172
+ );
167
173
  expect(existsSync(hermesWorkspace)).toBe(true);
168
174
  expect(existsSync(hermesHome)).toBe(false);
169
175
  });
@@ -43,6 +43,7 @@ interface FakeGateway {
43
43
  channels: Map<string, { id: string; status: Record<string, unknown> }>;
44
44
  addChannel: ReturnType<typeof vi.fn>;
45
45
  removeChannel: ReturnType<typeof vi.fn>;
46
+ sendOutbound: ReturnType<typeof vi.fn>;
46
47
  snapshot: () => { channels: Record<string, any>; turns: Record<string, any> };
47
48
  }
48
49
 
@@ -66,6 +67,7 @@ function makeFakeGateway(): FakeGateway {
66
67
  removeChannel: vi.fn(async (id: string) => {
67
68
  channels.delete(id);
68
69
  }),
70
+ sendOutbound: vi.fn(async () => ({ providerMessageId: "provider-msg-1" })),
69
71
  snapshot: () => ({
70
72
  channels: Object.fromEntries([...channels].map(([id, e]) => [id, e.status])),
71
73
  turns: {},
@@ -617,6 +619,75 @@ describe("list_gateways", () => {
617
619
  });
618
620
  });
619
621
 
622
+ describe("gateway_send", () => {
623
+ it("sends through an enabled allowed telegram gateway", async () => {
624
+ const gw = makeFakeGateway();
625
+ const gwId = uniqId("send");
626
+ const { io } = makeConfigIO({
627
+ ...baseCfg(),
628
+ thirdPartyGateways: [
629
+ {
630
+ id: gwId,
631
+ type: "telegram",
632
+ accountId: "ag_alice",
633
+ enabled: true,
634
+ allowedChatIds: ["-1001"],
635
+ },
636
+ ],
637
+ });
638
+ const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
639
+
640
+ const ack = await ctrl.handleSend({
641
+ agentId: "ag_alice",
642
+ gatewayId: gwId,
643
+ conversationId: "telegram:group:-1001",
644
+ text: "hello",
645
+ idempotencyKey: "k1",
646
+ });
647
+
648
+ expect(ack.ok).toBe(true);
649
+ expect(gw.sendOutbound).toHaveBeenCalledWith(
650
+ expect.objectContaining({
651
+ channel: gwId,
652
+ accountId: "ag_alice",
653
+ conversationId: "telegram:group:-1001",
654
+ text: "hello",
655
+ traceId: "gateway-send:k1",
656
+ }),
657
+ );
658
+ expect((ack.result as any).providerMessageId).toBe("provider-msg-1");
659
+ });
660
+
661
+ it("rejects conversations outside allowedChatIds", async () => {
662
+ const gw = makeFakeGateway();
663
+ const gwId = uniqId("send-deny");
664
+ const { io } = makeConfigIO({
665
+ ...baseCfg(),
666
+ thirdPartyGateways: [
667
+ {
668
+ id: gwId,
669
+ type: "feishu",
670
+ accountId: "ag_alice",
671
+ enabled: true,
672
+ allowedChatIds: ["oc_allowed"],
673
+ },
674
+ ],
675
+ });
676
+ const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
677
+
678
+ const ack = await ctrl.handleSend({
679
+ agentId: "ag_alice",
680
+ gatewayId: gwId,
681
+ conversationId: "feishu:chat:oc_other",
682
+ text: "hello",
683
+ });
684
+
685
+ expect(ack.ok).toBe(false);
686
+ expect(ack.error?.code).toBe("conversation_not_allowed");
687
+ expect(gw.sendOutbound).not.toHaveBeenCalled();
688
+ });
689
+ });
690
+
620
691
  describe("W4: handleLoginStatus accountId ownership check", () => {
621
692
  it("returns forbidden when accountId does not match the login session", async () => {
622
693
  const gw = makeFakeGateway();
@@ -28,6 +28,8 @@ describe("stripBotCordPromptScaffolding", () => {
28
28
  "",
29
29
  "When a message matches an active monitoring rule, automation goal, working-memory task, keyword, sender rule, or owner-approved workflow, perform the required action even if you do not reply to the group.",
30
30
  "",
31
+ "Before replying NO_REPLY in a non-owner group room, consider whether this message could match a memory-backed monitoring rule, automation goal, pending task, keyword, sender rule, or owner-approved workflow. If needed, use the botcord_memory skill to retrieve or update working memory.",
32
+ "",
31
33
  'If no group reply and no background action is needed, reply exactly "NO_REPLY".]',
32
34
  ].join("\n");
33
35
  expect(stripBotCordPromptScaffolding(wrapped)).toBe("hello world");
@@ -265,6 +265,8 @@ describe("wake_agent handler", () => {
265
265
  message: "【BotCord 自主任务】执行本轮工作目标。",
266
266
  run_id: "sr_test",
267
267
  schedule_id: "sch_test",
268
+ scheduled_for: "2026-05-19T01:30:00+00:00",
269
+ dispatched_at: "2026-05-19T01:30:02+00:00",
268
270
  dedupe_key: "sch_test:1:auto",
269
271
  },
270
272
  });
@@ -279,6 +281,14 @@ describe("wake_agent handler", () => {
279
281
  expect(msg.sender.kind).toBe("system");
280
282
  expect(msg.text).toContain("BotCord 自主任务");
281
283
  expect(msg.conversation.threadId).toBe("sch_test");
284
+ expect(msg.raw).toMatchObject({
285
+ source_type: "botcord_schedule",
286
+ schedule_id: "sch_test",
287
+ scheduled_for: "2026-05-19T01:30:00+00:00",
288
+ dispatched_at: "2026-05-19T01:30:02+00:00",
289
+ run_id: "sr_test",
290
+ dedupe_key: "sch_test:1:auto",
291
+ });
282
292
  });
283
293
 
284
294
  it("rejects wake_agent for an unloaded agent", async () => {
@@ -37,6 +37,8 @@ describe("composeBotCordUserTurn", () => {
37
37
  expect(out).toContain("do not send a message back to the current group room");
38
38
  expect(out).toContain("owner-approved or policy-approved background actions");
39
39
  expect(out).toContain("active monitoring rule");
40
+ expect(out).toContain("botcord_memory");
41
+ expect(out).toContain("retrieve or update working memory");
40
42
  expect(out).toContain('"NO_REPLY"');
41
43
  });
42
44
 
@@ -97,6 +99,31 @@ describe("composeBotCordUserTurn", () => {
97
99
  expect(out).not.toContain("do NOT reply unless");
98
100
  });
99
101
 
102
+ it("renders schedule timing metadata for proactive schedule turns", () => {
103
+ const out = composeBotCordUserTurn(
104
+ makeMessage({
105
+ conversation: { id: "rm_schedule_ag_me", kind: "direct", title: "BotCord Scheduler", threadId: "sch_daily" },
106
+ sender: { id: "hub", name: "BotCord Scheduler", kind: "system" },
107
+ text: "daily brief",
108
+ mentioned: true,
109
+ raw: {
110
+ source_type: "botcord_schedule",
111
+ schedule_id: "sch_daily",
112
+ scheduled_for: "2026-05-19T01:30:00+00:00",
113
+ dispatched_at: "2026-05-19T01:30:02+00:00",
114
+ run_id: "sr_daily",
115
+ },
116
+ }),
117
+ );
118
+ expect(out).toContain("[BotCord Schedule]");
119
+ expect(out).toContain("This turn was triggered by a proactive schedule.");
120
+ expect(out).toContain("schedule_id: sch_daily");
121
+ expect(out).toContain("scheduled_for: 2026-05-19T01:30:00+00:00");
122
+ expect(out).toContain("dispatched_at: 2026-05-19T01:30:02+00:00");
123
+ expect(out).toContain("run_id: sr_daily");
124
+ expect(out.indexOf("[BotCord Schedule]")).toBeLessThan(out.indexOf("<agent-message"));
125
+ });
126
+
100
127
  it("keeps the botcord_send delivery hint for non-owner BotCord rooms", () => {
101
128
  const out = composeBotCordUserTurn(
102
129
  makeMessage({
@@ -32,6 +32,7 @@ import {
32
32
  import { createRequire } from "node:module";
33
33
  import { homedir } from "node:os";
34
34
  import path from "node:path";
35
+ import { fileURLToPath } from "node:url";
35
36
 
36
37
  const require = createRequire(import.meta.url);
37
38
 
@@ -397,9 +398,23 @@ export function ensureAgentHermesWorkspace(
397
398
  * Seeded fresh per `ensureAgent*` call (force-overwrite) so daemon
398
399
  * upgrades propagate.
399
400
  */
400
- const BUNDLED_SKILLS = ["botcord", "botcord-user-guide"] as const;
401
+ const BUNDLED_SKILLS = ["botcord", "botcord-user-guide", "botcord_memory"] as const;
402
+
403
+ function resolveRepoCliSkillsRoot(): string | null {
404
+ let dir = path.dirname(fileURLToPath(import.meta.url));
405
+ for (let i = 0; i < 6; i += 1) {
406
+ const candidate = path.join(dir, "cli", "skills");
407
+ if (existsSync(candidate)) return candidate;
408
+ const parent = path.dirname(dir);
409
+ if (parent === dir) break;
410
+ dir = parent;
411
+ }
412
+ return null;
413
+ }
401
414
 
402
415
  function resolveBundledCliSkillsRoot(): string | null {
416
+ const repoRoot = resolveRepoCliSkillsRoot();
417
+ if (repoRoot) return repoRoot;
403
418
  try {
404
419
  const pkgJsonPath = require.resolve("@botcord/cli/package.json");
405
420
  const root = path.join(path.dirname(pkgJsonPath), "skills");
@@ -13,6 +13,7 @@ import type {
13
13
  GatewayChannelConfig,
14
14
  GatewayConfig,
15
15
  GatewayInboundMessage,
16
+ GatewayOutboundMessage,
16
17
  GatewayRoute,
17
18
  GatewayRuntimeSnapshot,
18
19
  InboundObserver,
@@ -271,4 +272,17 @@ export class Gateway {
271
272
  async injectInbound(message: GatewayInboundMessage): Promise<void> {
272
273
  await this.dispatcher.handle({ message });
273
274
  }
275
+
276
+ /**
277
+ * Send a daemon-control initiated outbound message through a registered
278
+ * channel. Used by proactive third-party gateway sends where the runtime
279
+ * explicitly targets an external provider conversation.
280
+ */
281
+ async sendOutbound(message: GatewayOutboundMessage): Promise<{ providerMessageId?: string | null }> {
282
+ const channel = this.channelMap.get(message.channel);
283
+ if (!channel) {
284
+ throw new Error(`channel "${message.channel}" is not registered`);
285
+ }
286
+ return channel.send({ message, log: this.log });
287
+ }
274
288
  }
@@ -176,6 +176,20 @@ interface GatewayRecentSendersResult {
176
176
  senders: GatewayRecentSender[];
177
177
  }
178
178
 
179
+ interface GatewaySendParams {
180
+ agentId: string;
181
+ gatewayId: string;
182
+ conversationId: string;
183
+ text: string;
184
+ idempotencyKey?: string;
185
+ }
186
+
187
+ interface GatewaySendResult {
188
+ gatewayId: string;
189
+ conversationId: string;
190
+ providerMessageId?: string | null;
191
+ }
192
+
179
193
  export type { FetchLike };
180
194
 
181
195
  export interface GatewayControlContext {
@@ -927,6 +941,80 @@ export function createGatewayControl(ctx: GatewayControlContext) {
927
941
  }
928
942
  }
929
943
 
944
+ // --- gateway_send -------------------------------------------------------
945
+ async function handleSend(params: GatewaySendParams): Promise<AckBody> {
946
+ if (!params.agentId || typeof params.agentId !== "string") {
947
+ return badParams("gateway_send: agentId is required");
948
+ }
949
+ if (!params.gatewayId || typeof params.gatewayId !== "string") {
950
+ return badParams("gateway_send: gatewayId is required");
951
+ }
952
+ if (!params.conversationId || typeof params.conversationId !== "string") {
953
+ return badParams("gateway_send: conversationId is required");
954
+ }
955
+ if (typeof params.text !== "string" || params.text.length === 0) {
956
+ return badParams("gateway_send: text is required");
957
+ }
958
+
959
+ const cfg = cfgIO.load();
960
+ const profile = (cfg.thirdPartyGateways ?? []).find((g) => g.id === params.gatewayId);
961
+ if (!profile) {
962
+ return {
963
+ ok: false,
964
+ error: { code: "unknown_gateway", message: `no gateway with id "${params.gatewayId}"` },
965
+ };
966
+ }
967
+ if (profile.accountId !== params.agentId) {
968
+ return {
969
+ ok: false,
970
+ error: { code: "account_mismatch", message: "gateway is bound to a different agent" },
971
+ };
972
+ }
973
+ if (profile.enabled === false) {
974
+ return {
975
+ ok: false,
976
+ error: { code: "gateway_disabled", message: "gateway is disabled" },
977
+ };
978
+ }
979
+ if (profile.type === "wechat") {
980
+ return {
981
+ ok: false,
982
+ error: { code: "unsupported_provider", message: "wechat gateway_send requires an inbound context_token and is not supported" },
983
+ };
984
+ }
985
+
986
+ const conversationErr = validateOutboundConversation(profile, params.conversationId);
987
+ if (conversationErr) return conversationErr;
988
+
989
+ try {
990
+ const sendResult = await ctx.gateway.sendOutbound({
991
+ channel: params.gatewayId,
992
+ accountId: params.agentId,
993
+ conversationId: params.conversationId,
994
+ text: params.text,
995
+ traceId: `gateway-send:${params.idempotencyKey ?? Date.now()}`,
996
+ });
997
+ const result: GatewaySendResult = {
998
+ gatewayId: params.gatewayId,
999
+ conversationId: params.conversationId,
1000
+ providerMessageId: sendResult.providerMessageId ?? null,
1001
+ };
1002
+ return { ok: true, result };
1003
+ } catch (err) {
1004
+ const message = err instanceof Error ? err.message : String(err);
1005
+ daemonLog.warn("gateway_send failed", {
1006
+ gatewayId: params.gatewayId,
1007
+ accountId: params.agentId,
1008
+ conversationId: params.conversationId,
1009
+ error: message,
1010
+ });
1011
+ return {
1012
+ ok: false,
1013
+ error: { code: "send_failed", message },
1014
+ };
1015
+ }
1016
+ }
1017
+
930
1018
  return {
931
1019
  handleList,
932
1020
  handleUpsert,
@@ -935,6 +1023,7 @@ export function createGatewayControl(ctx: GatewayControlContext) {
935
1023
  handleLoginStart,
936
1024
  handleLoginStatus,
937
1025
  handleRecentSenders,
1026
+ handleSend,
938
1027
  /** Exposed for tests — direct access to the in-memory session map. */
939
1028
  _sessions: sessions,
940
1029
  };
@@ -959,6 +1048,55 @@ function validateUpsertParams(p: UpsertGatewayParams): string | null {
959
1048
  return null;
960
1049
  }
961
1050
 
1051
+ function validateOutboundConversation(
1052
+ profile: ThirdPartyGatewayProfile,
1053
+ conversationId: string,
1054
+ ): AckBody | null {
1055
+ const chatId = chatIdFromConversation(profile.type, conversationId);
1056
+ if (!chatId) {
1057
+ return {
1058
+ ok: false,
1059
+ error: {
1060
+ code: "bad_conversation",
1061
+ message: `conversationId "${conversationId}" is not valid for ${profile.type}`,
1062
+ },
1063
+ };
1064
+ }
1065
+ const allowed = new Set((profile.allowedChatIds ?? []).map(String));
1066
+ if (!allowed.has(chatId)) {
1067
+ return {
1068
+ ok: false,
1069
+ error: {
1070
+ code: "conversation_not_allowed",
1071
+ message: "conversation is not in the gateway allowedChatIds list",
1072
+ },
1073
+ };
1074
+ }
1075
+ return null;
1076
+ }
1077
+
1078
+ function chatIdFromConversation(provider: ThirdPartyGatewayProfile["type"], conversationId: string): string | null {
1079
+ if (provider === "telegram") {
1080
+ if (conversationId.startsWith("telegram:user:")) {
1081
+ return conversationId.slice("telegram:user:".length);
1082
+ }
1083
+ if (conversationId.startsWith("telegram:group:")) {
1084
+ return conversationId.slice("telegram:group:".length);
1085
+ }
1086
+ return null;
1087
+ }
1088
+ if (provider === "feishu") {
1089
+ if (conversationId.startsWith("feishu:user:")) {
1090
+ return conversationId.slice("feishu:user:".length);
1091
+ }
1092
+ if (conversationId.startsWith("feishu:chat:")) {
1093
+ return conversationId.slice("feishu:chat:".length);
1094
+ }
1095
+ return null;
1096
+ }
1097
+ return null;
1098
+ }
1099
+
962
1100
  function annotateProfile(
963
1101
  p: ThirdPartyGatewayProfile,
964
1102
  status: import("./gateway/index.js").ChannelStatusSnapshot | undefined,
package/src/loop-risk.ts CHANGED
@@ -90,6 +90,9 @@ export function stripBotCordPromptScaffolding(text: string): string {
90
90
  if (line.startsWith("When a message matches an active monitoring rule")) return false;
91
91
  if (line.startsWith("keyword, sender rule")) return false;
92
92
  if (line.startsWith("you do not reply to the group")) return false;
93
+ if (line.startsWith("Before replying NO_REPLY in a non-owner group room")) return false;
94
+ if (line.startsWith("match a memory-backed monitoring rule")) return false;
95
+ if (line.startsWith("or owner-approved workflow. If needed")) return false;
93
96
  if (line.startsWith("[If the conversation has naturally concluded")) return false;
94
97
  if (line.startsWith("[You received a contact request")) return false;
95
98
  if (line.includes("no background action is needed")) return false;
package/src/provision.ts CHANGED
@@ -400,6 +400,16 @@ export function createProvisioner(opts: ProvisionerOptions): (
400
400
  );
401
401
  }
402
402
 
403
+ case "gateway_send": {
404
+ const v = validateGatewayParams(frame.params, {
405
+ required: ["agentId", "gatewayId", "conversationId", "text"],
406
+ });
407
+ if (!v.ok) return v.ack;
408
+ return gatewayControl.handleSend(
409
+ v.params as unknown as Parameters<typeof gatewayControl.handleSend>[0],
410
+ );
411
+ }
412
+
403
413
  case "list_agent_files": {
404
414
  const params = (frame.params ?? {}) as unknown as ListAgentFilesParams;
405
415
  if (!params.agentId) {
@@ -467,6 +477,10 @@ interface WakeAgentParams {
467
477
  runId?: string;
468
478
  schedule_id?: string;
469
479
  scheduleId?: string;
480
+ scheduled_for?: string;
481
+ scheduledFor?: string;
482
+ dispatched_at?: string;
483
+ dispatchedAt?: string;
470
484
  dedupe_key?: string;
471
485
  dedupeKey?: string;
472
486
  }
@@ -504,6 +518,8 @@ async function handleWakeAgent(gateway: Gateway, raw: unknown): Promise<AckBody>
504
518
 
505
519
  const runId = params.run_id || params.runId || `wake-${Date.now()}`;
506
520
  const scheduleId = params.schedule_id || params.scheduleId;
521
+ const scheduledFor = params.scheduled_for || params.scheduledFor;
522
+ const dispatchedAt = params.dispatched_at || params.dispatchedAt;
507
523
  const dedupeKey = params.dedupe_key || params.dedupeKey;
508
524
  const conversationId = `rm_schedule_${agentId}`;
509
525
  const msg: GatewayInboundMessage = {
@@ -525,6 +541,8 @@ async function handleWakeAgent(gateway: Gateway, raw: unknown): Promise<AckBody>
525
541
  raw: {
526
542
  source_type: "botcord_schedule",
527
543
  schedule_id: scheduleId,
544
+ scheduled_for: scheduledFor,
545
+ dispatched_at: dispatchedAt,
528
546
  run_id: runId,
529
547
  dedupe_key: dedupeKey,
530
548
  },
package/src/turn-text.ts CHANGED
@@ -39,6 +39,10 @@ const GROUP_HINT =
39
39
  "When a message matches an active monitoring rule, automation goal, working-memory task, " +
40
40
  "keyword, sender rule, or owner-approved workflow, perform the required action even if " +
41
41
  "you do not reply to the group.\n\n" +
42
+ "Before replying NO_REPLY in a non-owner group room, consider whether this message could " +
43
+ "match a memory-backed monitoring rule, automation goal, pending task, keyword, sender rule, " +
44
+ "or owner-approved workflow. If needed, use the botcord_memory skill to retrieve or update " +
45
+ "working memory.\n\n" +
42
46
  'If no group reply and no background action is needed, reply exactly "NO_REPLY".]';
43
47
  const DIRECT_HINT =
44
48
  '[If the conversation has naturally concluded or no response is needed, ' +
@@ -120,6 +124,41 @@ interface RoomContextRaw {
120
124
  my_can_send?: unknown;
121
125
  }
122
126
 
127
+ interface ScheduleRaw {
128
+ source_type?: unknown;
129
+ schedule_id?: unknown;
130
+ scheduled_for?: unknown;
131
+ dispatched_at?: unknown;
132
+ run_id?: unknown;
133
+ }
134
+
135
+ function formatScheduleContext(raw: unknown): string[] {
136
+ const r = raw && typeof raw === "object" ? (raw as ScheduleRaw) : {};
137
+ if (r.source_type !== "botcord_schedule") return [];
138
+
139
+ const fields: string[] = [];
140
+ if (typeof r.schedule_id === "string" && r.schedule_id) {
141
+ fields.push(`schedule_id: ${sanitizeSenderName(r.schedule_id)}`);
142
+ }
143
+ if (typeof r.scheduled_for === "string" && r.scheduled_for) {
144
+ fields.push(`scheduled_for: ${sanitizeSenderName(r.scheduled_for)}`);
145
+ }
146
+ if (typeof r.dispatched_at === "string" && r.dispatched_at) {
147
+ fields.push(`dispatched_at: ${sanitizeSenderName(r.dispatched_at)}`);
148
+ }
149
+ if (typeof r.run_id === "string" && r.run_id) {
150
+ fields.push(`run_id: ${sanitizeSenderName(r.run_id)}`);
151
+ }
152
+
153
+ return fields.length > 0
154
+ ? [
155
+ "[BotCord Schedule]",
156
+ "This turn was triggered by a proactive schedule.",
157
+ fields.join(" | "),
158
+ ]
159
+ : ["[BotCord Schedule]", "This turn was triggered by a proactive schedule."];
160
+ }
161
+
123
162
  /**
124
163
  * Read the `raw.batch` array emitted by the BotCord channel when inbox
125
164
  * drain groups multiple messages for the same `(room, topic)`. Returns the
@@ -256,6 +295,7 @@ export function composeBotCordUserTurn(msg: GatewayInboundMessage): string {
256
295
 
257
296
  const lines: string[] = [
258
297
  headerFields.join(" | "),
298
+ ...formatScheduleContext(msg.raw),
259
299
  ...(isGroup ? formatRoomContext(msg.raw, { id: conversation.id, title: roomTitle }) : []),
260
300
  `<${tag} sender="${sanitizedSenderLabel}" sender_kind="${senderKindAttr}">`,
261
301
  trimmed,