agent-orchestrator/ui-electrobun/src/bun/search-db.ts
Hibryda f2e8b07d7f refactor(electrobun): simplify bun backend — extract db-utils, merge handlers
- db-utils.ts: shared openDb() (WAL, busy_timeout, foreign_keys, mkdirSync)
- 5 DB modules use openDb() instead of duplicated PRAGMA boilerplate
- bttask-db shares btmsg-db's Database handle (was duplicate connection)
- misc-handlers.ts: 14 inline handlers extracted from index.ts
- index.ts: 349→195 lines (only window controls remain inline)
- updater.ts: removed dead getLastKnownVersion()
- Net reduction: ~700 lines of duplicated boilerplate
2026-03-23 21:09:57 +01:00

196 lines
5.8 KiB
TypeScript

/**
* 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 { join } from "path";
import { openDb } from "./db-utils.ts";
// ── 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) {
this.db = openDb(dbPath ?? DB_PATH, { busyTimeout: 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, '<b>', '</b>', '...', 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, '<b>', '</b>', '...', 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, '<b>', '</b>', '...', 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();
}
}