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>
229 lines
6.1 KiB
TypeScript
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";
|
|
}
|