@ynhcj/xiaoyi-channel 0.0.163-beta → 0.0.163-next

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/index.js +87 -1
  2. package/dist/src/bot.js +60 -5
  3. package/dist/src/cron-command.d.ts +2 -0
  4. package/dist/src/cron-command.js +14 -8
  5. package/dist/src/cron-query-handler.d.ts +1 -11
  6. package/dist/src/cron-query-handler.js +132 -8
  7. package/dist/src/cspl/call_api.d.ts +1 -1
  8. package/dist/src/cspl/call_api.js +4 -4
  9. package/dist/src/cspl/config.js +30 -10
  10. package/dist/src/cspl/constants.d.ts +2 -0
  11. package/dist/src/cspl/constants.js +3 -0
  12. package/dist/src/cspl/sentinel_hook.js +11 -6
  13. package/dist/src/cspl/upload_file.js +2 -2
  14. package/dist/src/cspl/utils.d.ts +9 -3
  15. package/dist/src/cspl/utils.js +17 -11
  16. package/dist/src/file-upload.d.ts +5 -0
  17. package/dist/src/file-upload.js +102 -0
  18. package/dist/src/formatter.d.ts +30 -0
  19. package/dist/src/formatter.js +108 -7
  20. package/dist/src/monitor.js +35 -23
  21. package/dist/src/parser.d.ts +6 -0
  22. package/dist/src/parser.js +23 -13
  23. package/dist/src/provider.js +41 -1
  24. package/dist/src/reply-dispatcher.js +70 -26
  25. package/dist/src/self-evolution-handler.d.ts +1 -1
  26. package/dist/src/self-evolution-handler.js +12 -1
  27. package/dist/src/tools/calendar-tool.js +1 -1
  28. package/dist/src/tools/call-phone-tool.js +1 -1
  29. package/dist/src/tools/check-plugin-privilege-tool.d.ts +6 -0
  30. package/dist/src/tools/check-plugin-privilege-tool.js +182 -0
  31. package/dist/src/tools/create-alarm-tool.js +1 -1
  32. package/dist/src/tools/create-all-tools.js +8 -2
  33. package/dist/src/tools/delete-alarm-tool.js +1 -1
  34. package/dist/src/tools/device-tool-map.d.ts +1 -1
  35. package/dist/src/tools/device-tool-map.js +9 -1
  36. package/dist/src/tools/display-a2ui-card-tool.d.ts +2 -0
  37. package/dist/src/tools/display-a2ui-card-tool.js +85 -0
  38. package/dist/src/tools/location-tool.js +1 -1
  39. package/dist/src/tools/modify-alarm-tool.js +18 -1
  40. package/dist/src/tools/modify-note-tool.js +1 -1
  41. package/dist/src/tools/note-tool.js +1 -1
  42. package/dist/src/tools/query-app-message-tool.js +1 -1
  43. package/dist/src/tools/query-memory-data-tool.js +1 -1
  44. package/dist/src/tools/query-todo-task-tool.js +1 -1
  45. package/dist/src/tools/save-file-to-phone-tool.js +1 -1
  46. package/dist/src/tools/save-media-to-gallery-tool.js +1 -1
  47. package/dist/src/tools/search-alarm-tool.js +1 -1
  48. package/dist/src/tools/search-calendar-tool.js +1 -1
  49. package/dist/src/tools/search-contact-tool.js +1 -1
  50. package/dist/src/tools/search-email-tool.js +1 -1
  51. package/dist/src/tools/search-note-tool.js +1 -1
  52. package/dist/src/tools/search-photo-gallery-tool.js +1 -1
  53. package/dist/src/tools/send-cross-device-task-tool.js +84 -15
  54. package/dist/src/tools/send-email-tool.js +1 -1
  55. package/dist/src/tools/send-file-to-user-tool.js +9 -11
  56. package/dist/src/tools/send-html-card-tool.d.ts +7 -0
  57. package/dist/src/tools/send-html-card-tool.js +113 -0
  58. package/dist/src/tools/session-manager.d.ts +12 -2
  59. package/dist/src/tools/session-manager.js +78 -18
  60. package/dist/src/tools/upload-file-tool.js +1 -1
  61. package/dist/src/tools/upload-photo-tool.js +1 -1
  62. package/dist/src/tools/xiaoyi-add-collection-tool.js +1 -1
  63. package/dist/src/tools/xiaoyi-collection-tool.js +1 -1
  64. package/dist/src/tools/xiaoyi-delete-collection-tool.js +1 -1
  65. package/dist/src/tools/xiaoyi-gui-tool.js +1 -1
  66. package/dist/src/types.d.ts +9 -7
  67. package/dist/src/utils/cron-push-map.d.ts +26 -0
  68. package/dist/src/utils/cron-push-map.js +131 -0
  69. package/dist/src/websocket.js +11 -13
  70. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -3,7 +3,9 @@ import { xiaoyiProvider } from "./src/provider.js";
3
3
  import { xyPlugin } from "./src/channel.js";
4
4
  import registerSentinelHook from "./src/cspl/sentinel_hook.js";
