mom: add centralized logging, usage tracking, and improve prompt caching

Major improvements to mom's logging and cost reporting:

Centralized Logging System:
- Add src/log.ts with type-safe logging functions
- Colored console output (green=user, yellow=mom, dim=details)
- Consistent format: [HH:MM:SS] [context] message
- Replace scattered console.log/error calls throughout codebase

Usage Tracking & Cost Reporting:
- Track tokens (input, output, cache read/write) and costs per run
- Display summary at end of each run in console and Slack thread
- Example: 💰 Usage: 12,543 in + 847 out (5,234 cache read) = $0.0234

Prompt Caching Optimization:
- Move recent messages from system prompt to user message
- System prompt now mostly static (only changes with memory files)
- Enables effective use of Anthropic's prompt caching
- Significantly reduces costs on subsequent requests

Model & Cost Improvements:
- Switch from Claude Opus 4.5 to Sonnet 4.5 (~40% cost reduction)
- Fix Claude Opus 4.5 cache pricing in ai package (was 3x too expensive)
- Add manual override in generate-models.ts until upstream fix merges
- Submitted PR to models.dev: https://github.com/sst/models.dev/pull/439

UI/UX Improvements:
- Extract actual text from tool results instead of JSON wrapper
- Cleaner Slack thread formatting with duration and labels
- Tool args formatting shows paths with offset:limit notation
- Add chalk for colored terminal output

Dependencies:
- Add chalk package for terminal colors
This commit is contained in:
Mario Zechner 2025-11-26 18:04:16 +01:00
parent 82d4ac93e1
commit 213bc4df1c
11 changed files with 478 additions and 63 deletions

226
packages/mom/src/log.ts Normal file
View file

