Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ WHISPER_CLI_PATH=whisper-cli # optional: override whisper-cli executable pa
# mkdir -p ./models && curl -L -o models/ggml-base.en.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin
WHISPER_MODEL_PATH=models/ggml-base.en.bin # optional: whisper.cpp model path
WHISPER_LANGUAGE=auto # optional: whisper language code, or 'auto' for detection
LOG_LEVEL=info # optional: minimum log level: debug | info | warn | error (default: info)

# --- Discord provider (loaded only if 'discord' is in ENABLED_PROVIDERS) ---
DISCORD_BOT_TOKEN=your_bot_token_here
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ node_modules/
dist/
.env
*.db
logs/

Auto Run Docs/
models
Expand Down
126 changes: 126 additions & 0 deletions src/__tests__/logger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import test, { afterEach } from 'node:test';
import assert from 'node:assert/strict';

const mod: { logger?: typeof import('../core/logger').logger } = {};

test('logger is a singleton with the expected surface', async () => {
mod.logger = (await import('../core/logger')).logger;
assert.equal(typeof mod.logger!.debug, 'function');
assert.equal(typeof mod.logger!.info, 'function');
assert.equal(typeof mod.logger!.warn, 'function');
assert.equal(typeof mod.logger!.error, 'function');
assert.equal(typeof mod.logger!.setLevel, 'function');
assert.equal(typeof mod.logger!.isEnabled, 'function');
assert.equal(typeof mod.logger!.getLevel, 'function');
});

afterEach(() => {
mod.logger?.setLevel('info');
});

test('default level is info', async () => {
const { logger } = await import('../core/logger');
assert.equal(logger.getLevel(), 'info');
assert.equal(logger.isEnabled('debug'), false);
assert.equal(logger.isEnabled('info'), true);
assert.equal(logger.isEnabled('warn'), true);
assert.equal(logger.isEnabled('error'), true);
});

test('setLevel changes the gate', async () => {
const { logger } = await import('../core/logger');
logger.setLevel('warn');
assert.equal(logger.isEnabled('debug'), false);
assert.equal(logger.isEnabled('info'), false);
assert.equal(logger.isEnabled('warn'), true);
assert.equal(logger.isEnabled('error'), true);
logger.setLevel('debug');
assert.equal(logger.isEnabled('debug'), true);
assert.equal(logger.isEnabled('info'), true);
logger.setLevel('error');
assert.equal(logger.isEnabled('debug'), false);
assert.equal(logger.isEnabled('info'), false);
assert.equal(logger.isEnabled('warn'), false);
assert.equal(logger.isEnabled('error'), true);
});

test('unknown levels fall back to info', async () => {
const { logger } = await import('../core/logger');
logger.setLevel('bogus');
assert.equal(logger.getLevel(), 'info');
});

test('level-gated methods do not emit when disabled', async () => {
const { logger } = await import('../core/logger');
const origDebug = console.debug;
const origInfo = console.info;
const origWarn = console.warn;
const debugCalls: string[] = [];
const infoCalls: string[] = [];
const warnCalls: string[] = [];
console.debug = (line: string) => debugCalls.push(line);
console.info = (line: string) => infoCalls.push(line);
console.warn = (line: string) => warnCalls.push(line);
try {
logger.setLevel('warn');
logger.debug('test/ctx', 'debug-detail');
logger.info('test/ctx', 'info-detail');
logger.warn('test/ctx', 'warn-detail');
assert.equal(debugCalls.length, 0, 'debug should be suppressed at warn level');
assert.equal(infoCalls.length, 0, 'info should be suppressed at warn level');
assert.equal(warnCalls.length, 1, 'warn should pass at warn level');
assert.match(warnCalls[0], /\[WARN\] \[test\/ctx\] warn-detail/);
} finally {
console.debug = origDebug;
console.info = origInfo;
console.warn = origWarn;
}
});

test('debug emits when level is debug', async () => {
const { logger } = await import('../core/logger');
const orig = console.debug;
const calls: string[] = [];
console.debug = (line: string) => calls.push(line);
try {
logger.setLevel('debug');
logger.debug('test/ctx', 'detail');
assert.equal(calls.length, 1);
assert.match(calls[0], /\[DEBUG\] \[test\/ctx\] detail/);
} finally {
console.debug = orig;
}
});

test('error always emits at any level that includes error', async () => {
const { logger } = await import('../core/logger');
const orig = console.error;
const calls: string[] = [];
console.error = (line: string) => calls.push(line);
try {
for (const level of ['debug', 'info', 'warn', 'error'] as const) {
logger.setLevel(level);
calls.length = 0;
await logger.error('test/ctx', 'err-detail');
assert.equal(calls.length, 1, `error should fire at level=${level}`);
assert.match(calls[0], /\[ERROR\] \[test\/ctx\] err-detail/);
}
} finally {
console.error = orig;
}
});