5
5
  import { setXYRuntime } from "./src/runtime.js";
6
- import { markCronToolCall, clearCronToolCall } from "./src/tools/session-manager.js";
6
+ import { markCronToolCall, clearCronToolCall, getSessionContext } from "./src/tools/session-manager.js";
7
+ import { configManager } from "./src/utils/config-manager.js";
8
+ import { setJobPushId } from "./src/utils/cron-push-map.js";
7
9
  import { registerSelfEvolutionToolResultNudge } from "./src/self-evolution-tool-result-nudge.js";
8
10
  import { createBeforePromptBuildHandler } from "./src/skill-retriever/hooks.js";
9
11
  import { normalizeToolRetrieverConfig } from "./src/skill-retriever/config.js";
@@ -25,8 +27,92 @@ function registerCronDetectionHook(api) {
25
27
  if (event.toolCallId) {
26
28
  clearCronToolCall(event.toolCallId);
27
29
  }
30
+ // 捕获对话创建的 cron job:agent 调 cron(add) 后,从 result 拿 jobId,
31
+ // 配合当前会话的 pushId,写入 jobId↔pushId 映射,供 fire 时反查设备。
32
+ await captureCronAddMapping(event, ctx).catch((err) => {
33
+ // 捕获失败不影响工具结果
34
+ console.error("[xy] captureCronAddMapping failed:", err);
35
+ });
28
36
  });
29
37
  }
