pi-telegram-plus 0.0.2 → 0.0.3

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/lib/logger.ts ADDED
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Zero-dependency file logger for pi-telegram-plus.
3
+ *
4
+ * Design (see README "Logging"):
5
+ * - Levels: debug / info / warn / error. A minimum level filters records.
6
+ * - Format: JSON Lines — one self-contained JSON object per line, so remote
7
+ * Telegram issues can be grep'd / jq'd after the fact.
8
+ * - Sink: file only, under the pi agent cache dir (`~/.pi/agent/logs/` or
9
+ * `$PI_CODING_AGENT_DIR/logs/`). One file per calendar day (UTC):
10
+ * `pi-telegram-plus-YYYY-MM-DD.log`. When a day file exceeds `maxFileSize`
11
+ * it is rotated logrotate-style (`…-YYYY-MM-DD.1.log`, `…-YYYY-MM-DD.2.log`,
12
+ * …) keeping at most `maxFiles` rotations per day.
13
+ * - Never touches the console: pi runs in TUI/RPC/JSON modes where console
14
+ * output pollutes the TUI input box. The logger is side-effect-free w.r.t.
15
+ * the terminal.
16
+ * - Never throws: every public method swallows its own internal errors so a
17
+ * logging failure can never crash the extension or alter control flow. The
18
+ * silent catches inside this module are intentional and localized to the
19
+ * logger itself.
20
+ * - Serialized writes: a single module-level promise chain orders all appends
21
+ * and rotations, so the root logger and every `child()` share one sink and
22
+ * cannot interleave or race on the same day file.
23
+ *
24
+ * Usage:
25
+ * import { log } from "./logger.ts";
26
+ * log.warn("HTML fallback", { reason, snippet });
27
+ * const apiLog = log.child("telegram-api");
28
+ * apiLog.error("send failed", { chatId, code });
29
+ *
30
+ * `index.ts` calls `initLogger({ dir?, level? })` at activation to pin the log
31
+ * directory to the active pi agent dir; before that the logger falls back to a
32
+ * computed default and degrades to a no-op if the directory cannot be created.
33
+ */
34
+
35
+ import { appendFile, mkdir, readdir, rename, stat } from "node:fs/promises";
36
+ import { homedir } from "node:os";
37
+ import { join, resolve } from "node:path";
38
+
39
+ export type LogLevel = "debug" | "info" | "warn" | "error";
40
+ export type LogFields = Record<string, unknown>;
41
+
42
+ const LEVEL_ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };
43
+
44
+ const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB per day file before rotation
45
+ const DEFAULT_MAX_FILES = 5; // rotated suffixes kept per day
46
+ const FILE_PREFIX = "pi-telegram-plus-";
47
+
48
+ export interface Logger {
49
+ readonly level: LogLevel;
50
+ debug(msg: string, fields?: LogFields): void;
51
+ info(msg: string, fields?: LogFields): void;
52
+ warn(msg: string, fields?: LogFields): void;
53
+ error(msg: string, fields?: LogFields): void;
54
+ /** Tagged sub-logger: every record carries `scope=<scope>` in addition to any parent scope. */
55
+ child(scope: string): Logger;
56
+ /** Returns a `.catch` handler that logs the rejection at `level` with the given msg/fields plus `err`, then resolves to `undefined` (preserves the `T | undefined` shape of the promise chain for probes like `stat(path).catch(swallow(...))`). */
57
+ swallow(level: LogLevel, msg: string, fields?: LogFields): (err: unknown) => undefined;
58
+ }
59
+
60
+ interface LoggerConfig {
61
+ dir: string;
62
+ minLevel: LogLevel;
63
+ maxFileSize: number;
64
+ maxFiles: number;
65
+ enabled: boolean;
66
+ }
67
+
68
+ /** Default log directory: mirrors config.getAgentDir() without importing it (avoid a cycle). */
69
+ function defaultLogDir(): string {
70
+ const agentDir = process.env.PI_CODING_AGENT_DIR
71
+ ? resolve(process.env.PI_CODING_AGENT_DIR)
72
+ : join(homedir(), ".pi", "agent");
73
+ return join(agentDir, "logs");
74
+ }
75
+
76
+ function todayStamp(d = new Date()): string {
77
+ const yyyy = d.getUTCFullYear();
78
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
79
+ const dd = String(d.getUTCDate()).padStart(2, "0");
80
+ return `${yyyy}-${mm}-${dd}`;
81
+ }
82
+
83
+ function isoTs(d = new Date()): string {
84
+ return d.toISOString();
85
+ }
86
+
87
+ function safeStringify(record: Record<string, unknown>): string {
88
+ try {
89
+ return JSON.stringify(record);
90
+ } catch {
91
+ // A non-serializable field (circular, BigInt) would break the line; replace
92
+ // fields with a safe fallback so we never lose the log event entirely.
93
+ try {
94
+ return JSON.stringify({ ...record, fields: "[unserializable]" });
95
+ } catch {
96
+ return JSON.stringify({ ts: isoTs(), level: record.level, msg: String(record.msg ?? ""), error: "[unserializable]" });
97
+ }
98
+ }
99
+ }
100
+
101
+ function ensureFieldSafety(fields: LogFields | undefined): LogFields {
102
+ if (!fields) return {};
103
+ const out: LogFields = {};
104
+ for (const [k, v] of Object.entries(fields)) {
105
+ if (typeof v === "bigint") out[k] = v.toString();
106
+ else if (typeof v === "symbol") out[k] = v.toString();
107
+ else if (typeof v === "function") out[k] = `[function ${v.name || "anonymous"}]`;
108
+ else if (v instanceof Error) out[k] = { name: v.name, message: v.message, stack: v.stack };
109
+ else {
110
+ // Probe serializability; downgrade to string if it fails.
111
+ try { JSON.stringify(v); out[k] = v; } catch { out[k] = String(v); }
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+
117
+ // ---- Shared sink: one queue + size tracker for ALL loggers (root + children) ----
118
+
119
+ const sink = {
120
+ queue: Promise.resolve(),
121
+ currentDate: undefined as string | undefined,
122
+ currentSize: 0,
123
+ dirReady: false,
124
+ };
125
+
126
+ /** Reset sink state — used by tests to get a clean queue between cases. */
127
+ export function __resetSinkForTests(): void {
128
+ sink.queue = Promise.resolve();
129
+ sink.currentDate = undefined;
130
+ sink.currentSize = 0;
131
+ sink.dirReady = false;
132
+ }
133
+
134
+ function basePath(cfg: LoggerConfig, stamp: string): string {
135
+ return join(cfg.dir, `${FILE_PREFIX}${stamp}.log`);
136
+ }
137
+ function rotatedPath(cfg: LoggerConfig, stamp: string, n: number): string {
138
+ return join(cfg.dir, `${FILE_PREFIX}${stamp}.${n}.log`);
139
+ }
140
+
141
+ async function sizeOfFile(path: string): Promise<number> {
142
+ try { return (await stat(path)).size; } catch { return 0; }
143
+ }
144
+
145
+ /**
146
+ * logrotate-style rotation for one day: shift `.N` → `.N+1` up to maxFiles-1,
147
+ * drop the one that falls off the end, then rename the base file to `.1`. The
148
+ * next append recreates the base file fresh.
149
+ */
150
+ async function rotate(cfg: LoggerConfig, stamp: string): Promise<void> {
151
+ try {
152
+ const max = cfg.maxFiles;
153
+ for (let n = max - 1; n >= 1; n--) {
154
+ await rename(rotatedPath(cfg, stamp, n), rotatedPath(cfg, stamp, n + 1)).catch(() => undefined);
155
+ }
156
+ await rename(basePath(cfg, stamp), rotatedPath(cfg, stamp, 1)).catch(() => undefined);
157
+ } catch {
158
+ // Best-effort: on failure keep appending to the base file (may exceed cap).
159
+ }
160
+ }
161
+
162
+ async function appendLine(cfg: LoggerConfig, line: string): Promise<void> {
163
+ if (!cfg.enabled) return;
164
+ if (!sink.dirReady) {
165
+ try {
166
+ await mkdir(cfg.dir, { recursive: true });
167
+ sink.dirReady = true;
168
+ } catch {
169
+ // Cannot create log dir (read-only fs, permissions). Degrade to no-op.
170
+ cfg.enabled = false;
171
+ return;
172
+ }
173
+ }
174
+ const stamp = todayStamp();
175
+ if (sink.currentDate !== stamp) {
176
+ sink.currentDate = stamp;
177
+ sink.currentSize = await sizeOfFile(basePath(cfg, stamp));
178
+ }
179
+ const base = basePath(cfg, stamp);
180
+ const lineBytes = Buffer.byteLength(line);
181
+ if (sink.currentSize + lineBytes > cfg.maxFileSize && sink.currentSize > 0) {
182
+ await rotate(cfg, stamp);
183
+ sink.currentSize = 0;
184
+ }
185
+ try {
186
+ await appendFile(base, line, { encoding: "utf8" });
187
+ sink.currentSize += lineBytes;
188
+ } catch {
189
+ // Append failed (disk full, etc.). Drop the line; next call retries.
190
+ }
191
+ }
192
+
193
+ function enqueue(cfg: LoggerConfig, line: string): void {
194
+ sink.queue = sink.queue
195
+ .then(() => appendLine(cfg, line))
196
+ .catch(() => undefined); // never let a logging error escape or break the chain
197
+ }
198
+
199
+ // ---- Logger implementation ----
200
+
201
+ class FileLogger implements Logger {
202
+ readonly level: LogLevel;
203
+ private readonly scope: string | undefined;
204
+ private readonly cfg: LoggerConfig;
205
+
206
+ constructor(cfg: LoggerConfig, scope?: string) {
207
+ this.cfg = cfg;
208
+ this.level = cfg.minLevel;
209
+ this.scope = scope;
210
+ }
211
+
212
+ child(scope: string): Logger {
213
+ const merged = this.scope ? `${this.scope}:${scope}` : scope;
214
+ return new FileLogger(this.cfg, merged);
215
+ }
216
+
217
+ swallow(level: LogLevel, msg: string, fields?: LogFields): (err: unknown) => undefined {
218
+ return (err) => { this.emit(level, msg, { ...fields, err }); return undefined; };
219
+ }
220
+
221
+ debug(msg: string, fields?: LogFields): void { this.emit("debug", msg, fields); }
222
+ info(msg: string, fields?: LogFields): void { this.emit("info", msg, fields); }
223
+ warn(msg: string, fields?: LogFields): void { this.emit("warn", msg, fields); }
224
+ error(msg: string, fields?: LogFields): void { this.emit("error", msg, fields); }
225
+
226
+ private emit(level: LogLevel, msg: string, fields?: LogFields): void {
227
+ if (!this.cfg.enabled) return;
228
+ if (LEVEL_ORDER[level] < LEVEL_ORDER[this.cfg.minLevel]) return;
229
+ const record: Record<string, unknown> = {
230
+ ts: isoTs(),
231
+ level,
232
+ msg,
233
+ ...(this.scope ? { scope: this.scope } : {}),
234
+ ...ensureFieldSafety(fields),
235
+ };
236
+ enqueue(this.cfg, safeStringify(record) + "\n");
237
+ }
238
+ }
239
+
240
+ // ---- Module-level singleton ----
241
+
242
+ const initialConfig: LoggerConfig = {
243
+ dir: defaultLogDir(),
244
+ minLevel: "info",
245
+ maxFileSize: DEFAULT_MAX_FILE_SIZE,
246
+ maxFiles: DEFAULT_MAX_FILES,
247
+ enabled: true,
248
+ };
249
+
250
+ export interface InitLoggerOptions {
251
+ dir?: string;
252
+ level?: LogLevel;
253
+ maxFileSize?: number;
254
+ maxFiles?: number;
255
+ enabled?: boolean;
256
+ }
257
+
258
+ /**
259
+ * Pin logger configuration at extension activation. Call once from `index.ts`
260
+ * before any subsystem logs. Mutates the shared config object in place so the
261
+ * root logger and all existing `child()` loggers pick up the new settings.
262
+ */
263
+ export function initLogger(options?: InitLoggerOptions): void {
264
+ initialConfig.dir = options?.dir ?? defaultLogDir();
265
+ initialConfig.minLevel = options?.level ?? "info";
266
+ initialConfig.maxFileSize = options?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
267
+ initialConfig.maxFiles = options?.maxFiles ?? DEFAULT_MAX_FILES;
268
+ initialConfig.enabled = options?.enabled ?? true;
269
+ // Re-stat the new day file on the next append (dir may have changed).
270
+ sink.currentDate = undefined;
271
+ sink.currentSize = 0;
272
+ sink.dirReady = false;
273
+ }
274
+
275
+ /** Read-only access to the resolved log directory (for /status diagnostics). */
276
+ export function getLogDir(): string {
277
+ return initialConfig.dir;
278
+ }
279
+
280
+ export function getLogLevel(): LogLevel {
281
+ return initialConfig.minLevel;
282
+ }
283
+
284
+ export function isLoggingEnabled(): boolean {
285
+ return initialConfig.enabled;
286
+ }
287
+
288
+ /** The default root logger. Module-scoped loggers should use `log.child("<scope>")`. */
289
+ export const log: Logger = new FileLogger(initialConfig);
290
+
291
+ /**
292
+ * Test/inspection helper: drain the in-flight write queue and return today's
293
+ * log file paths actually on disk. Not used at runtime.
294
+ */
295
+ export async function __drainAndListFiles(dir?: string): Promise<string[]> {
296
+ await sink.queue.catch(() => undefined);
297
+ const target = dir ?? initialConfig.dir;
298
+ try {
299
+ const entries = await readdir(target);
300
+ return entries.filter((e) => e.startsWith(FILE_PREFIX)).sort().map((e) => join(target, e));
301
+ } catch {
302
+ return [];
303
+ }
304
+ }