/** * FTS5 full-text search database — bun:sqlite. * * Three virtual tables: search_messages, search_tasks, search_btmsg. * Provides indexing and unified search across all tables. * DB path: ~/.local/share/agor/search.db */ import { Database } from "bun:sqlite"; import { homedir } from "os"; import { mkdirSync } from "fs"; import { join } from "path"; // ── Types ──────────────────────────────────────────────────────────────────── export interface SearchResult { resultType: string; id: string; title: string; snippet: string; score: number; } // ── DB path ────────────────────────────────────────────────────────────────── const DATA_DIR = join(homedir(), ".local", "share", "agor"); const DB_PATH = join(DATA_DIR, "search.db"); // ── SearchDb class ─────────────────────────────────────────────────────────── export class SearchDb { private db: Database; constructor(dbPath?: string) { const path = dbPath ?? DB_PATH; const dir = join(path, ".."); mkdirSync(dir, { recursive: true }); this.db = new Database(path); this.db.run("PRAGMA journal_mode = WAL"); this.db.run("PRAGMA busy_timeout = 2000"); this.createTables(); } private createTables(): void { this.db.run(` CREATE VIRTUAL TABLE IF NOT EXISTS search_messages USING fts5( session_id, role, content, timestamp ) `); this.db.run(` CREATE VIRTUAL TABLE IF NOT EXISTS search_tasks USING fts5( task_id, title, description, status, assigned_to ) `); this.db.run(` CREATE VIRTUAL TABLE IF NOT EXISTS search_btmsg USING fts5( message_id, from_agent, to_agent, content, channel_name ) `); } /** Index an agent message. */ indexMessage(sessionId: string, role: string, content: string): void { const ts = new Date().toISOString(); this.db.run( "INSERT INTO search_messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)", [sessionId, role, content, ts], ); } /** Index a task. */ indexTask(taskId: string, title: string, description: string, status: string, assignedTo: string): void { this.db.run( "INSERT INTO search_tasks (task_id, title, description, status, assigned_to) VALUES (?, ?, ?, ?, ?)", [taskId, title, description, status, assignedTo], ); } /** Index a btmsg message. */ indexBtmsg(msgId: string, fromAgent: string, toAgent: string, content: string, channel: string): void { this.db.run( "INSERT INTO search_btmsg (message_id, from_agent, to_agent, content, channel_name) VALUES (?, ?, ?, ?, ?)", [msgId, fromAgent, toAgent, content, channel], ); } /** Search across all FTS5 tables. */ searchAll(query: string, limit = 20): SearchResult[] { if (!query.trim()) return []; const results: SearchResult[] = []; // Search messages try { const msgRows = this.db .prepare( `SELECT session_id, role, snippet(search_messages, 2, '', '', '...', 32) as snip, rank FROM search_messages WHERE search_messages MATCH ? ORDER BY rank LIMIT ?`, ) .all(query, limit) as Array<{ session_id: string; role: string; snip: string; rank: number }>; for (const row of msgRows) { results.push({ resultType: "message", id: row.session_id, title: row.role, snippet: row.snip ?? "", score: Math.abs(row.rank ?? 0), }); } } catch { // FTS5 syntax error — skip messages } // Search tasks try { const taskRows = this.db .prepare( `SELECT task_id, title, snippet(search_tasks, 2, '', '', '...', 32) as snip, rank FROM search_tasks WHERE search_tasks MATCH ? ORDER BY rank LIMIT ?`, ) .all(query, limit) as Array<{ task_id: string; title: string; snip: string; rank: number }>; for (const row of taskRows) { results.push({ resultType: "task", id: row.task_id, title: row.title, snippet: row.snip ?? "", score: Math.abs(row.rank ?? 0), }); } } catch { // FTS5 syntax error — skip tasks } // Search btmsg try { const btmsgRows = this.db .prepare( `SELECT message_id, from_agent, snippet(search_btmsg, 3, '', '', '...', 32) as snip, rank FROM search_btmsg WHERE search_btmsg MATCH ? ORDER BY rank LIMIT ?`, ) .all(query, limit) as Array<{ message_id: string; from_agent: string; snip: string; rank: number }>; for (const row of btmsgRows) { results.push({ resultType: "btmsg", id: row.message_id, title: row.from_agent, snippet: row.snip ?? "", score: Math.abs(row.rank ?? 0), }); } } catch { // FTS5 syntax error — skip btmsg } // Sort by score (lower = more relevant for FTS5 rank) results.sort((a, b) => a.score - b.score); return results.slice(0, limit); } /** Drop and recreate all FTS5 tables. */ rebuildIndex(): void { this.db.run("DROP TABLE IF EXISTS search_messages"); this.db.run("DROP TABLE IF EXISTS search_tasks"); this.db.run("DROP TABLE IF EXISTS search_btmsg"); this.createTables(); } close(): void { this.db.close(); } }