@zhushanwen/pi-extension-logger 0.3.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-extension-logger",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
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
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -13,11 +13,10 @@
13
13
  ],
14
14
  "license": "MIT",
15
15
  "files": [
16
- "src/",
17
- "index.ts"
16
+ "src/"
18
17
  ],
19
18
  "peerDependencies": {
20
- "@earendil-works/pi-coding-agent": "^0.84.1"
19
+ "@earendil-works/pi-coding-agent": "^0.84.4"
21
20
  },
22
21
  "devDependencies": {
23
22
  "@vitest/coverage-v8": "^4.1.9",
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, rmSync, mkdirSync, chmodSync, mkdtempSync } from "node:fs";
1
+ import { existsSync, readFileSync, rmSync, mkdirSync, chmodSync, mkdtempSync, writeFileSync, utimesSync, readdirSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -8,6 +8,7 @@ import {
8
8
  getLogger,
9
9
  setPiHandle,
10
10
  clearRateLimiterState,
11
+ resetExtLogCleanupForTest,
11
12
  type PiLike,
12
13
  } from "../index.js";
13
14
 
@@ -272,6 +273,148 @@ describe("extension-logger", () => {
272
273
  });
273
274
  });
274
275
 
276
+ // ============================================================
277
+ // XYZ_AGENT_EXT_LOG 托管观测档(设计 file-lock-unification-and-reaper-sink §3.2-D4)
278
+ // ============================================================
279
+ describe("XYZ_AGENT_EXT_LOG 托管观测档", () => {
280
+ let tmpAgentDir: string;
281
+
282
+ beforeEach(() => {
283
+ tmpAgentDir = mkdtempSync(join(tmpdir(), "pi-ext-extlog-"));
284
+ process.env.PI_CODING_AGENT_DIR = tmpAgentDir;
285
+ delete process.env.XYZ_AGENT_DEBUG;
286
+ delete process.env.XYZ_AGENT_EXT_LOG;
287
+ resetExtLogCleanupForTest();
288
+ vi.useFakeTimers();
289
+ });
290
+
291
+ afterEach(() => {
292
+ vi.useRealTimers();
293
+ resetExtLogCleanupForTest();
294
+ rmSync(tmpAgentDir, { recursive: true, force: true });
295
+ });
296
+
297
+ it("均未注入时 no-op:debug/warn/error 调用后 logs 目录都不创建(裸 pi 用户零磁盘影响)", () => {
298
+ const logger = createLogger("bare-ext", pi);
299
+ logger.debug("d");
300
+ logger.warn("w");
301
+ logger.error("e");
302
+ expect(existsSync(join(tmpAgentDir, "logs"))).toBe(false);
303
+ });
304
+
305
+ it("仅 XYZ_AGENT_EXT_LOG=1:debug 调用以 info 级落盘(含序列化 data),不标 debug", () => {
306
+ process.env.XYZ_AGENT_EXT_LOG = "1";
307
+ vi.setSystemTime(new Date("2026-08-01T12:34:56.789Z"));
308
+
309
+ const logger = createLogger("extlog-ext", pi);
310
+ logger.debug("session maintenance ran", { session: "s-1" });
311
+
312
+ const logFile = join(tmpAgentDir, "logs", "extlog-ext-2026-08-01.log");
313
+ expect(existsSync(logFile)).toBe(true);
314
+ const content = readFileSync(logFile, "utf8");
315
+ expect(content).toContain("[info]");
316
+ expect(content).not.toContain("[debug]");
317
+ expect(content).toContain("session maintenance ran");
318
+ expect(content).toContain('"session":"s-1"');
319
+ // debug 仍不走 appendEntry(EXT_LOG 只改落盘档位,不改通道路由)
320
+ expect(appendSpy).not.toHaveBeenCalled();
321
+ });
322
+
323
+ it("仅 XYZ_AGENT_EXT_LOG=1:warn/error 照常落盘且保持原级标注", () => {
324
+ process.env.XYZ_AGENT_EXT_LOG = "1";
325
+ vi.setSystemTime(new Date("2026-08-01T12:34:56.789Z"));
326
+
327
+ const logger = createLogger("extlog-err", pi);
328
+ logger.warn("retry degraded");
329
+ logger.error("budget abort");
330
+
331
+ const logFile = join(tmpAgentDir, "logs", "extlog-err-2026-08-01.log");
332
+ const content = readFileSync(logFile, "utf8");
333
+ expect(content).toContain("[warn]");
334
+ expect(content).toContain("[error]");
335
+ expect(content).not.toContain("[info]");
336
+ });
337
+
338
+ it("XYZ_AGENT_DEBUG=1 与 EXT_LOG 同注入:按更详细的生效(debug 全量,原级标注)", () => {
339
+ process.env.XYZ_AGENT_EXT_LOG = "1";
340
+ process.env.XYZ_AGENT_DEBUG = "1";
341
+ vi.setSystemTime(new Date("2026-08-01T12:34:56.789Z"));
342
+
343
+ const logger = createLogger("both-ext", pi);
344
+ logger.debug("verbose trace");
345
+
346
+ const content = readFileSync(join(tmpAgentDir, "logs", "both-ext-2026-08-01.log"), "utf8");
347
+ expect(content).toContain("[debug]");
348
+ expect(content).not.toContain("[info]");
349
+ });
350
+
351
+ it("EXT_LOG 未注入、DEBUG 未设时,debug 不写文件(与 DEBUG 关闭现状一致)", () => {
352
+ const logger = createLogger("noop-ext", pi);
353
+ logger.debug("no-op");
354
+ expect(existsSync(join(tmpAgentDir, "logs"))).toBe(false);
355
+ });
356
+
357
+ it("保留期清理:7 天前的 <ext>-<date>.log 被删,近期与本包 pattern 外文件保留", () => {
358
+ process.env.XYZ_AGENT_EXT_LOG = "1";
359
+ // fake timers 冻结 Date 在真实当前时刻:文件 mtime 走真实时钟,utimesSync 把
360
+ // old 的 mtime 设为 8 天前(> 7 天保留期),recent 与 unrelated 保留。
361
+ const logDir = join(tmpAgentDir, "logs");
362
+ mkdirSync(logDir, { recursive: true });
363
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
364
+ const oldTime = new Date(Date.now() - 8 * MS_PER_DAY);
365
+ const oldFile = join(logDir, "cleanup-ext-2026-01-01.log");
366
+ const recentFile = join(logDir, `keep-ext-${new Date().toISOString().slice(0, 10)}.log`);
367
+ const unrelatedFile = join(logDir, "notes.txt");
368
+ writeFileSync(oldFile, "old");
369
+ writeFileSync(recentFile, "recent");
370
+ writeFileSync(unrelatedFile, "keep");
371
+ utimesSync(oldFile, oldTime, oldTime);
372
+
373
+ const logger = createLogger("cleanup-ext", pi);
374
+ logger.warn("trigger cleanup");
375
+
376
+ expect(existsSync(oldFile)).toBe(false);
377
+ expect(existsSync(recentFile)).toBe(true);
378
+ expect(existsSync(unrelatedFile)).toBe(true);
379
+ // 本次写入的文件在场(清理不误伤当前写路径)
380
+ expect(existsSync(join(logDir, `cleanup-ext-${new Date().toISOString().slice(0, 10)}.log`))).toBe(true);
381
+ });
382
+
383
+ it("清理只认 <ext>-YYYY-MM-DD.log pattern,不动其他 .log 文件", () => {
384
+ process.env.XYZ_AGENT_EXT_LOG = "1";
385
+ const logDir = join(tmpAgentDir, "logs");
386
+ mkdirSync(logDir, { recursive: true });
387
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
388
+ const oldTime = new Date(Date.now() - 30 * MS_PER_DAY);
389
+ const undated = join(logDir, "plain-old-name.log");
390
+ writeFileSync(undated, "keep");
391
+ utimesSync(undated, oldTime, oldTime);
392
+
393
+ vi.setSystemTime(new Date("2026-08-01T12:34:56.789Z"));
394
+ const logger = createLogger("pattern-ext", pi);
395
+ logger.warn("trigger");
396
+
397
+ expect(existsSync(undated)).toBe(true);
398
+ });
399
+
400
+ it("no-op 环境不触发保留期清理(零 fs 副作用覆盖清理路径)", () => {
401
+ const logDir = join(tmpAgentDir, "logs");
402
+ mkdirSync(logDir, { recursive: true });
403
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
404
+ const oldTime = new Date(Date.now() - 30 * MS_PER_DAY);
405
+ const oldFile = join(logDir, "stale-ext-2026-01-01.log");
406
+ writeFileSync(oldFile, "old");
407
+ utimesSync(oldFile, oldTime, oldTime);
408
+
409
+ const logger = createLogger("noop-clean-ext", pi);
410
+ logger.debug("no-op");
411
+ logger.warn("no-op");
412
+
413
+ expect(existsSync(oldFile)).toBe(true);
414
+ expect(readdirSync(logDir)).toContain("stale-ext-2026-01-01.log");
415
+ });
416
+ });
417
+
275
418
  // ============================================================
