@ynhcj/xiaoyi-channel 0.0.197-beta → 0.0.198-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.
@@ -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
+ }
@@ -267,7 +267,7 @@ function handleMemoryHistory() {
267
267
  // Build ans array sorted by date ascending, each entry is { <date>: [...] }.
268
268
  const ans = Array.from(byDate.keys())
269
269
  .sort()
270
- .map((dateStr) => ({ [dateStr]: byDate.get(dateStr) }));
270
+ .map((dateStr) => ({ [dateStr]: byDate.get(dateStr).reverse() }));
271
271
  // Prune memory.log: keep only the last 30 days.
272
272
  try {
273
273
  const newContent = keptLines.length > 0 ? `${keptLines.join("\n")}\n` : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ynhcj/xiaoyi-channel",
3
- "version": "0.0.197-beta",
3
+ "version": "0.0.198-beta",
4
4
  "description": "OpenClaw Xiaoyi Channel plugin - Xiaoyi A2A protocol integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",