38
+ /** 从 cron add 工具结果中提取 jobId 并写入 pushId 映射。 */
39
+ async function captureCronAddMapping(event, ctx) {
40
+ // 诊断:先看 after_tool_call 是否为 cron 工具触发
41
+ if (event.toolName !== "cron")
42
+ return;
43
+ const action = typeof event.params?.action === "string" ? event.params.action : "";
44
+ console.log(`[CRONMAP] after_tool_call cron, action=${action}, resultType=${typeof event.result}`);
45
+ if (action !== "add") {
46
+ console.log(`[CRONMAP] skip: action !== "add" (got ${action})`);
47
+ return;
48
+ }
49
+ const jobId = readJobIdFromResult(event.result);
50
+ if (!jobId) {
51
+ console.log(`[CRONMAP] skip: could not extract jobId from result. preview=${preview(event.result)}`);
52
+ return;
53
+ }
54
+ console.log(`[CRONMAP] extracted jobId=${jobId}`);
55
+ const sessionCtx = ctx.sessionKey ? getSessionContext(ctx.sessionKey) : null;
56
+ const sessionId = sessionCtx?.sessionId;
57
+ if (!sessionId) {
58
+ console.log(`[CRONMAP] skip: no sessionId (sessionKey=${ctx.sessionKey}, ctxFound=${!!sessionCtx})`);
59
+ return;
60
+ }
61
+ const pushId = configManager.getPushId(sessionId);
62
+ if (!pushId) {
63
+ console.log(`[CRONMAP] skip: configManager has no pushId for sessionId=${sessionId}`);
64
+ return;
65
+ }
66
+ console.log(`[CRONMAP] writing map: jobId=${jobId}, sessionId=${sessionId}, pushId=${pushId.substring(0, 16)}...`);
67
+ await setJobPushId(jobId, {
68
+ pushId,
69
+ sessionId,
70
+ deviceType: sessionCtx?.deviceType,
71
+ source: "conversation",
72
+ });
73
+ console.log(`[CRONMAP] map written OK`);
74
+ }
75
+ /** 取结果的短预览,用于诊断。 */
76
+ function preview(value) {
77
+ if (value == null)
78
+ return String(value);
79
+ const s = typeof value === "string" ? value : JSON.stringify(value);
80
+ return s.length > 200 ? s.slice(0, 200) + "…" : s;
81
+ }
82
+ /** 防御性地从 cron add 结果中取 job id(可能是对象、JSON 字符串或工具输出文本)。 */
83
+ function readJobIdFromResult(result) {
84
+ if (!result)
85
+ return undefined;
86
+ let obj = result;
87
+ if (typeof result === "string") {
88
+ // 优先尝试 JSON 解析
89
+ try {
90
+ obj = JSON.parse(result);
91
+ }
92
+ catch {
93
+ // 解析失败:可能是纯文本工具输出,尝试从文本里抓 "id":"..." 或 id=...
94
+ const m = result.match(/"id"\s*:\s*"([^"]+)"/);
95
+ if (m)
96
+ return m[1];
97
+ return undefined;
98
+ }
99
+ }
100
+ if (obj && typeof obj === "object") {
101
+ const id = obj.id;
102
+ if (typeof id === "string" && id.trim())
103
+ return id.trim();
104
+ // 兜底:result 可能把 job 包在 data/result 字段里
105
+ for (const k of ["data", "result", "job"]) {
106
+ const inner = obj[k];
107
+ if (inner && typeof inner === "object") {
108
+ const innerId = inner.id;
109
+ if (typeof innerId === "string" && innerId.trim())
110
+ return innerId.trim();
111
+ }
112
+ }
113
+ }
114
+ return undefined;
115
+ }
30
116
  function registerFullHooks(api) {
31
117
  // SKILL RETRIEVER HOOK: before_prompt_build hook
32
118
  const pluginConfig = api.pluginConfig || {};
package/dist/src/bot.js CHANGED
@@ -1,6 +1,7 @@
1
+ import { updateSessionStoreEntry, updateSessionStore, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
1
2
  import { getXYRuntime } from "./runtime.js";
2
3
  import { createXYReplyDispatcher } from "./reply-dispatcher.js";
3
- import { parseA2AMessage, extractTextFromParts, extractFileParts, extractPushId, extractDeviceType, extractTriggerData, extractRunCrossTaskContext } from "./parser.js";
4
+ import { parseA2AMessage, extractTextFromParts, extractFileParts, extractPushId, extractDeviceType, extractModelName, extractTriggerData, extractRunCrossTaskContext } from "./parser.js";
4
5
  import { downloadFilesFromParts } from "./file-download.js";
5
6
  import { resolveXYConfig } from "./config.js";
6
7
  import { sendStatusUpdate, sendClearContextResponse, sendTasksCancelResponse, sendA2AResponse } from "./formatter.js";
@@ -32,9 +33,9 @@ export async function handleXYMessage(params) {
32
33
  try {
33
34
  // Check for special messages BEFORE parsing (these have different param structures)
34
35
  const messageMethod = message.method;
35
- // Handle clearContext messages (params only has sessionId)
36
+ // Handle clearContext messages (sessionId at top level, no params)
36
37
  if (messageMethod === "clearContext" || messageMethod === "clear_context") {
37
- const sessionId = message.params?.sessionId;
38
+ const sessionId = message.sessionId ?? message.params?.sessionId;
38
39
  if (!sessionId) {
39
40
  throw new Error("clearContext request missing sessionId in params");
40
41
  }
@@ -48,9 +49,9 @@ export async function handleXYMessage(params) {
48
49
  });
49
50
  return;
50
51
  }
51
- // Handle tasks/cancel messages
52
+ // Handle tasks/cancel messages (sessionId at top level, no params)
52
53
  if (messageMethod === "tasks/cancel" || messageMethod === "tasks_cancel") {
53
- const sessionId = message.params?.sessionId;
54
+ const sessionId = message.sessionId ?? message.params?.sessionId;
54
55
  const taskId = message.params?.id || message.id;
55
56
  if (!sessionId) {
56
57
  throw new Error("tasks/cancel request missing sessionId in params");
@@ -138,6 +139,11 @@ export async function handleXYMessage(params) {
138
139
  if (deviceType) {
139
140
  log.log(`[BOT] Extracted deviceType: ${deviceType}`);
140
141
  }
142
+ // Extract modelName if present (used by provider.ts to override model.id)
143
+ const modelName = extractModelName(parsed.parts);
144
+ if (modelName) {
145
+ log.log(`[BOT] Extracted modelName: ${modelName}`);
146
+ }
141
147
  const runCrossTaskContext = extractRunCrossTaskContext(parsed.parts);
142
148
  // Resolve configuration (needed for status updates)
143
149
  const config = resolveXYConfig(cfg);
@@ -164,8 +170,56 @@ export async function handleXYMessage(params) {
164
170
  messageId: parsed.messageId,
165
171
  agentId: route.accountId,
166
172
  deviceType,
173
+ modelName,
167
174
  runCrossTaskContext: runCrossTaskContext ?? undefined,
168
175
  });
176
+ // 🔑 Sync A2A modelName to OpenClaw session store so that session_status
177
+ // reports the correct model. Without this, session_status returns the
178
+ // configured default model instead of the A2A-specified one.
179
+ if (modelName && modelName.trim() !== "" && modelName.toLowerCase() !== "none") {
180
+ try {
181
+ const storePath = resolveStorePath();
182
+ const result = await updateSessionStoreEntry({
183
+ storePath,
184
+ sessionKey: route.sessionKey,
185
+ update: async () => ({
186
+ providerOverride: "xiaoyiprovider",
187
+ modelOverride: modelName,
188
+ modelOverrideSource: "user",
189
+ model: "",
190
+ modelProvider: "",
191
+ contextTokens: 256_000,
192
+ }),
193
+ });
194
+ if (!result) {
195
+ // Session entry doesn't exist yet (first message, xy_channel
196
+ // bypasses the standard turn kernel). Create a minimal entry
197
+ // with the override via updateSessionStore.
198
+ await updateSessionStore(storePath, (store) => {
199
+ if (!store[route.sessionKey]) {
200
+ store[route.sessionKey] = {
201
+ // sessionId must pass validateSessionId regex /^[a-z0-9][a-z0-9._-]{0,127}$/i
202
+ // route.sessionKey like "agent:main:direct:xxx" contains colons which are invalid.
203
+ // Use parsed.sessionId (raw UUID from A2A) which is always safe.
204
+ sessionId: parsed.sessionId,
205
+ updatedAt: Date.now(),
206
+ providerOverride: "xiaoyiprovider",
207
+ modelOverride: modelName,
208
+ modelOverrideSource: "user",
209
+ contextTokens: 256_000,
210
+ };
211
+ }
212
+ });
213
+ log.log(`[BOT] Created session entry with model override: xiaoyiprovider/${modelName}`);
214
+ }
215
+ else {
216
+ log.log(`[BOT] Patched session store model override: xiaoyiprovider/${modelName}`);
217
+ }
218
+ }
219
+ catch (patchErr) {
220
+ log.error(`[BOT] Failed to patch session model override:`, patchErr);
221
+ }
222
+ }
169
223
  // 🔑 发送初始状态更新
170
224
  log.log(`[BOT] Sending initial status update`);
171
225
  void sendStatusUpdate({
@@ -311,6 +365,7 @@ export async function handleXYMessage(params) {
311
365
  messageId: parsed.messageId,
312
366
  agentId: route.accountId,
313
367
  deviceType,
368
+ modelName,
314
369
  runCrossTaskContext: runCrossTaskContext ?? undefined,
315
370
  };
316
371
  log.log(`[BOT-DISPATCH] withReplyDispatcher starting, sessionKey=${route.sessionKey}`);
@@ -2,6 +2,8 @@ import type { XYChannelConfig, A2ACommand } from "./types.js";
2
2
  export interface SendCommandViaPushParams {
3
3
  config: XYChannelConfig;
4
4
  command: A2ACommand;
5
+ /** 指定设备的 pushId(多设备路由)。未传时回退到 getAllPushIds()[0]。 */
6
+ pushId?: string;
5
7
  }
6
8
  /**
7
9
  * Send a tool command through the push channel (for cron-triggered tool calls).
@@ -24,16 +24,22 @@ export async function sendCommandViaPush(params) {
24
24
  command.header?.name ??
25
25
  "Command";
26
26
  logger.log(`[CRON-CMD] Sending command via push, intent=${intentName}`);
27
- // 1. Load push IDs, use first one
27
+ // 1. pushId:优先用调用方解析出的设备 pushId(多设备路由正确);
28
+ // 未提供时回退到 getAllPushIds()[0](单设备兼容旧行为)。
28
29
  let pushId = config.pushId;
29
- try {
30
- const pushIdList = await getAllPushIds();
31
- if (pushIdList.length > 0) {
32
- pushId = pushIdList[0];
33
- }
30
+ if (params.pushId) {
31
+ pushId = params.pushId;
34
32
  }
35
- catch (error) {
36
- logger.error("[CRON-CMD] Failed to load pushIds:", error);
33
+ else {
34
+ try {
35
+ const pushIdList = await getAllPushIds();
36
+ if (pushIdList.length > 0) {
37
+ pushId = pushIdList[0];
38
+ }
39
+ }
40
+ catch (error) {
41
+ logger.error("[CRON-CMD] Failed to load pushIds:", error);
42
+ }
37
43
  }
38
44
  // 2. Build and send push notification with command in directives
39
45
  const pushService = new XYPushService(config);
@@ -1,17 +1,7 @@
1
- export type CronQueryAction = "list" | "status" | "runs" | "add" | "update" | "remove" | "run";
2
- export interface CronQueryEventContext {
3
- action: CronQueryAction;
4
- jobId?: string;
5
- params?: Record<string, unknown>;
6
- /** Original A2A message fields for routing the response. */
7
- sessionId?: string;
8
- taskId?: string;
9
- messageId?: string;
10
- }
11
1
  /**
12
2
  * Handle a cron-query-event.
13
3
  *
14
4
  * Calls the Gateway cron RPC and sends the result back through sendCommand
15
5
  * as a System.CronQuery command with the full result object in payload.ans.
16
6
  */
17
- export declare function handleCronQueryEvent(context: CronQueryEventContext, cfg?: unknown): Promise<void>;
7
+ export declare function handleCronQueryEvent(context: any, cfg: any): Promise<void>;
@@ -4,9 +4,14 @@
4
4
  // result back to the client via sendCommand as a System.CronQuery
5
5
  // command with the result in payload.ans.
6
6
  import { callGatewayTool } from "openclaw/plugin-sdk/agent-harness-runtime";
7
+ import * as os from "os";
7
8
  import { sendCommand } from "./formatter.js";
8
9
  import { resolveXYConfig } from "./config.js";
10
+ import { configManager } from "./utils/config-manager.js";
11
+ import { setJobPushId } from "./utils/cron-push-map.js";
9
12
  import { logger } from "./utils/logger.js";
13
+ import { readFileSync, readdirSync } from "fs";
14
+ import { join } from "path";
10
15
  const GATEWAY_TIMEOUT_MS = 60_000;
11
16
  /**
12
17
  * Handle a cron-query-event.
@@ -16,7 +21,8 @@ const GATEWAY_TIMEOUT_MS = 60_000;
16
21
  */
17
22
  export async function handleCronQueryEvent(context, cfg) {
18
23
  const { action, jobId, params, sessionId, taskId, messageId } = context;
19
- logger.log(`[CRON-QUERY] Received event: action=${action}, jobId=${jobId ?? "(none)"}`);
24
+ const log = logger.withContext(sessionId ?? "", taskId ?? "");
25
+ log.log(`[CRON-QUERY] Received event: action=${action}, jobId=${jobId ?? "(none)"}`);
20
26
  let result;
21
27
  let error;
22
28
  try {
@@ -35,6 +41,11 @@ export async function handleCronQueryEvent(context, cfg) {
35
41
  break;
36
42
  case "add":
37
43
  result = await callGatewayTool("cron.add", { timeoutMs: GATEWAY_TIMEOUT_MS }, params ?? {});
44
+ // 捕获 jobId↔pushId:cron-query 路径由 channel 自己建 job,
45
+ // 此处 context 握着 sessionId,configManager 有对应设备 pushId。
46
+ await persistCronPushMap(context.sessionId, result).catch((err) => {
47
+ logger.error(`[CRON-QUERY] Failed to persist cron-push-map:`, err);
48
+ });
38
49
  break;
39
50
  case "update":
40
51
  result = await callGatewayTool("cron.update", { timeoutMs: GATEWAY_TIMEOUT_MS }, {
@@ -54,19 +65,22 @@ export async function handleCronQueryEvent(context, cfg) {
54
65
  ...params,
55
66
  });
56
67
  break;
68
+ case "queryTimeList":
69
+ result = await queryTimeListLocal();
70
+ break;
57
71
  default:
58
72
  error = `Unknown action: ${context.action}`;
59
- logger.error(`[CRON-QUERY] ${error}`);
73
+ log.error(`[CRON-QUERY] ${error}`);
60
74
  result = { error };
61
75
  }
62
76
  }
63
77
  catch (err) {
64
78
  error = err instanceof Error ? err.message : String(err);
65
- logger.error(`[CRON-QUERY] RPC call failed for action=${action}:`, err);
79
+ log.error(`[CRON-QUERY] RPC call failed for action=${action}:`, err);
66
80
  result = { error };
67
81
  }
68
82
  // Log the result
69
- logger.log(`[CRON-QUERY] RPC result for action=${action}: ${JSON.stringify(result, null, 2)}`);
83
+ log.log(`[CRON-QUERY] RPC result for action=${action}: ${JSON.stringify(result, null, 2)}`);
70
84
  // Send result back via sendCommand as System.CronQuery with payload.ans
71
85
  if (cfg && sessionId && taskId && messageId) {
72
86
  try {
@@ -87,15 +101,125 @@ export async function handleCronQueryEvent(context, cfg) {
87
101
  taskId,
88
102
  messageId,
89
103
  command,
90
- final: true,
104
+ final: sessionId.toLowerCase().endsWith("cronquery"),
91
105
  });
92
- logger.log(`[CRON-QUERY] Sent response via sendCommand, action=${action}`);
106
+ log.log(`[CRON-QUERY] Sent response via sendCommand, action=${action}`);
93
107
  }
94
108
  catch (sendErr) {
95
- logger.error(`[CRON-QUERY] Failed to send response via sendCommand:`, sendErr);
109
+ log.error(`[CRON-QUERY] Failed to send response via sendCommand:`, sendErr);
96
110
  }
97
111
  }
98
112
  else {
99
- logger.warn(`[CRON-QUERY] Missing cfg/sessionId/taskId/messageId, skipping sendCommand`);
113
+ log.warn(`[CRON-QUERY] Missing cfg/sessionId/taskId/messageId, skipping sendCommand`);
114
+ }
115
+ }
116
+ /**
117
+ * 从 cron.add 结果中提取 jobId,配合 sessionId 对应的 pushId 写入映射。
118
+ */
119
+ async function persistCronPushMap(sessionId, result) {
120
+ logger.log(`[CRONMAP] cron-query persist: sessionId=${sessionId ?? "(none)"}, resultType=${typeof result}`);
121
+ if (!sessionId) {
122
+ logger.log(`[CRONMAP] cron-query skip: no sessionId in context`);
123
+ return;
124
+ }
125
+ let jobId;
126
+ if (result && typeof result === "object") {
127
+ const id = result.id;
128
+ if (typeof id === "string" && id.trim())
129
+ jobId = id.trim();
130
+ }
131
+ if (!jobId) {
132
+ const preview = typeof result === "string" ? result.slice(0, 200) : JSON.stringify(result)?.slice(0, 200);
133
+ logger.log(`[CRONMAP] cron-query skip: no jobId in result. preview=${preview ?? "(empty)"}`);
134
+ return;
135
+ }
136
+ const pushId = configManager.getPushId(sessionId);
137
+ if (!pushId) {
138
+ logger.log(`[CRONMAP] cron-query skip: configManager has no pushId for sessionId=${sessionId}`);
139
+ return;
140
+ }
141
+ logger.log(`[CRONMAP] cron-query writing map: jobId=${jobId}, pushId=${pushId.substring(0, 16)}...`);
142
+ await setJobPushId(jobId, { pushId, sessionId, source: "cron-query" });
143
+ logger.log(`[CRONMAP] cron-query map written OK`);
144
+ }
145
+ /**
146
+ * Read local cron folder directly (bypassing openclaw RPC) and return
147
+ * run records from the last 7 days, grouped by date and sorted by time.
148
+ *
149
+ * Data sources:
150
+ * - state/cron/jobs.json → job id → name mapping
151
+ * - state/cron/runs/*.jsonl → run records (one JSON per line)
152
+ *
153
+ * Return format:
154
+ * [ { "YYYY-MM-DD": [ { run record with .name }, ... ] }, ... ]
155
+ */
156
+ async function queryTimeListLocal() {
157
+ const cronDir = join(os.homedir(), ".openclaw", "cron");
158
+ const jobsPath = join(cronDir, "jobs.json");
159
+ const runsDir = join(cronDir, "runs");
160
+ // 1. Build jobId → name map from jobs.json
161
+ const jobNameMap = {};
162
+ try {
163
+ const jobsRaw = readFileSync(jobsPath, "utf-8");
164
+ const jobsData = JSON.parse(jobsRaw);
165
+ for (const job of jobsData.jobs || []) {
166
+ jobNameMap[job.id] = job.name || job.id;
167
+ }
168
+ }
169
+ catch (err) {
170
+ logger.error(`[CRON-QUERY] Failed to read jobs.json: ${err.message}`);
171
+ }
172
+ // 2. Read all run files, collect runs within last 7 days
173
+ const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
174
+ const allRuns = [];
175
+ let files = [];
176
+ try {
177
+ files = readdirSync(runsDir);
178
+ }
179
+ catch {
180
+ files = [];
181
+ }
182
+ for (const file of files) {
183
+ if (!file.endsWith(".jsonl"))
184
+ continue;
185
+ try {
186
+ const content = readFileSync(join(runsDir, file), "utf-8");
187
+ const lines = content.trim().split("\n");
188
+ for (const line of lines) {
189
+ if (!line.trim())
190
+ continue;
191
+ try {
192
+ const run = JSON.parse(line);
193
+ if (run.ts && run.ts >= sevenDaysAgo) {
194
+ run.name = jobNameMap[run.jobId] || run.jobId || "";
195
+ allRuns.push(run);
196
+ }
197
+ }
198
+ catch {
199
+ // skip malformed line
200
+ }
201
+ }
202
+ }
203
+ catch (err) {
204
+ logger.error(`[CRON-QUERY] Failed to read run file ${file}: ${err.message}`);
205
+ }
206
+ }
207
+ // 3. Sort by ts ascending
208
+ allRuns.sort((a, b) => a.ts - b.ts);
209
+ // 4. Group by date (YYYY-MM-DD in local time)
210
+ const grouped = new Map();
211
+ for (const run of allRuns) {
212
+ const d = new Date(run.ts);
213
+ const label = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
214
+ if (!grouped.has(label)) {
215
+ grouped.set(label, []);
216
+ }
217
+ grouped.get(label).push(run);
218
+ }
219
+ // 5. Convert to ordered array of single-key objects
220
+ const result = [];
221
+ for (const [date, runs] of grouped) {
222
+ result.push({ [date]: runs });
100
223
  }
224
+ return result;
101
225
  }
@@ -1,2 +1,2 @@
1
1
  import { ApiResponse } from './constants.js';
2
- export declare function callApi(questionText: string, api: any, sessionId: string): Promise<ApiResponse>;
2
+ export declare function callApi(questionText: string, api: any, sessionId: string, action: string): Promise<ApiResponse>;
@@ -4,7 +4,7 @@
4
4
  import https from 'https';
5
5
  import { URL } from 'url';
6
6
  import { getConfig } from './config.js';
7
- import { DEFAULT_HTTP_PORT, HTTP_STATUS_BAD_REQUEST, API_URL_SUFFIX } from './constants.js';
7
+ import { DEFAULT_HTTPS_PORT, HTTP_STATUS_BAD_REQUEST, API_URL_SUFFIX } from './constants.js';
8
8
  function buildHeadersForCelia(config, sessionId) {
9
9
  if (!config.uid || !config.apiKey || !config.skillId || !config.requestFrom) {
10
10
  throw new Error('[SENTINEL HOOK] Missing required configuration: uid, apiKey, skillId, or requestFrom is not defined');
@@ -22,7 +22,7 @@ function buildRequestOptions(url, headers, timeout) {
22
22
  const urlObj = new URL(url);
23
23
  return {
24
24
  hostname: urlObj.hostname,
25
- port: urlObj.port || DEFAULT_HTTP_PORT,
25
+ port: urlObj.port || DEFAULT_HTTPS_PORT,
26
26
  path: urlObj.pathname,
27
27
  method: "POST",
28
28
  headers: headers,
@@ -78,13 +78,13 @@ function handleResponse(res, resolve, reject) {
78
78
  }
79
79
  });
80
80
  }
81
- export async function callApi(questionText, api, sessionId) {
81
+ export async function callApi(questionText, api, sessionId, action) {
82
82
  const config = getConfig(api);
83
83
  const headersForCelia = buildHeadersForCelia(config, sessionId);
84
84
  const payload = {
85
85
  questionText: questionText,
86
86
  textSource: config.textSource,
87
- action: config.action,
87
+ action: action,
88
88
  extra: `${JSON.stringify({ userId: config.uid })}`
89
89
  };
90
90
  const httpBody = JSON.stringify(payload);
@@ -2,9 +2,9 @@
2
2
  * 版权所有 (c) 华为技术有限公司 2026-2026
3
3
  */
4
4
  import fs from 'fs';
5
- import { ENV_FILE_PATH, REQUIRED_ENV_VARS } from './constants.js';
5
+ import path from 'path';
6
+ import { CONFIG_FILE_NAME, ENV_FILE_PATH, REQUIRED_ENV_VARS } from './constants.js';
6
7
  import { logger } from '../utils/logger.js';
7
- import defaultConfig from './configs.json' with { type: 'json' };
8
8
  let cachedConfig = null;
9
9
  function readEnvFile() {
10
10
  if (!fs.existsSync(ENV_FILE_PATH)) {
@@ -41,25 +41,45 @@ export function getConfig(api) {
41
41
  if (cachedConfig) {
42
42
  return cachedConfig;
43
43
  }
44
- // Use imported JSON (bundled at compile time, no runtime file read needed)
45
- const config = { ...defaultConfig };
44
+ const configPath = path.join(__dirname, CONFIG_FILE_NAME);
45
+ if (!fs.existsSync(configPath)) {
46
+ throw new Error(`Config file not found: ${CONFIG_FILE_NAME}`);
47
+ }
48
+ let configData;
49
+ try {
50
+ configData = fs.readFileSync(configPath, 'utf-8');
51
+ }
52
+ catch (error) {
53
+ throw new Error(`Failed to read config file: ${CONFIG_FILE_NAME}.`);
54
+ }
55
+ let parsedConfig;
56
+ try {
57
+ parsedConfig = JSON.parse(configData);
58
+ }
59
+ catch (error) {
60
+ throw new Error(`Failed to parse config file: ${CONFIG_FILE_NAME}.`);
61
+ }
62
+ if (!parsedConfig || typeof parsedConfig !== 'object') {
63
+ throw new Error(`Invalid config structure: ${CONFIG_FILE_NAME}. Expected an object.`);
64
+ }
65
+ const config = parsedConfig;
46
66
  if (!config.api || typeof config.api !== 'object') {
47
- throw new Error(`Invalid config: missing or invalid 'api' section`);
67
+ throw new Error(`Invalid config: missing or invalid 'api' section in ${CONFIG_FILE_NAME}`);
48
68
  }
49
69
  if (!config.api.timeout || typeof config.api.timeout !== 'number') {
50
- throw new Error(`Invalid config: missing or invalid 'api.timeout'`);
70
+ throw new Error(`Invalid config: missing or invalid 'api.timeout' in ${CONFIG_FILE_NAME}`);
51
71
  }
52
72
  if (!config.skillId || typeof config.skillId !== 'string') {
53
- throw new Error(`Invalid config: missing or invalid 'skillId'`);
73
+ throw new Error(`Invalid config: missing or invalid 'skillId' in ${CONFIG_FILE_NAME}`);
54
74
  }
55
75
  if (!config.requestFrom || typeof config.requestFrom !== 'string') {
56
- throw new Error(`Invalid config: missing or invalid 'requestFrom'`);
76
+ throw new Error(`Invalid config: missing or invalid 'requestFrom' in ${CONFIG_FILE_NAME}`);
57
77
  }
58
78
  if (!config.textSource || typeof config.textSource !== 'string') {
59
- throw new Error(`Invalid config: missing or invalid 'textSource'`);
79
+ throw new Error(`Invalid config: missing or invalid 'textSource' in ${CONFIG_FILE_NAME}`);
60
80
  }
61
81
  if (!config.action || typeof config.action !== 'string') {
62
- throw new Error(`Invalid config: missing or invalid 'action'`);
82
+ throw new Error(`Invalid config: missing or invalid 'action' in ${CONFIG_FILE_NAME}`);
63
83
  }
64
84
  let env;
65
85
  try {
@@ -43,6 +43,8 @@ export declare const TOOL_INPUT_DEFAULT: {
43
43
  readonly source: "";
44
44
  readonly content: "";
45
45
  };
46
+ export declare const TOOL_INPUT_ACTION = "TOOL_INPUT_SCAN";
47
+ export declare const TOOL_OUTPUT_ACTION = "TOOL_OUTPUT_SCAN";
46
48
  export declare const MAX_TIMES = 3;
47
49
  export declare const CONNECT_TIMEOUT = 15000;
48
50
  export declare const READ_TIMEOUT = 300000;
@@ -47,6 +47,9 @@ export const TOOL_INPUT_DEFAULT = {
47
47
  source: '',
48
48
  content: ''
49
49
  };
50
+ // 安全扫描 action 常量
51
+ export const TOOL_INPUT_ACTION = 'TOOL_INPUT_SCAN';
52
+ export const TOOL_OUTPUT_ACTION = 'TOOL_OUTPUT_SCAN';
50
53
  // OBS上传相关常量
51
54
  export const MAX_TIMES = 3;
52
55
  export const CONNECT_TIMEOUT = 15000;
@@ -4,7 +4,7 @@
4
4
  import crypto from 'crypto';
5
5
  import { callApi } from './call_api.js';
6
6
  import { processText, extractResultText, validateAndTruncateText, parseSecurityResult, handleExecToolInput, handleMessageToolInput, handleOtherToolInput } from './utils.js';
7
- import { ALLOWED_TOOLS, MAX_TEXT_LENGTH, MAX_TOTAL_LENGTH, MIN_TEXT_LENGTH, STEER_ABORT_MESSAGE } from './constants.js';
7
+ import { ALLOWED_TOOLS, MAX_TEXT_LENGTH, MAX_TOTAL_LENGTH, MIN_TEXT_LENGTH, STEER_ABORT_MESSAGE, TOOL_OUTPUT_ACTION } from './constants.js';
8
8
  import { logger } from '../utils/logger.js';
9
9
  import { getSessionContext } from '../tools/session-manager.js';
10
10
  import { tryInjectSteer } from './steer-context.js';
@@ -15,16 +15,21 @@ export default function register(api) {
15
15
  // 生成sessionID
16
16
  const sessionId = (event.runId?.replace(/-/g, '') || crypto.randomBytes(16).toString('hex'));
17
17
  logger.log(`[SENTINEL HOOK] Generated Session ID: ${sessionId}`);
18
- // 处理 TOOL_INPUT 数据采集、发送数据
18
+ // 处理 TOOL_INPUT 数据采集、发送数据,根据扫描结果决定是否阻塞
19
19
  try {
20
+ let scanResult = null;
20
21
  if (event.toolName === 'exec') {
21
- await handleExecToolInput(event, api, sessionId);
22
+ scanResult = await handleExecToolInput(event, api, sessionId);
22
23
  }
23
24
  else if (event.toolName === 'message') {
24
- await handleMessageToolInput(event, api, sessionId);
25
+ scanResult = await handleMessageToolInput(event, api, sessionId);
25
26
  }
26
27
  else {
27
- await handleOtherToolInput(event, api, sessionId);
28
+ scanResult = await handleOtherToolInput(event, api, sessionId);
29
+ }
30
+ if (scanResult?.status === 'REJECT') {
31
+ logger.warn(`[SENTINEL HOOK] TOOL_INPUT REJECT, blocking tool call: ${event.toolName}`);
32
+ return { block: true, blockReason: `安全扫描检测到风险,已阻止工具调用: ${event.toolName}` };
28
33
  }
29
34
  }
30
35
  catch (error) {
@@ -68,7 +73,7 @@ export default function register(api) {
68
73
  const postText = JSON.stringify(questionText);
69
74
  logger.log(`[SENTINEL HOOK] Content extracted successfully. Length: ${postText.length}`);
70
75
  try {
71
- const response = await callApi(postText, api, sessionId);
76
+ const response = await callApi(postText, api, sessionId, TOOL_OUTPUT_ACTION);
72
77
  const result = parseSecurityResult(response);
73
78
  logger.log(`[SENTINEL HOOK] TOOL_OUTPUT response: status=${result.status}.`);
74
79
  if (result.status === 'REJECT') {
@@ -6,7 +6,7 @@ import path from 'path';
6
6
  import https from 'https';
7
7
  import { URL } from 'url';
8
8
  import { getConfig } from './config.js';
9
- import { DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT, MAX_TIMES, CONNECT_TIMEOUT, READ_TIMEOUT, EXPIRE_TIME, OSMS_PREPARE_URL, OSMS_COMPLETE_URL, TEMPORARY_MATERIAL_PACKAGE, FILE_OWNER_UID, FILE_OWNER_TEAM_ID } from './constants.js';
9
+ import { DEFAULT_HTTPS_PORT, MAX_TIMES, CONNECT_TIMEOUT, READ_TIMEOUT, EXPIRE_TIME, OSMS_PREPARE_URL, OSMS_COMPLETE_URL, TEMPORARY_MATERIAL_PACKAGE, FILE_OWNER_UID, FILE_OWNER_TEAM_ID } from './constants.js';
10
10
  function buildOsmsHeaders(config, traceId) {
11
11
  return {
12
12
  'content-type': 'application/json',
@@ -22,7 +22,7 @@ function httpRequest(url, method, headers, body, timeout) {
22
22
  const urlObj = new URL(url);
23
23
  const options = {
24
24
  hostname: urlObj.hostname,
25
- port: urlObj.port || DEFAULT_HTTP_PORT,
25
+ port: urlObj.port || DEFAULT_HTTPS_PORT,
26
26
  path: urlObj.pathname + urlObj.search,
27
27
  method: method,
28
28
  headers: headers,