@botcord/daemon 0.2.69 → 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.
- package/dist/agent-workspace.js +18 -1
- package/dist/gateway/gateway.d.ts +9 -1
- package/dist/gateway/gateway.js +12 -0
- package/dist/gateway-control.d.ts +8 -0
- package/dist/gateway-control.js +117 -0
- package/dist/loop-risk.js +6 -0
- package/dist/provision.js +12 -0
- package/dist/turn-text.js +30 -0
- package/package.json +2 -2
- package/src/__tests__/agent-workspace.test.ts +6 -0
- package/src/__tests__/gateway-control.test.ts +71 -0
- package/src/__tests__/loop-risk.test.ts +2 -0
- package/src/__tests__/provision.test.ts +10 -0
- package/src/__tests__/turn-text.test.ts +27 -0
- package/src/agent-workspace.ts +16 -1
- package/src/gateway/gateway.ts +14 -0
- package/src/gateway-control.ts +138 -0
- package/src/loop-risk.ts +3 -0
- package/src/provision.ts +18 -0
- package/src/turn-text.ts +40 -0
package/dist/agent-workspace.js
CHANGED
|
@@ -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
|
}
|
package/dist/gateway/gateway.js
CHANGED
|
@@ -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
|
};
|
package/dist/gateway-control.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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({
|
package/src/agent-workspace.ts
CHANGED
|
@@ -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");
|
package/src/gateway/gateway.ts
CHANGED
|
@@ -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
|
}
|
package/src/gateway-control.ts
CHANGED
|
@@ -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,
|