@ynhcj/xiaoyi-channel 1.1.31 → 1.1.32

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.
@@ -0,0 +1,37 @@
1
+ import { type BindingTargetKind } from "openclaw/plugin-sdk/conversation-runtime";
2
+ type XyBindingTargetKind = "subagent" | "acp";
3
+ type XyAcpBindingRecord = {
4
+ accountId: string;
5
+ conversationId: string;
6
+ parentConversationId?: string;
7
+ deliveryTo?: string;
8
+ targetKind: XyBindingTargetKind;
9
+ targetSessionKey: string;
10
+ agentId?: string;
11
+ label?: string;
12
+ boundBy?: string;
13
+ boundAt: number;
14
+ lastActivityAt: number;
15
+ };
16
+ type XyAcpBindingManager = {
17
+ accountId: string;
18
+ getByConversationId: (conversationId: string) => XyAcpBindingRecord | undefined;
19
+ listBySessionKey: (targetSessionKey: string) => XyAcpBindingRecord[];
20
+ bindConversation: (params: {
21
+ conversationId: string;
22
+ parentConversationId?: string;
23
+ targetKind: BindingTargetKind;
24
+ targetSessionKey: string;
25
+ metadata?: Record<string, unknown>;
26
+ }) => XyAcpBindingRecord | null;
27
+ touchConversation: (conversationId: string, at?: number) => XyAcpBindingRecord | null;
28
+ unbindConversation: (conversationId: string) => XyAcpBindingRecord | null;
29
+ unbindBySessionKey: (targetSessionKey: string) => XyAcpBindingRecord[];
30
+ stop: () => void;
31
+ };
32
+ export declare function createXyAcpBindingManager(params: {
33
+ accountId?: string;
34
+ cfg: any;
35
+ }): XyAcpBindingManager;
36
+ export declare function getXyAcpBindingManager(accountId?: string): XyAcpBindingManager | null;
37
+ export {};
@@ -0,0 +1,237 @@
1
+ // ACP Session Binding Adapter for xiaoyi-channel.
2
+ // Follows the feishu thread-bindings.ts pattern.
3
+ //
4
+ // Maps A2A sessionId (stable conversation identifier) to ACP/subagent
5
+ // session keys so that openclaw can bind spawned sessions to the
6
+ // current xiaoyi conversation.
7
+ //
8
+ // Key design: xiaoyi-channel only supports `placement: "current"` —
9
+ // it cannot create child threads (unlike Discord). All spawned sessions
10
+ // are bound to the current A2A conversation identified by sessionId.
11
+ // NOTE: Using `any` for cfg type to avoid version mismatch between
12
+ // local and global openclaw installs (auth.profiles.aws-sdk union).
13
+ import { resolveThreadBindingIdleTimeoutMsForChannel, resolveThreadBindingMaxAgeMsForChannel, resolveThreadBindingConversationIdFromBindingId, registerSessionBindingAdapter, unregisterSessionBindingAdapter, } from "openclaw/plugin-sdk/conversation-runtime";
14
+ import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
15
+ import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
16
+ import { logger } from "./utils/logger.js";
17
+ // ─── Global state (survives module dedup) ─────────────────────
18
+ const XY_ACP_BINDINGS_KEY = Symbol.for("openclaw.xyAcpBindingsState");
19
+ let state;
20
+ function getState() {
21
+ if (!state) {
22
+ const globalStore = globalThis;
23
+ state = globalStore[XY_ACP_BINDINGS_KEY] ?? {
24
+ managersByAccountId: new Map(),
25
+ bindingsByAccountConversation: new Map(),
26
+ };
27
+ globalStore[XY_ACP_BINDINGS_KEY] = state;
28
+ }
29
+ return state;
30
+ }
31
+ function resolveBindingKey(params) {
32
+ return `${params.accountId}:${params.conversationId}`;
33
+ }
34
+ // ─── Kind conversion ──────────────────────────────────────────
35
+ function toSessionBindingTargetKind(raw) {
36
+ return raw === "subagent" ? "subagent" : "session";
37
+ }
38
+ function toXyTargetKind(raw) {
39
+ return raw === "subagent" ? "subagent" : "acp";
40
+ }
41
+ // ─── Record conversion ────────────────────────────────────────
42
+ function toSessionBindingRecord(record, defaults) {
43
+ const idleExpiresAt = defaults.idleTimeoutMs > 0 ? record.lastActivityAt + defaults.idleTimeoutMs : undefined;
44
+ const maxAgeExpiresAt = defaults.maxAgeMs > 0 ? record.boundAt + defaults.maxAgeMs : undefined;
45
+ const expiresAt = idleExpiresAt != null && maxAgeExpiresAt != null
46
+ ? Math.min(idleExpiresAt, maxAgeExpiresAt)
47
+ : (idleExpiresAt ?? maxAgeExpiresAt);
48
+ return {
49
+ bindingId: resolveBindingKey({
50
+ accountId: record.accountId,
51
+ conversationId: record.conversationId,
52
+ }),
53
+ targetSessionKey: record.targetSessionKey,
54
+ targetKind: toSessionBindingTargetKind(record.targetKind),
55
+ conversation: {
56
+ channel: "xiaoyi-channel",
57
+ accountId: record.accountId,
58
+ conversationId: record.conversationId,
59
+ parentConversationId: record.parentConversationId,
60
+ },
61
+ status: "active",
62
+ boundAt: record.boundAt,
63
+ expiresAt,
64
+ metadata: {
65
+ agentId: record.agentId,
66
+ label: record.label,
67
+ boundBy: record.boundBy,
68
+ deliveryTo: record.deliveryTo,
69
+ lastActivityAt: record.lastActivityAt,
70
+ idleTimeoutMs: defaults.idleTimeoutMs,
71
+ maxAgeMs: defaults.maxAgeMs,
72
+ },
73
+ };
74
+ }
75
+ // ─── Manager factory ──────────────────────────────────────────
76
+ export function createXyAcpBindingManager(params) {
77
+ const accountId = normalizeAccountId(params.accountId);
78
+ const existing = getState().managersByAccountId.get(accountId);
79
+ if (existing) {
80
+ return existing;
81
+ }
82
+ const idleTimeoutMs = resolveThreadBindingIdleTimeoutMsForChannel({
83
+ cfg: params.cfg,
84
+ channel: "xiaoyi-channel",
85
+ accountId,
86
+ });
87
+ const maxAgeMs = resolveThreadBindingMaxAgeMsForChannel({
88
+ cfg: params.cfg,
89
+ channel: "xiaoyi-channel",
90
+ accountId,
91
+ });
92
+ const log = logger.withContext("", "");
93
+ const manager = {
94
+ accountId,
95
+ getByConversationId: (conversationId) => getState().bindingsByAccountConversation.get(resolveBindingKey({ accountId, conversationId })),
96
+ listBySessionKey: (targetSessionKey) => [...getState().bindingsByAccountConversation.values()].filter((record) => record.accountId === accountId && record.targetSessionKey === targetSessionKey),
97
+ bindConversation: ({ conversationId, parentConversationId, targetKind, targetSessionKey, metadata, }) => {
98
+ const normalizedConversationId = conversationId.trim();
99
+ const normalizedTargetSessionKey = targetSessionKey.trim();
100
+ if (!normalizedConversationId || !normalizedTargetSessionKey) {
101
+ return null;
102
+ }
103
+ const existingLocal = getState().bindingsByAccountConversation.get(resolveBindingKey({ accountId, conversationId: normalizedConversationId }));
104
+ const now = Date.now();
105
+ const record = {
106
+ accountId,
107
+ conversationId: normalizedConversationId,
108
+ parentConversationId: normalizeOptionalString(parentConversationId) ?? existingLocal?.parentConversationId,
109
+ deliveryTo: typeof metadata?.deliveryTo === "string" && metadata.deliveryTo.trim()
110
+ ? metadata.deliveryTo.trim()
111
+ : existingLocal?.deliveryTo,
112
+ targetKind: toXyTargetKind(targetKind),
113
+ targetSessionKey: normalizedTargetSessionKey,
114
+ agentId: typeof metadata?.agentId === "string" && metadata.agentId.trim()
115
+ ? metadata.agentId.trim()
116
+ : (existingLocal?.agentId ?? resolveAgentIdFromSessionKey(normalizedTargetSessionKey)),
117
+ label: typeof metadata?.label === "string" && metadata.label.trim()
118
+ ? metadata.label.trim()
119
+ : existingLocal?.label,
120
+ boundBy: typeof metadata?.boundBy === "string" && metadata.boundBy.trim()
121
+ ? metadata.boundBy.trim()
122
+ : existingLocal?.boundBy,
123
+ boundAt: now,
124
+ lastActivityAt: now,
125
+ };
126
+ getState().bindingsByAccountConversation.set(resolveBindingKey({ accountId, conversationId: normalizedConversationId }), record);
127
+ log.log(`[XY-ACP-BIND] Bound ${targetKind} session ${normalizedTargetSessionKey.slice(0, 30)} to conversation ${normalizedConversationId.slice(0, 12)}`);
128
+ return record;
129
+ },
130
+ touchConversation: (conversationId, at = Date.now()) => {
131
+ const key = resolveBindingKey({ accountId, conversationId });
132
+ const existingRecord = getState().bindingsByAccountConversation.get(key);
133
+ if (!existingRecord) {
134
+ return null;
135
+ }
136
+ const updated = { ...existingRecord, lastActivityAt: at };
137
+ getState().bindingsByAccountConversation.set(key, updated);
138
+ return updated;
139
+ },
140
+ unbindConversation: (conversationId) => {
141
+ const key = resolveBindingKey({ accountId, conversationId });
142
+ const existingRecord = getState().bindingsByAccountConversation.get(key);
143
+ if (!existingRecord) {
144
+ return null;
145
+ }
146
+ getState().bindingsByAccountConversation.delete(key);
147
+ return existingRecord;
148
+ },
149
+ unbindBySessionKey: (targetSessionKey) => {
150
+ const removed = [];
151
+ for (const record of getState().bindingsByAccountConversation.values()) {
152
+ if (record.accountId !== accountId || record.targetSessionKey !== targetSessionKey) {
153
+ continue;
154
+ }
155
+ getState().bindingsByAccountConversation.delete(resolveBindingKey({ accountId, conversationId: record.conversationId }));
156
+ removed.push(record);
157
+ }
158
+ return removed;
159
+ },
160
+ stop: () => {
161
+ for (const key of getState().bindingsByAccountConversation.keys()) {
162
+ if (key.startsWith(`${accountId}:`)) {
163
+ getState().bindingsByAccountConversation.delete(key);
164
+ }
165
+ }
166
+ getState().managersByAccountId.delete(accountId);
167
+ unregisterSessionBindingAdapter({
168
+ channel: "xiaoyi-channel",
169
+ accountId,
170
+ adapter: sessionBindingAdapter,
171
+ });
172
+ log.log(`[XY-ACP-BIND] Stopped binding manager for account ${accountId}`);
173
+ },
174
+ };
175
+ const sessionBindingAdapter = {
176
+ channel: "xiaoyi-channel",
177
+ accountId,
178
+ capabilities: {
179
+ placements: ["current"],
180
+ },
181
+ bind: async (input) => {
182
+ if (input.conversation.channel !== "xiaoyi-channel" || input.placement === "child") {
183
+ return null;
184
+ }
185
+ const bound = manager.bindConversation({
186
+ conversationId: input.conversation.conversationId,
187
+ parentConversationId: input.conversation.parentConversationId,
188
+ targetKind: input.targetKind,
189
+ targetSessionKey: input.targetSessionKey,
190
+ metadata: input.metadata,
191
+ });
192
+ return bound ? toSessionBindingRecord(bound, { idleTimeoutMs, maxAgeMs }) : null;
193
+ },
194
+ listBySession: (targetSessionKey) => manager
195
+ .listBySessionKey(targetSessionKey)
196
+ .map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs })),
197
+ resolveByConversation: (ref) => {
198
+ if (ref.channel !== "xiaoyi-channel") {
199
+ return null;
200
+ }
201
+ const found = manager.getByConversationId(ref.conversationId);
202
+ return found ? toSessionBindingRecord(found, { idleTimeoutMs, maxAgeMs }) : null;
203
+ },
204
+ touch: (bindingId, at) => {
205
+ const conversationId = resolveThreadBindingConversationIdFromBindingId({
206
+ accountId,
207
+ bindingId,
208
+ });
209
+ if (conversationId) {
210
+ manager.touchConversation(conversationId, at);
211
+ }
212
+ },
213
+ unbind: async (input) => {
214
+ if (input.targetSessionKey?.trim()) {
215
+ return manager
216
+ .unbindBySessionKey(input.targetSessionKey.trim())
217
+ .map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs }));
218
+ }
219
+ const conversationId = resolveThreadBindingConversationIdFromBindingId({
220
+ accountId,
221
+ bindingId: input.bindingId,
222
+ });
223
+ if (!conversationId) {
224
+ return [];
225
+ }
226
+ const removed = manager.unbindConversation(conversationId);
227
+ return removed ? [toSessionBindingRecord(removed, { idleTimeoutMs, maxAgeMs })] : [];
228
+ },
229
+ };
230
+ registerSessionBindingAdapter(sessionBindingAdapter);
231
+ getState().managersByAccountId.set(accountId, manager);
232
+ log.log(`[XY-ACP-BIND] Created binding manager for account ${accountId} (idleTimeout=${idleTimeoutMs}ms, maxAge=${maxAgeMs}ms)`);
233
+ return manager;
234
+ }
235
+ export function getXyAcpBindingManager(accountId) {
236
+ return getState().managersByAccountId.get(normalizeAccountId(accountId)) ?? null;
237
+ }
@@ -0,0 +1,11 @@
1
+ import type { LogReporterConfig } from "./types.js";
2
+ /**
3
+ * Load and validate the JSON config file.
4
+ * Falls back to defaults for optional fields.
5
+ */
6
+ export declare function loadConfig(configPath: string): LogReporterConfig;
7
+ /**
8
+ * Resolve a path template with date wildcards to actual file paths on disk.
9
+ * Scans the directory and returns all files matching the pattern.
10
+ */
11
+ export declare function resolveLogFiles(templatePath: string): string[];
@@ -0,0 +1,68 @@
1
+ // Config loader: reads JSON config file, validates fields, resolves date wildcards to actual files
2
+ import { readFileSync, readdirSync } from "fs";
3
+ import { dirname, basename, join } from "path";
4
+ // Replace longer tokens first to avoid partial matches
5
+ const WILDCARD_TOKENS = [
6
+ ["{year-month-day}", "\\d{4}-\\d{2}-\\d{2}"],
7
+ ["{year}{month}{day}", "\\d{8}"],
8
+ ["{year}", "\\d{4}"],
9
+ ["{month}", "\\d{2}"],
10
+ ["{day}", "\\d{2}"],
11
+ ];
12
+ /**
13
+ * Load and validate the JSON config file.
14
+ * Falls back to defaults for optional fields.
15
+ */
16
+ export function loadConfig(configPath) {
17
+ const raw = readFileSync(configPath, "utf-8");
18
+ const parsed = JSON.parse(raw);
19
+ if (!parsed.logFiles || !Array.isArray(parsed.logFiles) || parsed.logFiles.length === 0) {
20
+ throw new Error("log-reporter config: 'logFiles' must be a non-empty array");
21
+ }
22
+ for (const lf of parsed.logFiles) {
23
+ if (!lf.path || !lf.name) {
24
+ throw new Error(`log-reporter config: each logFile must have 'path' and 'name', got ${JSON.stringify(lf)}`);
25
+ }
26
+ }
27
+ return {
28
+ scanIntervalMs: parsed.scanIntervalMs ?? 600000,
29
+ bakDir: parsed.bakDir ?? "/tmp/openclaw",
30
+ reportUrl: parsed.reportUrl ?? "",
31
+ logFiles: parsed.logFiles,
32
+ };
33
+ }
34
+ /**
35
+ * Convert a path with date wildcards into a RegExp that matches the filename part only.
36
+ * Returns { dir, regex } where dir is the directory portion and regex matches filenames.
37
+ */
38
+ function pathToPattern(templatePath) {
39
+ const dir = dirname(templatePath);
40
+ let pattern = basename(templatePath);
41
+ // Escape regex special chars in the literal parts, then replace tokens
42
+ pattern = escapeRegex(pattern);
43
+ for (const [token, replacement] of WILDCARD_TOKENS) {
44
+ pattern = pattern.replaceAll(escapeRegex(token), replacement);
45
+ }
46
+ return { dir, regex: new RegExp(`^${pattern}$`) };
47
+ }
48
+ function escapeRegex(s) {
49
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
50
+ }
51
+ /**
52
+ * Resolve a path template with date wildcards to actual file paths on disk.
53
+ * Scans the directory and returns all files matching the pattern.
54
+ */
55
+ export function resolveLogFiles(templatePath) {
56
+ const { dir, regex } = pathToPattern(templatePath);
57
+ let entries;
58
+ try {
59
+ entries = readdirSync(dir);
60
+ }
61
+ catch {
62
+ return [];
63
+ }
64
+ return entries
65
+ .filter((f) => regex.test(f))
66
+ .map((f) => join(dir, f))
67
+ .sort(); // chronological order for date-named logs
68
+ }
@@ -0,0 +1,5 @@
1
+ import type { CursorStore, FileCursor } from "./types.js";
2
+ export declare function loadCursorStore(storePath: string): CursorStore;
3
+ export declare function saveCursorStore(storePath: string, store: CursorStore): void;
4
+ export declare function getCursor(store: CursorStore, filePath: string): FileCursor | undefined;
5
+ export declare function setCursor(store: CursorStore, filePath: string, cursor: FileCursor): void;
@@ -0,0 +1,26 @@
1
+ // Cursor state persistence — reads/writes FileCursor entries keyed by file path
2
+ import { readFileSync, writeFileSync, mkdirSync } from "fs";
3
+ import { dirname } from "path";
4
+ export function loadCursorStore(storePath) {
5
+ try {
6
+ const raw = readFileSync(storePath, "utf-8");
7
+ const parsed = JSON.parse(raw);
8
+ return { files: parsed.files ?? {} };
9
+ }
10
+ catch {
11
+ return { files: {} };
12
+ }
13
+ }
14
+ export function saveCursorStore(storePath, store) {
15
+ try {
16
+ mkdirSync(dirname(storePath), { recursive: true });
17
+ }
18
+ catch { }
19
+ writeFileSync(storePath, JSON.stringify(store, null, 2), "utf-8");
20
+ }
21
+ export function getCursor(store, filePath) {
22
+ return store.files[filePath];
23
+ }
24
+ export function setCursor(store, filePath, cursor) {
25
+ store.files[filePath] = cursor;
26
+ }
@@ -0,0 +1,10 @@
1
+ import type { LogReporterOptions } from "./types.js";
2
+ /**
3
+ * Start the log reporter. Runs the first scan immediately, then on the configured interval.
4
+ * Returns a stop function.
5
+ */
6
+ export declare function startLogReporter(options: LogReporterOptions): Promise<() => void>;
7
+ /**
8
+ * Stop the log reporter timer.
9
+ */
10
+ export declare function stopLogReporter(): void;
@@ -0,0 +1,77 @@
1
+ // Log Reporter Framework
2
+ // Self-contained periodic log scanner + uploader + reporter.
3
+ // Start via startLogReporter(), stop via stopLogReporter().
4
+ import { resolveLogFiles, loadConfig } from "./config-loader.js";
5
+ import { scanFile } from "./scanner.js";
6
+ import { uploadIncrementalContent } from "./uploader.js";
7
+ import { sendReport } from "./reporter.js";
8
+ import { loadCursorStore, saveCursorStore, setCursor } from "./cursor-store.js";
9
+ import { join } from "path";
10
+ let intervalId = null;
11
+ let isRunning = false;
12
+ /**
13
+ * Start the log reporter. Runs the first scan immediately, then on the configured interval.
14
+ * Returns a stop function.
15
+ */
16
+ export async function startLogReporter(options) {
17
+ const config = loadConfig(options.configPath);
18
+ const cursorPath = join(config.bakDir, ".log-reporter-cursor.json");
19
+ console.log(`[log-reporter] Starting with interval ${config.scanIntervalMs}ms, ${config.logFiles.length} log file(s) configured`);
20
+ async function doScan() {
21
+ if (isRunning)
22
+ return; // skip if previous scan still running
23
+ isRunning = true;
24
+ try {
25
+ const cursorStore = loadCursorStore(cursorPath);
26
+ for (const logFile of config.logFiles) {
27
+ const resolvedFiles = resolveLogFiles(logFile.path);
28
+ console.log(`[log-reporter] Scanning "${logFile.name}": pattern=${logFile.path}, resolved ${resolvedFiles.length} file(s)`);
29
+ for (const filePath of resolvedFiles) {
30
+ await processFile(filePath, logFile.name, config, cursorStore, options);
31
+ }
32
+ }
33
+ saveCursorStore(cursorPath, cursorStore);
34
+ }
35
+ catch (err) {
36
+ console.error("[log-reporter] Scan failed:", err);
37
+ }
38
+ finally {
39
+ isRunning = false;
40
+ }
41
+ }
42
+ // Run first scan immediately
43
+ await doScan();
44
+ // Schedule periodic scans
45
+ intervalId = setInterval(doScan, config.scanIntervalMs);
46
+ intervalId.unref?.();
47
+ return () => stopLogReporter();
48
+ }
49
+ async function processFile(filePath, name, config, cursorStore, options) {
50
+ try {
51
+ const result = await scanFile(filePath, name, cursorStore);
52
+ if (!result)
53
+ return;
54
+ console.log(`[log-reporter] New content in "${name}": ${filePath} lines ${result.lineStart}-${result.lineEnd} (${result.newLineCount} lines)`);
55
+ // Upload .bak → get URL
56
+ const url = await uploadIncrementalContent(result, config.bakDir, options.uploadService);
57
+ console.log(`[log-reporter] Uploaded .bak for "${name}", url: ${url}`);
58
+ // Send report (mock)
59
+ await sendReport(config.reportUrl, url, result);
60
+ // Only persist cursor after successful upload + report
61
+ setCursor(cursorStore, filePath, result.newCursor);
62
+ }
63
+ catch (err) {
64
+ console.error(`[log-reporter] Failed processing "${name}" (${filePath}):`, err);
65
+ // Cursor NOT updated — will retry on next scan
66
+ }
67
+ }
68
+ /**
69
+ * Stop the log reporter timer.
70
+ */
71
+ export function stopLogReporter() {
72
+ if (intervalId !== null) {
73
+ clearInterval(intervalId);
74
+ intervalId = null;
75
+ console.log("[log-reporter] Stopped");
76
+ }
77
+ }
@@ -0,0 +1,6 @@
1
+ import type { ScanResult } from "./types.js";
2
+ /**
3
+ * Send a log report to the server with the uploaded file URL.
4
+ * MOCK implementation — real request logic will be added later.
5
+ */
6
+ export declare function sendReport(reportUrl: string, fileUrl: string, result: ScanResult): Promise<void>;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Send a log report to the server with the uploaded file URL.
3
+ * MOCK implementation — real request logic will be added later.
4
+ */
5
+ export async function sendReport(reportUrl, fileUrl, result) {
6
+ // TODO: Replace with actual HTTP request
7
+ const payload = {
8
+ logName: result.name,
9
+ filePath: result.filePath,
10
+ lineStart: result.lineStart,
11
+ lineEnd: result.lineEnd,
12
+ newLineCount: result.newLineCount,
13
+ fileUrl,
14
+ timestamp: new Date().toISOString(),
15
+ };
16
+ console.log(`[log-reporter] Mock report to ${reportUrl}:`, JSON.stringify(payload, null, 2));
17
+ }
@@ -0,0 +1,6 @@
1
+ import type { ScanResult, CursorStore } from "./types.js";
2
+ /**
3
+ * Scan a single log file for new content.
4
+ * Returns a ScanResult if there are new lines, or null if no changes.
5
+ */
6
+ export declare function scanFile(filePath: string, name: string, cursorStore: CursorStore): Promise<ScanResult | null>;
@@ -0,0 +1,82 @@
1
+ // Core scanner: resolves wildcard paths, reads incremental log content using byte-offset cursors
2
+ import { statSync, createReadStream } from "fs";
3
+ import { getCursor } from "./cursor-store.js";
4
+ /**
5
+ * Read file content starting from a byte offset.
6
+ * Returns the full text content from that offset to end of file.
7
+ */
8
+ function readFromOffset(filePath, startByte) {
9
+ return new Promise((resolve, reject) => {
10
+ const chunks = [];
11
+ const stream = createReadStream(filePath, {
12
+ start: startByte,
13
+ encoding: "utf-8",
14
+ });
15
+ stream.on("data", (chunk) => chunks.push(chunk));
16
+ stream.on("end", () => resolve(chunks.join("")));
17
+ stream.on("error", reject);
18
+ });
19
+ }
20
+ /**
21
+ * Determine the byte offset to start reading from, based on file state and cursor.
22
+ * Returns the startByte (0-based) or null if no new content.
23
+ */
24
+ function resolveStartByte(currentSize, currentMtimeMs, cursor) {
25
+ if (!cursor) {
26
+ // New file: first scan — read from beginning
27
+ return 0;
28
+ }
29
+ if (currentSize > cursor.lastSize) {
30
+ // File grew: read from where we left off
31
+ return cursor.lastSize;
32
+ }
33
+ if (currentSize < cursor.lastSize && currentMtimeMs > cursor.lastModified) {
34
+ // File was rotated (truncated + rewritten): reset to beginning
35
+ return 0;
36
+ }
37
+ // No change (currentSize === cursor.lastSize) or edge case — skip
38
+ return null;
39
+ }
40
+ /**
41
+ * Scan a single log file for new content.
42
+ * Returns a ScanResult if there are new lines, or null if no changes.
43
+ */
44
+ export async function scanFile(filePath, name, cursorStore) {
45
+ let stats;
46
+ try {
47
+ stats = statSync(filePath);
48
+ }
49
+ catch {
50
+ // File doesn't exist (yet) or was deleted — skip
51
+ return null;
52
+ }
53
+ const currentSize = stats.size;
54
+ const currentMtimeMs = stats.mtimeMs;
55
+ const cursor = getCursor(cursorStore, filePath);
56
+ const startByte = resolveStartByte(currentSize, currentMtimeMs, cursor);
57
+ if (startByte === null) {
58
+ return null; // no new content
59
+ }
60
+ const content = await readFromOffset(filePath, startByte);
61
+ // Count new lines — each \n represents one log line
62
+ const newLineCount = content.length > 0 ? content.split("\n").length - 1 : 0;
63
+ if (newLineCount === 0) {
64
+ // No complete lines yet (partial write), don't report
65
+ return null;
66
+ }
67
+ const prevLine = cursor?.lastLine ?? 0;
68
+ const newCursor = {
69
+ lastSize: currentSize,
70
+ lastLine: prevLine + newLineCount,
71
+ lastModified: currentMtimeMs,
72
+ };
73
+ return {
74
+ filePath,
75
+ name,
76
+ content,
77
+ lineStart: prevLine + 1,
78
+ lineEnd: prevLine + newLineCount,
79
+ newLineCount,
80
+ newCursor,
81
+ };
82
+ }
@@ -0,0 +1,59 @@
1
+ /** Configuration for a single log file to monitor */
2
+ export interface LogFileConfig {
3
+ /** File path with optional date wildcards: {year}, {month}, {day}, {year-month-day}, {year}{month}{day} */
4
+ path: string;
5
+ /** Logical name used in .bak filename and reporting */
6
+ name: string;
7
+ }
8
+ /** Top-level log reporter configuration (loaded from JSON file) */
9
+ export interface LogReporterConfig {
10
+ /** Scan interval in milliseconds (default: 600000 = 10 min) */
11
+ scanIntervalMs: number;
12
+ /** Directory for .bak files and cursor state */
13
+ bakDir: string;
14
+ /** Report server URL (mock for now) */
15
+ reportUrl: string;
16
+ /** Log files to monitor */
17
+ logFiles: LogFileConfig[];
18
+ }
19
+ /** Cursor state for a single log file */
20
+ export interface FileCursor {
21
+ /** Byte offset we last read to */
22
+ lastSize: number;
23
+ /** Cumulative line count read so far */
24
+ lastLine: number;
25
+ /** File mtime (ms) at last read */
26
+ lastModified: number;
27
+ }
28
+ /** Persisted cursor store structure */
29
+ export interface CursorStore {
30
+ files: Record<string, FileCursor>;
31
+ }
32
+ /** Result of scanning a single log file */
33
+ export interface ScanResult {
34
+ /** Resolved absolute file path */
35
+ filePath: string;
36
+ /** Logical name from config */
37
+ name: string;
38
+ /** The new log lines (incremental content) */
39
+ content: string;
40
+ /** Start line number (1-based, inclusive) */
41
+ lineStart: number;
42
+ /** End line number (1-based, inclusive) */
43
+ lineEnd: number;
44
+ /** Number of new lines */
45
+ newLineCount: number;
46
+ /** Updated cursor to persist after successful upload */
47
+ newCursor: FileCursor;
48
+ }
49
+ /** Options passed to startLogReporter */
50
+ export interface LogReporterOptions {
51
+ /** Absolute path to the JSON config file */
52
+ configPath: string;
53
+ /** File upload service instance (from xy_channel) */
54
+ uploadService: UploadService;
55
+ }
56
+ /** Minimal interface for the upload service (duck-typed from XYFileUploadService) */
57
+ export interface UploadService {
58
+ uploadFileAndGetUrl(filePath: string, objectType?: string): Promise<string>;
59
+ }
@@ -0,0 +1,2 @@
1
+ // Type definitions for log reporter framework
2
+ export {};
@@ -0,0 +1,6 @@
1
+ import type { UploadService, ScanResult } from "./types.js";
2
+ /**
3
+ * Write incremental content to .bak file, upload, and return the URL.
4
+ * Cleans up .bak file regardless of success/failure.
5
+ */
6
+ export declare function uploadIncrementalContent(result: ScanResult, bakDir: string, uploadService: UploadService): Promise<string>;
@@ -0,0 +1,32 @@
1
+ // Uploader: creates .bak file, uploads via XYFileUploadService, cleans up
2
+ import { writeFileSync, unlinkSync } from "fs";
3
+ import { join } from "path";
4
+ import { mkdirSync } from "fs";
5
+ /**
6
+ * Write incremental content to .bak file, upload, and return the URL.
7
+ * Cleans up .bak file regardless of success/failure.
8
+ */
9
+ export async function uploadIncrementalContent(result, bakDir, uploadService) {
10
+ const timestamp = Date.now();
11
+ const bakFileName = `${result.name}_${timestamp}.bak`;
12
+ const bakPath = join(bakDir, bakFileName);
13
+ // Ensure bakDir exists
14
+ try {
15
+ mkdirSync(bakDir, { recursive: true });
16
+ }
17
+ catch { }
18
+ try {
19
+ // Write incremental content to .bak file
20
+ writeFileSync(bakPath, result.content, "utf-8");
21
+ // Upload and get URL
22
+ const url = await uploadService.uploadFileAndGetUrl(bakPath);
23
+ return url;
24
+ }
25
+ finally {
26
+ // Always clean up the .bak file
27
+ try {
28
+ unlinkSync(bakPath);
29
+ }
30
+ catch { }
31
+ }
32
+ }
@@ -0,0 +1 @@
1
+ export declare function handleMemoryQueryEvent(context: any, cfg: any): Promise<void>;
@@ -0,0 +1,283 @@
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 "MemoryStateGet":
46
+ result = handleMemoryStateGet();
47
+ break;
48
+ case "UserMdQuery":
49
+ result = handleUserMdQuery();
50
+ break;
51
+ case "MemoryMdQuery":
52
+ result = handleMemoryMdQuery();
53
+ break;
54
+ case "MemoryHistory":
55
+ result = handleMemoryHistory();
56
+ break;
57
+ default:
58
+ log.error(`[MEMORY-QUERY] Unknown action: ${action}`);
59
+ result = { error: `Unknown action: ${action}` };
60
+ }
61
+ }
62
+ catch (err) {
63
+ const errorMsg = err instanceof Error ? err.message : String(err);
64
+ log.error(`[MEMORY-QUERY] Handler failed for action=${action}:`, err);
65
+ result = { error: errorMsg };
66
+ }
67
+ log.log(`[MEMORY-QUERY] Result for action=${action}: ${JSON.stringify(result)}`);
68
+ // Send result back via sendCommand
69
+ if (cfg && sessionId && taskId && messageId) {
70
+ try {
71
+ const config = resolveXYConfig(cfg);
72
+ const command = {
73
+ header: {
74
+ namespace: "AgentEvent",
75
+ name: "MemoryQuery",
76
+ },
77
+ payload: {
78
+ action,
79
+ ans: result,
80
+ },
81
+ };
82
+ await sendCommand({
83
+ config,
84
+ sessionId,
85
+ taskId,
86
+ messageId,
87
+ command,
88
+ final: true,
89
+ });
90
+ log.log(`[MEMORY-QUERY] Sent response via sendCommand, action=${action}`);
91
+ }
92
+ catch (sendErr) {
93
+ log.error(`[MEMORY-QUERY] Failed to send response via sendCommand:`, sendErr);
94
+ }
95
+ }
96
+ else {
97
+ log.warn(`[MEMORY-QUERY] Missing cfg/sessionId/taskId/messageId, skipping sendCommand`);
98
+ }
99
+ }
100
+ /**
101
+ * Write MEMORYSTATE=true/false to .xiaoyiruntime.
102
+ */
103
+ function handleMemoryStateSet(params) {
104
+ const memoryState = params?.memoryState;
105
+ if (typeof memoryState !== "boolean") {
106
+ logger.error(`[MEMORY-QUERY] memoryStateSet: invalid memoryState type: ${typeof memoryState}`);
107
+ return { code: 0 };
108
+ }
109
+ const value = String(memoryState);
110
+ const filePath = resolveXiaoyiRuntimePath();
111
+ let content;
112
+ try {
113
+ content = readFileSync(filePath, "utf-8");
114
+ }
115
+ catch {
116
+ logger.log(`[MEMORY-QUERY] ${filePath} not found, creating new file`);
117
+ writeFileSync(filePath, `${MEMORY_STATE_KEY}=${value}\n`, "utf-8");
118
+ logger.log(`[MEMORY-QUERY] wrote ${MEMORY_STATE_KEY}=${value}`);
119
+ return { code: 0 };
120
+ }
121
+ const lines = content.split("\n");
122
+ const key = MEMORY_STATE_KEY;
123
+ let found = false;
124
+ const updated = lines.map((line) => {
125
+ if (line.startsWith(`${key}=`)) {
126
+ found = true;
127
+ return `${key}=${value}`;
128
+ }
129
+ return line;
130
+ });
131
+ if (!found) {
132
+ const trimmed = content.trimEnd();
133
+ writeFileSync(filePath, `${trimmed}\n${key}=${value}\n`, "utf-8");
134
+ }
135
+ else {
136
+ writeFileSync(filePath, updated.join("\n"), "utf-8");
137
+ }
138
+ logger.log(`[MEMORY-QUERY] updated ${MEMORY_STATE_KEY}=${value} in ${filePath}`);
139
+ return { code: 0 };
140
+ }
141
+ /**
142
+ * Read MEMORYSTATE from .xiaoyiruntime and return its boolean value.
143
+ * Missing file or key defaults to false.
144
+ */
145
+ function handleMemoryStateGet() {
146
+ const filePath = resolveXiaoyiRuntimePath();
147
+ let content;
148
+ try {
149
+ content = readFileSync(filePath, "utf-8");
150
+ }
151
+ catch (err) {
152
+ if (err.code === "ENOENT") {
153
+ logger.log(`[MEMORY-QUERY] ${filePath} not found`);
154
+ }
155
+ else {
156
+ logger.error(`[MEMORY-QUERY] Failed to read ${filePath}:`, err);
157
+ }
158
+ return { memoryState: false };
159
+ }
160
+ for (const line of content.split("\n")) {
161
+ if (line.startsWith(`${MEMORY_STATE_KEY}=`)) {
162
+ const value = line.slice(`${MEMORY_STATE_KEY}=`.length).trim();
163
+ logger.log(`[MEMORY-QUERY] read ${MEMORY_STATE_KEY}=${value} from ${filePath}`);
164
+ return { memoryState: value === "true" };
165
+ }
166
+ }
167
+ logger.log(`[MEMORY-QUERY] ${MEMORY_STATE_KEY} not found in ${filePath}`);
168
+ return { memoryState: false };
169
+ }
170
+ /**
171
+ * Read ~/.openclaw/workspace/USER.md and return content in fileDetail.
172
+ */
173
+ function handleUserMdQuery() {
174
+ const filePath = path.join(os.homedir(), ".openclaw", "workspace", "USER.md");
175
+ return readMdFile(filePath);
176
+ }
177
+ /**
178
+ * Read ~/.openclaw/workspace/MEMORY.md and return content in fileDetail.
179
+ */
180
+ function handleMemoryMdQuery() {
181
+ const filePath = path.join(os.homedir(), ".openclaw", "workspace", "MEMORY.md");
182
+ return readMdFile(filePath);
183
+ }
184
+ function readMdFile(filePath) {
185
+ try {
186
+ const content = readFileSync(filePath, "utf-8");
187
+ logger.log(`[MEMORY-QUERY] Read file: ${filePath}, size: ${content.length}`);
188
+ return { fileDetail: content };
189
+ }
190
+ catch (err) {
191
+ if (err.code === "ENOENT") {
192
+ logger.log(`[MEMORY-QUERY] File not found: ${filePath}`);
193
+ }
194
+ else {
195
+ logger.error(`[MEMORY-QUERY] Failed to read ${filePath}:`, err);
196
+ }
197
+ return { fileDetail: "" };
198
+ }
199
+ }
200
+ const MEMORY_LOG_PATH = path.join(os.homedir(), ".openclaw", ".memory.log");
201
+ const MEMORY_HISTORY_DAYS = 7;
202
+ const MEMORY_RETENTION_DAYS = 30;
203
+ /**
204
+ * Read ~/.openclaw/.memory.log, return last 7 days grouped by date,
205
+ * then prune entries older than 30 days.
206
+ *
207
+ * Log line format: `2026-06-22T15:18:00|user.md|更新了xxxx`
208
+ * Only split on the first two `|`; everything after is the detail
209
+ * (detail itself may contain `|`).
210
+ */
211
+ function handleMemoryHistory() {
212
+ let content;
213
+ try {
214
+ content = readFileSync(MEMORY_LOG_PATH, "utf-8");
215
+ }
216
+ catch (err) {
217
+ if (err.code === "ENOENT") {
218
+ logger.log(`[MEMORY-QUERY] memory.log not found: ${MEMORY_LOG_PATH}`);
219
+ }
220
+ else {
221
+ logger.error(`[MEMORY-QUERY] Failed to read memory.log:`, err);
222
+ }
223
+ return [];
224
+ }
225
+ const lines = content.split("\n");
226
+ const now = new Date();
227
+ const historySince = new Date(now);
228
+ historySince.setDate(now.getDate() - (MEMORY_HISTORY_DAYS - 1));
229
+ historySince.setHours(0, 0, 0, 0);
230
+ const retentionSince = new Date(now);
231
+ retentionSince.setDate(now.getDate() - (MEMORY_RETENTION_DAYS - 1));
232
+ retentionSince.setHours(0, 0, 0, 0);
233
+ const byDate = new Map();
234
+ const keptLines = [];
235
+ for (const raw of lines) {
236
+ const line = raw.trimEnd();
237
+ if (!line)
238
+ continue;
239
+ // Split on only the first two `|`; rest is detail (may contain `|`).
240
+ const firstPipe = line.indexOf("|");
241
+ if (firstPipe === -1)
242
+ continue;
243
+ const secondPipe = line.indexOf("|", firstPipe + 1);
244
+ if (secondPipe === -1)
245
+ continue;
246
+ const timestamp = line.slice(0, firstPipe);
247
+ const fileName = line.slice(firstPipe + 1, secondPipe);
248
+ const detail = line.slice(secondPipe + 1);
249
+ // timestamp format: 2026-06-22T15:18:00 → extract hh:mm
250
+ const datePart = timestamp.slice(0, 10);
251
+ const timePart = timestamp.slice(11, 16);
252
+ const entryDate = new Date(`${datePart}T00:00:00`);
253
+ // Retain log lines within the 30-day window.
254
+ if (!isNaN(entryDate.getTime()) && entryDate >= retentionSince) {
255
+ keptLines.push(line);
256
+ }
257
+ // Include in response if within the 7-day window.
258
+ if (!isNaN(entryDate.getTime()) && entryDate >= historySince) {
259
+ let bucket = byDate.get(datePart);
260
+ if (!bucket) {
261
+ bucket = [];
262
+ byDate.set(datePart, bucket);
263
+ }
264
+ bucket.push({ fileName, detail, time: timePart });
265
+ }
266
+ }
267
+ // Build ans array sorted by date descending, each entry is { <date>: [...] }.
268
+ const ans = Array.from(byDate.keys())
269
+ .sort()
270
+ .reverse()
271
+ .map((dateStr) => ({ [dateStr]: byDate.get(dateStr).reverse() }));
272
+ // Prune memory.log: keep only the last 30 days.
273
+ try {
274
+ const newContent = keptLines.length > 0 ? `${keptLines.join("\n")}\n` : "";
275
+ writeFileSync(MEMORY_LOG_PATH, newContent, "utf-8");
276
+ logger.log(`[MEMORY-QUERY] Pruned memory.log, kept ${keptLines.length} entries (>= ${retentionSince.toISOString().slice(0, 10)})`);
277
+ }
278
+ catch (err) {
279
+ logger.error(`[MEMORY-QUERY] Failed to prune memory.log:`, err);
280
+ }
281
+ logger.log(`[MEMORY-QUERY] MemoryHistory: returning ${ans.length} date buckets`);
282
+ return ans;
283
+ }
@@ -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(() => {
@@ -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
+ }
@@ -632,6 +632,15 @@ export class XYWebSocketManager extends EventEmitter {
632
632
  messageId: a2aRequest.id,
633
633
  });