test('log lines are sanitized to keep them single-line', async () => {
const { logger } = await import('../core/logger');
const orig = console.error;
const calls: string[] = [];
console.error = (line: string) => calls.push(line);
try {
await logger.error('test/ctx', 'line one\nline two\rline three');
assert.equal(calls.length, 1);
assert.ok(!calls[0].includes('\n'), 'log line should not contain raw newlines');
assert.ok(!calls[0].includes('\r'), 'log line should not contain raw carriage returns');
} finally {
console.error = orig;
}
});
7 changes: 5 additions & 2 deletions src/__tests__/mockProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,13 @@ test('a minimal MockProvider satisfies BridgeProvider and works with the kernel
},
getProvider: (name) => (name === 'mock' ? mockProvider : undefined),
splitMessage: (text) => [text],
logger: { error: () => {} },
logger: { error: () => {}, warn: () => {}, info: () => {}, debug: () => {} },
});

await mockProvider.start({ enqueue: queue.enqueue, logger: { error: () => {} } });
await mockProvider.start({
enqueue: queue.enqueue,
logger: { error: () => {}, warn: () => {}, info: () => {}, debug: () => {} },
});

const message: IncomingMessage = {
provider: 'mock',
Expand Down
7 changes: 6 additions & 1 deletion src/__tests__/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,12 @@ function createMocks(overrides: Partial<ConversationRecord> = {}): MockSetup {
splitMessage: (text: string) => [text],
downloadAttachments: mockDownload as any,
formatAttachmentRefs: mockFormat as any,
logger: { error: mockLoggerError as any },
logger: {
error: mockLoggerError as any,
warn: () => undefined,
info: () => undefined,
debug: () => undefined,
},
_mocks: {
getAgentCwd: mockGetAgentCwd,
send: mockSend,
Expand Down
7 changes: 6 additions & 1 deletion src/__tests__/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,12 @@ function makeDeps(overrides: Partial<ApiDeps> = {}): ApiDeps {
return {
providers: new Map([['discord', makeProvider('discord')]]),
splitMessage: (s: string) => [s],
logger: { error: async () => undefined },
logger: {
error: async () => undefined,
warn: () => undefined,
info: () => undefined,
debug: () => undefined,
},
...overrides,
};
}
Expand Down
15 changes: 8 additions & 7 deletions src/core/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type ApiDeps = {
/** Map provider-name → BridgeProvider instance. */
providers: Map<string, BridgeProvider>;
splitMessage?: (text: string) => string[];
logger?: { error(...args: unknown[]): unknown };
logger?: import('./types').KernelLogger;
};

const MAX_BODY_SIZE = 1_048_576; // 1 MB
Expand Down Expand Up @@ -207,16 +207,17 @@ export function startServer(providers: Map<string, BridgeProvider>): http.Server
const server = http.createServer(handler);

server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.error(`API server failed to start: port ${config.apiPort} is already in use`);
} else {
console.error('API server error:', err.message);
}
void logger.error(
'api/startup',
err.code === 'EADDRINUSE'
? `API server failed to start: port ${config.apiPort} is already in use`
: `API server error: ${err.message}`,
);
process.exit(1);
});

server.listen(config.apiPort, '127.0.0.1', () => {
console.log(`API server listening on http://127.0.0.1:${config.apiPort}`);
logger.info('api/startup', `API server listening on http://127.0.0.1:${config.apiPort}`);
});

return server;
Expand Down
15 changes: 9 additions & 6 deletions src/core/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { mkdir, rm, writeFile } from 'fs/promises';
import { randomUUID } from 'crypto';
import path from 'path';
import type { IncomingAttachment } from './types';
import { logger } from './logger';