276
419
  // Per-message 固定窗口限流(P3 防线)
277
420
  // ============================================================
package/src/index.ts CHANGED
@@ -10,13 +10,25 @@
10
10
  // 三层通道:
11
11
  // 1. AI 实时 → tool result / block reason(pi 原生,本模块不涉及)
12
12
  // 2. 事后排查 → pi.appendEntry(custom entry 不进 LLM 上下文,不显 TUI)
13
- // 3. 开发者调试 → 文件日志(XYZ_AGENT_DEBUG=1 时写 ~/.pi/agent/logs/,默认 no-op
13
+ // 3. 开发者调试 → 文件日志,双开关分级(写 <agentDir>/logs/,均未注入默认 no-op):
14
+ // - XYZ_AGENT_DEBUG=1 → DEBUG 全量(现状语义,level 原样标注)
15
+ // - XYZ_AGENT_EXT_LOG=1 → INFO 级落盘(xyz 托管环境由 runtime spawn 时经
16
+ // buildOutboundChildEnv extras 注入,设计 file-lock-unification-and-reaper-sink
17
+ // §3.2-D4 / U3-3)+ 7 天保留期清理;debug() 调用此模式下重标 info 写入
18
+ // - 两变量同时注入按更详细的生效(DEBUG 全量优先)
19
+ // 裸 pi 独立用户(两个变量都未注入)保持 no-op,零磁盘/行为影响。
14
20
  //
