@ynhcj/xiaoyi-channel 0.0.190-beta → 0.0.192-beta

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/index.js CHANGED
@@ -3,7 +3,10 @@ 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";
9
+ import { getAllPushIds } from "./src/utils/pushid-manager.js";
7
10
  import { registerSelfEvolutionToolResultNudge } from "./src/self-evolution-tool-result-nudge.js";
8
11
  import { createBeforePromptBuildHandler } from "./src/skill-retriever/hooks.js";
9
12
  import { normalizeToolRetrieverConfig } from "./src/skill-retriever/config.js";
@@ -25,8 +28,145 @@ function registerCronDetectionHook(api) {
25
28
  if (event.toolCallId) {
26
29
  clearCronToolCall(event.toolCallId);
27
30
  }
31
+ // 捕获对话创建的 cron job:agent 调 cron(add) 后,从 result 拿 jobId,
32
+ // 配合当前会话的 pushId,写入 jobId↔pushId 映射,供 fire 时反查设备。
33
+ await captureCronAddMapping(event, ctx).catch((err) => {
34
+ // 捕获失败不影响工具结果
35
+ console.error("[xy] captureCronAddMapping failed:", err);
36
+ });
28
37
  });
29
38
  }
39
+ /** 从 cron add 工具结果中提取 jobId 并写入 pushId 映射。 */
40
+ async function captureCronAddMapping(event, ctx) {
41
+ // 两条创建路径都要捕获:
42
+ // 1) cron agent 工具:toolName==="cron", params.action==="add"
43
+ // 2) exec 跑 CLI:toolName==="exec", params.command 含 "cron add"
44
+ // (agent 实际用的是这条:openclaw cron add --name ... --cron ... --message ...)
45
+ const isCronAddTool = event.toolName === "cron" &&
46
+ (event.params?.action === "add" || event.params?.action === "create");
47
+ const isExecCronAdd = event.toolName === "exec" && isExecCronAddCommand(event.params?.command);
48
+ if (!isCronAddTool && !isExecCronAdd)
49
+ return;
50
+ console.log(`[CRONMAP] after_tool_call path=${event.toolName}, resultType=${typeof event.result}`);
51
+ const jobId = readJobIdFromResult(event.result);
52
+ if (!jobId) {
53
+ console.log(`[CRONMAP] skip: could not extract jobId. preview=${preview(event.result)}`);
54
+ return;
55
+ }
56
+ console.log(`[CRONMAP] extracted jobId=${jobId}`);
57
+ const sessionCtx = ctx.sessionKey ? getSessionContext(ctx.sessionKey) : null;
58
+ const sessionId = sessionCtx?.sessionId;
59
+ if (!sessionId) {
60
+ console.log(`[CRONMAP] skip: no sessionId (sessionKey=${ctx.sessionKey}, ctxFound=${!!sessionCtx})`);
61
+ return;
62
+ }
63
+ const pushId = await resolvePushId(sessionId);
64
+ if (!pushId) {
65
+ console.log(`[CRONMAP] skip: no pushId available for sessionId=${sessionId} (no session match, no global, no file)`);
66
+ return;
67
+ }
68
+ console.log(`[CRONMAP] writing map: jobId=${jobId}, sessionId=${sessionId}, pushId=${pushId.substring(0, 16)}...`);
69
+ await setJobPushId(jobId, {
70
+ pushId,
71
+ sessionId,
72
+ deviceType: sessionCtx?.deviceType,
73
+ source: event.toolName === "exec" ? "exec-cli" : "conversation",
74
+ });
75
+ console.log(`[CRONMAP] map written OK`);
76
+ }
77
+ /** 回退链取 pushId:当前会话 → 全局兜底 → 本地文件首个(保底)。 */
78
+ async function resolvePushId(sessionId) {
79
+ // 1. 同会话
80
+ const session = configManager.getPushId(sessionId);
81
+ if (session)
82
+ return session;
83
+ // 2. 全局(任何会话注册过的)
84
+ const global = configManager.getPushId();
85
+ if (global)
86
+ return global;
87
+ // 3. 文件兜底
88
+ try {
89
+ const all = await getAllPushIds();
90
+ if (all.length > 0)
91
+ return all[0];
92
+ }
93
+ catch {
94
+ // ignore
95
+ }
96
+ return null;
97
+ }
98
+ /** 判断 exec 命令是否为 cron add(匹配 "openclaw cron add" 或裸 "cron add",排除 list/remove 等)。 */
99
+ function isExecCronAddCommand(command) {
100
+ if (typeof command !== "string")
101
+ return false;
102
+ return /\bcron\s+add\b/.test(command);
103
+ }
104
+ /** 取结果的短预览,用于诊断。 */
105
+ function preview(value) {
106
+ if (value == null)
107
+ return String(value);
108
+ const s = typeof value === "string" ? value : JSON.stringify(value);
109
+ return s.length > 200 ? s.slice(0, 200) + "…" : s;
110
+ }
111
+ /** 防御性地从 cron add 结果中取 job id。
112
+ * 覆盖:裸 job 对象、JSON 字符串、exec 输出文本、
113
+ * {content:[{text}]} / {stdout} / data/result/job 嵌套。 */
114
+ function readJobIdFromResult(result) {
115
+ if (!result)
116
+ return undefined;
117
+ // {content: [{type:"text", text: "..."}]} — exec 工具的输出信封
118
+ if (result && typeof result === "object") {
119
+ const contentArr = result.content;
120
+ if (Array.isArray(contentArr)) {
121
+ for (const item of contentArr) {
122
+ if (item && typeof item === "object") {
123
+ const text = item.text;
124
+ if (typeof text === "string" && text.trim()) {
125
+ const fromContent = readJobIdFromResult(text);
126
+ if (fromContent)
127
+ return fromContent;
128
+ }
129
+ }
130
+ }
131
+ }
132
+ }
133
+ // {stdout} — 备选 exec 输出信封
134
+ if (result && typeof result === "object") {
135
+ const stdout = result.stdout;
136
+ if (typeof stdout === "string" && stdout.trim()) {
137
+ const fromStdout = readJobIdFromResult(stdout);
138
+ if (fromStdout)
139
+ return fromStdout;
140
+ }
141
+ }
142
+ let obj = result;
143
+ if (typeof result === "string") {
144
+ try {
145
+ obj = JSON.parse(result);
146
+ }
147
+ catch {
148
+ // 纯文本:可能含 stderr 前缀行 + JSON。用正则抓 "id":"..."。
149
+ const m = result.match(/"id"\s*:\s*"([^"]+)"/);
150
+ if (m)
151
+ return m[1];
152
+ return undefined;
153
+ }
154
+ }
155
+ if (obj && typeof obj === "object") {
156
+ const id = obj.id;
157
+ if (typeof id === "string" && id.trim())
158
+ return id.trim();
159
+ for (const k of ["data", "result", "job"]) {
160
+ const inner = obj[k];
161
+ if (inner && typeof inner === "object") {
162
+ const innerId = inner.id;
163
+ if (typeof innerId === "string" && innerId.trim())
164
+ return innerId.trim();
165
+ }
166
+ }
167
+ }
168
+ return undefined;
169
+ }
30
170
  function registerFullHooks(api) {
31
171
  // SKILL RETRIEVER HOOK: before_prompt_build hook
32
172
  const pluginConfig = api.pluginConfig || {};
@@ -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);
@@ -7,6 +7,8 @@ import { callGatewayTool } from "openclaw/plugin-sdk/agent-harness-runtime";
7
7
  import * as os from "os";
