- 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
196 lines
5.8 KiB
TypeScript
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();
|
|
}
|
|
}
|