@marenfei/message-logger-plugin 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +856 -0
- package/openclaw.plugin.json +10 -0
- package/package.json +37 -0
- package/tsconfig.json +22 -0
package/index.ts
ADDED
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message Logger Plugin
|
|
3
|
+
*
|
|
4
|
+
* Logs gateway lifecycle events to /tmp/plugin-message-hook.log using plugin hooks:
|
|
5
|
+
* message_received, session_start, before_model_resolve, before_prompt_build,
|
|
6
|
+
* llm_input, llm_output, before_tool_call, after_tool_call, agent_end,
|
|
7
|
+
* message_sending, message_sent, session_end
|
|
8
|
+
* Includes gateway instance information in each log entry.
|
|
9
|
+
*
|
|
10
|
+
* NOTE: Gateway instance info is collected directly via Node.js APIs to avoid
|
|
11
|
+
* coupling with internal source code, ensuring easier upgrades.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {AsyncLocalStorage} from "node:async_hooks";
|
|
15
|
+
import fs from "node:fs/promises";
|
|
16
|
+
import fsSync from "node:fs";
|
|
17
|
+
import {createRequire} from "node:module";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import type {OpenClawPluginApi} from "openclaw/plugin-sdk";
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
const LOG_FILE = "/tmp/plugin-message-hook.log";
|
|
23
|
+
const SESSION_KEY_HEADER = "X-Session-Key";
|
|
24
|
+
const SESSION_ID_HEADER = "X-Session-Id";
|
|
25
|
+
const CRON_TRIGGER_HEADER = "X-Cron-Trigger";
|
|
26
|
+
const LLM_REQUEST_URL_PREFIX = "https://mmc.sankuai.com/openclaw/v1";
|
|
27
|
+
|
|
28
|
+
type LlmRequestContext = {
|
|
29
|
+
sessionKey?: string;
|
|
30
|
+
sessionId?: string;
|
|
31
|
+
isCronTrigger?: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const llmRequestContextStorage = new AsyncLocalStorage<LlmRequestContext>();
|
|
35
|
+
|
|
36
|
+
type PatchedFetchGlobal = typeof globalThis & {
|
|
37
|
+
fetch?: (input: unknown, init?: unknown) => Promise<unknown>;
|
|
38
|
+
__openclawPluginsHookFetchPatched?: boolean;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type HeadersLike = {
|
|
42
|
+
set: (name: string, value: string) => void;
|
|
43
|
+
forEach?: (callback: (value: string, key: string) => void) => void;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function parseRequestUrl(input: unknown): string | undefined {
|
|
47
|
+
if (typeof input === "string") {
|
|
48
|
+
return input;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (typeof input === "object" && input !== null && "url" in input) {
|
|
52
|
+
const maybeUrl = (input as { url?: unknown }).url;
|
|
53
|
+
if (typeof maybeUrl === "string") {
|
|
54
|
+
return maybeUrl;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (typeof input === "object" && input !== null && "href" in input) {
|
|
59
|
+
const maybeHref = (input as { href?: unknown }).href;
|
|
60
|
+
if (typeof maybeHref === "string") {
|
|
61
|
+
return maybeHref;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function shouldAttachSessionKeyHeaders(url: string | undefined): boolean {
|
|
69
|
+
if (!url) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return url.startsWith(LLM_REQUEST_URL_PREFIX);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getHeadersCtor(): (new (init?: unknown) => HeadersLike) | undefined {
|
|
77
|
+
return (globalThis as { Headers?: new (init?: unknown) => HeadersLike }).Headers;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function mergeHeaders(baseHeaders: unknown, overrideHeaders: unknown): unknown {
|
|
81
|
+
const headersCtor = getHeadersCtor();
|
|
82
|
+
if (typeof headersCtor === "function") {
|
|
83
|
+
const merged = new headersCtor(baseHeaders);
|
|
84
|
+
if (overrideHeaders !== undefined) {
|
|
85
|
+
const override = new headersCtor(overrideHeaders);
|
|
86
|
+
if (typeof override.forEach === "function") {
|
|
87
|
+
override.forEach((value, key) => {
|
|
88
|
+
merged.set(key, value);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return merged;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (overrideHeaders !== undefined) {
|
|
96
|
+
return overrideHeaders;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return baseHeaders;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function mergeHeadersWithRequestContext(existingHeaders: unknown, requestContext: LlmRequestContext): unknown {
|
|
103
|
+
const headerEntries: Array<[string, string]> = [];
|
|
104
|
+
if (requestContext.sessionKey) {
|
|
105
|
+
headerEntries.push([SESSION_KEY_HEADER, requestContext.sessionKey]);
|
|
106
|
+
}
|
|
107
|
+
if (requestContext.sessionId) {
|
|
108
|
+
headerEntries.push([SESSION_ID_HEADER, requestContext.sessionId]);
|
|
109
|
+
}
|
|
110
|
+
if (requestContext.isCronTrigger) {
|
|
111
|
+
headerEntries.push([CRON_TRIGGER_HEADER, "true"]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (headerEntries.length === 0) {
|
|
115
|
+
return existingHeaders;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const headersCtor = getHeadersCtor();
|
|
119
|
+
if (typeof headersCtor === "function") {
|
|
120
|
+
const headers = new headersCtor(existingHeaders);
|
|
121
|
+
for (const [key, value] of headerEntries) {
|
|
122
|
+
headers.set(key, value);
|
|
123
|
+
}
|
|
124
|
+
return headers;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (Array.isArray(existingHeaders)) {
|
|
128
|
+
return [...existingHeaders, ...headerEntries];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const contextHeaders = Object.fromEntries(headerEntries);
|
|
132
|
+
if (existingHeaders && typeof existingHeaders === "object") {
|
|
133
|
+
return {
|
|
134
|
+
...(existingHeaders as Record<string, unknown>),
|
|
135
|
+
...contextHeaders,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return contextHeaders;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function getRequestHeaders(input: unknown): unknown {
|
|
143
|
+
if (typeof input === "object" && input !== null && "headers" in input) {
|
|
144
|
+
return (input as { headers?: unknown }).headers;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function withRequestContextHeaders(input: unknown, init: unknown, requestContext: LlmRequestContext): Record<string, unknown> {
|
|
151
|
+
const baseInit = init && typeof init === "object" ? { ...(init as Record<string, unknown>) } : {};
|
|
152
|
+
const requestHeaders = getRequestHeaders(input);
|
|
153
|
+
const mergedHeaders = mergeHeaders(requestHeaders, baseInit.headers);
|
|
154
|
+
baseInit.headers = mergeHeadersWithRequestContext(mergedHeaders, requestContext);
|
|
155
|
+
return baseInit;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function installLlmRequestHeaderFetchInterceptor(
|
|
159
|
+
onLog?: (entry: Record<string, unknown>) => void,
|
|
160
|
+
): void {
|
|
161
|
+
const globalState = globalThis as PatchedFetchGlobal;
|
|
162
|
+
if (typeof globalState.fetch !== "function") {
|
|
163
|
+
onLog?.({
|
|
164
|
+
hookType: "llm_header_interceptor_skip",
|
|
165
|
+
reason: "global_fetch_unavailable",
|
|
166
|
+
});
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (globalState.__openclawPluginsHookFetchPatched) {
|
|
171
|
+
onLog?.({
|
|
172
|
+
hookType: "llm_header_interceptor_skip",
|
|
173
|
+
reason: "already_patched",
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const originalFetch = globalState.fetch.bind(globalThis);
|
|
179
|
+
globalState.__openclawPluginsHookFetchPatched = true;
|
|
180
|
+
|
|
181
|
+
onLog?.({
|
|
182
|
+
hookType: "llm_header_interceptor_installed",
|
|
183
|
+
urlPrefix: LLM_REQUEST_URL_PREFIX,
|
|
184
|
+
headerNames: [SESSION_KEY_HEADER, SESSION_ID_HEADER, CRON_TRIGGER_HEADER],
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
globalState.fetch = async (input: unknown, init?: unknown): Promise<unknown> => {
|
|
188
|
+
const url = parseRequestUrl(input);
|
|
189
|
+
if (!shouldAttachSessionKeyHeaders(url)) {
|
|
190
|
+
return originalFetch(input, init);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const requestContext = llmRequestContextStorage.getStore();
|
|
194
|
+
if (!requestContext?.sessionKey && !requestContext?.sessionId && !requestContext?.isCronTrigger) {
|
|
195
|
+
onLog?.({
|
|
196
|
+
hookType: "llm_header_inject_skip",
|
|
197
|
+
reason: "missing_ctx",
|
|
198
|
+
targetUrl: url,
|
|
199
|
+
});
|
|
200
|
+
return originalFetch(input, init);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const headerNames = [
|
|
204
|
+
requestContext.sessionKey ? SESSION_KEY_HEADER : undefined,
|
|
205
|
+
requestContext.sessionId ? SESSION_ID_HEADER : undefined,
|
|
206
|
+
requestContext.isCronTrigger ? CRON_TRIGGER_HEADER : undefined,
|
|
207
|
+
].filter((name): name is string => Boolean(name));
|
|
208
|
+
|
|
209
|
+
onLog?.({
|
|
210
|
+
hookType: "llm_header_inject",
|
|
211
|
+
targetUrl: url,
|
|
212
|
+
sessionId: requestContext.sessionId,
|
|
213
|
+
hasSessionKey: Boolean(requestContext.sessionKey),
|
|
214
|
+
isCronTrigger: Boolean(requestContext.isCronTrigger),
|
|
215
|
+
headerNames,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
return originalFetch(input, withRequestContextHeaders(input, init, requestContext));
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
type BridgeRequestContext = {
|
|
223
|
+
traceId?: string;
|
|
224
|
+
senderMisId?: string;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
type BridgeStorageLike = {
|
|
228
|
+
getStore: () => BridgeRequestContext | undefined;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const DAXIANG_BRIDGE_MODULE_CANDIDATES = [
|
|
232
|
+
"@catclaw/daxiang/src/bridge-context.ts",
|
|
233
|
+
"@catclaw/daxiang/src/bridge-context.js",
|
|
234
|
+
"../daxiang/src/bridge-context.ts",
|
|
235
|
+
"../daxiang/dist/index.mjs",
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
const requireResolver = createRequire(import.meta.url);
|
|
239
|
+
|
|
240
|
+
function canResolveModuleSpecifier(modulePath: string): boolean {
|
|
241
|
+
try {
|
|
242
|
+
requireResolver.resolve(modulePath);
|
|
243
|
+
return true;
|
|
244
|
+
} catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let sharedBridgeStorage: BridgeStorageLike | null = null;
|
|
250
|
+
let bridgeStorageLoadPromise: Promise<void> | null = null;
|
|
251
|
+
|
|
252
|
+
async function ensureBridgeStorageLoaded(logDebug?: (message: string) => void): Promise<void> {
|
|
253
|
+
if (sharedBridgeStorage) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!bridgeStorageLoadPromise) {
|
|
258
|
+
bridgeStorageLoadPromise = (async () => {
|
|
259
|
+
for (const modulePath of DAXIANG_BRIDGE_MODULE_CANDIDATES) {
|
|
260
|
+
if (!canResolveModuleSpecifier(modulePath)) {
|
|
261
|
+
logDebug?.(`[trace-bridge] module not found, skip import: ${modulePath}`);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const mod = await import(modulePath);
|
|
267
|
+
const maybeStorage = (mod as { bridgeStorage?: BridgeStorageLike }).bridgeStorage;
|
|
268
|
+
if (maybeStorage && typeof maybeStorage.getStore === "function") {
|
|
269
|
+
sharedBridgeStorage = maybeStorage;
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
} catch (err) {
|
|
273
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
274
|
+
logDebug?.(`[trace-bridge] import failed from ${modulePath}: ${message}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
})();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
await bridgeStorageLoadPromise;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function readSharedBridgeContext(): BridgeRequestContext {
|
|
284
|
+
try {
|
|
285
|
+
if (!sharedBridgeStorage) {
|
|
286
|
+
return {};
|
|
287
|
+
}
|
|
288
|
+
const store = sharedBridgeStorage.getStore();
|
|
289
|
+
return {
|
|
290
|
+
traceId: store?.traceId,
|
|
291
|
+
senderMisId: store?.senderMisId,
|
|
292
|
+
};
|
|
293
|
+
} catch {
|
|
294
|
+
return {};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function __setSharedBridgeStorageForTest(storage: BridgeStorageLike | null): void {
|
|
299
|
+
sharedBridgeStorage = storage;
|
|
300
|
+
bridgeStorageLoadPromise = null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export default function register(api: OpenClawPluginApi) {
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Truncate content fields to a maximum of 30 characters.
|
|
307
|
+
* For strings: truncates if longer than 30 chars.
|
|
308
|
+
* For objects/arrays: recursively processes all string values within.
|
|
309
|
+
* Other types: returned as-is.
|
|
310
|
+
*/
|
|
311
|
+
const truncateContent = (content: unknown): unknown => {
|
|
312
|
+
// Handle null/undefined
|
|
313
|
+
if (content === null || content === undefined) {
|
|
314
|
+
return content;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Handle strings - truncate if needed
|
|
318
|
+
if (typeof content === "string") {
|
|
319
|
+
return content.length > 30 ? content.slice(0, 30) + "..." : content;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Handle arrays - recursively process each item
|
|
323
|
+
if (Array.isArray(content)) {
|
|
324
|
+
return content.map(item => truncateContent(item));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Handle objects - recursively process each property
|
|
328
|
+
if (typeof content === "object") {
|
|
329
|
+
const result: Record<string, unknown> = {};
|
|
330
|
+
for (const [key, value] of Object.entries(content)) {
|
|
331
|
+
result[key] = truncateContent(value);
|
|
332
|
+
}
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Handle other types (number, boolean, etc.)
|
|
337
|
+
return content;
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Parse jobId from sessionKey.
|
|
342
|
+
* Supports both "cron:<jobId>" and "<prefix>:cron:<jobId>" formats.
|
|
343
|
+
*
|
|
344
|
+
* @returns jobId string, or undefined if sessionKey is not a cron session
|
|
345
|
+
*/
|
|
346
|
+
const parseJobIdFromSessionKey = (sessionKey: string | undefined): string | undefined => {
|
|
347
|
+
if (!sessionKey) {
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
const segments = sessionKey.split(":");
|
|
351
|
+
const cronIndex = segments.lastIndexOf("cron");
|
|
352
|
+
if (cronIndex === -1 || cronIndex >= segments.length - 1) {
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
return segments[cronIndex + 1] || undefined;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Cron job type definition (minimal fields needed for plugin)
|
|
360
|
+
* Based on src/cron/types.ts CronJob and CronJobState
|
|
361
|
+
*/
|
|
362
|
+
type CronJobInfo = {
|
|
363
|
+
id: string;
|
|
364
|
+
name: string;
|
|
365
|
+
enabled: boolean;
|
|
366
|
+
description?: string;
|
|
367
|
+
schedule: {
|
|
368
|
+
kind: "at" | "every" | "cron";
|
|
369
|
+
expr?: string;
|
|
370
|
+
everyMs?: number;
|
|
371
|
+
at?: string;
|
|
372
|
+
tz?: string;
|
|
373
|
+
staggerMs?: number;
|
|
374
|
+
};
|
|
375
|
+
payload: {
|
|
376
|
+
kind: "systemEvent" | "agentTurn";
|
|
377
|
+
message?: string;
|
|
378
|
+
text?: string;
|
|
379
|
+
model?: string;
|
|
380
|
+
};
|
|
381
|
+
delivery?: {
|
|
382
|
+
mode: "none" | "announce" | "webhook";
|
|
383
|
+
channel?: string;
|
|
384
|
+
to?: string;
|
|
385
|
+
};
|
|
386
|
+
state?: {
|
|
387
|
+
nextRunAtMs?: number;
|
|
388
|
+
lastRunAtMs?: number;
|
|
389
|
+
lastRunStatus?: "ok" | "error" | "skipped";
|
|
390
|
+
lastError?: string;
|
|
391
|
+
lastDurationMs?: number;
|
|
392
|
+
};
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
type CronJobsFile = {
|
|
396
|
+
version: 1;
|
|
397
|
+
jobs: CronJobInfo[];
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Get cron job info by jobId from jobs.json.
|
|
402
|
+
*
|
|
403
|
+
* @param jobId - The cron job ID
|
|
404
|
+
* @returns CronJobInfo object, or undefined if not found or file cannot be read
|
|
405
|
+
*/
|
|
406
|
+
const getCronJobById = async (jobId: string): Promise<CronJobInfo | undefined> => {
|
|
407
|
+
try {
|
|
408
|
+
const homeDir = os.homedir();
|
|
409
|
+
const jobsPath = `${homeDir}/.openclaw/cron/jobs.json`;
|
|
410
|
+
const content = await fs.readFile(jobsPath, "utf-8");
|
|
411
|
+
const jobsFile = JSON.parse(content) as CronJobsFile;
|
|
412
|
+
if (!Array.isArray(jobsFile.jobs)) {
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
return jobsFile.jobs.find((job) => job.id === jobId);
|
|
416
|
+
} catch (err) {
|
|
417
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
418
|
+
// Best-effort lookup for plugin enrichment; don't block hook execution.
|
|
419
|
+
api.logger.warn(`Failed to read cron jobs.json: ${message}`);
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Get the gateway instance info using Node.js APIs.
|
|
428
|
+
* This approach avoids dependency on internal source code.
|
|
429
|
+
*
|
|
430
|
+
* instanceIp: first non-loopback IPv4 address from os.networkInterfaces().
|
|
431
|
+
*/
|
|
432
|
+
const getGatewayInstanceInfo = (): Record<string, unknown> => {
|
|
433
|
+
const host = os.hostname();
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
instanceHost: host
|
|
437
|
+
};
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Format a Unix millisecond timestamp into the two fields added to every log entry:
|
|
442
|
+
* eventTime — "yyyy-MM-dd HH:mm:ss.SSS" in Asia/Shanghai (UTC+8)
|
|
443
|
+
* eventAt — 13-digit Unix milliseconds
|
|
444
|
+
*/
|
|
445
|
+
const formatTimestampFields = (
|
|
446
|
+
ms: number,
|
|
447
|
+
): {
|
|
448
|
+
eventTime: string;
|
|
449
|
+
eventAt: number;
|
|
450
|
+
} => {
|
|
451
|
+
// Shift to UTC+8 for display
|
|
452
|
+
const offsetMs = 8 * 60 * 60 * 1000;
|
|
453
|
+
const local = new Date(ms + offsetMs);
|
|
454
|
+
const iso = local.toISOString(); // always UTC representation after shift
|
|
455
|
+
// iso format: "2026-03-12T10:00:58.382Z"
|
|
456
|
+
const datePart = iso.slice(0, 10); // "2026-03-12"
|
|
457
|
+
const timePart = iso.slice(11, 23); // "10:00:58.382"
|
|
458
|
+
return {
|
|
459
|
+
eventTime: `${datePart} ${timePart}`,
|
|
460
|
+
eventAt: ms,
|
|
461
|
+
};
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Resolve the openclaw binary version.
|
|
467
|
+
*
|
|
468
|
+
* Priority:
|
|
469
|
+
* 1. Build-time global __OPENCLAW_VERSION__ (injected by openclaw's own bundle)
|
|
470
|
+
* 2. openclaw/package.json resolved via createRequire → "version" field
|
|
471
|
+
* 3. npm global node_modules: execSync("npm root -g") + /openclaw/package.json
|
|
472
|
+
* 4. /app/package.json → "version" field
|
|
473
|
+
* 5. /app/dist/build-info.json → "version" field
|
|
474
|
+
* fallback: "unknown"
|
|
475
|
+
*/
|
|
476
|
+
const openclawVersion = (() => {
|
|
477
|
+
const readJsonSync = (filePath: string): Record<string, unknown> | null => {
|
|
478
|
+
try {
|
|
479
|
+
return JSON.parse(fsSync.readFileSync(filePath, "utf-8")) as Record<string, unknown>;
|
|
480
|
+
} catch {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
// 1. Build-time injected global
|
|
486
|
+
const injected = (globalThis as Record<string, unknown>)["__OPENCLAW_VERSION__"];
|
|
487
|
+
if (typeof injected === "string" && injected.trim()) {
|
|
488
|
+
return injected.trim();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// 2. openclaw/package.json via createRequire
|
|
492
|
+
try {
|
|
493
|
+
const pkgPath = requireResolver.resolve("openclaw/package.json");
|
|
494
|
+
const pkg = readJsonSync(pkgPath);
|
|
495
|
+
if (typeof pkg?.version === "string" && (pkg.version as string).trim()) {
|
|
496
|
+
return (pkg.version as string).trim();
|
|
497
|
+
}
|
|
498
|
+
} catch {
|
|
499
|
+
// module not resolvable, fall through
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// 3. npm global node_modules: `npm root -g` + /openclaw/package.json
|
|
503
|
+
try {
|
|
504
|
+
const { execSync } = requireResolver("node:child_process") as typeof import("node:child_process");
|
|
505
|
+
const globalRoot = execSync("npm root -g", { encoding: "utf-8" }).trim();
|
|
506
|
+
const pkg = readJsonSync(`${globalRoot}/openclaw/package.json`);
|
|
507
|
+
if (typeof pkg?.version === "string" && (pkg.version as string).trim()) {
|
|
508
|
+
return (pkg.version as string).trim();
|
|
509
|
+
}
|
|
510
|
+
} catch {
|
|
511
|
+
// npm not available or openclaw not installed globally, fall through
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// 4. /app/package.json
|
|
515
|
+
const pkg = readJsonSync("/app/package.json");
|
|
516
|
+
if (typeof pkg?.version === "string" && (pkg.version as string).trim()) {
|
|
517
|
+
return (pkg.version as string).trim();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// 5. /app/dist/build-info.json
|
|
521
|
+
const info = readJsonSync("/app/dist/build-info.json");
|
|
522
|
+
if (typeof info?.version === "string" && (info.version as string).trim()) {
|
|
523
|
+
return (info.version as string).trim();
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return "unknown";
|
|
527
|
+
})();
|
|
528
|
+
|
|
529
|
+
// Helper function to write log entries with instance info
|
|
530
|
+
const writeLog = async (entry: Record<string, unknown>) => {
|
|
531
|
+
const eventAt = Date.now();
|
|
532
|
+
try {
|
|
533
|
+
await ensureBridgeStorageLoaded((message) => api.logger.debug(message));
|
|
534
|
+
// Attach gateway instance info to every log entry
|
|
535
|
+
const instanceInfo = getGatewayInstanceInfo();
|
|
536
|
+
const bridgeCtx = readSharedBridgeContext();
|
|
537
|
+
const timestampFields = formatTimestampFields(eventAt);
|
|
538
|
+
const enrichedEntry = {
|
|
539
|
+
...entry,
|
|
540
|
+
...timestampFields,
|
|
541
|
+
...instanceInfo,
|
|
542
|
+
openclawVersion,
|
|
543
|
+
...(bridgeCtx.traceId ? {bridgeTraceId: bridgeCtx.traceId} : {bridgeTraceId : undefined}),
|
|
544
|
+
...(bridgeCtx.senderMisId ? {bridgeSenderMisId: bridgeCtx.senderMisId} : {}),
|
|
545
|
+
};
|
|
546
|
+
const line = JSON.stringify(enrichedEntry) + "\n";
|
|
547
|
+
|
|
548
|
+
// Write to local file
|
|
549
|
+
await fs.appendFile(LOG_FILE, line, "utf-8");
|
|
550
|
+
} catch (err) {
|
|
551
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
552
|
+
api.logger.error(`Failed to write log: ${message}`);
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
installLlmRequestHeaderFetchInterceptor((entry) => {
|
|
557
|
+
api.logger.info(`[llm_header] ${JSON.stringify(entry)}`);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// Hook: message_received — captures inbound messages from channels
|
|
561
|
+
api.on(
|
|
562
|
+
"message_received",
|
|
563
|
+
async (event, ctx) => {
|
|
564
|
+
await writeLog({
|
|
565
|
+
hookType: "message_received",
|
|
566
|
+
from: event.from,
|
|
567
|
+
content: truncateContent(event.content),
|
|
568
|
+
eventTimestamp: event.timestamp,
|
|
569
|
+
metadata: event.metadata,
|
|
570
|
+
channelId: ctx.channelId,
|
|
571
|
+
accountId: ctx.accountId,
|
|
572
|
+
conversationId: ctx.conversationId,
|
|
573
|
+
});
|
|
574
|
+
},
|
|
575
|
+
{priority: 10},
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
// Hook: session_start — fires when a new session is created or resumed
|
|
579
|
+
api.on(
|
|
580
|
+
"session_start",
|
|
581
|
+
async (event, ctx) => {
|
|
582
|
+
await writeLog({
|
|
583
|
+
hookType: "session_start",
|
|
584
|
+
sessionId: event.sessionId,
|
|
585
|
+
sessionKey: event.sessionKey,
|
|
586
|
+
resumedFrom: event.resumedFrom,
|
|
587
|
+
agentId: ctx.agentId,
|
|
588
|
+
});
|
|
589
|
+
},
|
|
590
|
+
{priority: 10},
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
// Hook: before_model_resolve — fires before the model/provider is selected for a run
|
|
594
|
+
api.on(
|
|
595
|
+
"before_model_resolve",
|
|
596
|
+
(_event, ctx) => {
|
|
597
|
+
const isCronTrigger = Boolean(parseJobIdFromSessionKey(ctx.sessionKey) || ctx.trigger === "cron");
|
|
598
|
+
llmRequestContextStorage.enterWith({
|
|
599
|
+
sessionKey: ctx.sessionKey,
|
|
600
|
+
sessionId: ctx.sessionId,
|
|
601
|
+
isCronTrigger,
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
// Keep existing resolve logs without blocking context propagation.
|
|
605
|
+
void writeLog({
|
|
606
|
+
hookType: "before_model_resolve",
|
|
607
|
+
agentId: ctx.agentId,
|
|
608
|
+
sessionKey: ctx.sessionKey,
|
|
609
|
+
sessionId: ctx.sessionId,
|
|
610
|
+
channelId: ctx.channelId,
|
|
611
|
+
trigger: ctx.trigger,
|
|
612
|
+
});
|
|
613
|
+
},
|
|
614
|
+
{priority: 10},
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
// Hook: before_prompt_build — fires after session messages are ready, before prompt assembly
|
|
618
|
+
api.on(
|
|
619
|
+
"before_prompt_build",
|
|
620
|
+
async (event, ctx) => {
|
|
621
|
+
await writeLog({
|
|
622
|
+
hookType: "before_prompt_build",
|
|
623
|
+
messagesCount: Array.isArray(event.messages) ? event.messages.length : undefined,
|
|
624
|
+
agentId: ctx.agentId,
|
|
625
|
+
sessionKey: ctx.sessionKey,
|
|
626
|
+
sessionId: ctx.sessionId,
|
|
627
|
+
channelId: ctx.channelId,
|
|
628
|
+
trigger: ctx.trigger,
|
|
629
|
+
});
|
|
630
|
+
},
|
|
631
|
+
{priority: 10},
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
// Hook: llm_input — captures the full prompt sent to the LLM
|
|
635
|
+
api.on(
|
|
636
|
+
"llm_input",
|
|
637
|
+
async (event, ctx) => {
|
|
638
|
+
await writeLog({
|
|
639
|
+
hookType: "llm_input",
|
|
640
|
+
role: "user",
|
|
641
|
+
runId: event.runId,
|
|
642
|
+
sessionId: event.sessionId,
|
|
643
|
+
agentId: ctx.agentId,
|
|
644
|
+
sessionKey: ctx.sessionKey,
|
|
645
|
+
channelId: ctx.channelId,
|
|
646
|
+
provider: event.provider,
|
|
647
|
+
model: event.model,
|
|
648
|
+
imagesCount: event.imagesCount,
|
|
649
|
+
});
|
|
650
|
+
},
|
|
651
|
+
{priority: 10},
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
// Hook: llm_output — captures the assistant reply from the LLM
|
|
655
|
+
api.on(
|
|
656
|
+
"llm_output",
|
|
657
|
+
async (event, ctx) => {
|
|
658
|
+
// Extract stopReason and errorMessage from lastAssistant (typed as unknown)
|
|
659
|
+
const last = event.lastAssistant as
|
|
660
|
+
| { stopReason?: string; errorMessage?: string }
|
|
661
|
+
| undefined;
|
|
662
|
+
await writeLog({
|
|
663
|
+
hookType: "llm_output",
|
|
664
|
+
role: "assistant",
|
|
665
|
+
runId: event.runId,
|
|
666
|
+
sessionId: event.sessionId,
|
|
667
|
+
agentId: ctx.agentId,
|
|
668
|
+
sessionKey: ctx.sessionKey,
|
|
669
|
+
channelId: ctx.channelId,
|
|
670
|
+
provider: event.provider,
|
|
671
|
+
model: event.model,
|
|
672
|
+
assistantTexts: truncateContent(event.assistantTexts),
|
|
673
|
+
lastAssistantStopReason: last?.stopReason,
|
|
674
|
+
lastAssistantErrorMessage: last?.errorMessage,
|
|
675
|
+
});
|
|
676
|
+
},
|
|
677
|
+
{priority: 10},
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
// Hook: before_tool_call — captures tool invocations before execution
|
|
681
|
+
api.on(
|
|
682
|
+
"before_tool_call",
|
|
683
|
+
async (event, ctx) => {
|
|
684
|
+
await writeLog({
|
|
685
|
+
hookType: "before_tool_call",
|
|
686
|
+
role: "tool",
|
|
687
|
+
phase: "before",
|
|
688
|
+
runId: event.runId ?? ctx.runId,
|
|
689
|
+
sessionId: ctx.sessionId,
|
|
690
|
+
agentId: ctx.agentId,
|
|
691
|
+
sessionKey: ctx.sessionKey,
|
|
692
|
+
toolName: event.toolName,
|
|
693
|
+
toolCallId: event.toolCallId ?? ctx.toolCallId,
|
|
694
|
+
params: truncateContent(event.params),
|
|
695
|
+
});
|
|
696
|
+
},
|
|
697
|
+
{priority: 10},
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
// Hook: after_tool_call — captures tool results after execution
|
|
701
|
+
api.on(
|
|
702
|
+
"after_tool_call",
|
|
703
|
+
async (event, ctx) => {
|
|
704
|
+
await writeLog({
|
|
705
|
+
hookType: "after_tool_call",
|
|
706
|
+
role: "tool",
|
|
707
|
+
phase: "after",
|
|
708
|
+
runId: event.runId ?? ctx.runId,
|
|
709
|
+
sessionId: ctx.sessionId,
|
|
710
|
+
agentId: ctx.agentId,
|
|
711
|
+
sessionKey: ctx.sessionKey,
|
|
712
|
+
toolName: event.toolName,
|
|
713
|
+
toolCallId: event.toolCallId ?? ctx.toolCallId,
|
|
714
|
+
params: truncateContent(event.params),
|
|
715
|
+
result: truncateContent(event.result),
|
|
716
|
+
error: event.error,
|
|
717
|
+
durationMs: event.durationMs,
|
|
718
|
+
});
|
|
719
|
+
},
|
|
720
|
+
{priority: 10},
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
// Hook: agent_end — captures the result of an agent run (success/failure, duration)
|
|
724
|
+
api.on(
|
|
725
|
+
"agent_end",
|
|
726
|
+
async (event, ctx) => {
|
|
727
|
+
const jobId = parseJobIdFromSessionKey(ctx.sessionKey);
|
|
728
|
+
const cronJob = jobId ? await getCronJobById(jobId) : undefined;
|
|
729
|
+
await writeLog({
|
|
730
|
+
hookType: "agent_end",
|
|
731
|
+
agentId: ctx.agentId,
|
|
732
|
+
sessionKey: ctx.sessionKey,
|
|
733
|
+
sessionId: ctx.sessionId,
|
|
734
|
+
channelId: ctx.channelId,
|
|
735
|
+
trigger: ctx.trigger,
|
|
736
|
+
success: event.success,
|
|
737
|
+
error: event.error,
|
|
738
|
+
durationMs: event.durationMs,
|
|
739
|
+
jobId: jobId,
|
|
740
|
+
cronJob: cronJob ? JSON.stringify(cronJob) : undefined,
|
|
741
|
+
messagesCount: Array.isArray(event.messages) ? event.messages.length : undefined,
|
|
742
|
+
});
|
|
743
|
+
},
|
|
744
|
+
{priority: 10},
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
// Hook: message_sending — fires just before an outbound message is dispatched
|
|
748
|
+
api.on(
|
|
749
|
+
"message_sending",
|
|
750
|
+
async (event, ctx) => {
|
|
751
|
+
await writeLog({
|
|
752
|
+
hookType: "message_sending",
|
|
753
|
+
to: event.to,
|
|
754
|
+
content: truncateContent(event.content),
|
|
755
|
+
metadata: event.metadata,
|
|
756
|
+
channelId: ctx.channelId,
|
|
757
|
+
accountId: ctx.accountId,
|
|
758
|
+
conversationId: ctx.conversationId,
|
|
759
|
+
});
|
|
760
|
+
},
|
|
761
|
+
{priority: 10},
|
|
762
|
+
);
|
|
763
|
+
|
|
764
|
+
// Hook: message_sent — fires after an outbound message has been delivered (or failed)
|
|
765
|
+
api.on(
|
|
766
|
+
"message_sent",
|
|
767
|
+
async (event, ctx) => {
|
|
768
|
+
await writeLog({
|
|
769
|
+
hookType: "message_sent",
|
|
770
|
+
to: event.to,
|
|
771
|
+
content: truncateContent(event.content),
|
|
772
|
+
success: event.success,
|
|
773
|
+
error: event.error,
|
|
774
|
+
channelId: ctx.channelId,
|
|
775
|
+
accountId: ctx.accountId,
|
|
776
|
+
conversationId: ctx.conversationId,
|
|
777
|
+
});
|
|
778
|
+
},
|
|
779
|
+
{priority: 10},
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
// Hook: session_end — fires when a session terminates
|
|
783
|
+
api.on(
|
|
784
|
+
"session_end",
|
|
785
|
+
async (event, ctx) => {
|
|
786
|
+
await writeLog({
|
|
787
|
+
hookType: "session_end",
|
|
788
|
+
sessionId: event.sessionId,
|
|
789
|
+
sessionKey: event.sessionKey,
|
|
790
|
+
messageCount: event.messageCount,
|
|
791
|
+
durationMs: event.durationMs,
|
|
792
|
+
agentId: ctx.agentId,
|
|
793
|
+
});
|
|
794
|
+
},
|
|
795
|
+
{priority: 10},
|
|
796
|
+
);
|
|
797
|
+
|
|
798
|
+
// Hook: before_reset — fires when /new or /reset clears a session
|
|
799
|
+
api.on(
|
|
800
|
+
"before_reset",
|
|
801
|
+
async (event, ctx) => {
|
|
802
|
+
await writeLog({
|
|
803
|
+
hookType: "before_reset",
|
|
804
|
+
sessionFile: event.sessionFile,
|
|
805
|
+
messagesCount: Array.isArray(event.messages) ? event.messages.length : undefined,
|
|
806
|
+
reason: event.reason,
|
|
807
|
+
agentId: ctx.agentId,
|
|
808
|
+
sessionKey: ctx.sessionKey,
|
|
809
|
+
sessionId: ctx.sessionId,
|
|
810
|
+
});
|
|
811
|
+
},
|
|
812
|
+
{priority: 10},
|
|
813
|
+
);
|
|
814
|
+
|
|
815
|
+
// Hook: subagent_spawned — fires when a subagent has been spawned
|
|
816
|
+
api.on(
|
|
817
|
+
"subagent_spawned",
|
|
818
|
+
async (event, ctx) => {
|
|
819
|
+
await writeLog({
|
|
820
|
+
hookType: "subagent_spawned",
|
|
821
|
+
childSessionKey: event.childSessionKey,
|
|
822
|
+
agentId: event.agentId,
|
|
823
|
+
label: event.label,
|
|
824
|
+
mode: event.mode,
|
|
825
|
+
threadRequested: event.threadRequested,
|
|
826
|
+
runId: event.runId,
|
|
827
|
+
requesterSessionKey: ctx.requesterSessionKey,
|
|
828
|
+
});
|
|
829
|
+
},
|
|
830
|
+
{priority: 10},
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
// Hook: subagent_ended — fires when a subagent session ends
|
|
834
|
+
api.on(
|
|
835
|
+
"subagent_ended",
|
|
836
|
+
async (event, ctx) => {
|
|
837
|
+
await writeLog({
|
|
838
|
+
hookType: "subagent_ended",
|
|
839
|
+
targetSessionKey: event.targetSessionKey,
|
|
840
|
+
targetKind: event.targetKind,
|
|
841
|
+
reason: event.reason,
|
|
842
|
+
sendFarewell: event.sendFarewell,
|
|
843
|
+
accountId: event.accountId,
|
|
844
|
+
runId: event.runId,
|
|
845
|
+
endedAt: event.endedAt,
|
|
846
|
+
outcome: event.outcome,
|
|
847
|
+
error: event.error,
|
|
848
|
+
childSessionKey: ctx.childSessionKey,
|
|
849
|
+
requesterSessionKey: ctx.requesterSessionKey,
|
|
850
|
+
});
|
|
851
|
+
},
|
|
852
|
+
{priority: 10},
|
|
853
|
+
);
|
|
854
|
+
|
|
855
|
+
api.logger.info(`Message logger plugin registered (logs to ${LOG_FILE})`);
|
|
856
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "message-logger-plugin",
|
|
3
|
+
"name": "Message Logger Plugin",
|
|
4
|
+
"description": "Logs user, assistant, and tool messages to /tmp/plugin-message-hook.log using plugin hooks.",
|
|
5
|
+
"configSchema": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {}
|
|
9
|
+
}
|
|
10
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@marenfei/message-logger-plugin",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "OpenClaw message logger plugin using plugin hooks",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.ts",
|
|
9
|
+
"openclaw.plugin.json",
|
|
10
|
+
"tsconfig.json"
|
|
11
|
+
],
|
|
12
|
+
"keywords": [
|
|
13
|
+
"openclaw",
|
|
14
|
+
"plugin",
|
|
15
|
+
"message-logger",
|
|
16
|
+
"gateway"
|
|
17
|
+
],
|
|
18
|
+
"author": "marenfei@meituan.com",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/ee/openclaw-plugins-hook.git"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"openclaw": ">=2026.2.0"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"registry": "https://registry.npmjs.org",
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"openclaw": {
|
|
33
|
+
"extensions": [
|
|
34
|
+
"./index.ts"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"allowSyntheticDefaultImports": true,
|
|
14
|
+
"forceConsistentCasingInFileNames": true,
|
|
15
|
+
"types": ["node"]
|
|
16
|
+
},
|
|
17
|
+
"include": ["index.ts"],
|
|
18
|
+
"exclude": [
|
|
19
|
+
"message-logger-plugin/node_modules"
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
|