634
634
  }
635
+ else if (item.header?.namespace === "AgentEvent" && item.header?.name === "MemoryQuery") {
636
+ log.log("[XY] AgentEvent.MemoryQuery detected, emitting memory-query-event");
637
+ this.emit("memory-query-event", {
638
+ ...(item.payload ?? {}),
639
+ sessionId,
640
+ taskId: a2aRequest.params?.id,
641
+ messageId: a2aRequest.id,
642
+ });
643
+ }
635
644
  else if (item.header?.namespace === "System" && item.header?.name === "ExecuteAgentAsSkillResponse") {
636
645
  log.log("[XY] ExecuteAgentAsSkillResponse detected, emitting agent-as-skill-response");
637
646
  this.emit("agent-as-skill-response", item);
@@ -721,6 +730,15 @@ export class XYWebSocketManager extends EventEmitter {
721
730
  messageId: a2aRequest.id,
722
731
  });
723
732
  }
733
+ else if (item.header?.namespace === "AgentEvent" && item.header?.name === "MemoryQuery") {
734
+ log.log("[XY] AgentEvent.MemoryQuery detected (wrapped format), emitting memory-query-event");
735
+ this.emit("memory-query-event", {
736
+ ...(item.payload ?? {}),
737
+ sessionId: inboundMsg.sessionId || a2aRequest.params?.sessionId,
738
+ taskId: inboundMsg.taskId || a2aRequest.params?.id,
739
+ messageId: a2aRequest.id,
740
+ });
741
+ }
724
742
  else if (item.header?.namespace === "System" && item.header?.name === "ExecuteAgentAsSkillResponse") {
725
743
  log.log("[XY] ExecuteAgentAsSkillResponse detected (wrapped format), emitting agent-as-skill-response");
726
744
  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": "1.1.31",
3
+ "version": "1.1.32",
4
4
  "description": "OpenClaw Xiaoyi Channel plugin - Xiaoyi A2A protocol integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",