export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; const LEVEL_PRIORITY: Record = { debug: 10, info: 20, warn: 30, error: 40, }; export interface LogEntry { ts: string; level: LogLevel; scope: string; msg: string; data?: Record; } export interface Logger { child(scope: string, context?: Record): Logger; debug(msg: string, data?: Record): void; info(msg: string, data?: Record): void; warn(msg: string, data?: Record): void; error(msg: string, data?: Record): void; setLevel(level: LogLevel): void; getLevel(): LogLevel; readonly scope: string; } export interface LoggerOptions { scope?: string; level?: LogLevel; json?: boolean; context?: Record; sink?: (entry: LogEntry) => void; sensitiveKeys?: readonly string[]; } const DEFAULT_SENSITIVE_KEYS = [ 'apikey', 'api_key', 'token', 'bearer', 'authorization', 'password', 'secret', 'sessionid', 'session_id', 'cookie', 'refresh_token', ] as const; function envLevel(): LogLevel | undefined { if (typeof globalThis === 'undefined') return undefined; const env = (globalThis as { process?: { env?: Record } }).process?.env ?? {}; const raw = String(env.MOSS_LOG_LEVEL ?? '') .trim() .toLowerCase(); if (raw === 'debug' || raw === 'info' || raw === 'warn' || raw === 'error') return raw; return undefined; } function envJson(): boolean { if (typeof globalThis === 'undefined') return false; const env = (globalThis as { process?: { env?: Record } }).process?.env ?? {}; const raw = String(env.MOSS_LOG_JSON ?? '') .trim() .toLowerCase(); return raw === '1' || raw === 'true'; } function nowIso(): string { return new Date().toISOString(); } export function redactSensitive( data: Record | undefined, sensitiveKeys: readonly string[] = DEFAULT_SENSITIVE_KEYS, depth = 0, seen?: WeakSet ): Record | string | undefined { if (!data || typeof data !== 'object') return data; if (depth > 4) return '[REDACTED:depth]'; if (!seen) seen = new WeakSet(); if (seen.has(data)) return '[Circular]'; seen.add(data); const lowerKeys = new Set(sensitiveKeys.map((k) => k.toLowerCase())); const out: Record = {}; for (const [k, v] of Object.entries(data)) { const isSensitive = lowerKeys.has(k.toLowerCase()); if (isSensitive) { out[k] = maskValue(v); continue; } if (v && typeof v === 'object') { if (Array.isArray(v)) { out[k] = v.map((item) => { if (item && typeof item === 'object' && !Array.isArray(item)) { return redactSensitive(item as Record, sensitiveKeys, depth + 1, seen); } return item; }); } else { out[k] = redactSensitive(v as Record, sensitiveKeys, depth + 1, seen); } } else { out[k] = v; } } return out; } function maskValue(v: unknown): string { if (typeof v !== 'string' || !v) return '***'; if (v.length <= 8) return '***'; return `${v.slice(0, 2)}***${v.slice(-4)}`; } function formatConsole(entry: LogEntry): string { const scope = entry.scope ? `[${entry.scope}]` : ''; const levelTag = entry.level === 'debug' ? ' [debug]' : ''; let suffix = ''; if (entry.data && Object.keys(entry.data).length > 0) { const parts: string[] = []; for (const [k, v] of Object.entries(entry.data)) { if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') { parts.push(`${k}=${v}`); } else { parts.push(`${k}=${safeStringify(v)}`); } } if (parts.length) suffix = ' · ' + parts.join(' · '); } return `${scope}${levelTag} ${entry.msg}${suffix}`; } function safeStringify(v: unknown, max = 400): string { try { const s = JSON.stringify(v); if (!s) return String(v); return s.length > max ? `${s.slice(0, max)}…` : s; } catch { return String(v); } } function defaultSink(entry: LogEntry, json: boolean): void { if (typeof globalThis === 'undefined') return; // Write directly to process.stderr to bypass Ink's patchConsole interception. // When Moss runs in TUI mode (patchConsole: true), console.warn/error are // redirected into the Ink render tree, making internal log lines appear in the // conversation UI. Direct stderr writes are unaffected by the patch. const stderrWrite = (globalThis as { process?: { stderr?: { write?: (s: string) => void } } }).process?.stderr?.write ?.bind( (globalThis as { process?: { stderr?: object } }).process!.stderr ); if (!stderrWrite) { // Fallback for non-Node environments (tests, browser): use console. const c = (globalThis as { console?: Console }).console; if (!c) return; const line = json ? JSON.stringify({ ts: entry.ts, level: entry.level, scope: entry.scope, msg: entry.msg, ...(entry.data ?? {}) }) : formatConsole(entry); if (entry.level === 'error') c.error(line); else c.warn(line); return; } if (json) { const payload = JSON.stringify({ ts: entry.ts, level: entry.level, scope: entry.scope, msg: entry.msg, ...(entry.data ?? {}), }); stderrWrite(`${payload}\n`); return; } const line = formatConsole(entry); stderrWrite(`${line}\n`); } export function createLogger(opts: LoggerOptions = {}): Logger { const parentScope = opts.scope ?? ''; let currentLevel: LogLevel = opts.level ?? envLevel() ?? 'info'; const useJson = opts.json ?? envJson(); const sink = opts.sink ?? ((entry: LogEntry) => defaultSink(entry, useJson)); const sensitiveKeys = opts.sensitiveKeys ?? DEFAULT_SENSITIVE_KEYS; const baseContext = { ...(opts.context ?? {}) }; function emit(level: LogLevel, msg: string, data?: Record): void { if (LEVEL_PRIORITY[level] < LEVEL_PRIORITY[currentLevel]) return; const merged: Record | undefined = (data && Object.keys(data).length > 0) || Object.keys(baseContext).length > 0 ? { ...baseContext, ...(data ?? {}) } : undefined; const safe = redactSensitive(merged, sensitiveKeys) as Record | undefined; sink({ ts: nowIso(), level, scope: parentScope, msg, data: safe, }); } const logger: Logger = { scope: parentScope, getLevel: () => currentLevel, setLevel: (level) => { currentLevel = level; }, debug: (msg, data) => emit('debug', msg, data), info: (msg, data) => emit('info', msg, data), warn: (msg, data) => emit('warn', msg, data), error: (msg, data) => emit('error', msg, data), child: (childScope, childContext) => { const nextScope = parentScope && childScope ? `${parentScope}:${childScope}` : childScope || parentScope; const childBaseContext = { ...baseContext, ...(childContext ?? {}) }; // Delegate the level check to THIS logger's `currentLevel` (read at emit // time, not snapshotted at child creation). This fixes module-level // `getRootLogger().child(...)` evaluated at import time BEFORE // `configureRootLogger` runs: the child checks the parent's CURRENT level, // so when configureRootLogger later sets the root level (via setLevel), // the child respects it. Previously the child snapshotted the default // 'info' and never saw the configured level. function childEmit(level: LogLevel, msg: string, data?: Record): void { if (LEVEL_PRIORITY[level] < LEVEL_PRIORITY[currentLevel]) return; const merged: Record | undefined = (data && Object.keys(data).length > 0) || Object.keys(childBaseContext).length > 0 ? { ...childBaseContext, ...(data ?? {}) } : undefined; const safe = redactSensitive(merged, sensitiveKeys) as Record | undefined; sink({ ts: nowIso(), level, scope: nextScope, msg, data: safe, }); } const childLogger: Logger = { scope: nextScope, getLevel: () => currentLevel, setLevel: (level) => { currentLevel = level; }, debug: (msg, data) => childEmit('debug', msg, data), info: (msg, data) => childEmit('info', msg, data), warn: (msg, data) => childEmit('warn', msg, data), error: (msg, data) => childEmit('error', msg, data), child: (grandScope, grandContext) => childLogger.child(grandScope, grandContext), }; return childLogger; }, }; return logger; } let rootLogger: Logger | null = null; let rootLoggerOptions: LoggerOptions = {}; export function configureRootLogger(opts: LoggerOptions = {}): void { rootLoggerOptions = opts; // Mutate the existing root logger in place (via setLevel) so that children // already created from it (e.g. module-level `getRootLogger().child(...)` // evaluated at import time, before this call) respect the new level — they // delegate their level check to the root's currentLevel. Previously this // created a NEW logger, leaving already-created children pinned to the old // default level. if (rootLogger) { if (opts.level !== undefined) rootLogger.setLevel(opts.level); } else { rootLogger = createLogger(opts); } } export function getRootLogger(): Logger { if (!rootLogger) { rootLogger = createLogger(rootLoggerOptions); } return rootLogger; }