- CodeEditor: CodeMirror 6 with Catppuccin theme, 15+ languages, Ctrl+S save, dirty tracking, save-on-blur - PdfViewer: pdfjs-dist canvas rendering, zoom 0.5-3x, HiDPI, lazy page load - CsvTable: RFC 4180 parser, delimiter auto-detect, sortable columns, sticky header - FileBrowser: real filesystem via files.list/read/write RPC, lazy dir loading, file type routing (code→editor, pdf→viewer, csv→table, images→display) - 10MB size gate, binary detection, base64 encoding for non-text files
202 lines
5.9 KiB
TypeScript
202 lines
5.9 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 { 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, '<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();
|
|
}
|
|
}
|