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/README.md +100 -232
- package/index.ts +17 -5
- package/lib/attachments.ts +7 -3
- package/lib/commands/session.ts +4 -1
- package/lib/commands/telegram-commands.ts +7 -4
- package/lib/config.ts +6 -3
- package/lib/controller.ts +53 -14
- package/lib/custom-dialogs.ts +668 -0
- package/lib/heartbeat.ts +5 -1
- package/lib/logger.ts +304 -0
- package/lib/markdown.ts +267 -61
- package/lib/menu-commands.ts +4 -1
- package/lib/polling.ts +13 -8
- package/lib/renderer.ts +141 -14
- package/lib/telegram-api.ts +83 -28
- package/lib/telegram-ui.ts +89 -10
- package/lib/text-split.ts +216 -1
- package/package.json +1 -1
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
|
+
}
|