@ -0,0 +1,226 @@
import chalk from "chalk";
export interface LogContext {
channelId: string;
userName?: string;
channelName?: string; // For display like #dev-team vs C16HET4EQ
}
function timestamp(): string {
const now = new Date();
const hh = String(now.getHours()).padStart(2, "0");
const mm = String(now.getMinutes()).padStart(2, "0");
const ss = String(now.getSeconds()).padStart(2, "0");
return `[${hh}:${mm}:${ss}]`;
}
function formatContext(ctx: LogContext): string {
// DMs: [DM:username]
// Channels: [#channel-name:username] or [C16HET4EQ:username] if no name
if (ctx.channelId.startsWith("D")) {
return `[DM:${ctx.userName || ctx.channelId}]`;
}
const channel = ctx.channelName || ctx.channelId;
const user = ctx.userName || "unknown";
return `[${channel.startsWith("#") ? channel : `#${channel}`}:${user}]`;
}
function truncate(text: string, maxLen: number): string {
if (text.length <= maxLen) return text;
return text.substring(0, maxLen) + `\n(truncated at ${maxLen} chars)`;
}
function formatToolArgs(args: Record<string, unknown>): string {
const lines: string[] = [];
for (const [key, value] of Object.entries(args)) {
// Skip the label - it's already shown in the tool name
if (key === "label") continue;
// For read tool, format path with offset/limit
if (key === "path" && typeof value === "string") {
const offset = args.offset as number | undefined;
const limit = args.limit as number | undefined;
if (offset !== undefined && limit !== undefined) {
lines.push(`${value}:${offset}-${offset + limit}`);
} else {
lines.push(value);
}
continue;
}
// Skip offset/limit since we already handled them
if (key === "offset" || key === "limit") continue;
// For other values, format them
if (typeof value === "string") {
// Multi-line strings get indented
if (value.includes("\n")) {
lines.push(value);
} else {
lines.push(value);
}
} else {
lines.push(JSON.stringify(value));
}
}
return lines.join("\n");
}
// User messages
export function logUserMessage(ctx: LogContext, text: string): void {
console.log(chalk.green(`${timestamp()} ${formatContext(ctx)} ${text}`));
}
// Tool execution
export function logToolStart(ctx: LogContext, toolName: string, label: string, args: Record<string, unknown>): void {
const formattedArgs = formatToolArgs(args);
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)}${toolName}: ${label}`));
if (formattedArgs) {
// Indent the args
const indented = formattedArgs
.split("\n")
.map((line) => ` ${line}`)
.join("\n");
console.log(chalk.dim(indented));
}
}
export function logToolSuccess(ctx: LogContext, toolName: string, durationMs: number, result: string): void {
const duration = (durationMs / 1000).toFixed(1);
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)}${toolName} (${duration}s)`));
const truncated = truncate(result, 1000);
if (truncated) {
const indented = truncated
.split("\n")
.map((line) => ` ${line}`)
.join("\n");
console.log(chalk.dim(indented));
}
}
export function logToolError(ctx: LogContext, toolName: string, durationMs: number, error: string): void {
const duration = (durationMs / 1000).toFixed(1);
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)}${toolName} (${duration}s)`));
const truncated = truncate(error, 1000);
const indented = truncated
.split("\n")
.map((line) => ` ${line}`)
.join("\n");
console.log(chalk.dim(indented));
}
// Response streaming
export function logResponseStart(ctx: LogContext): void {
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} → Streaming response...`));
}
export function logResponseComplete(ctx: LogContext, charCount: number): void {
console.log(
chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✓ Response sent (${charCount.toLocaleString()} chars)`),
);
}
// Attachments
export function logDownloadStart(ctx: LogContext, filename: string, localPath: string): void {
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ↓ Downloading attachment`));
console.log(chalk.dim(` ${filename}${localPath}`));
}
export function logDownloadSuccess(ctx: LogContext, sizeKB: number): void {
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✓ Downloaded (${sizeKB.toLocaleString()} KB)`));
}
export function logDownloadError(ctx: LogContext, filename: string, error: string): void {
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✗ Download failed`));
console.log(chalk.dim(` ${filename}: ${error}`));
}
// Control
export function logStopRequest(ctx: LogContext): void {
console.log(chalk.green(`${timestamp()} ${formatContext(ctx)} stop`));
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ⊗ Stop requested - aborting`));
}
// System
export function logWarning(message: string, details?: string): void {
console.log(chalk.yellow(`${timestamp()} [system] ⚠ ${message}`));
if (details) {
const indented = details
.split("\n")
.map((line) => ` ${line}`)
.join("\n");
console.log(chalk.dim(indented));
}
}
export function logAgentError(ctx: LogContext | "system", error: string): void {
const context = ctx === "system" ? "[system]" : formatContext(ctx);
console.log(chalk.yellow(`${timestamp()} ${context} ✗ Agent error`));
const indented = error
.split("\n")
.map((line) => ` ${line}`)
.join("\n");
console.log(chalk.dim(indented));
}
// Usage summary
export function logUsageSummary(
ctx: LogContext,
usage: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };
},
): string {
const lines: string[] = [];
lines.push("*Usage Summary*");
lines.push(`Tokens: ${usage.input.toLocaleString()} in, ${usage.output.toLocaleString()} out`);
if (usage.cacheRead > 0 || usage.cacheWrite > 0) {
lines.push(`Cache: ${usage.cacheRead.toLocaleString()} read, ${usage.cacheWrite.toLocaleString()} write`);
}
lines.push(
`Cost: $${usage.cost.input.toFixed(4)} in, $${usage.cost.output.toFixed(4)} out` +
(usage.cacheRead > 0 || usage.cacheWrite > 0
? `, $${usage.cost.cacheRead.toFixed(4)} cache read, $${usage.cost.cacheWrite.toFixed(4)} cache write`
: ""),
);
lines.push(`*Total: $${usage.cost.total.toFixed(4)}*`);
const summary = lines.join("\n");
// Log to console
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💰 Usage`));
console.log(
chalk.dim(
` ${usage.input.toLocaleString()} in + ${usage.output.toLocaleString()} out` +
(usage.cacheRead > 0 || usage.cacheWrite > 0
? ` (${usage.cacheRead.toLocaleString()} cache read, ${usage.cacheWrite.toLocaleString()} cache write)`
: "") +
` = $${usage.cost.total.toFixed(4)}`,
),
);
return summary;
}
// Startup (no context needed)
export function logStartup(workingDir: string, sandbox: string): void {
console.log("Starting mom bot...");
console.log(` Working directory: ${workingDir}`);
console.log(` Sandbox: ${sandbox}`);
}
export function logConnected(): void {
console.log("⚡️ Mom bot connected and listening!");
console.log("");
}
export function logDisconnected(): void {
console.log("Mom bot disconnected.");
}