singularity-forge/src/logger.ts
Mikael Hugo 02a4339a51 refactor: rename pi-* packages to forge-native names (Phase 1)
Rename all four packages/pi-* directories to forge-native names,
stripping the 'pi' identity and establishing forge's own:

- packages/pi-coding-agent → packages/coding-agent
- packages/pi-ai → packages/ai
- packages/pi-agent-core → packages/agent-core
- packages/pi-tui → packages/tui

Package names updated:
- @singularity-forge/pi-coding-agent → @singularity-forge/coding-agent
- @singularity-forge/pi-ai → @singularity-forge/ai
- @singularity-forge/pi-agent-core → @singularity-forge/agent-core
- @singularity-forge/pi-tui → @singularity-forge/tui

All import references, bare string references, path references,
internal variable names (_bundledPi*), and dist files updated.
@mariozechner/pi-* third-party compat aliases preserved.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-10 11:28:01 +02:00

229 lines
6.1 KiB
TypeScript

/**
* logger.ts — Centralized LogTape configuration for singularity-forge.
*
* Purpose: Provide a single, consistent structured logging surface across all
* SF surfaces (CLI, TUI, web, headless) with automatic PII redaction,
* per-session file rotation, and mode-aware formatting.
*
* Consumer: Every module in src/ and packages/ that needs application logging.
*/
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { getRotatingFileSink } from "@logtape/file";
import {
configure,
getConsoleSink,
getJsonLinesFormatter,
type LogRecord,
getLogger as logtapeGetLogger,
reset,
type Sink,
} from "@logtape/logtape";
import { getPrettyFormatter } from "@logtape/pretty";
import { redactByField, redactByPattern } from "@logtape/redaction";
export interface LoggerOptions {
/** Session identifier for per-session log directories. */
sessionId?: string;
/** Runtime mode: dev = pretty console, autonomous = JSON + file. */
mode?: "dev" | "autonomous";
/** Override the default log level. */
level?: "debug" | "info" | "warning" | "error" | "fatal";
/** Base directory for log files (defaults to cwd/.sf/logs). */
logDir?: string;
/** Optional custom sink for testing. */
customSink?: (record: LogRecord) => void;
/** Optional category filters for testing. */
filters?: Array<{
category: string[];
lowestLevel: "debug" | "info" | "warning" | "error" | "fatal";
}>;
}
let configured = false;
export function resetLoggerConfig(): void {
configured = false;
try {
reset();
} catch {
/* ignore if not configured */
}
}
const API_KEY_PATTERN = {
pattern: /(\b(?:sk|key)-[\w-]+|\bBearer\s+[\w-]+|\bapi_key=[\w-]+)/g,
replacement: "[REDACTED]",
};
/**
* Build a redacting sink wrapper that applies pattern-based redaction to
* formatted output and field-based redaction to structured properties.
*
* Purpose: Ensure API keys and home directory paths never leak into logs,
* regardless of whether they appear in message strings or property values.
*/
function buildRedactingSink(
downstream: (record: LogRecord) => void,
homeDir: string,
): Sink {
// Field-based redaction for structured properties
const fieldRedacted = redactByField(downstream, {
fieldPatterns: [/^api[_-]?key$/i, /^token$/i, /^secret$/i, /^password$/i],
action: () => "[REDACTED]",
});
// Pattern-based redaction for message strings (includes home path)
const homePathPattern = {
pattern: new RegExp(homeDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"),
replacement: "~",
};
// redactByPattern expects a formatter (returns string), not a sink.
// For sinks, we apply pattern redaction via a wrapper that mutates
// the record's message strings before passing to the downstream sink.
const redactedSink: Sink = (record: LogRecord) => {
const mutated: LogRecord = {
...record,
message: record.message.map((m) => {
if (typeof m !== "string") return m;
let s = m;
s = s.replace(API_KEY_PATTERN.pattern, API_KEY_PATTERN.replacement);
s = s.replace(homePathPattern.pattern, homePathPattern.replacement);
return s;
}) as unknown[],
};
fieldRedacted(mutated);
};
return redactedSink;
}
/**
* Configure LogTape sinks and loggers for the current process.
*
* Purpose: One-time setup that selects pretty vs JSON output, enables file
* rotation in autonomous mode, and wraps everything in PII redaction.
*
* Consumer: src/cli.ts early in startup, and test suites.
*/
export async function configureLogger(
options: LoggerOptions = {},
): Promise<void> {
if (configured) {
return;
}
const mode = options.mode ?? inferMode();
const level = options.level ?? (mode === "dev" ? "debug" : "info");
const logDir = options.logDir ?? join(process.cwd(), ".sf", "logs");
const sessionId = options.sessionId ?? "default";
const homeDir = process.env.HOME || process.env.USERPROFILE || "/home/user";
const sinks: Record<string, Sink> = {};
if (mode === "dev") {
const prettyFormatter = getPrettyFormatter();
const redactingFormatter = redactByPattern(prettyFormatter, [
API_KEY_PATTERN,
{
pattern: new RegExp(
homeDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
"g",
),
replacement: "~",
},
]);
sinks.pretty = getConsoleSink({ formatter: redactingFormatter });
} else {
const jsonFormatter = getJsonLinesFormatter();
const redactingJsonFormatter = redactByPattern(jsonFormatter, [
API_KEY_PATTERN,
{
pattern: new RegExp(
homeDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
"g",
),
replacement: "~",
},
]);
sinks.console = getConsoleSink({ formatter: redactingJsonFormatter });
const sessionLogDir = join(logDir, sessionId);
try {
mkdirSync(sessionLogDir, { recursive: true });
} catch {
/* non-fatal */
}
sinks.file = getRotatingFileSink(join(sessionLogDir, "sf.log"), {
maxSize: 10 * 1024 * 1024,
maxFiles: 5,
formatter: redactingJsonFormatter,
});
}
if (options.customSink) {
sinks.custom = buildRedactingSink(options.customSink, homeDir);
}
const loggers = [
// Silence LogTape's own "loggers are configured" meta info message.
{
category: ["logtape", "meta"],
lowestLevel: "warning" as const,
sinks: [],
},
{
category: ["sf"],
lowestLevel: level,
sinks:
mode === "dev"
? ["pretty", "custom"].filter((s) => s in sinks)
: ["console", "file", "custom"].filter((s) => s in sinks),
},
];
if (options.filters) {
for (const f of options.filters) {
loggers.push({
category: f.category,
lowestLevel: f.lowestLevel,
sinks: [],
});
}
}
await configure({
sinks,
loggers,
});
configured = true;
}
/**
* Get a LogTape logger for the given dot-separated category.
*
* Purpose: Provide a typed, category-hierarchical logger so modules can
* declare their logging domain and inherit filters from parent categories.
*
* Consumer: Every migrated module calls `const log = getLogger("sf.core.env")`.
*/
export function getLogger(
category: string,
): ReturnType<typeof logtapeGetLogger> {
return logtapeGetLogger(category.split("."));
}
function inferMode(): "dev" | "autonomous" {
if (
process.env.SF_AUTONOMOUS === "1" ||
process.env.NODE_ENV === "production"
) {
return "autonomous";
}
return "dev";
}