openclaw-plugin-yuanbao 2.13.2 → 2.13.4

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.
@@ -4,6 +4,10 @@
4
4
  * Uses Map<accountId, WsClient> to manage concurrent connections.
5
5
  * Each account's WsClient reference is stored when the ws-gateway starts
6
6
  * and consumed by the outbound sendText path.
7
+ *
8
+ * Uses globalThis + Symbol.for() to guarantee a process-wide singleton,
9
+ * because the bundled channel entry may load this module in separate scopes
10
+ * (plugin scope vs tool-registration scope).
7
11
  */
8
12
  import type { YuanbaoWsClient } from "./client.js";
9
13
  /**
@@ -1,4 +1,6 @@
1
- const activeClients = new Map();
1
+ const WS_CLIENTS_KEY = Symbol.for("yuanbao:ws:activeClients");
2
+ const activeClients = globalThis[WS_CLIENTS_KEY]
3
+ ?? (globalThis[WS_CLIENTS_KEY] = new Map());
2
4
  /**
3
5
  * Store a WebSocket client reference for the given account.
4
6
  */
@@ -53,7 +53,7 @@ export const dispatchReply = {
53
53
  },
54
54
  account,
55
55
  toAccount: fromAccount,
56
- groupCode,
56
+ groupCode: isGroup ? groupCode : undefined,
57
57
  };
58
58
  const heartbeat = createReplyHeartbeatController({ meta: heartbeatMeta });
59
59
  // Track deliver kind transitions, detect tool-call boundaries
@@ -7,6 +7,7 @@
7
7
  * Fetches group info via the queryGroupInfo interface (WS protocol).
8
8
  */
9
9
  import { getMember } from "../../infra/cache/member.js";
10
+ import { createLog } from "../../logger.js";
10
11
  import { extractGroupCode, json } from "../utils/utils.js";
11
12
  /**
12
13
  * Create the query_group_info tool definition.
@@ -14,6 +15,9 @@ import { extractGroupCode, json } from "../utils/utils.js";
14
15
  * Queries basic group info including name, owner (userId + nickname), and member count.
15
16
  */