8
8
  import { sendCommand } from "./formatter.js";
9
9
  import { resolveXYConfig } from "./config.js";
10
+ import { configManager } from "./utils/config-manager.js";
11
+ import { setJobPushId } from "./utils/cron-push-map.js";
10
12
  import { logger } from "./utils/logger.js";
11
13
  import { readFileSync, readdirSync } from "fs";
12
14
  import { join } from "path";
@@ -39,6 +41,11 @@ export async function handleCronQueryEvent(context, cfg) {
39
41
  break;
40
42
  case "add":
41
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
+ });
42
49
  break;
43
50
  case "update":
44
51
  result = await callGatewayTool("cron.update", { timeoutMs: GATEWAY_TIMEOUT_MS }, {
@@ -106,6 +113,35 @@ export async function handleCronQueryEvent(context, cfg) {
106
113
  log.warn(`[CRON-QUERY] Missing cfg/sessionId/taskId/messageId, skipping sendCommand`);
107
114
  }
108
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
+ }
109
145
  /**
110
146
  * Read local cron folder directly (bypassing openclaw RPC) and return
111
147
  * run records from the last 7 days, grouped by date and sorted by time.
@@ -5,7 +5,10 @@ import { logger } from "./utils/logger.js";
5
5
  import { getCurrentTaskId, getCurrentMessageId } from "./task-manager.js";
6
6
  import { redactSensitiveText, containsSensitiveInfo } from "./sensitive-redactor.js";
7
7
  import { rewriteOutboundApprovalText } from "./approval-bridge.js";
8
- import { isCronToolCall } from "./tools/session-manager.js";
8
+ import { isCronToolCall, getCurrentCronJobId } from "./tools/session-manager.js";
9
+ import { configManager } from "./utils/config-manager.js";
10
+ import { getPushIdByJobId } from "./utils/cron-push-map.js";
11
+ import { getAllPushIds } from "./utils/pushid-manager.js";
9
12
  // ─────────────────────────────────────────────────────────────
10
13
  // 敏感信息脱敏辅助函数
11
14
  // ─────────────────────────────────────────────────────────────
@@ -201,6 +204,45 @@ export async function sendStatusUpdate(params) {
201
204
  log.log(`[A2A_STATUS] Sending status-update, text="${redactedText}"`);
202
205
  await wsManager.sendMessage(sessionId, outboundMessage);
203
206
  }
207
+ /**
208
+ * 解析 cron fire 时应使用的 pushId(多设备路由)。
209
+ *
210
+ * 查询链(逐级回退):
211
+ * 1. 合成 sessionId → jobId → cron-push-map.json → 创建时记录的设备 pushId
212
+ * 2. configManager 同进程的 sessionId→pushId(进程未重启时兜底)
213
+ * 3. getAllPushIds()[0](单设备兼容旧行为)
214
+ * 返回 undefined 表示走兜底(由 sendCommandViaPush 内部处理)。
215
+ */
216
+ async function resolveCronPushId(sessionId, config) {
217
+ // 1. jobId → 持久化映射
218
+ const jobId = getCurrentCronJobId(sessionId);
219
+ if (jobId) {
220
+ const hit = await getPushIdByJobId(jobId);
221
+ if (hit?.pushId) {
222
+ logger.log(`[CRON-PUSH] Resolved pushId via map, jobId=${jobId}`);
223
+ return hit.pushId;
224
+ }
225
+ }
226
+ // 2. 同进程 configManager 兜底
227
+ const sessionPushId = configManager.getPushId(sessionId);
228
+ if (sessionPushId) {
229
+ logger.log(`[CRON-PUSH] Resolved pushId via configManager (fallback)`);
230
+ return sessionPushId;
231
+ }
232
+ // 3. config.pushId / getAllPushIds()[0] 交给 sendCommandViaPush 内部处理
233
+ void config;
234
+ try {
235
+ const all = await getAllPushIds();
236
+ if (all.length > 0) {
237
+ logger.log(`[CRON-PUSH] Resolved pushId via getAllPushIds[0] (legacy fallback)`);
238
+ return all[0];
239
+ }
240
+ }
241
+ catch (error) {
242
+ logger.error(`[CRON-PUSH] getAllPushIds failed:`, error);
243
+ }
244
+ return undefined;
245
+ }
204
246
  /**
205
247
  * Send a command as an artifact update (final=false).
206
248
  *
@@ -222,7 +264,10 @@ export async function sendCommand(params) {
222
264
  // (b) toolCallId marked by before_tool_call hook from openclaw's sessionKey.
223
265
  if (sessionId.startsWith("cron-") || isCronToolCall(toolCallId)) {
224
266
  const { sendCommandViaPush } = await import("./cron-command.js");
225
- return sendCommandViaPush({ config, command: commands[0] });
267
+ // 解析正确设备的 pushId:合成 sessionId jobId → cron-push-map。
268
+ // provider.ts 在 isCron 分支已把 jobId 绑定到该 sessionId。
269
+ const pushId = await resolveCronPushId(sessionId, config);
270
+ return sendCommandViaPush({ config, command: commands[0], pushId });
226
271
  }
227
272
  // ── Normal mode: WebSocket ─────────────────────────────────────
228
273
  // Dynamic lookup: use latest taskId/messageId from task-manager (handles steer/interrupt),
@@ -0,0 +1 @@
1
+ export declare function handleMemoryQueryEvent(context: any, cfg: any): Promise<void>;
@@ -0,0 +1,164 @@
1
+ // Memory query event handler.
2
+ // Listens for memory-query-event from the WebSocket manager,
3
+ // handles memory state read/write and MEMORY.md/USER.md file queries.
4
+ import * as os from "os";
5
+ import * as path from "path";
6
+ import { readFileSync, writeFileSync } from "fs";
7
+ import { sendCommand } from "./formatter.js";
8
+ import { resolveXYConfig } from "./config.js";
9
+ import { logger } from "./utils/logger.js";
10
+ const XIAOYIRUNTIME_PATH_PRIMARY = "/home/sandbox/.openclaw/.xiaoyiruntime";
11
+ const XIAOYIRUNTIME_PATH_FALLBACK = `${os.homedir()}/.openclaw/.xiaoyiruntime`;
12
+ const MEMORY_STATE_KEY = "MEMORYSTATE";
13
+ /** Resolve writable .xiaoyiruntime path: try sandbox path first, fallback to user home. */
14
+ function resolveXiaoyiRuntimePath() {
15
+ try {
16
+ // If primary path's parent dir exists and is writable, use it
17
+ const fs = require("fs");
18
+ fs.accessSync(XIAOYIRUNTIME_PATH_PRIMARY, fs.constants.W_OK);
19
+ return XIAOYIRUNTIME_PATH_PRIMARY;
20
+ }
21
+ catch {
22
+ // If primary path doesn't exist, try creating parent
23
+ try {
24
+ const fs = require("fs");
25
+ const dir = require("path").dirname(XIAOYIRUNTIME_PATH_PRIMARY);
26
+ fs.mkdirSync(dir, { recursive: true });
27
+ return XIAOYIRUNTIME_PATH_PRIMARY;
28
+ }
29
+ catch {
30
+ // Fallback to user home
31
+ }
32
+ }
33
+ return XIAOYIRUNTIME_PATH_FALLBACK;
34
+ }
35
+ export async function handleMemoryQueryEvent(context, cfg) {
36
+ const { action, params, sessionId, taskId, messageId } = context;
37
+ const log = logger.withContext(sessionId ?? "", taskId ?? "");
38
+ log.log(`[MEMORY-QUERY] Received event: action=${action}`);
39
+ let result;
40
+ try {
41
+ switch (action) {
42
+ case "memoryStateSet":
43
+ result = handleMemoryStateSet(params);
44
+ break;
45
+ case "userMdQuery":
46
+ result = handleUserMdQuery();
47
+ break;
48
+ case "memoryMdQuery":
49
+ result = handleMemoryMdQuery();
50
+ break;
51
+ default:
52
+ log.error(`[MEMORY-QUERY] Unknown action: ${action}`);
53
+ result = { error: `Unknown action: ${action}` };
54
+ }
55
+ }
56
+ catch (err) {
57
+ const errorMsg = err instanceof Error ? err.message : String(err);
58
+ log.error(`[MEMORY-QUERY] Handler failed for action=${action}:`, err);
59
+ result = { error: errorMsg };
60
+ }
61
+ log.log(`[MEMORY-QUERY] Result for action=${action}: ${JSON.stringify(result)}`);
62
+ // Send result back via sendCommand
63
+ if (cfg && sessionId && taskId && messageId) {
64
+ try {
65
+ const config = resolveXYConfig(cfg);
66
+ const command = {
67
+ header: {
68
+ namespace: "AgentEvent",
69
+ name: "memoryQuery",
70
+ },
71
+ payload: {
72
+ action,
73
+ ans: result,
74
+ },
75
+ };
76
+ await sendCommand({
77
+ config,
78
+ sessionId,
79
+ taskId,
80
+ messageId,
81
+ command,
82
+ final: true,
83
+ });
84
+ log.log(`[MEMORY-QUERY] Sent response via sendCommand, action=${action}`);
85
+ }
86
+ catch (sendErr) {
87
+ log.error(`[MEMORY-QUERY] Failed to send response via sendCommand:`, sendErr);
88
+ }
89
+ }
90
+ else {
91
+ log.warn(`[MEMORY-QUERY] Missing cfg/sessionId/taskId/messageId, skipping sendCommand`);
92
+ }
93
+ }
94
+ /**
95
+ * Write MEMORYSTATE=true/false to .xiaoyiruntime.
96
+ */
97
+ function handleMemoryStateSet(params) {
98
+ const memoryState = params?.memoryState;
99
+ if (typeof memoryState !== "boolean") {
100
+ logger.error(`[MEMORY-QUERY] memoryStateSet: invalid memoryState type: ${typeof memoryState}`);
101
+ return { code: 0 };
102
+ }
103
+ const value = String(memoryState);
104
+ const filePath = resolveXiaoyiRuntimePath();
105
+ let content;
106
+ try {
107
+ content = readFileSync(filePath, "utf-8");
108
+ }
109
+ catch {
110
+ logger.log(`[MEMORY-QUERY] ${filePath} not found, creating new file`);
111
+ writeFileSync(filePath, `${MEMORY_STATE_KEY}=${value}\n`, "utf-8");
112
+ logger.log(`[MEMORY-QUERY] wrote ${MEMORY_STATE_KEY}=${value}`);
113
+ return { code: 0 };
114
+ }
115
+ const lines = content.split("\n");
116
+ const key = MEMORY_STATE_KEY;
117
+ let found = false;
118
+ const updated = lines.map((line) => {
119
+ if (line.startsWith(`${key}=`)) {
120
+ found = true;
121
+ return `${key}=${value}`;
122
+ }
123
+ return line;
124
+ });
125
+ if (!found) {
126
+ const trimmed = content.trimEnd();
127
+ writeFileSync(filePath, `${trimmed}\n${key}=${value}\n`, "utf-8");
128
+ }
129
+ else {
130
+ writeFileSync(filePath, updated.join("\n"), "utf-8");
131
+ }
132
+ logger.log(`[MEMORY-QUERY] updated ${MEMORY_STATE_KEY}=${value} in ${filePath}`);
133
+ return { code: 0 };
134
+ }
135
+ /**
136
+ * Read ~/.openclaw/workspace/USER.md and return content in fileDetail.
137
+ */
138
+ function handleUserMdQuery() {
139
+ const filePath = path.join(os.homedir(), ".openclaw", "workspace", "USER.md");
140
+ return readMdFile(filePath);
141
+ }
142
+ /**
143
+ * Read ~/.openclaw/workspace/MEMORY.md and return content in fileDetail.
144
+ */
145
+ function handleMemoryMdQuery() {
146
+ const filePath = path.join(os.homedir(), ".openclaw", "workspace", "MEMORY.md");
147
+ return readMdFile(filePath);
148
+ }
149
+ function readMdFile(filePath) {
150
+ try {
151
+ const content = readFileSync(filePath, "utf-8");
152
+ logger.log(`[MEMORY-QUERY] Read file: ${filePath}, size: ${content.length}`);
153
+ return { fileDetail: content };
154
+ }
155
+ catch (err) {
156
+ if (err.code === "ENOENT") {
157
+ logger.log(`[MEMORY-QUERY] File not found: ${filePath}`);
158
+ }
159
+ else {
160
+ logger.error(`[MEMORY-QUERY] Failed to read ${filePath}:`, err);
161
+ }
162
+ return { fileDetail: "" };
163
+ }
164
+ }
@@ -8,6 +8,7 @@ import { handleTriggerEvent } from "./trigger-handler.js";
8
8
  import { handleSelfEvolutionEvent, handleSelfEvolutionStateGetEvent } from "./self-evolution-handler.js";
9
9
  import { handleLoginTokenEvent } from "./login-token-handler.js";
10
10
  import { handleCronQueryEvent } from "./cron-query-handler.js";
11
+ import { handleMemoryQueryEvent } from "./memory-query-handler.js";
11
12
  import { cleanupStaleTempFiles } from "./reply-dispatcher.js";
12
13
  import { cleanupStaleSessions, getActiveSessionCount, cleanupAllSessions } from "./tools/session-manager.js";
13
14
  import { logger } from "./utils/logger.js";
@@ -205,6 +206,12 @@ export async function monitorXYProvider(opts = {}) {
205
206
  logger.error(`[MONITOR] Failed to handle cron-query-event:`, err);
206
207
  });
