@ynhcj/xiaoyi-channel 0.0.209-beta → 0.0.211-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.
@@ -1,7 +1,7 @@
1
1
  /*
2
2
  * 版权所有 (c) 华为技术有限公司 2026-2026
3
3
  */
4
- import { MAX_TEXT_LENGTH, regex, SECURITY_NOTICE, MAX_FILE_COUNT, MAX_COMMAND_LENGTH, CODE_FILE_EXTENSIONS, TOOL_INPUT_DEFAULT, FILE_EXTENSION_REGEX, TOOL_INPUT_ACTION } from './constants.js';
4
+ import { MAX_TEXT_LENGTH, regex, SECURITY_NOTICE, MAX_FILE_COUNT, MAX_COMMAND_LENGTH, CODE_FILE_EXTENSIONS, FILE_EXTENSION_REGEX, RESULT_CODE_MAP } from './constants.js';
5
5
  import crypto from 'crypto';
6
6
  import fs from 'fs';
7
7
  import path from 'path';
@@ -46,7 +46,7 @@ export function extractResultText(event, toolName) {
46
46
  }
47
47
  return resultTexts.length > 0 ? resultTexts.join("; ") : "";
48
48
  }
49
- export function processText(resultText, api) {
49
+ export function processText(resultText) {
50
50
  const questionText = filterText(resultText);
51
51
  // 检查是否超过4096字符限制,进行截断
52
52
  const { text: finalText, truncated } = validateAndTruncateText(questionText, MAX_TEXT_LENGTH);
@@ -69,8 +69,14 @@ export function parseSecurityResult(response) {
69
69
  if (securityResult !== securityResult.trim()) {
70
70
  throw new Error('Response.data.securityResult contains leading or trailing spaces');
71
71
  }
72
- if (securityResult !== 'ACCEPT' && securityResult !== 'REJECT') {
73
- throw new Error(`Response.data.securityResult must be "ACCEPT" or "REJECT". Actual value: "${securityResult}"`);
72
+ if (securityResult !== 'accept' && securityResult !== 'reject') {
73
+ throw new Error(`Response.data.securityResult must be "accept" or "reject". Actual value: "${securityResult}"`);
74
+ }
75
+ // 解析 resultCode 并打印错误日志
76
+ const resultCode = response.data?.resultCode;
77
+ if (resultCode !== undefined && resultCode !== null && resultCode !== 0) {
78
+ const errorMsg = RESULT_CODE_MAP[resultCode] || `Unknown error code: ${resultCode}`;
79
+ logger.error(`[SENTINEL HOOK] API returned resultCode=${resultCode}: ${errorMsg}`);
74
80
  }
75
81
  return { status: securityResult };
76
82
  }
@@ -177,56 +183,103 @@ export function getFileSizeInKB(filePath) {
177
183
  return 0;
178
184
  }
179
185
  }
180
- // 动态计算content字段长度,确保body总长度<=4096
181
- export function adjustContentLength(data, api, fields) {
182
- const adjusted = { ...data };
183
- let bodyStr = JSON.stringify(adjusted);
184
- if (bodyStr.length <= MAX_TEXT_LENGTH) {
185
- return adjusted;
186
+ // 从taskId中提取sessionID(第一个&之前的内容)
187
+ export function extractSessionId(taskId) {
188
+ if (!taskId)
189
+ return '';
190
+ const idx = taskId.indexOf('&');
191
+ return idx === -1 ? taskId : taskId.substring(0, idx);
192
+ }
193
+ // 从taskId中提取interActionID(第一个&和第二个&之间的值)
194
+ export function extractInterActionId(taskId) {
195
+ if (!taskId)
196
+ return 1;
197
+ const parts = taskId.split('&');
198
+ if (parts.length >= 2) {
199
+ const id = parseInt(parts[1], 10);
200
+ if (!isNaN(id) && id > 0)
201
+ return id;
186
202
  }
187
- // 需要截断指定字段
188
- let lastFieldName = '';
189
- for (const fieldName of fields) {
190
- lastFieldName = fieldName;
191
- bodyStr = JSON.stringify(adjusted);
192
- const overSize = bodyStr.length - MAX_TEXT_LENGTH;
193
- const currentFieldValue = adjusted[fieldName];
194
- if (currentFieldValue && typeof currentFieldValue === 'string' && currentFieldValue.length > overSize) {
195
- // 从字段头部开始截断
196
- adjusted[fieldName] = currentFieldValue.substring(0, currentFieldValue.length - overSize);
197
- logger.warn(`[SENTINEL HOOK] Field "${fieldName}" truncated by ${overSize} characters to fit ${MAX_TEXT_LENGTH} limit`);
198
- }
199
- else {
200
- // 字段太短,清空字段
201
- adjusted[fieldName] = '';
202
- logger.warn(`[SENTINEL HOOK] Field "${fieldName}" cleared as it cannot fit within size limit`);
203
+ return 1;
204
+ }
205
+ // reqTime 生成工具函数
206
+ function formatReqTime() {
207
+ const now = new Date();
208
+ const pad = (n, len = 2) => String(n).padStart(len, '0');
209
+ const ms = String(now.getMilliseconds()).padStart(3, '0');
210
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` +
211
+ `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${ms}+0800`;
212
+ }
213
+ // 构建工具输入新消息体(新接口格式)
214
+ export function buildToolInputPayload(taskID, sessionID, toolName, toolArguments, toolCallId, interActionID, options) {
215
+ const callObj = {
216
+ type: 'function',
217
+ name: toolName,
218
+ arguments: toolArguments,
219
+ index: 0,
220
+ id: toolCallId
221
+ };
222
+ if (options?.file) {
223
+ callObj.file = [options.file];
224
+ }
225
+ return {
226
+ taskID,
227
+ sessionID,
228
+ businessID: 'xiaoyi_claw',
229
+ sceneID: 'ACTION_DETECT',
230
+ checkPoint: 4,
231
+ interActionID,
232
+ loginType: 'APP',
233
+ reqTime: formatReqTime(),
234
+ message: {
235
+ input: {
236
+ toolIn: [{
237
+ toolCalls: [callObj]
238
+ }]
239
+ }
203
240
  }
204
- // 检查是否满足要求
205
- bodyStr = JSON.stringify(adjusted);
206
- if (bodyStr.length <= MAX_TEXT_LENGTH) {
207
- break;
241
+ };
242
+ }
243
+ // 构建工具输出新消息体(新接口格式)
244
+ export function buildToolOutputPayload(taskID, sessionID, funcName, content, toolCallId, interActionID) {
245
+ return {
246
+ taskID,
247
+ sessionID,
248
+ businessID: 'xiaoyi_claw',
249
+ sceneID: 'PROMPT_DETECT',
250
+ checkPoint: 6,
251
+ interActionID,
252
+ loginType: 'APP',
253
+ reqTime: formatReqTime(),
254
+ message: {
255
+ output: {
256
+ toolOut: [{
257
+ funcName,
258
+ content: [{
259
+ type: 'text',
260
+ rawText: content
261
+ }],
262
+ toolCallId
263
+ }]
264
+ }
208
265
  }
209
- }
210
- bodyStr = JSON.stringify(adjusted);
211
- if (bodyStr.length > MAX_TEXT_LENGTH) {
212
- throw new Error(`Field ${lastFieldName} exceeds length limit, unable to send data.`);
213
- }
214
- return adjusted;
266
+ };
215
267
  }
216
- // 发送TOOL_INPUT请求并处理响应,返回扫描结果
217
- async function sendToolInputRequest(postText, api, sessionId) {
218
- const response = await callApi(postText, api, sessionId, TOOL_INPUT_ACTION);
268
+ // 发送新接口请求并处理响应,返回扫描结果(保留block/steer能力)
269
+ async function sendToolInputRequest(payload, api, sessionId) {
270
+ const response = await callApi(payload, api, sessionId);
219
271
  const result = parseSecurityResult(response);
220
272
  logger.log(`[SENTINEL HOOK] TOOL_INPUT response: status=${result.status}`);
221
273
  return result;
222
274
  }
223
- // 处理exec工具的TOOL_INPUT数据采集,返回最终扫描结果
224
- export async function handleExecToolInput(event, api, sessionId) {
275
+ // 处理exec工具的TOOL_INPUT数据采集,返回扫描结果(保留block/steer能力)
276
+ export async function handleExecToolInput(event, api, sessionId, taskId) {
225
277
  const command = extractInputParams(event, 'exec');
226
278
  if (!command) {
227
279
  logger.log('[SENTINEL HOOK] No command found for exec tool');
228
280
  return null;
229
281
  }
282
+ const interActionID = extractInterActionId(taskId);
230
283
  // 解析命令提取文件路径
231
284
  const filePaths = extractFilePathsFromCommand(command);
232
285
  if (filePaths.length > 0) {
@@ -243,14 +296,17 @@ export async function handleExecToolInput(event, api, sessionId) {
243
296
  const fileHash = calculateContentHash(fileContent);
244
297
  const fileSize = getFileSizeInKB(filePath);
245
298
  const obsUrl = await uploadFileToObsMain(filePath, api, fileHash, sessionId);
246
- const toolInputData = { ...TOOL_INPUT_DEFAULT, tool: 'exec', hash: fileHash, url: obsUrl, size: fileSize,
247
- source: command, content: fileContent };
248
- const adjustedData = adjustContentLength(toolInputData, api, ['content', 'source']);
249
- const postText = JSON.stringify(adjustedData);
250
- logger.log(`[SENTINEL HOOK] Sending TOOL_INPUT for file: ${path.basename(filePath)}, body length: ${postText.length}`);
299
+ // 截断 body MAX_TEXT_LENGTH
300
+ let bodyContent = fileContent;
301
+ if (Buffer.byteLength(bodyContent, 'utf8') > MAX_TEXT_LENGTH) {
302
+ bodyContent = bodyContent.substring(0, MAX_TEXT_LENGTH);
303
+ logger.warn(`[SENTINEL HOOK] File content truncated to ${MAX_TEXT_LENGTH} characters`);
304
+ }
305
+ const toolInputPayload = buildToolInputPayload(taskId, sessionId, 'exec', command, event.toolCallId, interActionID, { file: { type: 'doc', url: obsUrl, hash: fileHash, size: fileSize, body: bodyContent } });
306
+ logger.log(`[SENTINEL HOOK] Sending TOOL_INPUT for file: ${path.basename(filePath)}, body length: ${JSON.stringify(toolInputPayload).length}`);
251
307
  try {
252
- lastResult = await sendToolInputRequest(postText, api, sessionId);
253
- if (lastResult.status === 'REJECT') {
308
+ lastResult = await sendToolInputRequest(toolInputPayload, api, sessionId);
309
+ if (lastResult.status === 'reject') {
254
310
  return lastResult;
255
311
  }
256
312
  }
@@ -268,33 +324,26 @@ export async function handleExecToolInput(event, api, sessionId) {
268
324
  else {
269
325
  // 场景2:直接执行代码(heredoc场景)
270
326
  logger.log('[SENTINEL HOOK] No code files found in command, treating as direct code execution');
271
- const commandHash = calculateContentHash(command);
272
- const commandSizeKB = Math.ceil(Buffer.byteLength(command, 'utf8') / 1024);
273
- const toolInputData = { ...TOOL_INPUT_DEFAULT, tool: 'exec', hash: commandHash, size: commandSizeKB, source: command };
274
- const adjustedData = adjustContentLength(toolInputData, api, ['source']);
275
- const postText = JSON.stringify(adjustedData);
276
- logger.log(`[SENTINEL HOOK] Sending TOOL_INPUT for direct code execution, body length: ${postText.length}`);
277
- return await sendToolInputRequest(postText, api, sessionId);
327
+ const toolInputPayload = buildToolInputPayload(taskId, sessionId, 'exec', command, event.toolCallId, interActionID);
328
+ logger.log(`[SENTINEL HOOK] Sending TOOL_INPUT for direct code execution, body length: ${JSON.stringify(toolInputPayload).length}`);
329
+ return await sendToolInputRequest(toolInputPayload, api, sessionId);
278
330
  }
279
331
  }
280
- // 处理message工具的TOOL_INPUT数据采集,返回扫描结果
281
- export async function handleMessageToolInput(event, api, sessionId) {
332
+ // 处理message工具的TOOL_INPUT数据采集,返回扫描结果(保留block/steer能力)
333
+ export async function handleMessageToolInput(event, api, sessionId, taskId) {
282
334
  const message = extractInputParams(event, 'message');
283
335
  if (!message) {
284
336
  logger.log('[SENTINEL HOOK] No message found for message tool');
285
337
  return null;
286
338
  }
287
339
  logger.log(`[SENTINEL HOOK] Processing message tool input, message length: ${message.length}`);
288
- const messageHash = calculateContentHash(message);
289
- const messageSizeKB = Math.ceil(Buffer.byteLength(message, 'utf8') / 1024);
290
- const toolInputData = { ...TOOL_INPUT_DEFAULT, tool: 'message', hash: messageHash, size: messageSizeKB, content: message };
291
- const adjustedData = adjustContentLength(toolInputData, api, ['content']);
292
- const postText = JSON.stringify(adjustedData);
293
- logger.log(`[SENTINEL HOOK] Sending TOOL_INPUT for message, body length: ${postText.length}`);
294
- return await sendToolInputRequest(postText, api, sessionId);
340
+ const interActionID = extractInterActionId(taskId);
341
+ const toolInputPayload = buildToolInputPayload(taskId, sessionId, 'message', message, event.toolCallId, interActionID);
342
+ logger.log(`[SENTINEL HOOK] Sending TOOL_INPUT for message, body length: ${JSON.stringify(toolInputPayload).length}`);
343
+ return await sendToolInputRequest(toolInputPayload, api, sessionId);
295
344
  }
296
- // 处理其他工具(非 exec 和非 message)的 TOOL_INPUT 数据采集,返回扫描结果
297
- export async function handleOtherToolInput(event, api, sessionId) {
345
+ // 处理其他工具(非 exec 和非 message)的 TOOL_INPUT 数据采集,返回扫描结果(保留block/steer能力)
346
+ export async function handleOtherToolInput(event, api, sessionId, taskId) {
298
347
  const params = event.params;
299
348
  if (!params) {
300
349
  logger.log('[SENTINEL HOOK] No params found for tool');
@@ -303,13 +352,8 @@ export async function handleOtherToolInput(event, api, sessionId) {
303
352
  logger.log(`[SENTINEL HOOK] Processing other tool input, toolName: ${event.toolName}`);
304
353
  // 将 params 序列化为 JSON 字符串
305
354
  const paramsJson = JSON.stringify(params);
306
- const paramsHash = calculateContentHash(paramsJson);
307
- const paramsSizeKB = Math.ceil(Buffer.byteLength(paramsJson, 'utf8') / 1024);
308
- // 创建 toolInputData,将 params 放到 source 字段
309
- const toolInputData = { ...TOOL_INPUT_DEFAULT, tool: event.toolName, hash: paramsHash, size: paramsSizeKB, content: paramsJson };
310
- // 对 source 字段进行长度截断处理
311
- const adjustedData = adjustContentLength(toolInputData, api, ['content']);
312
- const postText = JSON.stringify(adjustedData);
313
- logger.log(`[SENTINEL HOOK] Sending TOOL_INPUT for ${event.toolName}, body length: ${postText.length}`);
314
- return await sendToolInputRequest(postText, api, sessionId);
355
+ const interActionID = extractInterActionId(taskId);
356
+ const toolInputPayload = buildToolInputPayload(taskId, sessionId, event.toolName, paramsJson, event.toolCallId, interActionID);
357
+ logger.log(`[SENTINEL HOOK] Sending TOOL_INPUT for ${event.toolName}, body length: ${JSON.stringify(toolInputPayload).length}`);
358
+ return await sendToolInputRequest(toolInputPayload, api, sessionId);
315
359
  }
@@ -1,6 +1,6 @@
1
1
  import type { LogReporterOptions } from "./types.js";
2
2
  /**
3
- * Start the log reporter. Runs the first scan immediately, then on the configured interval.
3
+ * Start the log reporter. Runs the first scan immediately, then on a 5-minute interval.
4
4
  * Returns a stop function.
5
5
  */
6
6
  export declare function startLogReporter(options: LogReporterOptions): Promise<() => void>;
@@ -1,39 +1,182 @@
1
1
  // Log Reporter Framework
2
2
  // Self-contained periodic log scanner + uploader + reporter.
3
3
  // Start via startLogReporter(), stop via stopLogReporter().
4
- import { resolveLogFiles, loadConfig } from "./config-loader.js";
4
+ import { readFileSync } from "fs";
5
+ import { resolveLogFiles } from "./path-resolver.js";
5
6
  import { scanFile } from "./scanner.js";
6
- import { uploadIncrementalContent } from "./uploader.js";
7
+ import { uploadContent } from "./uploader.js";
7
8
  import { sendReport } from "./reporter.js";
8
9
  import { loadCursorStore, saveCursorStore, setCursor } from "./cursor-store.js";
9
- import { join } from "path";
10
+ import { parseAndFormatLogContent } from "./openclaw-parser.js";
11
+ import { logger } from "../utils/logger.js";
12
+ import crypto from "crypto";
13
+ // ── Constants ────────────────────────────────────────────────────────────────
14
+ const SCAN_INTERVAL_MS = 300000; // 5 minutes
15
+ const CURSOR_PATH = "/home/sandbox/.openclaw/.xiaoyilogging/.log-reporter-cursor.json";
16
+ const BAK_DIR = "/tmp/openclaw";
17
+ const ENV_FILE_PATH = "/home/sandbox/.openclaw/.xiaoyienv";
18
+ /** Hardcoded log monitors */
19
+ const MONITORS = [
20
+ {
21
+ path: "/tmp/openclaw/openclaw-{year-month-day}.log",
22
+ businessType: "openclaw-gateway",
23
+ jsonParse: true,
24
+ },
25
+ {
26
+ path: "/tmp/openclaw/xiaoyi-channel-{year}{month}{day}.log",
27
+ businessType: "xiaoyi-channel",
28
+ jsonParse: false,
29
+ },
30
+ {
31
+ path: "/home/sandbox/.openclaw/workspace/logs/init_{year}{month}{day}_{hour}{minute}{second}.log",
32
+ businessType: "openclaw-init",
33
+ jsonParse: false,
34
+ },
35
+ ];
36
+ // ── State ────────────────────────────────────────────────────────────────────
10
37
  let intervalId = null;
11
38
  let isRunning = false;
39
+ // ── Helpers ──────────────────────────────────────────────────────────────────
12
40
  /**
13
- * Start the log reporter. Runs the first scan immediately, then on the configured interval.
41
+ * Read the .xiaoyienv file and extract required environment variables.
42
+ * Expected format: key=value, one per line. Lines starting with # are comments.
43
+ */
44
+ function readEnvFile() {
45
+ let raw;
46
+ try {
47
+ raw = readFileSync(ENV_FILE_PATH, "utf-8");
48
+ }
49
+ catch {
50
+ throw new Error(`Environment file not found: ${ENV_FILE_PATH}`);
51
+ }
52
+ const env = {};
53
+ for (const line of raw.split("\n")) {
54
+ const trimmed = line.trim();
55
+ if (!trimmed || trimmed.startsWith("#"))
56
+ continue;
57
+ const eqIdx = trimmed.indexOf("=");
58
+ if (eqIdx === -1)
59
+ continue;
60
+ const key = trimmed.substring(0, eqIdx).trim();
61
+ const value = trimmed.substring(eqIdx + 1).trim();
62
+ env[key] = value;
63
+ }
64
+ const required = ["SERVICE_URL", "PERSONAL-API-KEY", "PERSONAL-UID"];
65
+ for (const key of required) {
66
+ if (!env[key]) {
67
+ throw new Error(`Missing required env variable: ${key}`);
68
+ }
69
+ }
70
+ return {
71
+ serviceUrl: env["SERVICE_URL"],
72
+ apiKey: env["PERSONAL-API-KEY"],
73
+ uid: env["PERSONAL-UID"],
74
+ };
75
+ }
76
+ /** Generate a stable instance ID (UUID) */
77
+ function generateInstanceId() {
78
+ return crypto.randomUUID();
79
+ }
80
+ // ── Public API ───────────────────────────────────────────────────────────────
81
+ /**
82
+ * Start the log reporter. Runs the first scan immediately, then on a 5-minute interval.
14
83
  * Returns a stop function.
15
84
  */
16
85
  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`);
86
+ const env = readEnvFile();
87
+ const instanceId = generateInstanceId();
88
+ logger.log(`[log-reporter] Starting with interval ${SCAN_INTERVAL_MS}ms, ${MONITORS.length} monitor(s) configured`);
89
+ logger.log(`[log-reporter] Instance ID: ${instanceId}`);
20
90
  async function doScan() {
21
91
  if (isRunning)
22
92
  return; // skip if previous scan still running
23
93
  isRunning = true;
24
94
  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)`);
95
+ const cursorStore = loadCursorStore(CURSOR_PATH);
96
+ // Accumulated cursors to persist after a successful cycle
97
+ const pendingCursors = {};
98
+ // Phase 1: Scan all monitors, aggregate content by businessType
99
+ const contentMap = new Map();
100
+ const cursorMap = new Map();
101
+ for (const monitor of MONITORS) {
102
+ const resolvedFiles = resolveLogFiles(monitor.path);
103
+ logger.log(`[log-reporter] Scanning "${monitor.businessType}": pattern=${monitor.path}, resolved ${resolvedFiles.length} file(s)`);
104
+ const btCursors = {};
105
+ const btParts = [];
29
106
  for (const filePath of resolvedFiles) {
30
- await processFile(filePath, logFile.name, config, cursorStore, options);
107
+ try {
108
+ const result = await scanFile(filePath, cursorStore);
109
+ if (!result)
110
+ continue;
111
+ // Apply JSON parsing for openclaw gateway logs
112
+ const finalContent = monitor.jsonParse
113
+ ? parseAndFormatLogContent(result.content)
114
+ : result.content;
115
+ if (!finalContent)
116
+ continue;
117
+ btParts.push(finalContent);
118
+ btCursors[filePath] = result.newCursor;
119
+ }
120
+ catch (err) {
121
+ logger.error(`[log-reporter] Error scanning "${filePath}": ${String(err)}`);
122
+ // Don't persist cursors for this business type on any error
123
+ }
124
+ }
125
+ if (btParts.length > 0) {
126
+ const existing = contentMap.get(monitor.businessType);
127
+ contentMap.set(monitor.businessType, existing ? existing + "\n" + btParts.join("\n") : btParts.join("\n"));
128
+ cursorMap.set(monitor.businessType, btCursors);
31
129
  }
32
130
  }
33
- saveCursorStore(cursorPath, cursorStore);
131
+ // Phase 2: Skip if no content at all
132
+ if (contentMap.size === 0) {
133
+ logger.log("[log-reporter] No new content across all monitors, skipping report");
134
+ saveCursorStore(CURSOR_PATH, cursorStore);
135
+ return;
136
+ }
137
+ // Phase 3: Upload each business type's content → get URL
138
+ const logFiles = [];
139
+ for (const [businessType, content] of contentMap) {
140
+ try {
141
+ const url = await uploadContent(content, businessType, BAK_DIR, options.uploadService);
142
+ logger.log(`[log-reporter] Uploaded content for "${businessType}", url: ${url}`);
143
+ logFiles.push({ businessType, fileUrl: url });
144
+ // Merge cursors for successful uploads
145
+ const btCursors = cursorMap.get(businessType);
146
+ if (btCursors) {
147
+ for (const [fp, cursor] of Object.entries(btCursors)) {
148
+ pendingCursors[fp] = cursor;
149
+ }
150
+ }
151
+ }
152
+ catch (err) {
153
+ logger.error(`[log-reporter] Upload failed for "${businessType}": ${String(err)}`);
154
+ // Don't persist cursors for this business type — will retry next cycle
155
+ }
156
+ }
157
+ if (logFiles.length === 0) {
158
+ logger.log("[log-reporter] All uploads failed, skipping report");
159
+ return;
160
+ }
161
+ // Phase 4: Send report
162
+ const payload = { instanceId, logFiles };
163
+ try {
164
+ await sendReport(payload, env);
165
+ }
166
+ catch (err) {
167
+ logger.error(`[log-reporter] Report failed: ${String(err)}`);
168
+ // Don't persist cursors on report failure — will retry next cycle
169
+ return;
170
+ }
171
+ // Phase 5: Persist cursors after successful upload + report
172
+ for (const [fp, cursor] of Object.entries(pendingCursors)) {
173
+ setCursor(cursorStore, fp, cursor);
174
+ }
175
+ saveCursorStore(CURSOR_PATH, cursorStore);
34
176
  }
35
177
  catch (err) {
36
- console.error("[log-reporter] Scan failed:", err);
178
+ logger.error(`[log-reporter] Scan failed: ${String(err)}`);
179
+ // Cursor NOT updated — will retry on next cycle
37
180
  }
38
181
  finally {
39
182
  isRunning = false;
@@ -42,29 +185,10 @@ export async function startLogReporter(options) {
42
185
  // Run first scan immediately
43
186
  await doScan();
44
187
  // Schedule periodic scans
45
- intervalId = setInterval(doScan, config.scanIntervalMs);
188
+ intervalId = setInterval(doScan, SCAN_INTERVAL_MS);
46
189
  intervalId.unref?.();
47
190
  return () => stopLogReporter();
48
191
  }
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
192
  /**
69
193
  * Stop the log reporter timer.
70
194
  */
@@ -72,6 +196,6 @@ export function stopLogReporter() {
72
196
  if (intervalId !== null) {
73
197
  clearInterval(intervalId);
74
198
  intervalId = null;
75
- console.log("[log-reporter] Stopped");
199
+ logger.log("[log-reporter] Stopped");
76
200
  }
77
201
  }
@@ -0,0 +1,18 @@
1
+ type ParsedLogLine = {
2
+ time?: string;
3
+ level?: string;
4
+ subsystem?: string;
5
+ module?: string;
6
+ message: string;
7
+ raw: string;
8
+ };
9
+ /** Parse a single raw JSON log line into structured fields. Returns null for non-JSON lines. */
10
+ export declare function parseLogLine(raw: string): ParsedLogLine | null;
11
+ /** Format a parsed log line as: "time level subsystem message" (matching openclaw logs output) */
12
+ export declare function formatParsedLogLine(parsed: ParsedLogLine): string;
13
+ /**
14
+ * Parse and format all lines in a raw log content block.
15
+ * JSON lines are parsed and formatted; non-JSON lines are passed through unchanged.
16
+ */
17
+ export declare function parseAndFormatLogContent(rawContent: string): string;
18
+ export {};
@@ -0,0 +1,94 @@
1
+ // OpenClaw JSON log line parser
2
+ // Inlined from ~/code/openclaw/src/logging/parse-log-line.ts
3
+ // Parses tslog JSON log lines into human-readable text format matching "openclaw logs --follow" output
4
+ function trimLower(raw) {
5
+ if (typeof raw !== "string")
6
+ return undefined;
7
+ const trimmed = raw.trim();
8
+ return trimmed ? trimmed.toLowerCase() : undefined;
9
+ }
10
+ function extractMessage(value) {
11
+ const parts = [];
12
+ for (const key of Object.keys(value)) {
13
+ if (!/^\d+$/.test(key))
14
+ continue;
15
+ const item = value[key];
16
+ if (typeof item === "string") {
17
+ parts.push(item);
18
+ }
19
+ else if (item != null) {
20
+ parts.push(JSON.stringify(item));
21
+ }
22
+ }
23
+ return parts.join(" ");
24
+ }
25
+ function parseMetaName(raw) {
26
+ if (typeof raw !== "string")
27
+ return {};
28
+ try {
29
+ const parsed = JSON.parse(raw);
30
+ return {
31
+ subsystem: typeof parsed.subsystem === "string" ? parsed.subsystem : undefined,
32
+ module: typeof parsed.module === "string" ? parsed.module : undefined,
33
+ };
34
+ }
35
+ catch {
36
+ return {};
37
+ }
38
+ }
39
+ /** Parse a single raw JSON log line into structured fields. Returns null for non-JSON lines. */
40
+ export function parseLogLine(raw) {
41
+ try {
42
+ const parsed = JSON.parse(raw);
43
+ const meta = parsed["_meta"];
44
+ const nameMeta = parseMetaName(meta?.name);
45
+ const levelRaw = typeof meta?.logLevelName === "string" ? meta.logLevelName : undefined;
46
+ return {
47
+ time: typeof parsed.time === "string"
48
+ ? parsed.time
49
+ : typeof meta?.date === "string"
50
+ ? meta.date
51
+ : undefined,
52
+ level: trimLower(levelRaw),
53
+ subsystem: nameMeta.subsystem,
54
+ module: nameMeta.module,
55
+ message: extractMessage(parsed),
56
+ raw,
57
+ };
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ /** Format a parsed log line as: "time level subsystem message" (matching openclaw logs output) */
64
+ export function formatParsedLogLine(parsed) {
65
+ const parts = [];
66
+ if (parsed.time)
67
+ parts.push(parsed.time);
68
+ if (parsed.level)
69
+ parts.push(parsed.level.toUpperCase());
70
+ if (parsed.subsystem)
71
+ parts.push(parsed.subsystem);
72
+ if (parsed.message)
73
+ parts.push(parsed.message);
74
+ return parts.join(" ");
75
+ }
76
+ /**
77
+ * Parse and format all lines in a raw log content block.
78
+ * JSON lines are parsed and formatted; non-JSON lines are passed through unchanged.
79
+ */
80
+ export function parseAndFormatLogContent(rawContent) {
81
+ const lines = rawContent.split("\n");
82
+ const formatted = [];
83
+ for (const line of lines) {
84
+ const parsed = parseLogLine(line);
85
+ if (parsed) {
86
+ formatted.push(formatParsedLogLine(parsed));
87
+ }
88
+ else if (line.length > 0) {
89
+ // Pass through non-JSON, non-empty lines as-is
90
+ formatted.push(line);
91
+ }
92
+ }
93
+ return formatted.join("\n");
94
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Resolve a path template with date wildcards to actual file paths on disk.
3
+ * Scans the directory and returns all files matching the pattern.
4
+ */
5
+ export declare function resolveLogFiles(templatePath: string): string[];