15
21
  // notify(用户操作反馈)刻意不封装——它是 UI 决策,留给各 extension 在命令/视图层
16
22
  // 直接调 ctx.ui.notify。
17
23
 
18
24
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
19
- import { appendFileSync, mkdirSync } from "node:fs";
25
+ import {
26
+ appendFileSync,
27
+ mkdirSync,
28
+ readdirSync,
29
+ statSync,
30
+ unlinkSync,
31
+ } from "node:fs";
20
32
  import { join } from "node:path";
21
33
 
22
34
  // ============================================================
@@ -32,7 +44,7 @@ import { join } from "node:path";
32
44
  // - key = msg 原文(不包含 data 参数)。若调用方把动态 id 拼进 msg
33
45
  // (如 `session=${id}`),每条 msg 不同则限流不命中。根治靠调用方把
34
46
  // 动态值放 data 参数(D4 已声明)。
35
- // - fileLog 通道全量不限流(XYZ_AGENT_DEBUG=1 时所有日志写文件)。
47
+ // - fileLog 通道全量不限流(XYZ_AGENT_DEBUG=1 / XYZ_AGENT_EXT_LOG=1 时日志写文件)。
36
48
  // - Map cap 512 超限时全量清空(简化策略,对齐 cap-1024 先例),
37
49
  // 等价于所有 key 窗口重置,防无界增长。
38
50
  //
@@ -62,7 +74,7 @@ interface RateLimiterEntry {
62
74
  * 同进程所有 logger 实例共享(模块级 singleton)。
63
75
  *
64
76
  * ⚠️ 生命周期与进程一致——xyz-agent 每 session 一个独立 pi 进程
65
- * process-manager.ts L142-143),故不存在跨 session 残留问题。
77
+ * (见 runtime process-manager.ts 的类注释),故不存在跨 session 残留问题。
66
78
  */
67
79
  const rateLimiterState = new Map<string, RateLimiterEntry>();
68
80
 
@@ -141,15 +153,19 @@ export interface PiLike {
141
153
  appendEntry?(customType: string, data?: unknown): void;
142
154
  }
