// CRITICAL import { Database } from "bun:sqlite"; /** * SQLite-backed chat session storage. */ export class ChatStore { private readonly db: Database; /** * Create a chat store. * @param dbPath - SQLite database path. */ public constructor(dbPath: string) { this.db = new Database(dbPath); this.db.run("PRAGMA busy_timeout = 5000"); this.migrate(); } /** * Initialize schema. * @returns void */ private migrate(): void { this.db.run(` CREATE TABLE IF NOT EXISTS chat_sessions ( id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT 'New Chat', model TEXT, parent_id TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ) `); this.db.run(` CREATE TABLE IF NOT EXISTS chat_messages ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, model TEXT, tool_calls TEXT, request_prompt_tokens INTEGER, request_tools_tokens INTEGER, request_total_input_tokens INTEGER, request_completion_tokens INTEGER, created_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE ) `); this.db.run("CREATE INDEX IF NOT EXISTS idx_messages_session ON chat_messages(session_id)"); } /** * List all chat sessions. * @returns List of sessions. */ public listSessions(): Array> { const rows = this.db .query("SELECT id, title, model, parent_id, created_at, updated_at FROM chat_sessions ORDER BY updated_at DESC") .all() as Array>; return rows.map((row) => ({ ...row })); } /** * Get a session and its messages. * @param sessionId - Session identifier. * @returns Session object or null. */ public getSession(sessionId: string): Record | null { const session = this.db .query("SELECT id, title, model, parent_id, created_at, updated_at FROM chat_sessions WHERE id = ?") .get(sessionId) as Record | null; if (!session) { return null; } const messages = this.db .query(`SELECT id, role, content, model, tool_calls, request_prompt_tokens, request_tools_tokens, request_total_input_tokens, request_completion_tokens, created_at FROM chat_messages WHERE session_id = ? ORDER BY created_at`) .all(sessionId) as Array>; const hydrated = messages.map((message) => { if (typeof message["tool_calls"] === "string") { try { return { ...message, tool_calls: JSON.parse(String(message["tool_calls"])) }; } catch { return { ...message, tool_calls: null }; } } return message; }); return { ...session, messages: hydrated }; } /** * Create a new chat session. * @param sessionId - Session identifier. * @param title - Session title. * @param model - Model name. * @param parentId - Parent session id. * @returns Created session. */ public createSession( sessionId: string, title = "New Chat", model?: string, parentId?: string, ): Record { this.db .query("INSERT INTO chat_sessions (id, title, model, parent_id) VALUES (?, ?, ?, ?)") .run(sessionId, title, model ?? null, parentId ?? null); const row = this.db .query("SELECT id, title, model, parent_id, created_at, updated_at FROM chat_sessions WHERE id = ?") .get(sessionId) as Record; return { ...row }; } /** * Update session title or model. * @param sessionId - Session identifier. * @param title - New title. * @param model - New model. * @returns True if updated. */ public updateSession(sessionId: string, title?: string, model?: string): boolean { const updates: string[] = []; const params: Array = []; if (title !== undefined) { updates.push("title = ?"); params.push(title); } if (model !== undefined) { updates.push("model = ?"); params.push(model); } if (updates.length === 0) { return true; } updates.push("updated_at = CURRENT_TIMESTAMP"); params.push(sessionId); const result = this.db .query(`UPDATE chat_sessions SET ${updates.join(", ")} WHERE id = ?`) .run(...params); return result.changes > 0; } /** * Delete a chat session and its messages. * @param sessionId - Session identifier. * @returns True if deleted. */ public deleteSession(sessionId: string): boolean { this.db.query("DELETE FROM chat_messages WHERE session_id = ?").run(sessionId); const result = this.db.query("DELETE FROM chat_sessions WHERE id = ?").run(sessionId); return result.changes > 0; } /** * Add a message to a session. * @param sessionId - Session identifier. * @param messageId - Message identifier. * @param role - Message role. * @param content - Message content. * @param model - Model name. * @param toolCalls - Tool call array. * @param promptTokens - Prompt token count. * @param toolsTokens - Tools token count. * @param totalInputTokens - Total input tokens. * @param completionTokens - Completion tokens. * @returns Stored message. */ public addMessage( sessionId: string, messageId: string, role: string, content?: string, model?: string, toolCalls?: unknown[], promptTokens?: number, toolsTokens?: number, totalInputTokens?: number, completionTokens?: number, ): Record { const toolCallsJson = toolCalls ? JSON.stringify(toolCalls) : null; this.db.query( `INSERT INTO chat_messages (id, session_id, role, content, model, tool_calls, request_prompt_tokens, request_tools_tokens, request_total_input_tokens, request_completion_tokens) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET content = excluded.content, tool_calls = excluded.tool_calls, request_prompt_tokens = excluded.request_prompt_tokens, request_tools_tokens = excluded.request_tools_tokens, request_total_input_tokens = excluded.request_total_input_tokens, request_completion_tokens = excluded.request_completion_tokens` ).run( messageId, sessionId, role, content ?? null, model ?? null, toolCallsJson, promptTokens ?? null, toolsTokens ?? null, totalInputTokens ?? null, completionTokens ?? null, ); this.db.query("UPDATE chat_sessions SET updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(sessionId); const row = this.db .query( `SELECT id, role, content, model, tool_calls, request_prompt_tokens, request_tools_tokens, request_total_input_tokens, request_completion_tokens, created_at FROM chat_messages WHERE id = ?`, ) .get(messageId) as Record; if (typeof row["tool_calls"] === "string") { try { row["tool_calls"] = JSON.parse(String(row["tool_calls"])); } catch { row["tool_calls"] = null; } } return row; } /** * Get usage totals for a session. * @param sessionId - Session identifier. * @returns Usage stats. */ public getUsage(sessionId: string): Record { const rows = this.db .query( "SELECT request_prompt_tokens, request_tools_tokens, request_total_input_tokens, request_completion_tokens FROM chat_messages WHERE session_id = ?", ) .all(sessionId) as Array>; let prompt = 0; let completion = 0; for (const row of rows) { const totalInput = Number(row["request_total_input_tokens"] ?? 0); const promptTokens = Number(row["request_prompt_tokens"] ?? 0); const completionTokens = Number(row["request_completion_tokens"] ?? 0); prompt += totalInput > 0 ? totalInput : promptTokens; completion += completionTokens; } return { prompt_tokens: prompt, completion_tokens: completion, total_tokens: prompt + completion }; } /** * Fork an existing session. * @param sessionId - Original session id. * @param newId - New session id. * @param messageId - Optional message id to stop copying. * @param model - New model. * @param title - New title. * @returns Forked session or null. */ public forkSession( sessionId: string, newId: string, messageId?: string, model?: string, title?: string, ): Record | null { const toNullableString = (value: unknown): string | null => { if (value === null || value === undefined) { return null; } return String(value); }; const toNullableNumber = (value: unknown): number | null => { if (value === null || value === undefined) { return null; } const parsed = Number(value); return Number.isNaN(parsed) ? null : parsed; }; const original = this.getSession(sessionId); if (!original) { return null; } const newTitle = title ?? `${String(original["title"])} (fork)`; const newModel = model ?? (original["model"] ? String(original["model"]) : undefined); this.db.query("INSERT INTO chat_sessions (id, title, model, parent_id) VALUES (?, ?, ?, ?)") .run(newId, newTitle, newModel ?? null, sessionId); const messages = (original["messages"] ?? []) as Array>; for (const message of messages) { const toolCallsJson = message["tool_calls"] ? JSON.stringify(message["tool_calls"]) : null; const newMessageId = `${newId}_${String(message["id"])}`; const role = String(message["role"] ?? ""); const content = toNullableString(message["content"]); const messageModel = toNullableString(message["model"]); const promptTokens = toNullableNumber(message["request_prompt_tokens"]); const toolTokens = toNullableNumber(message["request_tools_tokens"]); const totalTokens = toNullableNumber(message["request_total_input_tokens"]); const completionTokens = toNullableNumber(message["request_completion_tokens"]); this.db.query( `INSERT INTO chat_messages (id, session_id, role, content, model, tool_calls, request_prompt_tokens, request_tools_tokens, request_total_input_tokens, request_completion_tokens) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( newMessageId, newId, role, content, messageModel, toolCallsJson, promptTokens, toolTokens, totalTokens, completionTokens, ); if (messageId && message["id"] === messageId) { break; } } const row = this.db .query("SELECT id, title, model, parent_id, created_at, updated_at FROM chat_sessions WHERE id = ?") .get(newId) as Record; return { ...row }; } }