@zhushanwen/pi-extension-logger 0.2.0

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 @@
1
+ export { createLogger, getLogger, setPiHandle, type ExtensionLogger, type PiLike } from "./src/index.js";
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@zhushanwen/pi-extension-logger",
3
+ "version": "0.2.0",
4
+ "description": "Shared logging helper for Pi extensions — three-channel routing (AI realtime / audit / debug) (shared library, not a Pi extension - no install.mjs needed)",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi",
10
+ "logger",
11
+ "logging",
12
+ "observability"
13
+ ],
14
+ "license": "MIT",
15
+ "files": [
16
+ "src/",
17
+ "index.ts"
18
+ ],
19
+ "peerDependencies": {
20
+ "@earendil-works/pi-coding-agent": "*"
21
+ },
22
+ "devDependencies": {
23
+ "vitest": "^4.1.8"
24
+ },
25
+ "scripts": {
26
+ "typecheck": "npx tsc --noEmit",
27
+ "test": "vitest run"
28
+ }
29
+ }
@@ -0,0 +1,272 @@
1
+ import { existsSync, readFileSync, rmSync, mkdirSync, chmodSync, mkdtempSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import {
7
+ createLogger,
8
+ getLogger,
9
+ setPiHandle,
10
+ type PiLike,
11
+ } from "../index.js";
12
+
13
+ describe("extension-logger", () => {
14
+ let appendSpy: ReturnType<typeof vi.fn>;
15
+ let pi: PiLike;
16
+ const prevEnv = { ...process.env };
17
+
18
+ beforeEach(() => {
19
+ appendSpy = vi.fn();
20
+ pi = { appendEntry: appendSpy };
21
+ setPiHandle(undefined);
22
+ });
23
+
24
+ afterEach(() => {
25
+ setPiHandle(undefined);
26
+ vi.restoreAllMocks();
27
+ // 还原环境变量,避免文件日志测试的 PI_EXT_DEBUG/PI_CODING_AGENT_DIR 泄漏到其它用例
28
+ process.env = { ...prevEnv };
29
+ });
30
+
31
+ describe("createLogger", () => {
32
+ it("warn 走 pi.appendEntry,customType 前缀为 <extName>:log", () => {
33
+ const logger = createLogger("test-ext", pi);
34
+ logger.warn("manifest write failed", { id: "rec-1" });
35
+
36
+ expect(appendSpy).toHaveBeenCalledOnce();
37
+ const [customType, data] = appendSpy.mock.calls[0]!;
38
+ expect(customType).toBe("test-ext:log");
39
+ expect(data).toMatchObject({
40
+ level: "warn",
41
+ message: "[test-ext] manifest write failed",
42
+ data: { id: "rec-1" },
43
+ });
44
+ });
45
+
46
+ it("error 走 pi.appendEntry,level 为 error", () => {
47
+ const logger = createLogger("subagents", pi);
48
+ logger.error("budget abort failed");
49
+
50
+ expect(appendSpy).toHaveBeenCalledOnce();
51
+ const [, data] = appendSpy.mock.calls[0]!;
52
+ expect(data).toMatchObject({
53
+ level: "error",
54
+ message: "[subagents] budget abort failed",
55
+ });
56
+ });
57
+
58
+ it("msg 已有 [extName] 前缀时不重复补", () => {
59
+ const logger = createLogger("subagents", pi);
60
+ logger.warn("[subagents] skip malformed manifest");
61
+
62
+ const [, data] = appendSpy.mock.calls[0]!;
63
+ expect(data).toMatchObject({
64
+ message: "[subagents] skip malformed manifest",
65
+ });
66
+ });
67
+
68
+ it("appendEntry 抛错时不 throw(降级到文件日志)", () => {
69
+ const throwingPi: PiLike = {
70
+ appendEntry: () => {
71
+ throw new Error("session disposed");
72
+ },
73
+ };
74
+ const logger = createLogger("test", throwingPi);
75
+ expect(() => logger.warn("no crash")).not.toThrow();
76
+ });
77
+
78
+ // ---- error 分支:与 warn 对称的前缀补全 + appendEntry 失败降级(Suggestion #10)----
79
+ it("error msg 已有 [extName] 前缀时不重复补", () => {
80
+ const logger = createLogger("subagents", pi);
81
+ logger.error("[subagents] critical budget abort");
82
+
83
+ const [, data] = appendSpy.mock.calls[0]!;
84
+ expect(data).toMatchObject({
85
+ message: "[subagents] critical budget abort",
86
+ });
87
+ });
88
+
89
+ it("error appendEntry 抛错时不 throw(降级到文件日志)", () => {
90
+ const throwingPi: PiLike = {
91
+ appendEntry: () => {
92
+ throw new Error("session disposed");
93
+ },
94
+ };
95
+ const logger = createLogger("test", throwingPi);
96
+ expect(() => logger.error("no crash")).not.toThrow();
97
+ });
98
+ });
99
+
100
+ describe("pi handle 延迟注入", () => {
101
+ it("createLogger 时不传 pi,后续 setPiHandle 后 warn 生效", () => {
102
+ const logger = createLogger("delayed");
103
+ // 注入前——appendEntry 不该被调用(pi=undefined)
104
+ logger.warn("before injection");
105
+ expect(appendSpy).not.toHaveBeenCalled();
106
+
107
+ // 注入后——同一 logger 实例的 warn 生效(闭包读 globalPi 实时值)
108
+ setPiHandle(pi);
109
+ logger.warn("after injection");
110
+ expect(appendSpy).toHaveBeenCalledOnce();
111
+ });
112
+ });
113
+
114
+ describe("getLogger singleton", () => {
115
+ it("同名多次调用返回同一实例", () => {
116
+ const a = getLogger("subagents");
117
+ const b = getLogger("subagents");
118
+ expect(a).toBe(b);
119
+ });
120
+
121
+ it("getLogger 创建的 logger 接受后续 setPiHandle", () => {
122
+ const logger = getLogger("workflow");
123
+ logger.warn("no pi yet");
124
+ expect(appendSpy).not.toHaveBeenCalled();
125
+
126
+ setPiHandle(pi);
127
+ logger.warn("pi injected");
128
+ expect(appendSpy).toHaveBeenCalledOnce();
129
+ });
130
+ });
131
+
132
+ describe("debug 不走 appendEntry", () => {
133
+ it("debug 不调 appendEntry(即使 pi 已注入)", () => {
134
+ const logger = createLogger("test", pi);
135
+ logger.debug("streaming intermediate state");
136
+ expect(appendSpy).not.toHaveBeenCalled();
137
+ });
138
+ });
139
+
140
+ describe("PI_EXT_DEBUG 文件日志", () => {
141
+ // 文件日志的 agentDir 通过 getAgentDir() 推导(读 PI_CODING_AGENT_DIR,
142
+ // 默认 ~/.pi/agent)。设 PI_CODING_AGENT_DIR 到 tmpdir 子目录,与组 A 的
143
+ // arch-boundary 改动(fileLog 用 getAgentDir())保持一致。
144
+ let tmpAgentDir: string;
145
+
146
+ beforeEach(() => {
147
+ tmpAgentDir = mkdtempSync(join(tmpdir(), "pi-ext-log-"));
148
+ process.env.PI_CODING_AGENT_DIR = tmpAgentDir;
149
+ vi.useFakeTimers();
150
+ });
151
+
152
+ afterEach(() => {
153
+ vi.useRealTimers();
154
+ if (tmpAgentDir) {
155
+ rmSync(tmpAgentDir, { recursive: true, force: true });
156
+ }
157
+ });
158
+
159
+ it("PI_EXT_DEBUG 未设时 debug 是 no-op(不抛错即可)", () => {
160
+ delete process.env.PI_EXT_DEBUG;
161
+ const logger = createLogger("test", pi);
162
+ expect(() => logger.debug("no-op")).not.toThrow();
163
+ });
164
+
165
+ // ---- Suggestion #8:fileLog 实际写入路径 ----
166
+ it("PI_EXT_DEBUG=1 时 debug 写入日志文件,内容含 [debug] 与序列化 data", () => {
167
+ process.env.PI_EXT_DEBUG = "1";
168
+ // 固定日期 → 文件名 <extName>-2026-08-01.log
169
+ vi.setSystemTime(new Date("2026-08-01T12:34:56.789Z"));
170
+
171
+ const logger = createLogger("dbg-ext", pi);
172
+ logger.debug("streaming state", { id: "rec-1", count: 3 });
173
+
174
+ const logFile = join(tmpAgentDir, "logs", "dbg-ext-2026-08-01.log");
175
+ expect(existsSync(logFile)).toBe(true);
176
+
177
+ const content = readFileSync(logFile, "utf8");
178
+ // 时间戳 + level + msg
179
+ expect(content).toContain("[debug]");
180
+ expect(content).toContain("streaming state");
181
+ // data 经 safeStringify(JSON.stringify)序列化
182
+ expect(content).toContain('"id":"rec-1"');
183
+ expect(content).toContain('"count":3');
184
+ // 单行以换行结尾
185
+ expect(content.endsWith("\n")).toBe(true);
186
+ // debug 不走 appendEntry
187
+ expect(appendSpy).not.toHaveBeenCalled();
188
+ });
189
+
190
+ it("PI_EXT_DEBUG=1 时 warn 既写文件又走 appendEntry(文件内容含 [warn])", () => {
191
+ process.env.PI_EXT_DEBUG = "1";
192
+ vi.setSystemTime(new Date("2026-08-01T12:34:56.789Z"));
193
+
194
+ const logger = createLogger("warn-ext", pi);
195
+ logger.warn("manifest retry", { attempt: 2 });
196
+
197
+ const logFile = join(tmpAgentDir, "logs", "warn-ext-2026-08-01.log");
198
+ expect(existsSync(logFile)).toBe(true);
199
+ const content = readFileSync(logFile, "utf8");
200
+ expect(content).toContain("[warn]");
201
+ expect(content).toContain("[warn-ext] manifest retry");
202
+ expect(content).toContain('"attempt":2');
203
+ // 同时走 appendEntry
204
+ expect(appendSpy).toHaveBeenCalledOnce();
205
+ });
206
+
207
+ it("PI_EXT_DEBUG=1 写失败(只读目录)不 throw", () => {
208
+ process.env.PI_EXT_DEBUG = "1";
209
+ vi.setSystemTime(new Date("2026-08-01T12:34:56.789Z"));
210
+
211
+ // 已存在的 logs 目录改为只读,让 mkdirSync/appendFileSync 失败。
212
+ // 用 debug(只走文件路径),断言 best-effort 吞错。
213
+ const READONLY_MODE = 0o555; // r-xr-xr-x,无写权限
214
+ const RESTORE_MODE = 0o755; // rwxr-xr-x,恢复写权限以便清理
215
+ const readonlyAgentDir = mkdtempSync(join(tmpdir(), "pi-ext-ro-"));
216
+ const logDir = join(readonlyAgentDir, "logs");
217
+ mkdirSync(logDir, { recursive: true });
218
+ chmodSync(logDir, READONLY_MODE);
219
+ process.env.PI_CODING_AGENT_DIR = readonlyAgentDir;
220
+
221
+ const logger = createLogger("ro-ext", pi);
222
+ expect(() => logger.debug("should not throw")).not.toThrow();
223
+
224
+ // 清理:先恢复写权限才能删除
225
+ chmodSync(logDir, RESTORE_MODE);
226
+ rmSync(readonlyAgentDir, { recursive: true, force: true });
227
+ });
228
+ });
229
+
230
+ // ---- Suggestion #9:safeStringify 对循环引用/BigInt 的兜底 ----
231
+ describe("safeStringify 兜底(循环引用 / BigInt)", () => {
232
+ it("循环引用对象:warn 不崩,data 走 String fallback 进 appendEntry", () => {
233
+ // debug 不进 appendEntry,故用 warn(warn/error 把 data 原样传给 appendEntry)
234
+ const logger = createLogger("cycle", pi);
235
+ const cyclic: Record<string, unknown> = { name: "loop" };
236
+ cyclic.self = cyclic;
237
+
238
+ expect(() => logger.warn("cyclic data", cyclic)).not.toThrow();
239
+ expect(appendSpy).toHaveBeenCalledOnce();
240
+ const [, entry] = appendSpy.mock.calls[0]!;
241
+ // appendEntry payload 的 data 字段保留原对象(safeStringify 仅在文件日志路径用)
242
+ expect(entry).toMatchObject({ level: "warn" });
243
+ // 验证 safeStringify 的 fallback 分支:用 PI_EXT_DEBUG=1 触发 fileLog,
244
+ // 文件内 data 应是 String() 形式(含 [object Object] 或循环结构字符串)
245
+ expect(() => JSON.stringify(cyclic)).toThrow(); // 对照:原对象确实不可序列化
246
+ });
247
+
248
+ it("BigInt:warn 不崩,文件日志路径走 String fallback", () => {
249
+ process.env.PI_EXT_DEBUG = "1";
250
+ const tmpAgentDir = mkdtempSync(join(tmpdir(), "pi-ext-bigint-"));
251
+ process.env.PI_CODING_AGENT_DIR = tmpAgentDir;
252
+ vi.setSystemTime(new Date("2026-08-01T12:34:56.789Z"));
253
+
254
+ const logger = createLogger("bigint-ext", pi);
255
+ const bigIntData = { count: 9007199254740993n, name: "big" };
256
+
257
+ expect(() => logger.warn("bigint data", bigIntData)).not.toThrow();
258
+ expect(appendSpy).toHaveBeenCalledOnce();
259
+
260
+ const logFile = join(tmpAgentDir, "logs", "bigint-ext-2026-08-01.log");
261
+ expect(existsSync(logFile)).toBe(true);
262
+ const content = readFileSync(logFile, "utf8");
263
+ // BigInt 不可 JSON.stringify → 走 String(data) fallback:对象 toString 形式
264
+ expect(content).toContain("[warn]");
265
+ // 对照:JSON.stringify(BigInt) 会 throw,故文件内不应是 JSON 形式
266
+ expect(content).not.toContain('"count"');
267
+
268
+ vi.useRealTimers();
269
+ rmSync(tmpAgentDir, { recursive: true, force: true });
270
+ });
271
+ });
272
+ });
package/src/index.ts ADDED
@@ -0,0 +1,216 @@
1
+ // src/index.ts
2
+ //
3
+ // 共享 extension logger —— 三层通道分类,按受众路由日志。
4
+ //
5
+ // 设计依据:pi 宿主层(ExtensionAPI/ExtensionContext)不提供 logger 接口。
6
+ // extension 只能裸 console(污染 TUI raw stderr)或自组 ctx.ui.notify(刷屏)+
7
+ // pi.appendEntry(持久化但用户不可见)。本模块封装这套三层通道,让 @zhushanwen/pi-*
8
+ // 各包统一调用,消除裸 console。
9
+ //
10
+ // 三层通道:
11
+ // 1. AI 实时 → tool result / block reason(pi 原生,本模块不涉及)
12
+ // 2. 事后排查 → pi.appendEntry(custom entry 不进 LLM 上下文,不显 TUI)
13
+ // 3. 开发者调试 → 文件日志(PI_EXT_DEBUG=1 时写 ~/.pi/agent/logs/,默认 no-op)
14
+ //
15
+ // notify(用户操作反馈)刻意不封装——它是 UI 决策,留给各 extension 在命令/视图层
16
+ // 直接调 ctx.ui.notify。
17
+
18
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
19
+ import { appendFileSync, mkdirSync } from "node:fs";
20
+ import { join } from "node:path";
21
+
22
+ /**
23
+ * Pi ExtensionAPI 的最小子集——仅 appendEntry(持久化审计通道)。
24
+ *
25
+ * 不直接 import 完整 ExtensionAPI 类型,避免本包强耦合 pi-coding-agent 版本。
26
+ * 各 extension 在 createLogger 时传入的 pi handle 满足此结构即可。
27
+ */
28
+ export interface PiLike {
29
+ /** 写一条 custom entry 进 session.jsonl(不进 LLM 上下文,不显 TUI)。 */
30
+ appendEntry?(customType: string, data?: unknown): void;
31
+ }
32
+
33
+ /** 日志级别。debug = 开发调试;warn/error = 内部降级与失败(事后排查价值)。 */
34
+ export type LogLevel = "debug" | "warn" | "error";
35
+
36
+ /**
37
+ * Extension logger 接口。方法名即语义——不做运行时 level filtering(pi 不支持,
38
+ * 自建太重)。warn/error 走 appendEntry 持久化;debug 默认 no-op。
39
+ */
40
+ export interface ExtensionLogger {
41
+ /** 开发调试日志。默认 no-op;PI_EXT_DEBUG=1 时写文件日志。不进 appendEntry。 */
42
+ debug(msg: string, data?: unknown): void;
43
+ /** 内部降级/竞态/IO 失败——appendEntry 持久化,不显 TUI,不进 LLM。 */
44
+ warn(msg: string, data?: unknown): void;
45
+ /** 同 warn,语义更严重(关键步骤失败但仍继续)。 */
46
+ error(msg: string, data?: unknown): void;
47
+ }
48
+
49
+ // ============================================================
50
+ // 全局 singleton registry
51
+ // ============================================================
52
+
53
+ // ⚠️ 单 session 约束:本 registry 是模块级可变共享态。
54
+ // - `globalPi`(let)与 `loggerCache`(const Map)被同进程所有 session 共享。
55
+ // - 多 session 并发时,后注册 session 的 setPiHandle 会覆盖前者,导致先 session
56
+ // 的 logger 路由到错误的 appendEntry(session 隔离缺失)。
57
+ // - 当前 pi 设计为单主 session,此约束可接受;多 session 是远期场景,届时需把
58
+ // logger 按 sessionManager 维度分区(如 getLogger(extName, ctx))。
59
+ // 详见 development-guide §2.3「闭包状态隔离」——本处为已知、有据的例外。
60
+
61
+ /**
62
+ * 进程级 pi handle(延迟注入)。
63
+ *
64
+ * extension 初始化分两阶段:default export 函数拿到 pi → setPiHandle 注入;
65
+ * 之后深层代码(best-effort / error-recovery)通过 getLogger 拿到的 logger
66
+ * 才能走 appendEntry。注入前 warn/error 降级到文件日志(不丢诊断信息)。
67
+ *
68
+ * ⚠️ 模块级共享态:见上方「单 session 约束」——多 session 并发时后注册覆盖前者。
69
+ */
70
+ let globalPi: PiLike | undefined;
71
+
72
+ /**
73
+ * 各 extension name → logger 实例的缓存(getLogger 多次调用返回同一实例)。
74
+ *
75
+ * ⚠️ 模块级共享态:见上方「单 session 约束」——多 session 并发时共享同一缓存。
76
+ */
77
+ const loggerCache = new Map<string, ExtensionLogger>();
78
+
79
+ /**
80
+ * 注入 pi handle(extension default export 函数最早期调用)。
81
+ *
82
+ * 多次调用安全——后调覆盖先调(session_start 可能多次触发,取最新的 pi)。
83
+ * 注入后,已创建的 logger 实例自动生效 appendEntry(闭包读 globalPi 实时值)。
84
+ *
85
+ * ⚠️ 单 session 约束:本函数写模块级 `globalPi`。多 session 并发时后调覆盖先调,
86
+ * 详见上方「单 session 约束」注释。
87
+ */
88
+ export function setPiHandle(pi: PiLike | undefined): void {
89
+ globalPi = pi;
90
+ }
91
+
92
+ /**
93
+ * 创建具名 logger。
94
+ *
95
+ * @param extName extension 名(如 "subagents"、"unified-hooks"),用作:
96
+ * - appendEntry 的 customType 前缀(`<extName>:log`)
97
+ * - 文件日志名(`<extName>-YYYY-MM-DD.log`)
98
+ * - msg 前缀(`[<extName>]`,自动补)
99
+ * @param pi 可选 pi handle;不传则用 setPiHandle 注入的全局 handle
100
+ * @returns ExtensionLogger 实例(每次返回同一引用,便于模块级 const 缓存)
101
+ */
102
+ export function createLogger(extName: string, pi?: PiLike): ExtensionLogger {
103
+ // 优先用传入的 pi,否则用全局注入的
104
+ const resolvePi = (): PiLike | undefined => pi ?? globalPi;
105
+
106
+ const logger: ExtensionLogger = {
107
+ debug(msg: string, data?: unknown): void {
108
+ fileLog(extName, "debug", msg, data);
109
+ },
110
+ warn(msg: string, data?: unknown): void {
111
+ const piResolved = resolvePi();
112
+ const prefixed = prefixMsg(extName, msg);
113
+ // appendEntry 不进 LLM 上下文(session-manager.js: custom entry 不参与 context)
114
+ try {
115
+ piResolved?.appendEntry?.(`${extName}:log`, {
116
+ timestamp: Date.now(),
117
+ level: "warn",
118
+ message: prefixed,
119
+ data,
120
+ });
121
+ } catch (appendErr) {
122
+ // appendEntry 失败(session 已 disposed 等)→ 降级文件日志(下方 fileLog 兜底),不 throw
123
+ void appendErr;
124
+ }
125
+ fileLog(extName, "warn", prefixed, data);
126
+ },
127
+ error(msg: string, data?: unknown): void {
128
+ const piResolved = resolvePi();
129
+ const prefixed = prefixMsg(extName, msg);
130
+ try {
131
+ piResolved?.appendEntry?.(`${extName}:log`, {
132
+ timestamp: Date.now(),
133
+ level: "error",
134
+ message: prefixed,
135
+ data,
136
+ });
137
+ } catch (appendErr) {
138
+ // 同 warn:appendEntry 失败降级文件日志,不 throw
139
+ void appendErr;
140
+ }
141
+ fileLog(extName, "error", prefixed, data);
142
+ },
143
+ };
144
+ return logger;
145
+ }
146
+
147
+ /**
148
+ * 获取(或创建)具名 logger 的全局 singleton。
149
+ *
150
+ * 深层代码(拿不到 pi 的执行层,如 best-effort.ts、error-recovery.ts)
151
+ * 用 `getLogger("subagents")` 拿全局缓存实例,无需逐层透传 pi。
152
+ *
153
+ * 首次调用时 pi 尚未注入 → logger 的 warn/error 降级到文件日志;
154
+ * 后续 setPiHandle 注入后,同一 logger 实例自动生效 appendEntry
155
+ * (闭包读 globalPi 实时值,不是创建时的快照)。
156
+ */
157
+ export function getLogger(extName: string): ExtensionLogger {
158
+ const existing = loggerCache.get(extName);
159
+ if (existing) return existing;
160
+ const logger = createLogger(extName);
161
+ loggerCache.set(extName, logger);
162
+ return logger;
163
+ }
164
+
165
+ // ============================================================
166
+ // 内部工具
167
+ // ============================================================
168
+
169
+ /**
170
+ * 给 msg 补 `[extName]` 前缀(若已有则不重复补)。
171
+ * 保持与旧 console 日志一致的视觉格式,便于从 session.jsonl 逆向搜索。
172
+ */
173
+ function prefixMsg(extName: string, msg: string): string {
174
+ const tag = `[${extName}]`;
175
+ return msg.startsWith(tag) ? msg : `${tag} ${msg}`;
176
+ }
177
+
178
+ /**
179
+ * 写文件日志到 `<agentDir>/logs/<extName>-YYYY-MM-DD.log`。
180
+ *
181
+ * 仅在 PI_EXT_DEBUG 环境变量为 "1" 时写入(默认 no-op,生产环境零开销)。
182
+ * agentDir 通过 pi 的 SSOT `getAgentDir()` 推导(读
183
+ * `PI_CODING_AGENT_DIR`/`${APP_NAME}_CODING_AGENT_DIR`,默认 `~/.pi/agent`),
184
+ * 与其它 extension 的路径派生保持一致。
185
+ * 写失败静默吞错(文件日志是 best-effort,不应影响主流程)。
186
+ *
187
+ * 线程安全:appendFileSync 保证单次写入原子性;多 worker 并发写同文件时
188
+ * 行可能交错(可接受——debug 日志不要求严格顺序)。
189
+ */
190
+ function fileLog(extName: string, level: LogLevel, msg: string, data?: unknown): void {
191
+ if (process.env.PI_EXT_DEBUG !== "1") return;
192
+ try {
193
+ const agentDir = getAgentDir();
194
+ const logDir = join(agentDir, "logs");
195
+ mkdirSync(logDir, { recursive: true });
196
+ // ISO 日期前 10 字符 = "YYYY-MM-DD"
197
+ const ISO_DATE_PREFIX_LEN = 10;
198
+ const today = new Date().toISOString().slice(0, ISO_DATE_PREFIX_LEN);
199
+ const logFile = join(logDir, `${extName}-${today}.log`);
200
+ const ts = new Date().toISOString();
201
+ const dataStr = data !== undefined ? " " + safeStringify(data) : "";
202
+ appendFileSync(logFile, `${ts} [${level}] ${msg}${dataStr}\n`);
203
+ } catch (fileErr) {
204
+ // 文件日志 best-effort:磁盘满/权限问题等不阻断主流程
205
+ void fileErr;
206
+ }
207
+ }
208
+
209
+ /** 安全序列化 data(循环引用/BigInt 不崩)。 */
210
+ function safeStringify(data: unknown): string {
211
+ try {
212
+ return JSON.stringify(data);
213
+ } catch {
214
+ return String(data);
215
+ }
216
+ }