143
155
 
144
- /** 日志级别。debug = 开发调试;warn/error = 内部降级与失败(事后排查价值)。 */
145
- export type LogLevel = "debug" | "warn" | "error";
156
+ /** 日志级别。debug = 开发调试;info = XYZ_AGENT_EXT_LOG 模式下 debug() 的落盘标注;warn/error = 内部降级与失败(事后排查价值)。 */
157
+ export type LogLevel = "debug" | "info" | "warn" | "error";
146
158
 
147
159
  /**
148
160
  * Extension logger 接口。方法名即语义——不做运行时 level filtering(pi 不支持,
149
161
  * 自建太重)。warn/error 走 appendEntry 持久化;debug 默认 no-op。
150
162
  */
151
163
  export interface ExtensionLogger {
152
- /** 开发调试日志。默认 no-op;XYZ_AGENT_DEBUG=1 时写文件日志。不进 appendEntry。 */
164
+ /**
165
+ * 开发调试日志。默认 no-op;XYZ_AGENT_DEBUG=1 时写文件日志(原级标注);
166
+ * XYZ_AGENT_EXT_LOG=1(无 DEBUG)时以 info 级落盘(托管环境 INFO 观测档)。
167
+ * 不进 appendEntry。
168
+ */
153
169
  debug(msg: string, data?: unknown): void;
154
170
  /** 内部降级/竞态/IO 失败——appendEntry 持久化,不显 TUI,不进 LLM。 */
155
171
  warn(msg: string, data?: unknown): void;
@@ -326,31 +342,99 @@ function prefixMsg(extName: string, msg: string): string {
326
342
  return msg.startsWith(tag) ? msg : `${tag} ${msg}`;
327
343
  }
328
344
 
345
+ // ============================================================
346
+ // XYZ_AGENT_EXT_LOG 托管观测档 + 保留期清理
347
+ // ============================================================
348
+
349
+ /** 托管观测档(XYZ_AGENT_EXT_LOG=1)的日志保留天数,超期文件在首次落盘时清理。 */
350
+ const EXT_LOG_KEEP_DAYS = 7;
351
+ // 一天的毫秒数(数字分隔符形式对齐 session-reader hash-provider 的时间常量惯例)
352
+ const MS_PER_DAY = 86_400_000;
353
+ /** ISO 日期前 10 字符 = "YYYY-MM-DD" */
354
+ const ISO_DATE_PREFIX_LEN = 10;
355
+
356
+ /**
357
+ * 保留期清理是否已执行(进程级 once)。
358
+ *
359
+ * pi 进程按 session 短命(runtime 每 session 一个独立 pi),进程生命周期清理一次
360
+ * 足够;无需 timer——惰性挂在首次实际落盘时执行,未落盘(两个开关都没开)则
361
+ * 永不触发任何 fs 调用(no-op 契约:裸 pi 独立用户零磁盘影响)。
362
+ */
363
+ let extLogCleanupDone = false;
364
+
365
+ /**
366
+ * 清理 `<logDir>` 下超保留期(EXT_LOG_KEEP_DAYS 天)的本包日志文件。
367
+ *
368
+ * 只清本包命名惯例内的文件(`<extName>-YYYY-MM-DD.log`,日期后缀 pattern 精确匹配),
369
+ * 不触碰目录内其他产物。逐文件 best-effort:单个 stat/unlink 失败(并发删除/权限)
370
+ * 不影响其余文件。读目录失败(目录刚创建为空等)整体跳过。
371
+ */
372
+ function cleanExpiredExtLogsOnce(logDir: string): void {
373
+ if (extLogCleanupDone) return;
374
+ extLogCleanupDone = true;
375
+ let entries: string[];
376
+ try {
377
+ entries = readdirSync(logDir);
378
+ } catch (readErr) {
379
+ // 目录不存在/不可读:无清理对象,跳过(首次 mkdir 由调用方完成,此处兜底)
380
+ void readErr;
381
+ return;
382
+ }
383
+ const cutoff = Date.now() - EXT_LOG_KEEP_DAYS * MS_PER_DAY;
384
+ for (const name of entries) {
385
+ if (!/^.+-\d{4}-\d{2}-\d{2}\.log$/.test(name)) continue;
386
+ const full = join(logDir, name);
387
+ try {
388
+ if (statSync(full).mtimeMs < cutoff) {
389
+ unlinkSync(full);
390
+ }
391
+ } catch (fileErr) {
392
+ // 单文件清理失败不阻断(best-effort,与 fileLog 主路径容错同档)
393
+ void fileErr;
394
+ }
395
+ }
396
+ }
397
+
398
+ /**
399
+ * 重置保留期清理的 once 标记(测试用导出,生产代码不调用)。
400
+ * 对齐 clearRateLimiterState 的测试出口先例——模块级状态跨用例需可重置。
401
+ */
402
+ export function resetExtLogCleanupForTest(): void {
403
+ extLogCleanupDone = false;
404
+ }
405
+
329
406
  /**
330
407
  * 写文件日志到 `<agentDir>/logs/<extName>-YYYY-MM-DD.log`。
331
408
  *
332
- * 仅在 XYZ_AGENT_DEBUG 环境变量为 "1" 时写入(默认 no-op,生产环境零开销)。
409
+ * 双开关分级(设计 file-lock-unification-and-reaper-sink §3.2-D4):
410
+ * - XYZ_AGENT_DEBUG=1 → DEBUG 全量,level 原样标注(现状语义不变);
411
+ * - 仅 XYZ_AGENT_EXT_LOG=1 → INFO 级落盘:debug() 调用重标 info 写入,warn/error 照写;
412
+ * - 均未注入 → no-op(零 fs 调用,裸 pi 独立用户零磁盘/行为影响)。
333
413
  * agentDir 通过 pi 的 SSOT `getAgentDir()` 推导(读
334
414
  * `PI_CODING_AGENT_DIR`/`${APP_NAME}_CODING_AGENT_DIR`,默认 `~/.pi/agent`),
335
415
  * 与其它 extension 的路径派生保持一致。
416
+ * 首次实际落盘时顺带执行一次保留期清理(进程级 once)。
336
417
  * 写失败静默吞错(文件日志是 best-effort,不应影响主流程)。
337
418
  *
338
419
  * 线程安全:appendFileSync 保证单次写入原子性;多 worker 并发写同文件时
339
420
  * 行可能交错(可接受——debug 日志不要求严格顺序)。
340
421
  */
341
422
  function fileLog(extName: string, level: LogLevel, msg: string, data?: unknown): void {
342
- if (process.env.XYZ_AGENT_DEBUG !== "1") return;
423
+ const debugMode = process.env.XYZ_AGENT_DEBUG === "1";
424
+ const extLogMode = !debugMode && process.env.XYZ_AGENT_EXT_LOG === "1";
425
+ if (!debugMode && !extLogMode) return;
426
+ // 托管观测档:debug() 调用降档标注为 info(INFO 级观测,非 DEBUG 全量)
427
+ const effectiveLevel: LogLevel = extLogMode && level === "debug" ? "info" : level;
343
428
  try {
344
429
  const agentDir = getAgentDir();
345
430
  const logDir = join(agentDir, "logs");
346
431
  mkdirSync(logDir, { recursive: true });
347
- // ISO 日期前 10 字符 = "YYYY-MM-DD"
348
- const ISO_DATE_PREFIX_LEN = 10;
432
+ cleanExpiredExtLogsOnce(logDir);
349
433
  const today = new Date().toISOString().slice(0, ISO_DATE_PREFIX_LEN);
350
434
  const logFile = join(logDir, `${extName}-${today}.log`);
351
435
  const ts = new Date().toISOString();
352
436
  const dataStr = data !== undefined ? " " + safeStringify(data) : "";
353
- appendFileSync(logFile, `${ts} [${level}] ${msg}${dataStr}\n`);
437
+ appendFileSync(logFile, `${ts} [${effectiveLevel}] ${msg}${dataStr}\n`);
354
438
  } catch (fileErr) {
355
439
  // 文件日志 best-effort:磁盘满/权限问题等不阻断主流程
356
440
  void fileErr;
package/index.ts DELETED
@@ -1 +0,0 @@
1
- export { createLogger, getLogger, setPiHandle, clearRateLimiterState, type ExtensionLogger, type PiLike } from "./src/index.js";