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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 61 additions & 7 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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(
Expand Down Expand Up @@ -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 || [];

Expand All @@ -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: {
Expand All @@ -177,7 +230,7 @@ Bun.serve({
},
},
onError: (error) => {
console.error("Error:", JSON.stringify(error, null, 2));
logger.error("Chat API error", { error: String(error) });
},
});

Expand All @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions src/utils/__tests__/diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
17 changes: 17 additions & 0 deletions src/utils/__tests__/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
53 changes: 53 additions & 0 deletions src/utils/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
114 changes: 114 additions & 0 deletions src/utils/errors.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading