@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 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
+