export interface DownloadedFile {
originalName: string;
Expand Down Expand Up @@ -30,7 +31,7 @@ export async function downloadAttachments(
try {
await mkdir(targetDir, { recursive: true });
} catch (err) {
console.warn(`[attachments] Failed to create directory "${targetDir}":`, err);
logger.warn('attachments/mkdir', `Failed to create directory "${targetDir}": ${String(err)}`);
return { downloaded: [], failed: attachments.map((a) => a.name) };
}

Expand All @@ -39,8 +40,9 @@ export async function downloadAttachments(

for (const attachment of attachments) {
if (attachment.size > MAX_FILE_SIZE) {
console.warn(
`[attachments] Skipping "${attachment.name}" (${attachment.size} bytes) — exceeds ${MAX_FILE_SIZE} byte limit`,
logger.warn(
'attachments/skip',
`Skipping "${attachment.name}" (${attachment.size} bytes) — exceeds ${MAX_FILE_SIZE} byte limit`,
);
failed.push(attachment.name);
continue;
Expand All @@ -53,8 +55,9 @@ export async function downloadAttachments(
try {
const response = await fetch(attachment.url);
if (!response.ok) {
console.warn(
`[attachments] Failed to download "${attachment.name}": HTTP ${response.status}`,
logger.warn(
'attachments/download',
`Failed to download "${attachment.name}": HTTP ${response.status}`,
);
failed.push(attachment.name);
continue;
Expand All @@ -64,7 +67,7 @@ export async function downloadAttachments(
await writeFile(savedPath, buffer);
downloaded.push({ originalName: attachment.name, savedPath });
} catch (err) {
console.warn(`[attachments] Error downloading "${attachment.name}":`, err);
logger.warn('attachments/download', `Error downloading "${attachment.name}": ${String(err)}`);
failed.push(attachment.name);
}
}
Expand Down
9 changes: 9 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,13 @@ export const config = {
get whisperLanguage() {
return process.env.WHISPER_LANGUAGE || 'auto';
},
/**
* Minimum log level for console output. One of `debug`, `info`, `warn`,
* `error`. Defaults to `info`. Console output for every level (including
* `error`) is gated by this level, but `error` always appends to the log
* file regardless of level.
*/
get logLevel(): string {
return (process.env.LOG_LEVEL || 'info').toLowerCase();
},
};
52 changes: 48 additions & 4 deletions src/core/logger.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import { appendFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { config } from './config';

const LOG_DIR = process.env.LOG_DIR || join(process.cwd(), 'logs');
const LOG_FILE = join(LOG_DIR, 'errors.log');

let dirReady = false;

const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 } as const;
export type LogLevel = keyof typeof LEVELS;

function parseLevel(raw: string): number {
const key = raw.toLowerCase() as LogLevel;
return LEVELS[key] ?? LEVELS.info;
}

let currentLevel = parseLevel(config.logLevel);

async function ensureDir(): Promise<void> {
if (dirReady) return;
await mkdir(LOG_DIR, { recursive: true });
Expand All @@ -21,15 +32,48 @@ function formatEntry(level: string, context: string, detail: string): string {
return `[${ts}] ${level} [${sanitize(context)}] ${sanitize(detail)}\n`;
}

function formatLine(level: string, context: string, detail: string): string {
return `[${level}] [${sanitize(context)}] ${sanitize(detail)}`;
}

function shouldEmit(level: LogLevel): boolean {
return LEVELS[level] >= currentLevel;
}

function emit(level: LogLevel, context: string, detail: string, sink: (line: string) => void) {
if (!shouldEmit(level)) return;
sink(formatLine(level.toUpperCase(), context, detail));
}

export const logger = {
/** Update the minimum log level at runtime (e.g. for tests or operator hot-toggle). */
setLevel(level: LogLevel | string): void {
currentLevel = parseLevel(level);
},
/** Current minimum level. */
getLevel(): LogLevel {
return (Object.keys(LEVELS) as LogLevel[]).find((k) => LEVELS[k] === currentLevel) ?? 'info';
},
/** Returns true if messages at the given level would be emitted. */
isEnabled(level: LogLevel): boolean {
return shouldEmit(level);
},
debug(context: string, detail: string): void {
emit('debug', context, detail, (line) => console.debug(line));
},
info(context: string, detail: string): void {
emit('info', context, detail, (line) => console.info(line));
},
warn(context: string, detail: string): void {
emit('warn', context, detail, (line) => console.warn(line));
},
async error(context: string, detail: string): Promise<void> {
const line = formatEntry('ERROR', context, detail);
console.error(line.trimEnd());
if (shouldEmit('error')) console.error(formatLine('ERROR', context, detail));
try {
await ensureDir();
await appendFile(LOG_FILE, line);
await appendFile(LOG_FILE, formatEntry('ERROR', context, detail));
} catch {
// If file logging fails, console.error above still ran
// If file logging fails, console.error above still ran (if enabled)
}
},
};
7 changes: 4 additions & 3 deletions src/core/maestro.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { execFile, spawn } from 'child_process';
import { promisify } from 'util';
import { logger } from './logger';

const execFileAsync = promisify(execFile);

Expand Down Expand Up @@ -212,7 +213,7 @@ async function run(args: string[], opts: RunOptions = {}): Promise<string> {
if (e.stdout?.trim()) parts.push(`stdout: ${e.stdout.trim()}`);
if (parts.length === 0) parts.push(e.message || String(err));
const detail = parts.join(' | ');
console.error(`[maestro-cli ${args[0]}] ${detail}`);
void logger.error(`maestro-cli/${args[0]}`, detail);
throw new Error(`maestro-cli ${args[0]} failed: ${detail}`, { cause: err });
}
}
Expand All @@ -232,7 +233,7 @@ function runSpawn(args: string[]): Promise<string> {
child.stderr.on('data', (data: Buffer) => stderrChunks.push(data));

child.on('error', (err) => {
console.error(`[maestro-cli ${args[0]}] spawn error: ${err.message}`);
void logger.error(`maestro-cli/${args[0]}`, `spawn error: ${err.message}`);
reject(new Error(`maestro-cli ${args[0]} failed: spawn error: ${err.message}`));
});

Expand All @@ -248,7 +249,7 @@ function runSpawn(args: string[]): Promise<string> {
if (stderr) parts.push(`stderr: ${stderr}`);
if (stdout) parts.push(`stdout: ${stdout}`);
const detail = parts.join(' | ');
console.error(`[maestro-cli ${args[0]}] ${detail}`);
void logger.error(`maestro-cli/${args[0]}`, detail);
reject(new Error(`maestro-cli ${args[0]} failed: ${detail}`));
}
});
Expand Down
Loading