import Database from 'better-sqlite3' import { mkdirSync } from 'fs' import { dirname } from 'path' import type { SessionMeta } from '../../types/session.js' export interface ListFilters { days?: number project?: string tool?: string skill?: string q?: string limit?: number offset?: number } export interface SessionsDb { upsertSession(meta: SessionMeta): void listSessions(filters: ListFilters): SessionMeta[] countSessions(filters: ListFilters): number getSession(id: string): SessionMeta | null deleteByJsonlPath(path: string): void close(): void } const SCHEMA = ` CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL, project_slug TEXT NOT NULL, jsonl_path TEXT NOT NULL UNIQUE, started_at TEXT NOT NULL, ended_at TEXT, duration_sec INTEGER, event_count INTEGER NOT NULL, user_messages INTEGER NOT NULL, assistant_msgs INTEGER NOT NULL, tool_calls INTEGER NOT NULL, first_prompt TEXT, tools_used TEXT NOT NULL, skills_invoked TEXT NOT NULL, outcome TEXT NOT NULL, permission_mode TEXT, file_size INTEGER NOT NULL, indexed_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_started ON sessions(started_at DESC); CREATE INDEX IF NOT EXISTS idx_project ON sessions(project_slug, started_at DESC); ` function rowToMeta(row: Record): SessionMeta { return { session_id: row.session_id as string, project_path: row.project_path as string, project_slug: row.project_slug as string, jsonl_path: row.jsonl_path as string, started_at: row.started_at as string, ended_at: (row.ended_at as string | null) ?? null, duration_sec: (row.duration_sec as number | null) ?? null, event_count: row.event_count as number, user_messages: row.user_messages as number, assistant_msgs: row.assistant_msgs as number, tool_calls: row.tool_calls as number, first_prompt: (row.first_prompt as string | null) ?? null, tools_used: JSON.parse(row.tools_used as string), skills_invoked: JSON.parse(row.skills_invoked as string), outcome: row.outcome as SessionMeta['outcome'], permission_mode: (row.permission_mode as string | null) ?? null, file_size: row.file_size as number, indexed_at: row.indexed_at as string, } } function buildWhere(f: ListFilters): { sql: string; params: Record } { const parts: string[] = [] const params: Record = {} if (f.days) { const cutoff = new Date(Date.now() - f.days * 86400_000).toISOString() parts.push('started_at >= @cutoff') params.cutoff = cutoff } if (f.project) { parts.push('project_slug = @project') params.project = f.project } if (f.tool) { parts.push("tools_used LIKE @toolLike") params.toolLike = `%"${f.tool}"%` } if (f.skill) { parts.push('skills_invoked LIKE @skillLike') params.skillLike = `%"${f.skill}"%` } if (f.q) { parts.push('first_prompt LIKE @q') params.q = `%${f.q}%` } return { sql: parts.length ? 'WHERE ' + parts.join(' AND ') : '', params, } } export function openSessionsDb(dbPath: string): SessionsDb { mkdirSync(dirname(dbPath), { recursive: true }) const db = new Database(dbPath) db.pragma('journal_mode = WAL') db.exec(SCHEMA) const upsertStmt = db.prepare(` INSERT INTO sessions (session_id, project_path, project_slug, jsonl_path, started_at, ended_at, duration_sec, event_count, user_messages, assistant_msgs, tool_calls, first_prompt, tools_used, skills_invoked, outcome, permission_mode, file_size, indexed_at) VALUES (@session_id, @project_path, @project_slug, @jsonl_path, @started_at, @ended_at, @duration_sec, @event_count, @user_messages, @assistant_msgs, @tool_calls, @first_prompt, @tools_used, @skills_invoked, @outcome, @permission_mode, @file_size, @indexed_at) ON CONFLICT(session_id) DO UPDATE SET project_path = excluded.project_path, project_slug = excluded.project_slug, jsonl_path = excluded.jsonl_path, started_at = excluded.started_at, ended_at = excluded.ended_at, duration_sec = excluded.duration_sec, event_count = excluded.event_count, user_messages = excluded.user_messages, assistant_msgs = excluded.assistant_msgs, tool_calls = excluded.tool_calls, first_prompt = excluded.first_prompt, tools_used = excluded.tools_used, skills_invoked = excluded.skills_invoked, outcome = excluded.outcome, permission_mode = excluded.permission_mode, file_size = excluded.file_size, indexed_at = excluded.indexed_at `) return { upsertSession(meta) { upsertStmt.run({ ...meta, tools_used: JSON.stringify(meta.tools_used), skills_invoked: JSON.stringify(meta.skills_invoked), }) }, listSessions(filters) { const { sql, params } = buildWhere(filters) const limit = filters.limit ?? 50 const offset = filters.offset ?? 0 const rows = db .prepare(`SELECT * FROM sessions ${sql} ORDER BY started_at DESC LIMIT @limit OFFSET @offset`) .all({ ...params, limit, offset }) as Record[] return rows.map(rowToMeta) }, countSessions(filters) { const { sql, params } = buildWhere(filters) const row = db.prepare(`SELECT COUNT(*) as c FROM sessions ${sql}`).get(params) as { c: number } return row.c }, getSession(id) { const row = db.prepare('SELECT * FROM sessions WHERE session_id = ?').get(id) as Record | undefined return row ? rowToMeta(row) : null }, deleteByJsonlPath(path) { db.prepare('DELETE FROM sessions WHERE jsonl_path = ?').run(path) }, close() { db.close() }, } }