16
17
  function createQueryGroupInfoTool(ctx) {
18
+ const log = createLog("tools.group");
19
+ if (!ctx.messageChannel?.includes('yuanbao'))
20
+ return null;
17
21
  const sessionKey = ctx.sessionKey ?? "";
18
22
  const accountId = ctx.agentAccountId ?? "";
19
23
  return {
@@ -32,8 +36,8 @@ function createQueryGroupInfoTool(ctx) {
32
36
  * 1. No groupCode -> inform model no group context
33
37
  * 2. Call queryGroupInfo to get basic group info
34
38
  */
35
- async execute(_toolCallId, _params) {
36
- // Extract groupCode from sessionKey
39
+ async execute(toolCallId, _params) {
40
+ log.debug("execute", { toolCallId });
37
41
  const groupCode = extractGroupCode(sessionKey);
38
42
  // 1. No groupCode -> cannot locate group
39
43
  if (!groupCode) {
@@ -75,5 +79,7 @@ function createQueryGroupInfoTool(ctx) {
75
79
  * - query_group_info: Query basic group info (always available)
76
80
  */
77
81
  export function registerGroupTools(api) {
78
- api.registerTool(createQueryGroupInfoTool, { optional: false });
82
+ const log = createLog("tools.group");
83
+ log.info("register tool", { name: "query_group_info", optional: false });
84
+ api.registerTool(createQueryGroupInfoTool, { name: "query_group_info", optional: false });
79
85
  }
@@ -8,6 +8,7 @@
8
8
  * For group owner info, use the query_group_info tool.
9
9
  */
10
10
  import { getMember } from "../../infra/cache/member.js";
11
+ import { createLog } from "../../logger.js";
11
12
  import { extractGroupCode, json } from "../utils/utils.js";
12
13
  /** @mention hint text (used as JSON field value) */
13
14
  const MENTION_HINT_TEXT = 'To @mention a user, you MUST use the format: space + @ + nickname + space (e.g. " @Alice ").';
@@ -92,7 +93,11 @@ function handleListAll(allMembers, mention) {
92
93
  *
93
94
  * Merges the original lookup_session_members and query_group_members into one tool.
94
95
  * Prefers API-fetched full member list; session cache as fallback.
95
- */ function createQuerySessionMembersTool(ctx) {
96
+ */
97
+ function createQuerySessionMembersTool(ctx) {
98
+ const log = createLog("tools.member");
99
+ if (!ctx.messageChannel?.includes('yuanbao'))
100
+ return null;
96
101
  const sessionKey = ctx.sessionKey ?? "";
97
102
  const accountId = ctx.agentAccountId ?? "";
98
103
  return {
@@ -130,11 +135,11 @@ function handleListAll(allMembers, mention) {
130
135
  * 2. Query via Member facade: prefer GroupMember (WS API) -> fallback SessionMember (cache)
131
136
  * 3. Dispatch to action handlers
132
137
  */
133
- async execute(_toolCallId, params) {
138
+ async execute(toolCallId, params) {
139
+ log.debug("execute", { toolCallId });
134
140
  const action = typeof params.action === "string" ? params.action : "list_all";
135
141
  const nameFilter = typeof params.name === "string" ? params.name.trim() : "";
136
142
  const mention = params.mention === true || params.mention === "true";
137
- // Extract groupCode from sessionKey
138
143
  const groupCode = extractGroupCode(sessionKey);
139
144
  if (!groupCode) {
140
145
  return json({
@@ -169,5 +174,7 @@ function handleListAll(allMembers, mention) {
169
174
  * - query_session_members: Query session members (always available)
170
175
  */
171
176
  export function registerMemberTools(api) {
172
- api.registerTool(createQuerySessionMembersTool, { optional: false });
177
+ const log = createLog("tools.member");
178
+ log.info("register tool", { name: "query_session_members", optional: false });
179
+ api.registerTool(createQuerySessionMembersTool, { name: "query_session_members", optional: false });
173
180
  }
@@ -1,8 +1,9 @@
1
- /** Scheduled reminder guidance tool (yuanbao_remind). */
2
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
1
+ /** Scheduled reminder tool (yuanbao_remind). */
3
2
  /**
4
- * Register reminder-related tools to the OpenClaw plugin API.
5
- *
6
- * Centralized registration ensures the tool is reliably enabled during the plugin lifecycle.
3
+ * Three-tier execution chain, attempted top-down:
4
+ * 1. Gateway API (agent-harness-runtime available): calls cron API directly, status="ok"
5
+ * 2. CLI (plugin-sdk/matrix available): runs `openclaw cron` command, status="ok"; falls back to tier 3 on failure
6
+ * 3. Legacy fallback: returns status="PENDING_CRON_CALL" with cronToolParams to guide the model
7
7
  */
8
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
8
9
  export declare function registerRemindTools(api: OpenClawPluginApi): void;
@@ -1,106 +1,112 @@
1
- /** Scheduled reminder guidance tool (yuanbao_remind). */
1
+ /** Scheduled reminder tool (yuanbao_remind). */
2
+ import { createLog } from "../../logger.js";
2
3
  import { json } from "../utils/utils.js";
3
- // Prompt constants
4
+ let _callGatewayTool;
5
+ let _runPluginCmd;
6
+ async function resolveCallGatewayTool() {
7
+ if (_callGatewayTool !== undefined)
8
+ return _callGatewayTool;
9
+ try {
10
+ const sdkPath = 'openclaw/plugin-sdk/agent-harness-runtime';
11
+ const mod = await import(sdkPath);
12
+ _callGatewayTool = mod.callGatewayTool;
13
+ }
14
+ catch {
15
+ _callGatewayTool = null;
16
+ }
17
+ return _callGatewayTool;
18
+ }
19
+ async function resolveRunPluginCommand() {
20
+ if (_runPluginCmd !== undefined)
21
+ return _runPluginCmd;
22
+ try {
23
+ const sdkPath = 'openclaw/plugin-sdk/matrix';
24
+ const mod = await import(sdkPath);
25
+ _runPluginCmd = mod.runPluginCommandWithTimeout;
26
+ }
27
+ catch {
28
+ _runPluginCmd = null;
29
+ }
30
+ return _runPluginCmd;
31
+ }
32
+ // ============================================================================
33
+ // Constants
34
+ // ============================================================================
35
+ const DEFAULT_GATEWAY_TIMEOUT_MS = 60_000;
36
+ const DEFAULT_CLI_TIMEOUT_MS = 60_000;
4
37
  const RemindSchema = {
5
- type: "object",
38
+ type: 'object',
6
39
  properties: {
7
40
  action: {
8
- type: "string",
9
- enum: ["add", "list", "remove"],
10
- description: "操作类型: add=创建定时任务, list=查询已有任务, remove=删除任务。"
11
- + "删除前请先通过 list 获取 jobId。",
41
+ type: 'string',
42
+ enum: ['add', 'list', 'remove'],
43
+ description: '操作类型: add=创建定时任务, list=查询已有任务, remove=删除任务。'
44
+ + '删除前请先通过 list 获取 jobId。',
12
45
  },
13
46
  intent: {
14
- type: "string",
15
- enum: ["remind", "task"],
16
- description: "内容语义类型: 明确是提醒类文案时使用 remind; "
17
- + "需要定时执行某项任务时使用 task。默认 remind。",
47
+ type: 'string',
48
+ enum: ['remind', 'task'],
49
+ description: '语义类型,决定触发后的行为模式。'
50
+ + 'remind: 只需要提醒用户去做某事(如"休息一下""开会"); '
51
+ + 'task: 需要 AI 实际执行并输出结果(如"查询今日新闻""检查服务状态")。'
52
+ + '判断标准: 用户期望 AI 动手做事选 task,只需要一句提醒选 remind。',
18
53
  },
19
54
  content: {
20
- type: "string",
21
- description: "任务内容。action=add 时必填。"
22
- + '例如: "喝水"、"检查服务状态"、"整理今天会议纪要并发送总结"。',
55
+ type: 'string',
56
+ description: '任务需求的详细内容。action=add 时必填。'
57
+ + '例如: "提醒用户休息一下"、"查询今日新闻"、"检查服务状态"。',
23
58
  },
24
59
  time: {
25
- type: "string",
26
- description: "时间描述, action=add 时必填。"
27
- + "相对时间: 5m、1h、1h30m、2d(一次性任务); "
28
- + 'cron 表达式: "0 8 * * *""0 9 * * 1-5"(循环任务)'
29
- + "包含空格识别为 cron, 否则按相对时间处理。",
30
- },
31
- timezone: {
32
- type: "string",
33
- description: "时区, 仅循环任务(cron)生效。默认 Asia/Shanghai。",
60
+ type: 'string',
61
+ description: '时间描述, action=add 时必填。'
62
+ + '绝对时间: ISO 8601 UTC 格式, 如 "2026-04-29T14:00:00.000Z"(一次性任务); '
63
+ + '相对时间: 5m1h、1h30m、2d(一次性任务); '
64
+ + 'cron 表达式: "0 8 * * *"、"0 9 * * 1-5"(循环任务)。',
34
65
  },
35
66
  name: {
36
- type: "string",
37
- description: "任务名称(可选)。默认根据 content 自动生成, 便于后续 list/remove 管理。",
67
+ type: 'string',
68
+ description: '任务名称(20字以内),action=add 时可基于 content 内容生成,便于后续 list/remove 管理。',
38
69
  },
39
70
  jobId: {
40
- type: "string",
41
- description: "任务 ID。仅 action=remove 时必填, 需先通过 action=list 获取。",
71
+ type: 'string',
72
+ description: '任务 ID。仅 action=remove 时必填, 需先通过 action=list 获取。',
42
73
  },
43
74
  },
44
- required: ["action"],
75
+ required: ['action'],
45
76
  };
46
- /** Task scenario: execute scheduled task, no reply length limit */
47
- const TASK_AGENT_PROMPT_TEMPLATE = (content) => `你是一个任务执行助手。请在当前时刻完成以下任务:${content}。`
48
- + "要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 "
49
- + "(3) 直接执行任务并输出可直接给用户的结果 (4) 此时是在执行任务,不要再新建定时任务";
50
- /** Remind scenario: warm reminder, limited to short reply */
51
- const REMIND_AGENT_PROMPT_TEMPLATE = (content) => `你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:${content}。`
52
- + "要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 "
53
- + "(3) 直接输出一条暖心的提醒消息,不要携带其他和该提醒无关的内容 (4) 可以加一句简短的关怀话语 "
54
- + "(5) 控制在2-3句话以内 (6) 此时是在执行任务,不要再新建定时任务";
55
- /** Tool-level description: guide model to prioritize this tool and use cron result as source of truth */
56
- const YUANBAO_REMIND_TOOL_DESCRIPTION = [
57
- "用于规范化创建/查询/删除元宝定时任务,并生成可直接提交给 cron 工具的参数。",
58
- "当用户提出提醒、闹钟、定时、X分钟/小时后、每天/每周/每月/每年X点等需求时,或需要查询/删除定时任务/提醒/闹钟时,必须优先调用本工具,",
59
- "并在 cron 执行后核对返回结果,只能在确认成功后再告知用户已设置。",
60
- ].join("\n");
61
- /** Common execution constraint for cron calls */
62
- const CRON_COMMON_REQUIREMENT = [
63
- "请立即调用 cron 工具执行以下参数,并严格以 cron 返回结果为准。",
64
- "未执行或执行失败时,不得回复“已设置成功”。",
65
- "若执行失败,请明确告知用户执行失败并说明失败原因。",
66
- ].join("\n");
67
- /** add/create scenario instruction */
68
- const CRON_ADD_REQUIREMENT = [
69
- CRON_COMMON_REQUIREMENT,
70
- "仅在确认成功后,才向用户确认定时任务已创建。",
71
- "必要时请再调用 cron list 确认任务是否真实创建。",
72
- "用法: openclaw cron add|create [options], Options:",
73
- " --at <when>: Run once at time (ISO) or +duration (e.g. 20m)",
74
- ' --channel <channel>: Delivery channel (last) (default: "last")',
75
- " --cron <expr>: Cron expression (5-field or 6-field with seconds)",
76
- " --delete-after-run: Delete one-shot job after it succeeds (default: false)",
77
- " --message <text>: Agent message payload",
78
- " --name <name>: Job name",
79
- " --session <target>: Session target (main|isolated)",
80
- " --to <dest>: Delivery destination. For this tool, use direct:userId, group:groupCode",
81
- ' --tz <iana>: Timezone for cron expressions (IANA) (default: "")',
82
- ].join("\n");
83
- /** list scenario instruction */
84
- const CRON_LIST_REQUIREMENT = [
85
- CRON_COMMON_REQUIREMENT,
86
- "这是查询定时任务列表场景: 调用 openclaw cron list --json",
87
- "获取全量任务后,必须基于当前会话目标进行过滤,只返回当前会话的任务。",
88
- "过滤规则: 优先使用本次参数中的 to;若未显式提供 to,则使用当前会话自动解析出的目标(例如 direct:<userId> 或 group:<groupCode>)进行匹配。",
89
- "如过滤后结果为空,请明确告知“当前会话没有定时任务”。",
90
- ].join("\n");
91
- /** remove scenario instruction */
92
- const CRON_REMOVE_REQUIREMENT = [
93
- CRON_COMMON_REQUIREMENT,
94
- "这是删除任务场景: 调用 cron remove 后仅在确认成功时回复已删除;失败时必须返回失败原因。",
95
- "删除后建议再调用 cron list 做二次确认。",
96
- "用法: openclaw cron rm|remove <jobId>",
97
- ].join("\n");
98
- // Parse relative time
77
+ /** task mode: execute scheduled task, no reply length limit */
78
+ const TASK_AGENT_PROMPT_TEMPLATE = (content) => `你是一个任务执行助手。请在当前时刻完成以下任务:${content}。\n\n`
79
+ + '## 要求\n'
80
+ + '- 不要回复 HEARTBEAT_OK\n'
81
+ + '- 不要解释你是谁\n'
82
+ + '- 直接执行任务并输出可直接给用户的结果\n'
83
+ + '- **禁止**调用 yuanbao_remind cron 工具,**禁止**新建任何定时任务\n';
84
+ /** remind mode: warm reminder, keep reply brief */
85
+ const REMIND_AGENT_PROMPT_TEMPLATE = (content) => `你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:${content}。\n\n`
86
+ + '## 要求\n'
87
+ + '- 不要回复 HEARTBEAT_OK\n'
88
+ + '- 不要解释你是谁\n'
89
+ + '- 直接输出一条暖心的提醒消息,不要携带其他和该提醒无关的内容\n'
90
+ + '- 可以加一句简短的关怀话语\n'
91
+ + '- 控制在 2-3 句话以内\n'
92
+ + '- **禁止**调用 yuanbao_remind cron 工具,**禁止**新建任何定时任务\n';
93
+ const TOOL_DESCRIPTION = [
94
+ '创建/查询/删除元宝定时任务。当用户提出提醒、闹钟、定时、X分钟/小时后、每天/每周/每月/每年X点等需求时,必须调用本工具。',
95
+ '重要:调用后必须检查返回结果中的 status 字段判断操作是否完成。',
96
+ 'status="ok" 表示已完成,可以告知用户;status="PENDING_CRON_CALL" 表示还需要调用 cron 工具才能生效。',
97
+ '禁止在 status 不是 "ok" 时告知用户任务已设置成功。',
98
+ ].join('\n');
99
+ // Legacy fallback: imperative instructions guiding the model to call cron (concise, non-ignorable)
100
+ const LEGACY_ADD_NEXT_STEP = '你必须立即调用 cron 工具,将 cronToolParams 中的参数传入。只有 cron 返回成功后才能告知用户。未调用 cron 之前,禁止回复"已设置"或"已创建"。';
101
+ const LEGACY_LIST_NEXT_STEP = '你必须立即调用 cron 工具执行 list,获取全量任务后按 filter.to 过滤,只返回当前会话的任务。禁止在调用 cron 之前回复任务列表。';
102
+ const LEGACY_REMOVE_NEXT_STEP = '你必须立即调用 cron 工具执行 remove,传入 cronToolParams.jobId。只有 cron 返回成功后才能告知用户已删除。';
103
+ // ============================================================================
104
+ // Time parsing & formatting
105
+ // ============================================================================
99
106
  function parseRelativeTime(raw) {
100
107
  const s = raw.trim().toLowerCase();
101
- if (!s) {
108
+ if (!s)
102
109
  return null;
103
- }
104
110
  if (/^\d+$/.test(s)) {
105
111
  return parseInt(s, 10) * 60_000;
106
112
  }
@@ -113,214 +119,505 @@ function parseRelativeTime(raw) {
113
119
  const value = parseFloat(match[1]);
114
120
  const unit = match[2];
115
121
  switch (unit) {
116
- case "d":
122
+ case 'd':
117
123
  totalMs += value * 86_400_000;
118
124
  break;
119
- case "h":
125
+ case 'h':
120
126
  totalMs += value * 3_600_000;
121
127
  break;
122
- case "m":
128
+ case 'm':
123
129
  totalMs += value * 60_000;
124
130
  break;
125
- case "s":
131
+ case 's':
126
132
  totalMs += value * 1_000;
127
133
  break;
128
- default:
129
- break;
134
+ default: break;
130
135
  }
131
136
  }
132
137
  return matched ? Math.round(totalMs) : null;
133
138
  }
134
- // Check if string is a cron expression
139
+ /** Parses an ISO 8601 absolute time string; returns epoch ms, or null if invalid/expired. */
140
+ function parseAbsoluteTime(time) {
141
+ if (!/\d{4}-\d{2}/.test(time))
142
+ return null;
143
+ const ms = Date.parse(time);
144
+ if (Number.isNaN(ms))
145
+ return null;
146
+ return ms > Date.now() ? ms : null;
147
+ }
135
148
  function isCronExpression(timeText) {
136
149
  const parts = timeText.trim().split(/\s+/);
137
150
  return parts.length >= 3 && parts.length <= 6;
138
151
  }
139
- // Format delay duration
140
152
  function formatDelay(ms) {
141
153
  const seconds = Math.round(ms / 1000);
142
- if (seconds < 60) {
154
+ if (seconds < 60)
143
155
  return `${seconds}秒`;
144
- }
145
156
  const minutes = Math.round(ms / 60_000);
146
- if (minutes < 60) {
157
+ if (minutes < 60)
147
158
  return `${minutes}分钟`;
148
- }
149
159
  const hours = Math.floor(minutes / 60);
150
160
  const remains = minutes % 60;
151
- if (remains === 0) {
161
+ if (remains === 0)
152
162
  return `${hours}小时`;
153
- }
154
163
  return `${hours}小时${remains}分钟`;
155
164
  }
156
- // Resolve delivery target from session
157
- // If session contains group:groupCode or direct:userId, return the corresponding target
158
- // Otherwise return null
165
+ // ============================================================================
166
+ // Session resolution
167
+ // ============================================================================
159
168
  function resolveToFromSession(ctx) {
160
- const sessionKey = ctx.sessionKey ?? "";
161
- const groupPrefix = "yuanbao:group:";
162
- const directPrefix = "yuanbao:direct:";
169
+ const sessionKey = ctx.sessionKey ?? '';
170
+ const groupPrefix = 'yuanbao:group:';
171
+ const directPrefix = 'yuanbao:direct:';
163
172
  const groupIdx = sessionKey.indexOf(groupPrefix);
164
173
  if (groupIdx !== -1) {
165
174
  const groupCode = sessionKey.slice(groupIdx + groupPrefix.length).trim();
166
- if (groupCode) {
175
+ if (groupCode)
167
176
  return `group:${groupCode}`;
168
- }
169
177
  }
170
178
  const directIdx = sessionKey.indexOf(directPrefix);
171
179
  if (directIdx !== -1) {
172
- // Cannot parse userID from sessionKey because sessionKey lowercases it, while userID is mixed-case
180
+ // sessionKey lowercases userId, so use requesterSenderId instead
173
181
  const userId = ctx.requesterSenderId;
174
- if (userId) {
182
+ if (userId)
175
183
  return `direct:${userId}`;
176
- }
177
184
  }
178
185
  return null;
179
186
  }
180
- // Build SubAgent execution prompt
187
+ // ============================================================================
188
+ // Prompt & name generation
189
+ // ============================================================================
181
190
  function buildReminderPrompt(content, intent) {
182
- if (intent === "task") {
191
+ if (intent === 'task') {
183
192
  return TASK_AGENT_PROMPT_TEMPLATE(content);
184
193
  }
185
194
  return REMIND_AGENT_PROMPT_TEMPLATE(content);
186
195
  }
187
- // Generate job name
188
- function generateJobName(content, intent) {
196
+ function generateJobName(content) {
189
197
  const text = content.trim();
190
- const short = text.length > 20 ? `${text.slice(0, 20)}...` : text;
191
- return `${intent === "task" ? "任务" : "提醒"}: ${short}`;
198
+ return text.length > 20 ? `${text.slice(0, 20)}...` : text;
192
199
  }
193
- /**
194
- * Build cron params for a one-time (relative time) reminder task.
195
- *
196
- * Encapsulates one-time task scheduling strategy (kind=at, delete after run)
197
- * to prevent callers from missing critical fields.
198
- */
199
- function buildOnceCronParams(params, delayMs, to, intent) {
200
+ /** Resolves shared fields for a one-time job. */
201
+ function resolveOnceBase(params, time, intent) {
200
202
  const content = params.content;
201
- const at = `${Math.max(1, Math.round(delayMs / 1000))}s`;
202
203
  return {
203
- action: "add",
204
- name: params.name || generateJobName(content, intent),
205
- at,
206
- session: "isolated",
207
- deleteAfterRun: true,
204
+ name: params.name || generateJobName(content),
205
+ atMs: time.type === 'absolute' ? time.atMs : Date.now() + time.delayMs,
206
+ atStr: time.type === 'absolute' ? time.iso : `${Math.max(1, Math.round(time.delayMs / 1000))}s`,
208
207
  message: buildReminderPrompt(content, intent),
209
- channel: "yuanbao",
210
- to,
211
208
  };
212
209
  }
213
- /**
214
- * Build cron params for a recurring (cron expression) reminder task.
215
- *
216
- * Separated from one-time tasks to ensure recurring-specific fields (timezone, expression)
217
- * are always complete, reducing LLM parameter assembly ambiguity.
218
- */
219
- function buildCronParams(params, to, intent) {
210
+ /** Resolves shared fields for a recurring (cron) job. */
211
+ function resolveCronBase(params, intent) {
220
212
  const content = params.content;
221
213
  return {
222
- action: "add",
223
- name: params.name || generateJobName(content, intent),
224
- cron: params.time.trim(),
225
- tz: params.timezone || "Asia/Shanghai",
226
- session: "isolated",
227
- deleteAfterRun: false,
214
+ name: params.name || generateJobName(content),
215
+ expr: params.time.trim(),
216
+ tz: params.timezone || 'Asia/Shanghai',
228
217
  message: buildReminderPrompt(content, intent),
229
- channel: "yuanbao",
230
- to,
231
218
  };
232
219
  }
220
+ // ============================================================================
221
+ // Gateway mode: job builders
222
+ // ============================================================================
223
+ /** Builds a Gateway job config for a one-time job. */
224
+ function buildOnceJob(params, time, to, accountId, intent) {
225
+ const { name, atMs, message } = resolveOnceBase(params, time, intent);
226
+ return {
227
+ name,
228
+ schedule: { kind: 'at', atMs },
229
+ sessionTarget: 'isolated',
230
+ wakeMode: 'now',
231
+ deleteAfterRun: true,
232
+ payload: { kind: 'agentTurn', message },
233
+ delivery: { mode: 'announce', channel: 'yuanbao', to, accountId },
234
+ };
235
+ }
236
+ /** Builds a Gateway job config for a recurring (cron) job. */
237
+ function buildCronJob(params, to, accountId, intent) {
238
+ const { name, expr, tz, message } = resolveCronBase(params, intent);
239
+ return {
240
+ name,
241
+ schedule: { kind: 'cron', expr, tz },
242
+ sessionTarget: 'isolated',
243
+ wakeMode: 'now',
244
+ payload: { kind: 'agentTurn', message },
245
+ delivery: { mode: 'announce', channel: 'yuanbao', to, accountId },
246
+ };
247
+ }
248
+ // ============================================================================
249
+ // CLI mode: argv builders
250
+ // ============================================================================
251
+ /** Builds CLI argv for `openclaw cron add --at ...` (one-time job). */
252
+ function buildOnceCliArgs(params, time, to, accountId, intent) {
253
+ const { name, atStr, message } = resolveOnceBase(params, time, intent);
254
+ const argv = [
255
+ 'openclaw', 'cron', 'add',
256
+ '--name', name,
257
+ '--at', atStr,
258
+ '--message', message,
259
+ '--session', 'isolated',
260
+ '--delete-after-run',
261
+ '--announce',
262
+ '--channel', 'yuanbao',
263
+ '--to', to,
264
+ '--json',
265
+ ];
266
+ if (accountId)
267
+ argv.push('--account', accountId);
268
+ return argv;
269
+ }
270
+ /** Builds CLI argv for `openclaw cron add --cron ...` (recurring job). */
271
+ function buildCronCliArgs(params, to, accountId, intent) {
272
+ const { name, expr, tz, message } = resolveCronBase(params, intent);
273
+ const argv = [
274
+ 'openclaw', 'cron', 'add',
275
+ '--name', name,
276
+ '--cron', expr,
277
+ '--tz', tz,
278
+ '--message', message,
279
+ '--session', 'isolated',
280
+ '--announce',
281
+ '--channel', 'yuanbao',
282
+ '--to', to,
283
+ '--json',
284
+ ];
285
+ if (accountId)
286
+ argv.push('--account', accountId);
287
+ return argv;
288
+ }
289
+ // ============================================================================
290
+ // Legacy mode: cron params builders (used by executeLegacy)
291
+ // ============================================================================
292
+ /** Builds Legacy cronToolParams for a one-time job. */
293
+ function buildOnceLegacyParams(params, time, to, intent) {
294
+ const { name, atStr, message } = resolveOnceBase(params, time, intent);
295
+ return { action: 'add', name, at: atStr, session: 'isolated', deleteAfterRun: true, message, channel: 'yuanbao', to };
296
+ }
297
+ /** Builds Legacy cronToolParams for a recurring (cron) job. */
298
+ function buildCronLegacyParams(params, to, intent) {
299
+ const { name, expr, tz, message } = resolveCronBase(params, intent);
300
+ return { action: 'add', name, cron: expr, tz, session: 'isolated', deleteAfterRun: false, message, channel: 'yuanbao', to };
301
+ }
302
+ // ============================================================================
303
+ // Result filtering & error formatting
304
+ // ============================================================================
305
+ function filterJobsByTarget(cronResult, to) {
306
+ let jobs;
307
+ if (Array.isArray(cronResult)) {
308
+ jobs = cronResult;
309
+ }
310
+ else if (cronResult && typeof cronResult === 'object') {
311
+ const r = cronResult;
312
+ jobs = Array.isArray(r.jobs) ? r.jobs : [];
313
+ }
314
+ else {
315
+ jobs = [];
316
+ }
317
+ return jobs.filter((job) => {
318
+ const j = job;
319
+ const delivery = (j.delivery ?? j.job?.delivery);
320
+ if (delivery?.channel !== 'yuanbao')
321
+ return false;
322
+ return !to || delivery?.to === to;
323
+ });
324
+ }
325
+ function formatTimeLabel(time, intent) {
326
+ if (time.type === 'absolute') {
327
+ const d = new Date(time.atMs);
328
+ const hh = String(d.getHours()).padStart(2, '0');
329
+ const mm = String(d.getMinutes()).padStart(2, '0');
330
+ const label = intent === 'task' ? '执行任务' : '提醒';
331
+ return `${d.getMonth() + 1}/${d.getDate()} ${hh}:${mm} ${label}`;
332
+ }
333
+ const suffix = intent === 'task' ? '后执行任务' : '后提醒';
334
+ return `${formatDelay(time.delayMs)}${suffix}`;
335
+ }
336
+ function formatSchedulerError(error) {
337
+ return error instanceof Error ? error.message : String(error);
338
+ }
339
+ function tryParseJson(text) {
340
+ try {
341
+ return JSON.parse(text);
342
+ }
343
+ catch {
344
+ return null;
345
+ }
346
+ }
347
+ // ============================================================================
348
+ // Shared parameter validation
349
+ // ============================================================================
350
+ function validateAddParams(p, resolvedTo) {
351
+ if (!p.content?.trim())
352
+ return { error: 'action=add 时 content 为必填。' };
353
+ if (!p.time?.trim())
354
+ return { error: 'action=add 时 time 为必填。示例:5m / 1h30m / 0 8 * * *' };
355
+ if (!resolvedTo)
356
+ return { error: '无法确定投递目标。请确保在元宝会话中发起请求。' };
357
+ return null;
358
+ }
359
+ function validateRemoveParams(p) {
360
+ if (!p.jobId?.trim())
361
+ return { error: 'action=remove 时 jobId 为必填,请先调用 action=list 获取任务 ID。' };
362
+ return null;
363
+ }
364
+ function parseAndValidateTime(time) {
365
+ const atMs = parseAbsoluteTime(time);
366
+ if (atMs !== null) {
367
+ if (atMs - Date.now() < 30_000)
368
+ return { error: '提醒时间不能少于 30 秒。' };
369
+ return { timeSpec: { type: 'absolute', atMs, iso: new Date(atMs).toISOString() } };
370
+ }
371
+ const delayMs = parseRelativeTime(time);
372
+ if (!delayMs || delayMs <= 0) {
373
+ return { error: `无法解析时间 "${time}"。支持 ISO 时间(如 2026-04-29T14:00:00Z)、相对时间(5m/1h/1h30m/2d)或 cron 表达式(如 0 8 * * *)。` };
374
+ }
375
+ if (delayMs < 30_000)
376
+ return { error: '提醒时间不能少于 30 秒。' };
377
+ return { timeSpec: { type: 'relative', delayMs } };
378
+ }
379
+ // ============================================================================
380
+ // Gateway execute implementation
381
+ // ============================================================================
382
+ async function executeGateway(gatewayTool, p, resolvedTo, accountId) {
383
+ switch (p.action) {
384
+ case 'list': {
385
+ try {
386
+ const cronResult = await gatewayTool('cron.list', { timeoutMs: DEFAULT_GATEWAY_TIMEOUT_MS }, {});
387
+ const filtered = filterJobsByTarget(cronResult, resolvedTo);
388
+ return json({ status: 'ok', action: 'list', _via: 'gateway', jobs: filtered });
389
+ }
390
+ catch (error) {
391
+ return json({ error: `查询定时任务失败: ${formatSchedulerError(error)}` });
392
+ }
393
+ }
394
+ case 'remove': {
395
+ const err = validateRemoveParams(p);
396
+ if (err)
397
+ return json(err);
398
+ try {
399
+ const cronResult = await gatewayTool('cron.remove', { timeoutMs: DEFAULT_GATEWAY_TIMEOUT_MS }, { jobId: p.jobId.trim() });
400
+ return json({ status: 'ok', action: 'remove', _via: 'gateway', cronResult });
401
+ }
402
+ catch (error) {
403
+ return json({ error: `删除定时任务失败: ${formatSchedulerError(error)}` });
404
+ }
405
+ }
406
+ case 'add': {
407
+ const addErr = validateAddParams(p, resolvedTo);
408
+ if (addErr)
409
+ return json(addErr);
410
+ const intent = p.intent ?? 'remind';
411
+ if (isCronExpression(p.time)) {
412
+ const job = buildCronJob({ ...p, content: p.content.trim() }, resolvedTo, accountId, intent);
413
+ try {
414
+ const cronResult = await gatewayTool('cron.add', { timeoutMs: DEFAULT_GATEWAY_TIMEOUT_MS }, { job });
415
+ const typeLabel = intent === 'task' ? '循环任务' : '周期提醒';
416
+ return json({
417
+ status: 'ok',
418
+ action: 'add',
419
+ _via: 'gateway',
420
+ summary: `${typeLabel}: "${p.content.trim()}" (${p.time.trim()}, tz=${p.timezone || 'Asia/Shanghai'})`,
421
+ cronResult,
422
+ });
423
+ }
424
+ catch (error) {
425
+ return json({ error: `创建周期任务失败: ${formatSchedulerError(error)}` });
426
+ }
427
+ }
428
+ const timeResult = parseAndValidateTime(p.time);
429
+ if ('error' in timeResult)
430
+ return json(timeResult);
431
+ const job = buildOnceJob({ ...p, content: p.content.trim() }, timeResult.timeSpec, resolvedTo, accountId, intent);
432
+ try {
433
+ const cronResult = await gatewayTool('cron.add', { timeoutMs: DEFAULT_GATEWAY_TIMEOUT_MS }, { job });
434
+ return json({
435
+ status: 'ok',
436
+ action: 'add',
437
+ _via: 'gateway',
438
+ summary: `${formatTimeLabel(timeResult.timeSpec, intent)}: "${p.content.trim()}"`,
439
+ cronResult,
440
+ });
441
+ }
442
+ catch (error) {
443
+ return json({ error: `创建定时任务失败: ${formatSchedulerError(error)}` });
444
+ }
445
+ }
446
+ default:
447
+ return json({ error: `不支持的 action: ${String(p.action)}。可选值: add/list/remove。` });
448
+ }
449
+ }
450
+ // ============================================================================
451
+ // CLI execute implementation
452
+ // Returns null on command failure; validation errors return json directly (no fallback).
453
+ // ============================================================================
233
454
  /**
234
- * Create the `yuanbao_remind` tool definition.
235
- *
236
- * Standardizes natural language "reminder/task" requests into cron-executable params,
237
- * guiding the model to complete structured scheduling before replying to the user.
238
- * The execute callback handles add/list/remove branches with centralized validation,
239
- * target resolution, param building, and error messaging.
455
+ * CLI execution tier. Returns null on command failure to trigger Legacy fallback.
240
456
  */
457
+ async function executeCli(runner, p, resolvedTo, accountId) {
458
+ switch (p.action) {
459
+ case 'list': {
460
+ try {
461
+ const result = await runner({ argv: ['openclaw', 'cron', 'list', '--json'], timeoutMs: DEFAULT_CLI_TIMEOUT_MS });
462
+ if (result.code !== 0)
463
+ return null;
464
+ const parsed = tryParseJson(result.stdout);
465
+ if (parsed) {
466
+ const filtered = filterJobsByTarget(parsed, resolvedTo);
467
+ return json({ status: 'ok', action: 'list', _via: 'cli', jobs: filtered });
468
+ }
469
+ return json({ status: 'ok', action: 'list', _via: 'cli', raw: result.stdout.trim() });
470
+ }
471
+ catch {
472
+ return null;
473
+ }
474
+ }
475
+ case 'remove': {
476
+ const err = validateRemoveParams(p);
477
+ if (err)
478
+ return json(err);
479
+ try {
480
+ const result = await runner({
481
+ argv: ['openclaw', 'cron', 'rm', p.jobId.trim(), '--json'],
482
+ timeoutMs: DEFAULT_CLI_TIMEOUT_MS,
483
+ });
484
+ if (result.code !== 0)
485
+ return null;
486
+ const parsed = tryParseJson(result.stdout);
487
+ return json({ status: 'ok', action: 'remove', _via: 'cli', cronResult: parsed ?? result.stdout.trim() });
488
+ }
489
+ catch {
490
+ return null;
491
+ }
492
+ }
493
+ case 'add': {
494
+ const addErr = validateAddParams(p, resolvedTo);
495
+ if (addErr)
496
+ return json(addErr);
497
+ const intent = p.intent ?? 'remind';
498
+ let argv;
499
+ let summary;
500
+ if (isCronExpression(p.time)) {
501
+ argv = buildCronCliArgs({ ...p, content: p.content.trim() }, resolvedTo, accountId, intent);
502
+ const typeLabel = intent === 'task' ? '循环任务' : '周期提醒';
503
+ summary = `${typeLabel}: "${p.content.trim()}" (${p.time.trim()}, tz=${p.timezone || 'Asia/Shanghai'})`;
504
+ }
505
+ else {
506
+ const timeResult = parseAndValidateTime(p.time);
507
+ if ('error' in timeResult)
508
+ return json(timeResult);
509
+ argv = buildOnceCliArgs({ ...p, content: p.content.trim() }, timeResult.timeSpec, resolvedTo, accountId, intent);
510
+ summary = `${formatTimeLabel(timeResult.timeSpec, intent)}: "${p.content.trim()}"`;
511
+ }
512
+ try {
513
+ const result = await runner({ argv, timeoutMs: DEFAULT_CLI_TIMEOUT_MS });
514
+ if (result.code !== 0)
515
+ return null;
516
+ const parsed = tryParseJson(result.stdout);
517
+ return json({ status: 'ok', action: 'add', _via: 'cli', summary, cronResult: parsed ?? result.stdout.trim() });
518
+ }
519
+ catch {
520
+ return null;
521
+ }
522
+ }
523
+ default:
524
+ return json({ error: `不支持的 action: ${String(p.action)}。可选值: add/list/remove。` });
525
+ }
526
+ }
527
+ // ============================================================================
528
+ // Legacy execute implementation
529
+ // ============================================================================
530
+ /** Returns PENDING_CRON_CALL to guide the model to invoke the cron tool. */
531
+ function executeLegacy(p, resolvedTo) {
532
+ switch (p.action) {
533
+ case 'list':
534
+ return json({
535
+ status: 'PENDING_CRON_CALL',
536
+ _via: 'legacy',
537
+ completed: false,
538
+ next_step: LEGACY_LIST_NEXT_STEP,
539
+ cronToolParams: { action: 'list' },
540
+ filter: { to: resolvedTo },
541
+ });
542
+ case 'remove': {
543
+ const err = validateRemoveParams(p);
544
+ if (err)
545
+ return json(err);
546
+ return json({
547
+ status: 'PENDING_CRON_CALL',
548
+ _via: 'legacy',
549
+ completed: false,
550
+ next_step: LEGACY_REMOVE_NEXT_STEP,
551
+ cronToolParams: { action: 'remove', jobId: p.jobId.trim() },
552
+ });
553
+ }
554
+ case 'add': {
555
+ const addErr = validateAddParams(p, resolvedTo);
556
+ if (addErr)
557
+ return json(addErr);
558
+ const intent = p.intent ?? 'remind';
559
+ if (isCronExpression(p.time)) {
560
+ const cronToolParams = buildCronLegacyParams({ ...p, content: p.content.trim() }, resolvedTo, intent);
561
+ return json({
562
+ status: 'PENDING_CRON_CALL',
563
+ _via: 'legacy',
564
+ completed: false,
565
+ next_step: LEGACY_ADD_NEXT_STEP,
566
+ cronToolParams,
567
+ });
568
+ }
569
+ const timeResult = parseAndValidateTime(p.time);
570
+ if ('error' in timeResult)
571
+ return json(timeResult);
572
+ const cronToolParams = buildOnceLegacyParams({ ...p, content: p.content.trim() }, timeResult.timeSpec, resolvedTo, intent);
573
+ return json({
574
+ status: 'PENDING_CRON_CALL',
575
+ _via: 'legacy',
576
+ completed: false,
577
+ next_step: LEGACY_ADD_NEXT_STEP,
578
+ cronToolParams,
579
+ });
580
+ }
581
+ default:
582
+ return json({ error: `不支持的 action: ${String(p.action)}。可选值: add/list/remove。` });
583
+ }
584
+ }
585
+ // ============================================================================
586
+ // Tool definition & registration
587
+ // ============================================================================
588
+ /** Returns null for non-yuanbao channels. */
241
589
  function createYuanbaoRemindTool(ctx) {
242
- const isYuanbaoChannel = ctx.messageChannel === "yuanbao";
243
- if (!isYuanbaoChannel) {
590
+ const log = createLog("tools.remind");
591
+ if (!ctx.messageChannel?.includes('yuanbao'))
244
592
  return null;
245
- }
246
- // Only enable in yuanbao channel
593
+ const resolvedTo = resolveToFromSession(ctx);
594
+ const accountId = ctx.deliveryContext?.accountId ?? ctx.agentAccountId ?? '';
247
595
  return {
248
- name: "yuanbao_remind",
249
- label: "元宝定时任务",
250
- description: YUANBAO_REMIND_TOOL_DESCRIPTION,
596
+ name: 'yuanbao_remind',
597
+ label: '元宝定时任务',
598
+ description: TOOL_DESCRIPTION,
251
599
  parameters: RemindSchema,
252
- execute: async (_toolCallId, params) => {
600
+ async execute(toolCallId, params) {
601
+ log.debug("execute", { toolCallId });
253
602
  const p = params;
254
- const resolvedTo = resolveToFromSession(ctx);
255
- switch (p.action) {
256
- case "list":
257
- return json({
258
- instruction: CRON_LIST_REQUIREMENT,
259
- cronParams: { action: "list" },
260
- filter: { to: resolvedTo },
261
- });
262
- case "remove":
263
- if (!p.jobId?.trim()) {
264
- return json({
265
- error: "action=remove 时 jobId 为必填,请先调用 action=list 获取任务 ID。",
266
- });
267
- }
268
- return json({
269
- instruction: CRON_REMOVE_REQUIREMENT,
270
- cronParams: { action: "remove", jobId: p.jobId.trim() },
271
- });
272
- case "add": {
273
- if (!p.content?.trim()) {
274
- return json({ error: "action=add 时 content 为必填。" });
275
- }
276
- if (!p.time?.trim()) {
277
- return json({ error: "action=add 时 time 为必填。示例:5m / 1h30m / 0 8 * * *" });
278
- }
279
- const intent = p.intent ?? "remind";
280
- const isCron = isCronExpression(p.time);
281
- if (!resolvedTo) {
282
- return json({
283
- error: "无法确定投递目标 to。请显式传入 to,例如 direct:<userId> 或 group:<groupCode>。",
284
- });
285
- }
286
- if (isCron) {
287
- const cronParams = buildCronParams({ ...p, content: p.content.trim() }, resolvedTo, intent);
288
- const typeLabel = intent === "task" ? "循环任务" : "周期提醒";
289
- return json({
290
- instruction: CRON_ADD_REQUIREMENT,
291
- cronParams,
292
- summary: `⏰ ${typeLabel}: "${p.content.trim()}" (${p.time.trim()}, tz=${p.timezone || "Asia/Shanghai"})`,
293
- });
294
- }
295
- const delayMs = parseRelativeTime(p.time);
296
- if (!delayMs || delayMs <= 0) {
297
- return json({
298
- error: `无法解析时间 "${p.time}"。支持相对时间(5m/1h/1h30m/2d)`
299
- + "或 cron 表达式(如 0 8 * * *)。",
300
- });
301
- }
302
- if (delayMs < 30_000) {
303
- return json({ error: "提醒时间不能少于 30 秒。" });
304
- }
305
- const cronParams = buildOnceCronParams({ ...p, content: p.content.trim() }, delayMs, resolvedTo, intent);
306
- const typeLabel = intent === "task" ? "后执行任务" : "后提醒";
307
- return json({
308
- instruction: CRON_ADD_REQUIREMENT,
309
- cronParams,
310
- summary: `⏰ ${formatDelay(delayMs)}${typeLabel}: "${p.content.trim()}"`,
311
- });
312
- }
313
- default:
314
- return json({ error: `不支持的 action: ${String(p.action)}。可选值: add/list/remove。` });
603
+ const gatewayTool = await resolveCallGatewayTool();
604
+ if (gatewayTool) {
605
+ return executeGateway(gatewayTool, p, resolvedTo, accountId);
315
606
  }
607
+ const runner = await resolveRunPluginCommand();
608
+ if (runner) {
609
+ const cliResult = await executeCli(runner, p, resolvedTo, accountId);
610
+ if (cliResult !== null)
611
+ return cliResult;
612
+ // CLI command failed (non-zero exit or exception), fall through to Legacy
613
+ }
614
+ // Final fallback: guide the model to call cron via PENDING response
615
+ return executeLegacy(p, resolvedTo);
316
616
  },
317
617
  };
318
618
  }
319
- /**
320
- * Register reminder-related tools to the OpenClaw plugin API.
321
- *
322
- * Centralized registration ensures the tool is reliably enabled during the plugin lifecycle.
323
- */
324
619
  export function registerRemindTools(api) {
325
- api.registerTool(createYuanbaoRemindTool, { optional: false });
620
+ const log = createLog("tools.remind");
621
+ log.info("register tool", { name: "yuanbao_remind", optional: false });
622
+ api.registerTool(createYuanbaoRemindTool, { name: "yuanbao_remind", optional: false });
326
623
  }
@@ -22,7 +22,7 @@ export declare const yuanbaoMeta: {
22
22
  readonly docsPath: "/plugins/community#yuanbao";
23
23
  readonly docsLabel: "yuanbao";
24
24
  readonly blurb: "Tencent Yuanbao AI assistant conversation channel";
25
- readonly aliases: readonly ["yb", "tencent-yuanbao", "元宝"];
25
+ readonly aliases: readonly ["yb", "yuanbao", "tencent-yuanbao", "元宝"];
26
26
  readonly order: 85;
27
27
  readonly quickstartAllowFrom: true;
28
28
  };
@@ -22,7 +22,7 @@ export const yuanbaoMeta = {
22
22
  docsPath: "/plugins/community#yuanbao",
23
23
  docsLabel: "yuanbao",
24
24
  blurb: "Tencent Yuanbao AI assistant conversation channel",
25
- aliases: ["yb", "tencent-yuanbao", "元宝"],
25
+ aliases: ["yb", "yuanbao", "tencent-yuanbao", "元宝"],
26
26
  order: 85,
27
27
  quickstartAllowFrom: true,
28
28
  };
@@ -42,7 +42,7 @@ const assertHostVersionCompatible = (hostVersion) => {
42
42
  const constraint = getMinHostVersion();
43
43
  if (!constraint)
44
44
  return;
45
- if (!semver.satisfies(hostVersion, constraint)) {
45
+ if (!semver.satisfies(hostVersion, constraint, { includePrerelease: true })) {
46
46
  throw new Error(`openclaw-plugin-yuanbao requires openclaw ${constraint}, but current version is ${hostVersion}. Please upgrade openclaw first.`);
47
47
  }
48
48
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "openclaw-plugin-yuanbao",
3
- "version": "2.13.2",
3
+ "version": "2.13.4",
4
4
  "name": "YuanBao",
5
5
  "description": "YuanBao channel plugin",
6
6
  "channels": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-plugin-yuanbao",
3
- "version": "2.13.2",
3
+ "version": "2.13.4",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "Tencent YuanBao intelligent bot channel plugin",