207
208
  };
209
+ const memoryQueryEventHandler = (context) => {
210
+ logger.log(`[MONITOR] Received memory-query-event, dispatching to handler...`);
211
+ handleMemoryQueryEvent(context, cfg).catch((err) => {
212
+ logger.error(`[MONITOR] Failed to handle memory-query-event:`, err);
213
+ });
214
+ };
208
215
  const cleanup = () => {
209
216
  logger.log("XY gateway: cleaning up...");
210
217
  // 🔍 Diagnose before cleanup
@@ -226,6 +233,7 @@ export async function monitorXYProvider(opts = {}) {
226
233
  wsManager.off("self-evolution-state-get-event", selfEvolutionStateGetHandler);
227
234
  wsManager.off("login-token-event", loginTokenEventHandler);
228
235
  wsManager.off("cron-query-event", cronQueryEventHandler);
236
+ wsManager.off("memory-query-event", memoryQueryEventHandler);
229
237
  // ✅ Disconnect the wsManager to prevent connection leaks
230
238
  // This is safe because each gateway lifecycle should have clean connections
231
239
  wsManager.disconnect();
@@ -290,6 +298,7 @@ export async function monitorXYProvider(opts = {}) {
290
298
  wsManager.on("self-evolution-state-get-event", selfEvolutionStateGetHandler);
291
299
  wsManager.on("login-token-event", loginTokenEventHandler);
292
300
  wsManager.on("cron-query-event", cronQueryEventHandler);
301
+ wsManager.on("memory-query-event", memoryQueryEventHandler);
293
302
  // Start periodic health check (every 6 hours)
294
303
  logger.log("Starting periodic health check (every 6 hours)...");
295
304
  healthCheckInterval = setInterval(() => {
@@ -9,7 +9,7 @@
9
9
  // models.providers.xiaoyiprovider.models = [...]
10
10
  import { createHash } from "crypto";
11
11
  import { logger } from "./utils/logger.js";
12
- import { getCurrentSessionContext } from "./tools/session-manager.js";
12
+ import { getCurrentSessionContext, setCurrentCronJobId } from "./tools/session-manager.js";
13
13
  import { selfEvolutionManager } from "./utils/self-evolution-manager.js";
14
14
  import { notifyModelStreaming } from "./bot.js";
15
15
  // ── Retry config ──────────────────────────────────────────────
@@ -506,6 +506,14 @@ export const xiaoyiProvider = {
506
506
  // 3. UID-based fallback: sha256(uid).hex[:32]_timestamp
507
507
  const isCron = isCronTriggered(context.messages);
508
508
  if (isCron) {
509
+ // fire 期 jobId 桥:把首条消息 `[cron:<jobId> ...]` 解析出的真实 jobId
510
+ // 绑定到本次 cron run 的合成 sessionId。sendCommand 凭同一 sessionId
511
+ // 反查 jobId → cron-push-map → 正确设备的 pushId(多设备路由)。
512
+ const cronJobId = extractCronUuid(context.messages);
513
+ const cronCtx = getCurrentSessionContext();
514
+ if (cronJobId && cronCtx?.sessionId) {
515
+ setCurrentCronJobId(cronCtx.sessionId, cronJobId);
516
+ }
509
517
  const fallbackPrefix = ctx.extraParams?.[FALLBACK_PREFIX_KEY];
510
518
  if (typeof fallbackPrefix === "string") {
511
519
  const fallbackValue = `${fallbackPrefix}_${Date.now()}`;
@@ -27,6 +27,12 @@ export declare function markCronToolCall(toolCallId: string): void;
27
27
  export declare function isCronToolCall(toolCallId?: string): boolean;
28
28
  /** Clean up a cron tool call marker after use. */
29
29
  export declare function clearCronToolCall(toolCallId: string): void;
30
+ /** 把 fire 期解析出的 jobId 绑定到当前 cron run 的合成 sessionId。 */
31
+ export declare function setCurrentCronJobId(cronSessionId: string, jobId: string): void;
32
+ /** 凭合成 cron sessionId 取本次 run 的 jobId(供 sendCommand 反查 pushId)。 */
33
+ export declare function getCurrentCronJobId(cronSessionId?: string): string | undefined;
34
+ /** cron run 结束后清理。 */
35
+ export declare function clearCronJobId(cronSessionId: string): void;
30
36
  export declare const asyncLocalStorage: AsyncLocalStorage<SessionContext>;
31
37
  /**
32
38
  * Register a session context for tool access.
@@ -48,6 +48,32 @@ export function isCronToolCall(toolCallId) {
48
48
  export function clearCronToolCall(toolCallId) {
49
49
  cronToolCallMap.delete(toolCallId);
50
50
  }
51
+ // ── Cron session ↔ jobId bridge ────────────────────────────────────
52
+ // fire 期 jobId 传递桥。provider.ts 在 isCron 分支从首条消息
53
+ // `[cron:<jobId> ...]` 解析出真实 jobId,写入合成 cron sessionId;
54
+ // sendCommand/formatter 凭同一合成 sessionId 反查 jobId,再去
55
+ // cron-push-map 取对应设备的 pushId。同一 cron run 内 ALS 上下文共享,
56
+ // 合成 sessionId 在 provider 与 sendCommand 之间一致。
57
+ if (!_g.__xyCronSessionJobId) {
58
+ _g.__xyCronSessionJobId = new Map();
59
+ }
60
+ const cronSessionJobIdMap = _g.__xyCronSessionJobId;
61
+ /** 把 fire 期解析出的 jobId 绑定到当前 cron run 的合成 sessionId。 */
62
+ export function setCurrentCronJobId(cronSessionId, jobId) {
63
+ if (cronSessionId && jobId) {
64
+ cronSessionJobIdMap.set(cronSessionId, jobId);
65
+ }
66
+ }
67
+ /** 凭合成 cron sessionId 取本次 run 的 jobId(供 sendCommand 反查 pushId)。 */
68
+ export function getCurrentCronJobId(cronSessionId) {
69
+ if (!cronSessionId)
70
+ return undefined;
71
+ return cronSessionJobIdMap.get(cronSessionId);
72
+ }
73
+ /** cron run 结束后清理。 */
74
+ export function clearCronJobId(cronSessionId) {
75
+ cronSessionJobIdMap.delete(cronSessionId);
76
+ }
51
77
  // AsyncLocalStorage for thread-safe session context isolation
52
78
  export const asyncLocalStorage = new AsyncLocalStorage();
53
79
  // Export AsyncLocalStorage to globalThis so logger.ts can access it
@@ -3,8 +3,9 @@
3
3
  * Specifically handles pushId which can be updated per-session.
4
4
  */
5
5
  declare class ConfigManager {
6
- private sessionPushIds;
7
- private globalPushId;
6
+ private get sessionPushIds();
7
+ private get globalPushId();
8
+ private set globalPushId(value);
8
9
  /**
9
10
  * Update push ID for a specific session.
10
11
  */
@@ -1,12 +1,32 @@
1
1
  // Dynamic configuration manager for runtime updates
2
+ //
3
+ // NOTE: xy_channel is loaded from multiple module resolution paths
4
+ // (plugin entry vs tool registration), which duplicates class instances.
5
+ // sessionPushIds and globalPushId must live on globalThis so all copies
6
+ // share the same Map — same reason activeSessions is on globalThis in
7
+ // session-manager.ts.
2
8
  import { logger } from "./logger.js";
9
+ const _g = globalThis;
10
+ if (!_g.__xyConfigSessionPushIds) {
11
+ _g.__xyConfigSessionPushIds = new Map();
12
+ }
13
+ if (!_g.__xyConfigGlobalPushId) {
14
+ _g.__xyConfigGlobalPushId = null;
15
+ }
3
16
  /**
4
17
  * Manages dynamic configuration updates that can change at runtime.
5
18
  * Specifically handles pushId which can be updated per-session.
6
19
  */
7
20
  class ConfigManager {
8
- sessionPushIds = new Map();
9
- globalPushId = null;
21
+ get sessionPushIds() {
22
+ return _g.__xyConfigSessionPushIds;
23
+ }
24
+ get globalPushId() {
25
+ return _g.__xyConfigGlobalPushId;
26
+ }
27
+ set globalPushId(value) {
28
+ _g.__xyConfigGlobalPushId = value;
29
+ }
10
30
  /**
11
31
  * Update push ID for a specific session.
12
32
  */
@@ -0,0 +1,26 @@
1
+ /** 映射来源,区分两种创建路径。 */
2
+ export type CronPushMapSource = "conversation" | "cron-query" | "exec-cli";
3
+ export interface CronPushMapEntry {
4
+ pushId: string;
5
+ /** 创建该 cron 时的 xy sessionId,便于同进程兜底。 */
6
+ sessionId?: string;
7
+ /** 冗余记录设备类型,fire 时可按设备类型做差异化处理。 */
8
+ deviceType?: string;
9
+ source?: CronPushMapSource;
10
+ createdAt: number;
11
+ }
12
+ export interface CronPushMapFile {
13
+ version: 1;
14
+ entries: Record<string, CronPushMapEntry>;
15
+ }
16
+ /** 写入/更新一条 jobId → pushId 映射。 */
17
+ export declare function setJobPushId(jobId: string, entry: Omit<CronPushMapEntry, "createdAt">): Promise<void>;
18
+ /** fire 时主查询:按 jobId 取 pushId。 */
19
+ export declare function getPushIdByJobId(jobId: string): Promise<{
20
+ pushId: string;
21
+ entry: CronPushMapEntry;
22
+ } | null>;
23
+ /** 删除一条映射(cron job 被移除时清理)。 */
24
+ export declare function removeJob(jobId: string): Promise<void>;
25
+ /** 对账:删除 openclaw 里已不存在的 job,避免映射无限增长。 */
26
+ export declare function pruneStale(existingJobIds: Set<string>): Promise<void>;
@@ -0,0 +1,131 @@
1
+ // Cron job ↔ pushId 持久化映射
2
+ //
3
+ // 背景:cron 定时任务触发时,工具调用走 push 通道(sendCommandViaPush),
4
+ // 需要正确的 pushId 才能推到"创建该任务的那台设备"。pushIdList.json 是
5
+ // 扁平去重数组,无法区分设备;本文件按真实 jobId 保存创建时的 pushId,
6
+ // fire 时通过 jobId 反查即可定位到正确设备。
7
+ //
8
+ // jobId 的两端来源(均为真实 jobId,无需规范化反解):
9
+ // - 写入:after_tool_call 拦 cron/add → event.result.id;
10
+ // cron-query-handler inline → result.id
11
+ // - 读取:provider.ts extractCronUuid(context.messages) → "[cron:<jobId> ...]"
12
+ import { promises as fs } from "fs";
13
+ import * as path from "path";
14
+ import { logger } from "./logger.js";
15
+ const CRON_PUSH_MAP_FILE = "/home/sandbox/.openclaw/cron-push-map.json";
16
+ async function ensureDirectoryExists(filePath) {
17
+ const dir = path.dirname(filePath);
18
+ try {
19
+ await fs.mkdir(dir, { recursive: true });
20
+ }
21
+ catch (error) {
22
+ logger.error(`[CronPushMap] Failed to create directory ${dir}:`, error);
23
+ }
24
+ }
25
+ async function readMap() {
26
+ try {
27
+ await ensureDirectoryExists(CRON_PUSH_MAP_FILE);
28
+ const content = await fs.readFile(CRON_PUSH_MAP_FILE, "utf-8");
29
+ const parsed = JSON.parse(content);
30
+ if (parsed &&
31
+ typeof parsed === "object" &&
32
+ parsed.version === 1 &&
33
+ parsed.entries &&
34
+ typeof parsed.entries === "object") {
35
+ return parsed;
36
+ }
37
+ logger.warn(`[CronPushMap] Unexpected file shape, returning empty map`);
38
+ return { version: 1, entries: {} };
39
+ }
40
+ catch (error) {
41
+ if (error.code === "ENOENT") {
42
+ return { version: 1, entries: {} };
43
+ }
44
+ logger.error(`[CronPushMap] Failed to read map:`, error);
45
+ return { version: 1, entries: {} };
46
+ }
47
+ }
48
+ async function writeMap(map) {
49
+ try {
50
+ await ensureDirectoryExists(CRON_PUSH_MAP_FILE);
51
+ await fs.writeFile(CRON_PUSH_MAP_FILE, JSON.stringify(map, null, 2), "utf-8");
52
+ }
53
+ catch (error) {
54
+ logger.error(`[CronPushMap] Failed to write map:`, error);
55
+ throw error;
56
+ }
57
+ }
58
+ /** 写入/更新一条 jobId → pushId 映射。 */
59
+ export async function setJobPushId(jobId, entry) {
60
+ if (!jobId || typeof jobId !== "string") {
61
+ logger.warn(`[CronPushMap] Invalid jobId: ${jobId}`);
62
+ return;
63
+ }
64
+ if (!entry.pushId || typeof entry.pushId !== "string") {
65
+ logger.warn(`[CronPushMap] Skipping setJobPushId: missing pushId for jobId=${jobId}`);
66
+ return;
67
+ }
68
+ try {
69
+ const map = await readMap();
70
+ map.entries[jobId] = { ...entry, createdAt: Date.now() };
71
+ await writeMap(map);
72
+ logger.log(`[CronPushMap] Saved jobId=${jobId}, source=${entry.source ?? "?"}, pushId=${entry.pushId.substring(0, 20)}...`);
73
+ }
74
+ catch (error) {
75
+ logger.error(`[CronPushMap] Failed to setJobPushId:`, error);
76
+ // 不抛出,避免影响主流程
77
+ }
78
+ }
79
+ /** fire 时主查询:按 jobId 取 pushId。 */
80
+ export async function getPushIdByJobId(jobId) {
81
+ if (!jobId)
82
+ return null;
83
+ try {
84
+ const map = await readMap();
85
+ const entry = map.entries[jobId];
86
+ if (entry && entry.pushId) {
87
+ return { pushId: entry.pushId, entry };
88
+ }
89
+ return null;
90
+ }
91
+ catch (error) {
92
+ logger.error(`[CronPushMap] Failed to getPushIdByJobId:`, error);
93
+ return null;
94
+ }
95
+ }
96
+ /** 删除一条映射(cron job 被移除时清理)。 */
97
+ export async function removeJob(jobId) {
98
+ if (!jobId)
99
+ return;
100
+ try {
101
+ const map = await readMap();
102
+ if (map.entries[jobId]) {
103
+ delete map.entries[jobId];
104
+ await writeMap(map);
105
+ logger.log(`[CronPushMap] Removed jobId=${jobId}`);
106
+ }
107
+ }
108
+ catch (error) {
109
+ logger.error(`[CronPushMap] Failed to removeJob:`, error);
110
+ }
111
+ }
112
+ /** 对账:删除 openclaw 里已不存在的 job,避免映射无限增长。 */
113
+ export async function pruneStale(existingJobIds) {
114
+ try {
115
+ const map = await readMap();
116
+ let removed = 0;
117
+ for (const key of Object.keys(map.entries)) {
118
+ if (!existingJobIds.has(key)) {
119
+ delete map.entries[key];
120
+ removed++;
121
+ }
122
+ }
123
+ if (removed > 0) {
124
+ await writeMap(map);
125
+ logger.log(`[CronPushMap] Pruned ${removed} stale entries`);
126
+ }
127
+ }
128
+ catch (error) {
129
+ logger.error(`[CronPushMap] Failed to pruneStale:`, error);
130
+ }
131
+ }
@@ -630,6 +630,15 @@ export class XYWebSocketManager extends EventEmitter {
630
630
  messageId: a2aRequest.id,
631
631
  });
632
632
  }
633
+ else if (item.header?.namespace === "AgentEvent" && item.header?.name === "memoryQuery") {
634
+ log.log("[XY] AgentEvent.memoryQuery detected, emitting memory-query-event");
635
+ this.emit("memory-query-event", {
636
+ ...(item.payload ?? {}),
637
+ sessionId,
638
+ taskId: a2aRequest.params?.id,
639
+ messageId: a2aRequest.id,
640
+ });
641
+ }
633
642
  else if (item.header?.namespace === "System" && item.header?.name === "ExecuteAgentAsSkillResponse") {
634
643
  log.log("[XY] ExecuteAgentAsSkillResponse detected, emitting agent-as-skill-response");
635
644
  this.emit("agent-as-skill-response", item);
@@ -719,6 +728,15 @@ export class XYWebSocketManager extends EventEmitter {
719
728
  messageId: a2aRequest.id,
720
729
  });
721
730
  }
731
+ else if (item.header?.namespace === "AgentEvent" && item.header?.name === "memoryQuery") {
732
+ log.log("[XY] AgentEvent.memoryQuery detected (wrapped format), emitting memory-query-event");
733
+ this.emit("memory-query-event", {
734
+ ...(item.payload ?? {}),
735
+ sessionId: inboundMsg.sessionId || a2aRequest.params?.sessionId,
736
+ taskId: inboundMsg.taskId || a2aRequest.params?.id,
737
+ messageId: a2aRequest.id,
738
+ });
739
+ }
722
740
  else if (item.header?.namespace === "System" && item.header?.name === "ExecuteAgentAsSkillResponse") {
723
741
  log.log("[XY] ExecuteAgentAsSkillResponse detected (wrapped format), emitting agent-as-skill-response");
724
742
  this.emit("agent-as-skill-response", item);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ynhcj/xiaoyi-channel",
3
- "version": "0.0.190-beta",
3
+ "version": "0.0.192-beta",
4
4
  "description": "OpenClaw Xiaoyi Channel plugin - Xiaoyi A2A protocol integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",