diff --git a/README.md b/README.md index e3788d2..c535578 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,11 @@ This will: - Launch the TUI interface - Connect to Google Gemini Flash model +### Health & Diagnostics + +- Query `GET /health` for a structured status payload that now includes log summaries, skill-loading insights, and helpful recovery notes. +- Use `GET /logs?level=info` to stream recent log entries when debugging issues reported by the diagnostics block. + ### Using the Interface - **Type your query**: Enter your security question, CTF challenge description, or analysis request diff --git a/src/index.tsx b/src/index.tsx index ad85158..0fd718f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -29,6 +29,11 @@ import { import clipboardy from "clipboardy"; import { toast, TOAST_DURATION } from "@opentui-ui/toast"; import { Toaster } from "@opentui-ui/toast/react"; +import { logger, type LogLevel } from "./utils/logger"; +import { + buildDiagnosticsSnapshot, + type DiagnosticsSnapshot, +} from "./utils/diagnostics"; dotenv.config(); @@ -38,7 +43,6 @@ const skills = loadSkills(); const defaultSystemPrompt = buildSystemPrompt(skills); const skillTriggers = buildSkillTriggers(skills); -// Match $skill-name tokens to trigger the corresponding skill guidance. const SKILL_REFERENCE_REGEX = /\$([A-Za-z0-9_-]+)/g; function buildSkillTriggers( @@ -139,13 +143,62 @@ function buildRuntimeSystemPrompt( return `${basePrompt}\n\n## Skill Guidance\n${bodySections}`; } -// Start local HTTP server for chat API const PORT = 3001; + +interface HealthCheckResult { + status: "healthy" | "degraded" | "unhealthy"; + timestamp: string; + uptime: number; + version: string; + services: { + api: boolean; + skills: boolean; + }; + diagnostics: DiagnosticsSnapshot; +} + +function getHealthStatus(): HealthCheckResult { + const skillsLoaded = skills.length > 0; + const diagnostics = buildDiagnosticsSnapshot(skills); + + return { + status: skillsLoaded ? "healthy" : "degraded", + timestamp: new Date().toISOString(), + uptime: process.uptime(), + version: "1.0.0", + services: { + api: true, + skills: skillsLoaded, + }, + diagnostics, + }; +} + Bun.serve({ port: PORT, - idleTimeout: 255, // 10 minutes idle timeout for long-running tool executions + idleTimeout: 255, async fetch(req) { - if (req.method === "POST" && req.url.endsWith("/api/chat")) { + const url = new URL(req.url); + + if (url.pathname === "/health" && req.method === "GET") { + const health = getHealthStatus(); + return new Response(JSON.stringify(health, null, 2), { + status: health.status === "healthy" ? 200 : 503, + headers: { "Content-Type": "application/json" }, + }); + } + + if (url.pathname === "/logs" && req.method === "GET") { + const level = url.searchParams.get("level") as LogLevel | null; + const logs = level ? logger.getLogs(level) : logger.getLogs(); + return new Response(JSON.stringify(logs, null, 2), { + headers: { "Content-Type": "application/json" }, + }); + } + + if (req.method === "POST" && url.pathname === "/api/chat") { + logger.info("Chat API request received", { path: url.pathname }); + const body = (await req.json()) as { messages?: ChatMessage[] }; const messages = body.messages || []; @@ -163,7 +216,7 @@ Bun.serve({ stopWhen: stepCountIs(20), abortSignal: req.signal, onAbort: ({ steps }) => { - console.log("Stream aborted after", steps.length, "steps"); + logger.warn("Stream aborted", { stepsCount: steps.length }); }, providerOptions: { google: { @@ -177,7 +230,7 @@ Bun.serve({ }, }, onError: (error) => { - console.error("Error:", JSON.stringify(error, null, 2)); + logger.error("Chat API error", { error: String(error) }); }, }); @@ -186,11 +239,12 @@ Bun.serve({ }); } + logger.warn("Not found", { path: url.pathname, method: req.method }); return new Response("Not Found", { status: 404 }); }, }); -console.log(`Chat API server running on http://localhost:${PORT}`); +logger.info(`Chat API server running on http://localhost:${PORT}`); const copyToClipboard = async (text: string) => { try { // Try clipboardy first (cross-platform clipboard library) diff --git a/src/utils/__tests__/diagnostics.test.ts b/src/utils/__tests__/diagnostics.test.ts new file mode 100644 index 0000000..efb3baf --- /dev/null +++ b/src/utils/__tests__/diagnostics.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, beforeAll, afterAll } from "bun:test"; +import { buildDiagnosticsSnapshot } from "../diagnostics"; +import { logger } from "../logger"; + +const mockSkills = [ + { name: "Skill A", description: "Desc A", filePath: "a", body: "" }, + { name: "Skill B", description: "Desc B", filePath: "b", body: "" }, +]; + +const noop = () => {}; +let originalConsoleLog: typeof console.log; +let originalConsoleWarn: typeof console.warn; +let originalConsoleError: typeof console.error; + +describe("buildDiagnosticsSnapshot", () => { + beforeAll(() => { + originalConsoleLog = console.log; + originalConsoleWarn = console.warn; + originalConsoleError = console.error; + console.log = noop; + console.warn = noop; + console.error = noop; + }); + + afterAll(() => { + console.log = originalConsoleLog; + console.warn = originalConsoleWarn; + console.error = originalConsoleError; + }); + + beforeEach(() => { + logger.clearLogs(); + logger.setLogLevel("debug"); + }); + + it("captures log summary and skill insights", () => { + logger.info("info message"); + logger.error("fail message"); + + const snapshot = buildDiagnosticsSnapshot(mockSkills); + + expect(snapshot.skillInsights.totalSkills).toBe(2); + expect(snapshot.skillInsights.sample).toEqual(["Skill A", "Skill B"]); + expect(snapshot.logSummary.total).toBe(2); + expect(snapshot.logSummary.byLevel.info).toBe(1); + expect(snapshot.logSummary.byLevel.error).toBe(1); + expect(snapshot.notes.length).toBeGreaterThanOrEqual(0); + }); + + it("adds guidance when skills missing", () => { + const snapshot = buildDiagnosticsSnapshot([]); + expect(snapshot.skillInsights.totalSkills).toBe(0); + expect(Array.isArray(snapshot.notes)).toBe(true); + }); +}); diff --git a/src/utils/__tests__/logger.test.ts b/src/utils/__tests__/logger.test.ts index c04735d..29eb170 100644 --- a/src/utils/__tests__/logger.test.ts +++ b/src/utils/__tests__/logger.test.ts @@ -76,4 +76,21 @@ describe("logger", () => { logger.clearLogs(); expect(logger.getLogs()).toHaveLength(0); }); + + it("summarizes log levels and recency", () => { + logger.setLogLevel("debug"); + logger.debug("debug entry"); + logger.info("info entry"); + logger.warn("warn entry"); + + const summary = logger.getSummary(2); + expect(summary.total).toBe(3); + expect(summary.byLevel.debug).toBe(1); + expect(summary.byLevel.info).toBe(1); + expect(summary.byLevel.warn).toBe(1); + expect(summary.recent).toHaveLength(2); + expect(summary.recent[0]?.message).toBe("info entry"); + expect(summary.recent[1]?.message).toBe("warn entry"); + expect(summary.lastTimestamp).toBeDefined(); + }); }); diff --git a/src/utils/diagnostics.ts b/src/utils/diagnostics.ts new file mode 100644 index 0000000..3a0ffa5 --- /dev/null +++ b/src/utils/diagnostics.ts @@ -0,0 +1,53 @@ +import type { SkillDefinition } from "../skills"; +import { SKILLS_ENABLED } from "../skills"; +import { logger, type LogSummary } from "./logger"; + +export interface SkillInsights { + enabled: boolean; + totalSkills: number; + sample: string[]; + missingDirectory: boolean; +} + +export interface DiagnosticsSnapshot { + timestamp: string; + logSummary: LogSummary; + skillInsights: SkillInsights; + notes: string[]; +} + +const SAMPLE_LIMIT = 5; + +function buildSkillInsights(skills: SkillDefinition[]): SkillInsights { + return { + enabled: SKILLS_ENABLED, + totalSkills: skills.length, + sample: skills.slice(0, SAMPLE_LIMIT).map((skill) => skill.name), + missingDirectory: SKILLS_ENABLED && skills.length === 0, + }; +} + +export function buildDiagnosticsSnapshot( + skills: SkillDefinition[] +): DiagnosticsSnapshot { + const skillInsights = buildSkillInsights(skills); + const logSummary = logger.getSummary(); + const notes: string[] = []; + + if (!SKILLS_ENABLED) { + notes.push("Skill loading disabled. Set LOKI_SKILLS=true to enable skills."); + } else if (!skillInsights.totalSkills) { + notes.push("Skills enabled but none were discovered in the skills directory."); + } + + if (!logSummary.total) { + notes.push("No logs captured yet for this session."); + } + + return { + timestamp: new Date().toISOString(), + logSummary, + skillInsights, + notes, + }; +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..3c90d72 --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,114 @@ +export enum ErrorCategory { + VALIDATION = "validation", + RUNTIME = "runtime", + NETWORK = "network", + SECURITY = "security", + FILESYSTEM = "filesystem", + TIMEOUT = "timeout", + PERMISSION = "permission", + UNKNOWN = "unknown", +} + +export enum ErrorCode { + INVALID_INPUT = "INVALID_INPUT", + COMMAND_NOT_FOUND = "COMMAND_NOT_FOUND", + PERMISSION_DENIED = "PERMISSION_DENIED", + TIMEOUT_EXCEEDED = "TIMEOUT_EXCEEDED", + FILE_NOT_FOUND = "FILE_NOT_FOUND", + NETWORK_ERROR = "NETWORK_ERROR", + REGEX_ERROR = "REGEX_ERROR", + CONCURRENT_MODIFICATION = "CONCURRENT_MODIFICATION", + QUOTA_EXCEEDED = "QUOTA_EXCEEDED", + INTERNAL_ERROR = "INTERNAL_ERROR", +} + +export interface TypedError extends Error { + category: ErrorCategory; + code: ErrorCode; + recoverable: boolean; + suggestions?: string[]; +} + +export function categorizeError(error: unknown): ErrorCategory { + if (error instanceof Error) { + const message = error.message.toLowerCase(); + const name = error.name.toLowerCase(); + + if ( + name.includes("validation") || + message.includes("invalid") || + message.includes("validation") + ) { + return ErrorCategory.VALIDATION; + } + + if ( + name.includes("permission") || + message.includes("permission denied") || + message.includes("eacces") + ) { + return ErrorCategory.PERMISSION; + } + + if ( + name.includes("timeout") || + message.includes("timed out") || + message.includes("etimedout") + ) { + return ErrorCategory.TIMEOUT; + } + + if ( + name.includes("enoent") || + message.includes("not found") || + message.includes("file not found") + ) { + return ErrorCategory.FILESYSTEM; + } + + if ( + name.includes("network") || + message.includes("connection") || + message.includes("econn") + ) { + return ErrorCategory.NETWORK; + } + + if ( + name.includes("security") || + message.includes("forbidden") || + message.includes("access denied") + ) { + return ErrorCategory.SECURITY; + } + } + + return ErrorCategory.UNKNOWN; +} + +export function createTypedError( + message: string, + category: ErrorCategory, + code: ErrorCode, + options?: { + recoverable?: boolean; + suggestions?: string[]; + cause?: Error; + } +): TypedError { + const error = new Error(message) as TypedError; + error.category = category; + error.code = code; + error.recoverable = options?.recoverable ?? false; + error.suggestions = options?.suggestions; + error.cause = options?.cause; + return error; +} + +export function isRecoverable(error: unknown): boolean { + if (error instanceof Error && "recoverable" in error) { + return (error as TypedError).recoverable; + } + const category = categorizeError(error); + return category === ErrorCategory.NETWORK || category === ErrorCategory.TIMEOUT; +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..17448c4 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,144 @@ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface LogEntry { + timestamp: string; + level: LogLevel; + message: string; + module?: string; + error?: { + name: string; + message: string; + stack?: string; + }; + metadata?: Record; +} + +export interface LogSummary { + total: number; + byLevel: Record; + recent: Array>; + lastTimestamp?: string; +} + +class Logger { + private logs: LogEntry[] = []; + private maxLogs = 1000; + private logLevel: LogLevel = 'info'; + private moduleName: string = 'app'; + + setModule(module: string) { + this.moduleName = module; + } + + setLogLevel(level: LogLevel) { + this.logLevel = level; + } + + private shouldLog(level: LogLevel): boolean { + const levels: LogLevel[] = ['debug', 'info', 'warn', 'error']; + return levels.indexOf(level) >= levels.indexOf(this.logLevel); + } + + private createLogEntry( + level: LogLevel, + message: string, + metadata?: Record, + error?: Error + ): LogEntry { + const entry: LogEntry = { + timestamp: new Date().toISOString(), + level, + message, + module: this.moduleName, + }; + + if (metadata) { + entry.metadata = metadata; + } + + if (error) { + entry.error = { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + this.logs.push(entry); + + if (this.logs.length > this.maxLogs) { + this.logs = this.logs.slice(-this.maxLogs); + } + + return entry; + } + + debug(message: string, metadata?: Record, error?: Error) { + if (!this.shouldLog('debug')) return; + const entry = this.createLogEntry('debug', message, metadata, error); + console.debug(JSON.stringify(entry)); + } + + info(message: string, metadata?: Record, error?: Error) { + if (!this.shouldLog('info')) return; + const entry = this.createLogEntry('info', message, metadata, error); + console.log(JSON.stringify(entry)); + } + + warn(message: string, metadata?: Record, error?: Error) { + if (!this.shouldLog('warn')) return; + const entry = this.createLogEntry('warn', message, metadata, error); + console.warn(JSON.stringify(entry)); + } + + error(message: string, metadata?: Record, error?: Error) { + if (!this.shouldLog('error')) return; + const entry = this.createLogEntry('error', message, metadata, error); + console.error(JSON.stringify(entry)); + } + + getLogs(level?: LogLevel): LogEntry[] { + if (!level) return [...this.logs]; + return this.logs.filter((log) => log.level === level); + } + + getSummary(limit = 10): LogSummary { + const byLevel: Record = { + debug: 0, + info: 0, + warn: 0, + error: 0, + }; + + for (const entry of this.logs) { + byLevel[entry.level] += 1; + } + + const recent = this.logs.slice(-limit).map((entry) => ({ + timestamp: entry.timestamp, + level: entry.level, + message: entry.message, + module: entry.module, + })); + + return { + total: this.logs.length, + byLevel, + recent, + lastTimestamp: this.logs.length + ? this.logs[this.logs.length - 1]!.timestamp + : undefined, + }; + } + + clearLogs() { + this.logs = []; + } + + exportLogs(): string { + return JSON.stringify(this.logs, null, 2); + } +} + +export const logger = new Logger(); +export default logger; diff --git a/src/utils/retry.ts b/src/utils/retry.ts new file mode 100644 index 0000000..4867b69 --- /dev/null +++ b/src/utils/retry.ts @@ -0,0 +1,72 @@ +export interface RetryOptions { + maxRetries?: number; + initialDelayMs?: number; + maxDelayMs?: number; + backoffMultiplier?: number; + retryOn?: (error: unknown) => boolean; +} + +const DEFAULT_OPTIONS: Required = { + maxRetries: 3, + initialDelayMs: 1000, + maxDelayMs: 30000, + backoffMultiplier: 2, + retryOn: () => true, +}; + +export async function withRetry( + fn: () => Promise, + options?: RetryOptions +): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options }; + let lastError: unknown; + let delay = opts.initialDelayMs; + + for (let attempt = 0; attempt <= opts.maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error; + + if (attempt === opts.maxRetries) { + break; + } + + if (!opts.retryOn(error)) { + break; + } + + await new Promise((resolve) => setTimeout(resolve, delay)); + delay = Math.min(delay * opts.backoffMultiplier, opts.maxDelayMs); + } + } + + throw lastError; +} + +export function createExponentialBackoff( + initialDelayMs: number, + multiplier: number, + maxDelayMs: number +): (attempt: number) => number { + return (attempt: number) => { + const delay = initialDelayMs * Math.pow(multiplier, attempt); + return Math.min(delay, maxDelayMs); + }; +} + +export function isTransientError(error: unknown): boolean { + if (error instanceof Error) { + const message = error.message.toLowerCase(); + return ( + message.includes("econnreset") || + message.includes("econnrefused") || + message.includes("etimedout") || + message.includes("socket hang up") || + message.includes("network") || + message.includes("timeout") || + message.includes("temporarily unavailable") + ); + } + return false; +}