feat(electrobun): file management — CodeMirror editor, PDF viewer, CSV table, real file I/O
- 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
This commit is contained in:
parent
29a3370e79
commit
252fca70df
22 changed files with 8116 additions and 227 deletions
379
ui-electrobun/src/bun/btmsg-db.ts
Normal file
379
ui-electrobun/src/bun/btmsg-db.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
/**
|
||||
* btmsg — Inter-agent messaging SQLite store.
|
||||
* DB: ~/.local/share/agor/btmsg.db (shared with btmsg CLI + bttask).
|
||||
* Uses bun:sqlite. Schema matches Rust btmsg.rs.
|
||||
*/
|
||||
|
||||
import { Database } from "bun:sqlite";
|
||||
import { homedir } from "os";
|
||||
import { mkdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
// ── DB path ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const DATA_DIR = join(homedir(), ".local", "share", "agor");
|
||||
const DB_PATH = join(DATA_DIR, "btmsg.db");
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BtmsgAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
groupId: string;
|
||||
tier: number;
|
||||
model: string | null;
|
||||
status: string;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export interface BtmsgMessage {
|
||||
id: string;
|
||||
fromAgent: string;
|
||||
toAgent: string;
|
||||
content: string;
|
||||
read: boolean;
|
||||
replyTo: string | null;
|
||||
createdAt: string;
|
||||
senderName: string | null;
|
||||
senderRole: string | null;
|
||||
}
|
||||
|
||||
export interface BtmsgChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
groupId: string;
|
||||
createdBy: string;
|
||||
memberCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface BtmsgChannelMessage {
|
||||
id: string;
|
||||
channelId: string;
|
||||
fromAgent: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
senderName: string;
|
||||
senderRole: string;
|
||||
}
|
||||
|
||||
export interface DeadLetter {
|
||||
id: number;
|
||||
fromAgent: string;
|
||||
toAgent: string;
|
||||
content: string;
|
||||
error: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number;
|
||||
agentId: string;
|
||||
eventType: string;
|
||||
detail: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ── Schema (create-if-absent, matches Rust open_db_or_create) ────────────────
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL,
|
||||
group_id TEXT NOT NULL, tier INTEGER NOT NULL DEFAULT 2,
|
||||
model TEXT, cwd TEXT, system_prompt TEXT,
|
||||
status TEXT DEFAULT 'stopped', last_active_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
agent_id TEXT NOT NULL, contact_id TEXT NOT NULL,
|
||||
PRIMARY KEY (agent_id, contact_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY, from_agent TEXT NOT NULL, to_agent TEXT NOT NULL,
|
||||
content TEXT NOT NULL, read INTEGER DEFAULT 0, reply_to TEXT,
|
||||
group_id TEXT NOT NULL, sender_group_id TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_agent, read);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_from ON messages(from_agent);
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, group_id TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS channel_members (
|
||||
channel_id TEXT NOT NULL, agent_id TEXT NOT NULL,
|
||||
joined_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (channel_id, agent_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS channel_messages (
|
||||
id TEXT PRIMARY KEY, channel_id TEXT NOT NULL, from_agent TEXT NOT NULL,
|
||||
content TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_messages ON channel_messages(channel_id, created_at);
|
||||
CREATE TABLE IF NOT EXISTS heartbeats (
|
||||
agent_id TEXT PRIMARY KEY, timestamp INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS dead_letter_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, from_agent TEXT NOT NULL,
|
||||
to_agent TEXT NOT NULL, content TEXT NOT NULL, error TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL, detail TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS seen_messages (
|
||||
session_id TEXT NOT NULL, message_id TEXT NOT NULL,
|
||||
seen_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
PRIMARY KEY (session_id, message_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_seen_messages_session ON seen_messages(session_id);
|
||||
`;
|
||||
|
||||
// Also create tasks/task_comments (shared DB with bttask)
|
||||
const TASK_SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'todo', priority TEXT DEFAULT 'medium',
|
||||
assigned_to TEXT, created_by TEXT NOT NULL, group_id TEXT NOT NULL,
|
||||
parent_task_id TEXT, sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')), version INTEGER DEFAULT 1
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_group ON tasks(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
||||
CREATE TABLE IF NOT EXISTS task_comments (
|
||||
id TEXT PRIMARY KEY, task_id TEXT NOT NULL, agent_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_comments_task ON task_comments(task_id);
|
||||
`;
|
||||
|
||||
// ── BtmsgDb class ────────────────────────────────────────────────────────────
|
||||
|
||||
export class BtmsgDb {
|
||||
private db: Database;
|
||||
|
||||
constructor() {
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
this.db = new Database(DB_PATH);
|
||||
this.db.exec("PRAGMA journal_mode = WAL");
|
||||
this.db.exec("PRAGMA busy_timeout = 5000");
|
||||
this.db.exec("PRAGMA foreign_keys = ON");
|
||||
this.db.exec(SCHEMA);
|
||||
this.db.exec(TASK_SCHEMA);
|
||||
}
|
||||
|
||||
// ── Agents ───────────────────────────────────────────────────────────────
|
||||
|
||||
registerAgent(
|
||||
id: string, name: string, role: string,
|
||||
groupId: string, tier: number, model?: string,
|
||||
): void {
|
||||
this.db.query(
|
||||
`INSERT INTO agents (id, name, role, group_id, tier, model)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name=excluded.name, role=excluded.role,
|
||||
group_id=excluded.group_id, tier=excluded.tier, model=excluded.model`
|
||||
).run(id, name, role, groupId, tier, model ?? null);
|
||||
}
|
||||
|
||||
getAgents(groupId: string): BtmsgAgent[] {
|
||||
return this.db.query<{
|
||||
id: string; name: string; role: string; group_id: string;
|
||||
tier: number; model: string | null; status: string | null;
|
||||
unread_count: number;
|
||||
}, [string]>(
|
||||
`SELECT a.*, (SELECT COUNT(*) FROM messages m
|
||||
WHERE m.to_agent = a.id AND m.read = 0) as unread_count
|
||||
FROM agents a WHERE a.group_id = ? ORDER BY a.tier, a.role, a.name`
|
||||
).all(groupId).map(r => ({
|
||||
id: r.id, name: r.name, role: r.role, groupId: r.group_id,
|
||||
tier: r.tier, model: r.model, status: r.status ?? 'stopped',
|
||||
unreadCount: r.unread_count,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Direct messages ──────────────────────────────────────────────────────
|
||||
|
||||
sendMessage(fromAgent: string, toAgent: string, content: string): string {
|
||||
// Get sender's group_id
|
||||
const sender = this.db.query<{ group_id: string }, [string]>(
|
||||
"SELECT group_id FROM agents WHERE id = ?"
|
||||
).get(fromAgent);
|
||||
if (!sender) throw new Error(`Sender agent '${fromAgent}' not found`);
|
||||
|
||||
const id = randomUUID();
|
||||
this.db.query(
|
||||
`INSERT INTO messages (id, from_agent, to_agent, content, group_id, sender_group_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?5)`
|
||||
).run(id, fromAgent, toAgent, content, sender.group_id);
|
||||
return id;
|
||||
}
|
||||
|
||||
listMessages(agentId: string, otherId: string, limit = 50): BtmsgMessage[] {
|
||||
return this.db.query<{
|
||||
id: string; from_agent: string; to_agent: string; content: string;
|
||||
read: number; reply_to: string | null; created_at: string;
|
||||
sender_name: string | null; sender_role: string | null;
|
||||
}, [string, string, string, string, number]>(
|
||||
`SELECT m.id, m.from_agent, m.to_agent, m.content, m.read,
|
||||
m.reply_to, m.created_at,
|
||||
a.name AS sender_name, a.role AS sender_role
|
||||
FROM messages m JOIN agents a ON m.from_agent = a.id
|
||||
WHERE (m.from_agent = ?1 AND m.to_agent = ?2)
|
||||
OR (m.from_agent = ?3 AND m.to_agent = ?4)
|
||||
ORDER BY m.created_at ASC LIMIT ?5`
|
||||
).all(agentId, otherId, otherId, agentId, limit).map(r => ({
|
||||
id: r.id, fromAgent: r.from_agent, toAgent: r.to_agent,
|
||||
content: r.content, read: r.read !== 0, replyTo: r.reply_to,
|
||||
createdAt: r.created_at, senderName: r.sender_name,
|
||||
senderRole: r.sender_role,
|
||||
}));
|
||||
}
|
||||
|
||||
markRead(agentId: string, messageIds: string[]): void {
|
||||
if (messageIds.length === 0) return;
|
||||
const stmt = this.db.prepare(
|
||||
"UPDATE messages SET read = 1 WHERE id = ? AND to_agent = ?"
|
||||
);
|
||||
const tx = this.db.transaction(() => {
|
||||
for (const mid of messageIds) stmt.run(mid, agentId);
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
// ── Channels ─────────────────────────────────────────────────────────────
|
||||
|
||||
createChannel(name: string, groupId: string, createdBy: string): string {
|
||||
const id = randomUUID();
|
||||
this.db.query(
|
||||
`INSERT INTO channels (id, name, group_id, created_by)
|
||||
VALUES (?1, ?2, ?3, ?4)`
|
||||
).run(id, name, groupId, createdBy);
|
||||
return id;
|
||||
}
|
||||
|
||||
listChannels(groupId: string): BtmsgChannel[] {
|
||||
return this.db.query<{
|
||||
id: string; name: string; group_id: string; created_by: string;
|
||||
member_count: number; created_at: string;
|
||||
}, [string]>(
|
||||
`SELECT c.id, c.name, c.group_id, c.created_by, c.created_at,
|
||||
(SELECT COUNT(*) FROM channel_members cm WHERE cm.channel_id = c.id) AS member_count
|
||||
FROM channels c WHERE c.group_id = ? ORDER BY c.name`
|
||||
).all(groupId).map(r => ({
|
||||
id: r.id, name: r.name, groupId: r.group_id, createdBy: r.created_by,
|
||||
memberCount: r.member_count, createdAt: r.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
getChannelMessages(channelId: string, limit = 100): BtmsgChannelMessage[] {
|
||||
return this.db.query<{
|
||||
id: string; channel_id: string; from_agent: string;
|
||||
content: string; created_at: string;
|
||||
sender_name: string; sender_role: string;
|
||||
}, [string, number]>(
|
||||
`SELECT cm.id, cm.channel_id, cm.from_agent, cm.content, cm.created_at,
|
||||
COALESCE(a.name, cm.from_agent) AS sender_name,
|
||||
COALESCE(a.role, 'unknown') AS sender_role
|
||||
FROM channel_messages cm
|
||||
LEFT JOIN agents a ON cm.from_agent = a.id
|
||||
WHERE cm.channel_id = ?
|
||||
ORDER BY cm.created_at ASC LIMIT ?`
|
||||
).all(channelId, limit).map(r => ({
|
||||
id: r.id, channelId: r.channel_id, fromAgent: r.from_agent,
|
||||
content: r.content, createdAt: r.created_at,
|
||||
senderName: r.sender_name, senderRole: r.sender_role,
|
||||
}));
|
||||
}
|
||||
|
||||
sendChannelMessage(channelId: string, fromAgent: string, content: string): string {
|
||||
const id = randomUUID();
|
||||
this.db.query(
|
||||
`INSERT INTO channel_messages (id, channel_id, from_agent, content)
|
||||
VALUES (?1, ?2, ?3, ?4)`
|
||||
).run(id, channelId, fromAgent, content);
|
||||
return id;
|
||||
}
|
||||
|
||||
// ── Heartbeats ───────────────────────────────────────────────────────────
|
||||
|
||||
heartbeat(agentId: string): void {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
this.db.query(
|
||||
`INSERT INTO heartbeats (agent_id, timestamp) VALUES (?1, ?2)
|
||||
ON CONFLICT(agent_id) DO UPDATE SET timestamp = excluded.timestamp`
|
||||
).run(agentId, now);
|
||||
}
|
||||
|
||||
// ── Dead letter queue ────────────────────────────────────────────────────
|
||||
|
||||
getDeadLetters(limit = 50): DeadLetter[] {
|
||||
return this.db.query<{
|
||||
id: number; from_agent: string; to_agent: string;
|
||||
content: string; error: string; created_at: string;
|
||||
}, [number]>(
|
||||
`SELECT id, from_agent, to_agent, content, error, created_at
|
||||
FROM dead_letter_queue ORDER BY created_at DESC LIMIT ?`
|
||||
).all(limit).map(r => ({
|
||||
id: r.id, fromAgent: r.from_agent, toAgent: r.to_agent,
|
||||
content: r.content, error: r.error, createdAt: r.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Audit log ────────────────────────────────────────────────────────────
|
||||
|
||||
logAudit(agentId: string, eventType: string, detail: string): void {
|
||||
this.db.query(
|
||||
`INSERT INTO audit_log (agent_id, event_type, detail)
|
||||
VALUES (?1, ?2, ?3)`
|
||||
).run(agentId, eventType, detail);
|
||||
}
|
||||
|
||||
getAuditLog(limit = 100): AuditEntry[] {
|
||||
return this.db.query<{
|
||||
id: number; agent_id: string; event_type: string;
|
||||
detail: string; created_at: string;
|
||||
}, [number]>(
|
||||
`SELECT id, agent_id, event_type, detail, created_at
|
||||
FROM audit_log ORDER BY created_at DESC LIMIT ?`
|
||||
).all(limit).map(r => ({
|
||||
id: r.id, agentId: r.agent_id, eventType: r.event_type,
|
||||
detail: r.detail, createdAt: r.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Seen messages (per-session acknowledgment) ───────────────────────────
|
||||
|
||||
markSeen(sessionId: string, messageIds: string[]): void {
|
||||
if (messageIds.length === 0) return;
|
||||
const stmt = this.db.prepare(
|
||||
"INSERT OR IGNORE INTO seen_messages (session_id, message_id) VALUES (?, ?)"
|
||||
);
|
||||
const tx = this.db.transaction(() => {
|
||||
for (const mid of messageIds) stmt.run(sessionId, mid);
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
pruneSeen(maxAgeSecs = 7 * 24 * 3600): number {
|
||||
const result = this.db.query(
|
||||
"DELETE FROM seen_messages WHERE seen_at < unixepoch() - ?"
|
||||
).run(maxAgeSecs);
|
||||
return (result as { changes: number }).changes;
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
export const btmsgDb = new BtmsgDb();
|
||||
187
ui-electrobun/src/bun/bttask-db.ts
Normal file
187
ui-electrobun/src/bun/bttask-db.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* bttask — Task board SQLite store.
|
||||
* DB: ~/.local/share/agor/btmsg.db (shared with btmsg).
|
||||
* Uses bun:sqlite. Schema matches Rust bttask.rs.
|
||||
*/
|
||||
|
||||
import { Database } from "bun:sqlite";
|
||||
import { homedir } from "os";
|
||||
import { mkdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
// ── DB path (same DB as btmsg) ──────────────────────────────────────────────
|
||||
|
||||
const DATA_DIR = join(homedir(), ".local", "share", "agor");
|
||||
const DB_PATH = join(DATA_DIR, "btmsg.db");
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
assignedTo: string | null;
|
||||
createdBy: string;
|
||||
groupId: string;
|
||||
parentTaskId: string | null;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface TaskComment {
|
||||
id: string;
|
||||
taskId: string;
|
||||
agentId: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const VALID_STATUSES = ["todo", "progress", "review", "done", "blocked"] as const;
|
||||
|
||||
// ── BttaskDb class ───────────────────────────────────────────────────────────
|
||||
|
||||
export class BttaskDb {
|
||||
private db: Database;
|
||||
|
||||
constructor() {
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
this.db = new Database(DB_PATH);
|
||||
this.db.exec("PRAGMA journal_mode = WAL");
|
||||
this.db.exec("PRAGMA busy_timeout = 5000");
|
||||
|
||||
// Ensure tables exist (idempotent — btmsg-db may have created them)
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'todo', priority TEXT DEFAULT 'medium',
|
||||
assigned_to TEXT, created_by TEXT NOT NULL, group_id TEXT NOT NULL,
|
||||
parent_task_id TEXT, sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')), version INTEGER DEFAULT 1
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_group ON tasks(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
||||
CREATE TABLE IF NOT EXISTS task_comments (
|
||||
id TEXT PRIMARY KEY, task_id TEXT NOT NULL, agent_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_comments_task ON task_comments(task_id);
|
||||
`);
|
||||
|
||||
// Migration: add version column if missing
|
||||
try {
|
||||
this.db.exec("ALTER TABLE tasks ADD COLUMN version INTEGER DEFAULT 1");
|
||||
} catch { /* column already exists */ }
|
||||
}
|
||||
|
||||
// ── Tasks ────────────────────────────────────────────────────────────────
|
||||
|
||||
listTasks(groupId: string): Task[] {
|
||||
return this.db.query<{
|
||||
id: string; title: string; description: string; status: string;
|
||||
priority: string; assigned_to: string | null; created_by: string;
|
||||
group_id: string; parent_task_id: string | null; sort_order: number;
|
||||
created_at: string; updated_at: string; version: number;
|
||||
}, [string]>(
|
||||
`SELECT id, title, description, status, priority, assigned_to,
|
||||
created_by, group_id, parent_task_id, sort_order,
|
||||
created_at, updated_at, COALESCE(version, 1) AS version
|
||||
FROM tasks WHERE group_id = ?
|
||||
ORDER BY sort_order ASC, created_at DESC`
|
||||
).all(groupId).map(r => ({
|
||||
id: r.id, title: r.title, description: r.description ?? '',
|
||||
status: r.status ?? 'todo', priority: r.priority ?? 'medium',
|
||||
assignedTo: r.assigned_to, createdBy: r.created_by,
|
||||
groupId: r.group_id, parentTaskId: r.parent_task_id,
|
||||
sortOrder: r.sort_order ?? 0,
|
||||
createdAt: r.created_at ?? '', updatedAt: r.updated_at ?? '',
|
||||
version: r.version ?? 1,
|
||||
}));
|
||||
}
|
||||
|
||||
createTask(
|
||||
title: string, description: string, priority: string,
|
||||
groupId: string, createdBy: string, assignedTo?: string,
|
||||
): string {
|
||||
const id = randomUUID();
|
||||
this.db.query(
|
||||
`INSERT INTO tasks (id, title, description, priority, group_id, created_by, assigned_to)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`
|
||||
).run(id, title, description, priority, groupId, createdBy, assignedTo ?? null);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update task status with optimistic locking.
|
||||
* Returns new version on success. Throws on version conflict.
|
||||
*/
|
||||
updateTaskStatus(taskId: string, status: string, expectedVersion: number): number {
|
||||
if (!VALID_STATUSES.includes(status as typeof VALID_STATUSES[number])) {
|
||||
throw new Error(`Invalid status '${status}'. Valid: ${VALID_STATUSES.join(', ')}`);
|
||||
}
|
||||
|
||||
const result = this.db.query(
|
||||
`UPDATE tasks SET status = ?1, version = version + 1, updated_at = datetime('now')
|
||||
WHERE id = ?2 AND version = ?3`
|
||||
).run(status, taskId, expectedVersion);
|
||||
|
||||
if ((result as { changes: number }).changes === 0) {
|
||||
throw new Error("Task was modified by another agent (version conflict)");
|
||||
}
|
||||
|
||||
return expectedVersion + 1;
|
||||
}
|
||||
|
||||
deleteTask(taskId: string): void {
|
||||
this.db.query("DELETE FROM task_comments WHERE task_id = ?").run(taskId);
|
||||
this.db.query("DELETE FROM tasks WHERE id = ?").run(taskId);
|
||||
}
|
||||
|
||||
// ── Comments ─────────────────────────────────────────────────────────────
|
||||
|
||||
addComment(taskId: string, agentId: string, content: string): string {
|
||||
const id = randomUUID();
|
||||
this.db.query(
|
||||
`INSERT INTO task_comments (id, task_id, agent_id, content)
|
||||
VALUES (?1, ?2, ?3, ?4)`
|
||||
).run(id, taskId, agentId, content);
|
||||
return id;
|
||||
}
|
||||
|
||||
listComments(taskId: string): TaskComment[] {
|
||||
return this.db.query<{
|
||||
id: string; task_id: string; agent_id: string;
|
||||
content: string; created_at: string;
|
||||
}, [string]>(
|
||||
`SELECT id, task_id, agent_id, content, created_at
|
||||
FROM task_comments WHERE task_id = ?
|
||||
ORDER BY created_at ASC`
|
||||
).all(taskId).map(r => ({
|
||||
id: r.id, taskId: r.task_id, agentId: r.agent_id,
|
||||
content: r.content, createdAt: r.created_at ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Review queue ─────────────────────────────────────────────────────────
|
||||
|
||||
reviewQueueCount(groupId: string): number {
|
||||
const row = this.db.query<{ cnt: number }, [string]>(
|
||||
"SELECT COUNT(*) AS cnt FROM tasks WHERE group_id = ? AND status = 'review'"
|
||||
).get(groupId);
|
||||
return row?.cnt ?? 0;
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
export const bttaskDb = new BttaskDb();
|
||||
|
|
@ -1,10 +1,17 @@
|
|||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { BrowserWindow, BrowserView, Updater } from "electrobun/bun";
|
||||
import { PtyClient } from "./pty-client.ts";
|
||||
import { settingsDb } from "./settings-db.ts";
|
||||
import { sessionDb } from "./session-db.ts";
|
||||
import { btmsgDb } from "./btmsg-db.ts";
|
||||
import { bttaskDb } from "./bttask-db.ts";
|
||||
import { SidecarManager } from "./sidecar-manager.ts";
|
||||
import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts";
|
||||
import { randomUUID } from "crypto";
|
||||
import { SearchDb } from "./search-db.ts";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
const DEV_SERVER_PORT = 9760; // Project convention: 9700+ range
|
||||
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
|
||||
|
|
@ -13,6 +20,8 @@ const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
|
|||
|
||||
const ptyClient = new PtyClient();
|
||||
const sidecarManager = new SidecarManager();
|
||||
const searchDb = new SearchDb();
|
||||
const PLUGINS_DIR = join(homedir(), ".config", "agor", "plugins");
|
||||
|
||||
async function connectToDaemon(retries = 5, delayMs = 500): Promise<boolean> {
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
|
|
@ -200,6 +209,79 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
|||
}
|
||||
},
|
||||
|
||||
// ── File I/O handlers ────────────────────────────────────────────────
|
||||
|
||||
"files.list": async ({ path: dirPath }) => {
|
||||
try {
|
||||
const dirents = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
const entries = dirents
|
||||
.filter((d) => !d.name.startsWith("."))
|
||||
.map((d) => {
|
||||
let size = 0;
|
||||
if (d.isFile()) {
|
||||
try {
|
||||
size = fs.statSync(path.join(dirPath, d.name)).size;
|
||||
} catch { /* ignore stat errors */ }
|
||||
}
|
||||
return {
|
||||
name: d.name,
|
||||
type: (d.isDirectory() ? "dir" : "file") as "file" | "dir",
|
||||
size,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Directories first, then alphabetical
|
||||
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return { entries };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[files.list]", error);
|
||||
return { entries: [], error };
|
||||
}
|
||||
},
|
||||
|
||||
"files.read": async ({ path: filePath }) => {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
if (stat.size > MAX_SIZE) {
|
||||
return { encoding: "utf8" as const, size: stat.size, error: `File too large (${(stat.size / 1024 / 1024).toFixed(1)}MB). Maximum is 10MB.` };
|
||||
}
|
||||
|
||||
// Detect binary by reading first 8KB
|
||||
const buf = Buffer.alloc(Math.min(8192, stat.size));
|
||||
const fd = fs.openSync(filePath, "r");
|
||||
fs.readSync(fd, buf, 0, buf.length, 0);
|
||||
fs.closeSync(fd);
|
||||
|
||||
const isBinary = buf.includes(0); // null byte = binary
|
||||
if (isBinary) {
|
||||
const content = fs.readFileSync(filePath).toString("base64");
|
||||
return { content, encoding: "base64" as const, size: stat.size };
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
return { content, encoding: "utf8" as const, size: stat.size };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[files.read]", error);
|
||||
return { encoding: "utf8" as const, size: 0, error };
|
||||
}
|
||||
},
|
||||
|
||||
"files.write": async ({ path: filePath, content }) => {
|
||||
try {
|
||||
fs.writeFileSync(filePath, content, "utf8");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[files.write]", error);
|
||||
return { ok: false, error };
|
||||
}
|
||||
},
|
||||
|
||||
// ── Groups handlers ──────────────────────────────────────────────────
|
||||
|
||||
"groups.list": () => {
|
||||
|
|
@ -445,6 +527,353 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
|||
return { sessions: [] };
|
||||
}
|
||||
},
|
||||
|
||||
// ── Session persistence handlers ──────────────────────────────────
|
||||
|
||||
"session.save": ({ projectId, sessionId, provider, status, costUsd, inputTokens, outputTokens, model, error, createdAt, updatedAt }) => {
|
||||
try {
|
||||
sessionDb.saveSession({
|
||||
projectId, sessionId, provider, status, costUsd,
|
||||
inputTokens, outputTokens, model, error, createdAt, updatedAt,
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[session.save]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"session.load": ({ projectId }) => {
|
||||
try {
|
||||
return { session: sessionDb.loadSession(projectId) };
|
||||
} catch (err) {
|
||||
console.error("[session.load]", err);
|
||||
return { session: null };
|
||||
}
|
||||
},
|
||||
|
||||
"session.list": ({ projectId }) => {
|
||||
try {
|
||||
return { sessions: sessionDb.listSessionsByProject(projectId) };
|
||||
} catch (err) {
|
||||
console.error("[session.list]", err);
|
||||
return { sessions: [] };
|
||||
}
|
||||
},
|
||||
|
||||
"session.messages.save": ({ messages }) => {
|
||||
try {
|
||||
sessionDb.saveMessages(messages.map((m) => ({
|
||||
sessionId: m.sessionId, msgId: m.msgId, role: m.role,
|
||||
content: m.content, toolName: m.toolName, toolInput: m.toolInput,
|
||||
timestamp: m.timestamp, costUsd: m.costUsd ?? 0,
|
||||
inputTokens: m.inputTokens ?? 0, outputTokens: m.outputTokens ?? 0,
|
||||
})));
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[session.messages.save]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"session.messages.load": ({ sessionId }) => {
|
||||
try {
|
||||
return { messages: sessionDb.loadMessages(sessionId) };
|
||||
} catch (err) {
|
||||
console.error("[session.messages.load]", err);
|
||||
return { messages: [] };
|
||||
}
|
||||
},
|
||||
|
||||
// ── btmsg handlers ────────────────────────────────────────────────
|
||||
|
||||
"btmsg.registerAgent": ({ id, name, role, groupId, tier, model }) => {
|
||||
try {
|
||||
btmsgDb.registerAgent(id, name, role, groupId, tier, model);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[btmsg.registerAgent]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"btmsg.getAgents": ({ groupId }) => {
|
||||
try {
|
||||
return { agents: btmsgDb.getAgents(groupId) };
|
||||
} catch (err) {
|
||||
console.error("[btmsg.getAgents]", err);
|
||||
return { agents: [] };
|
||||
}
|
||||
},
|
||||
|
||||
"btmsg.sendMessage": ({ fromAgent, toAgent, content }) => {
|
||||
try {
|
||||
const messageId = btmsgDb.sendMessage(fromAgent, toAgent, content);
|
||||
return { ok: true, messageId };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[btmsg.sendMessage]", err);
|
||||
return { ok: false, error };
|
||||
}
|
||||
},
|
||||
|
||||
"btmsg.listMessages": ({ agentId, otherId, limit }) => {
|
||||
try {
|
||||
return { messages: btmsgDb.listMessages(agentId, otherId, limit ?? 50) };
|
||||
} catch (err) {
|
||||
console.error("[btmsg.listMessages]", err);
|
||||
return { messages: [] };
|
||||
}
|
||||
},
|
||||
|
||||
"btmsg.markRead": ({ agentId, messageIds }) => {
|
||||
try {
|
||||
btmsgDb.markRead(agentId, messageIds);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[btmsg.markRead]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"btmsg.listChannels": ({ groupId }) => {
|
||||
try {
|
||||
return { channels: btmsgDb.listChannels(groupId) };
|
||||
} catch (err) {
|
||||
console.error("[btmsg.listChannels]", err);
|
||||
return { channels: [] };
|
||||
}
|
||||
},
|
||||
|
||||
"btmsg.createChannel": ({ name, groupId, createdBy }) => {
|
||||
try {
|
||||
const channelId = btmsgDb.createChannel(name, groupId, createdBy);
|
||||
return { ok: true, channelId };
|
||||
} catch (err) {
|
||||
console.error("[btmsg.createChannel]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"btmsg.getChannelMessages": ({ channelId, limit }) => {
|
||||
try {
|
||||
return { messages: btmsgDb.getChannelMessages(channelId, limit ?? 100) };
|
||||
} catch (err) {
|
||||
console.error("[btmsg.getChannelMessages]", err);
|
||||
return { messages: [] };
|
||||
}
|
||||
},
|
||||
|
||||
"btmsg.sendChannelMessage": ({ channelId, fromAgent, content }) => {
|
||||
try {
|
||||
const messageId = btmsgDb.sendChannelMessage(channelId, fromAgent, content);
|
||||
return { ok: true, messageId };
|
||||
} catch (err) {
|
||||
console.error("[btmsg.sendChannelMessage]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"btmsg.heartbeat": ({ agentId }) => {
|
||||
try {
|
||||
btmsgDb.heartbeat(agentId);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[btmsg.heartbeat]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"btmsg.getDeadLetters": ({ limit }) => {
|
||||
try {
|
||||
return { letters: btmsgDb.getDeadLetters(limit ?? 50) };
|
||||
} catch (err) {
|
||||
console.error("[btmsg.getDeadLetters]", err);
|
||||
return { letters: [] };
|
||||
}
|
||||
},
|
||||
|
||||
"btmsg.logAudit": ({ agentId, eventType, detail }) => {
|
||||
try {
|
||||
btmsgDb.logAudit(agentId, eventType, detail);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[btmsg.logAudit]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"btmsg.getAuditLog": ({ limit }) => {
|
||||
try {
|
||||
return { entries: btmsgDb.getAuditLog(limit ?? 100) };
|
||||
} catch (err) {
|
||||
console.error("[btmsg.getAuditLog]", err);
|
||||
return { entries: [] };
|
||||
}
|
||||
},
|
||||
|
||||
// ── bttask handlers ───────────────────────────────────────────────
|
||||
|
||||
"bttask.listTasks": ({ groupId }) => {
|
||||
try {
|
||||
return { tasks: bttaskDb.listTasks(groupId) };
|
||||
} catch (err) {
|
||||
console.error("[bttask.listTasks]", err);
|
||||
return { tasks: [] };
|
||||
}
|
||||
},
|
||||
|
||||
"bttask.createTask": ({ title, description, priority, groupId, createdBy, assignedTo }) => {
|
||||
try {
|
||||
const taskId = bttaskDb.createTask(title, description, priority, groupId, createdBy, assignedTo);
|
||||
return { ok: true, taskId };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[bttask.createTask]", err);
|
||||
return { ok: false, error };
|
||||
}
|
||||
},
|
||||
|
||||
"bttask.updateTaskStatus": ({ taskId, status, expectedVersion }) => {
|
||||
try {
|
||||
const newVersion = bttaskDb.updateTaskStatus(taskId, status, expectedVersion);
|
||||
return { ok: true, newVersion };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[bttask.updateTaskStatus]", err);
|
||||
return { ok: false, error };
|
||||
}
|
||||
},
|
||||
|
||||
"bttask.deleteTask": ({ taskId }) => {
|
||||
try {
|
||||
bttaskDb.deleteTask(taskId);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[bttask.deleteTask]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"bttask.addComment": ({ taskId, agentId, content }) => {
|
||||
try {
|
||||
const commentId = bttaskDb.addComment(taskId, agentId, content);
|
||||
return { ok: true, commentId };
|
||||
} catch (err) {
|
||||
console.error("[bttask.addComment]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"bttask.listComments": ({ taskId }) => {
|
||||
try {
|
||||
return { comments: bttaskDb.listComments(taskId) };
|
||||
} catch (err) {
|
||||
console.error("[bttask.listComments]", err);
|
||||
return { comments: [] };
|
||||
}
|
||||
},
|
||||
|
||||
"bttask.reviewQueueCount": ({ groupId }) => {
|
||||
try {
|
||||
return { count: bttaskDb.reviewQueueCount(groupId) };
|
||||
} catch (err) {
|
||||
console.error("[bttask.reviewQueueCount]", err);
|
||||
return { count: 0 };
|
||||
}
|
||||
},
|
||||
|
||||
// ── Search handlers ──────────────────────────────────────────────────
|
||||
|
||||
"search.query": ({ query, limit }) => {
|
||||
try {
|
||||
const results = searchDb.searchAll(query, limit ?? 20);
|
||||
return { results };
|
||||
} catch (err) {
|
||||
console.error("[search.query]", err);
|
||||
return { results: [] };
|
||||
}
|
||||
},
|
||||
|
||||
"search.indexMessage": ({ sessionId, role, content }) => {
|
||||
try {
|
||||
searchDb.indexMessage(sessionId, role, content);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[search.indexMessage]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"search.rebuild": () => {
|
||||
try {
|
||||
searchDb.rebuildIndex();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[search.rebuild]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
// ── Plugin handlers ──────────────────────────────────────────────────
|
||||
|
||||
"plugin.discover": () => {
|
||||
try {
|
||||
const plugins: Array<{
|
||||
id: string; name: string; version: string;
|
||||
description: string; main: string; permissions: string[];
|
||||
}> = [];
|
||||
|
||||
if (!fs.existsSync(PLUGINS_DIR)) return { plugins };
|
||||
|
||||
const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const manifestPath = join(PLUGINS_DIR, entry.name, "plugin.json");
|
||||
if (!fs.existsSync(manifestPath)) continue;
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(manifestPath, "utf-8");
|
||||
const manifest = JSON.parse(raw);
|
||||
plugins.push({
|
||||
id: manifest.id ?? entry.name,
|
||||
name: manifest.name ?? entry.name,
|
||||
version: manifest.version ?? "0.0.0",
|
||||
description: manifest.description ?? "",
|
||||
main: manifest.main ?? "index.js",
|
||||
permissions: Array.isArray(manifest.permissions) ? manifest.permissions : [],
|
||||
});
|
||||
} catch (parseErr) {
|
||||
console.error(`[plugin.discover] Bad manifest in ${entry.name}:`, parseErr);
|
||||
}
|
||||
}
|
||||
|
||||
return { plugins };
|
||||
} catch (err) {
|
||||
console.error("[plugin.discover]", err);
|
||||
return { plugins: [] };
|
||||
}
|
||||
},
|
||||
|
||||
"plugin.readFile": ({ pluginId, filePath }) => {
|
||||
try {
|
||||
// Path traversal protection: resolve and verify within plugins dir
|
||||
const pluginDir = join(PLUGINS_DIR, pluginId);
|
||||
const resolved = path.resolve(pluginDir, filePath);
|
||||
if (!resolved.startsWith(pluginDir + path.sep) && resolved !== pluginDir) {
|
||||
return { ok: false, content: "", error: "Path traversal blocked" };
|
||||
}
|
||||
if (!fs.existsSync(resolved)) {
|
||||
return { ok: false, content: "", error: "File not found" };
|
||||
}
|
||||
const content = fs.readFileSync(resolved, "utf-8");
|
||||
return { ok: true, content };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[plugin.readFile]", err);
|
||||
return { ok: false, content: "", error };
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
messages: {},
|
||||
|
|
|
|||
202
ui-electrobun/src/bun/search-db.ts
Normal file
202
ui-electrobun/src/bun/search-db.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
326
ui-electrobun/src/bun/session-db.ts
Normal file
326
ui-electrobun/src/bun/session-db.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
/**
|
||||
* Session persistence — SQLite-backed agent session & message storage.
|
||||
* Uses bun:sqlite. DB: ~/.config/agor/settings.db (shared with SettingsDb).
|
||||
*
|
||||
* Tables: agent_sessions, agent_messages
|
||||
*/
|
||||
|
||||
import { Database } from "bun:sqlite";
|
||||
import { homedir } from "os";
|
||||
import { mkdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
// ── DB path ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const CONFIG_DIR = join(homedir(), ".config", "agor");
|
||||
const DB_PATH = join(CONFIG_DIR, "settings.db");
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StoredSession {
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
provider: string;
|
||||
status: string;
|
||||
costUsd: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
model: string;
|
||||
error?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface StoredMessage {
|
||||
sessionId: string;
|
||||
msgId: string;
|
||||
role: string;
|
||||
content: string;
|
||||
toolName?: string;
|
||||
toolInput?: string;
|
||||
timestamp: number;
|
||||
costUsd: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
// ── Schema ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const SESSION_SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||
project_id TEXT NOT NULL,
|
||||
session_id TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'idle',
|
||||
cost_usd REAL NOT NULL DEFAULT 0,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
model TEXT NOT NULL DEFAULT '',
|
||||
error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_sessions_project
|
||||
ON agent_sessions(project_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_messages (
|
||||
session_id TEXT NOT NULL,
|
||||
msg_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
tool_name TEXT,
|
||||
tool_input TEXT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
cost_usd REAL NOT NULL DEFAULT 0,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (session_id, msg_id),
|
||||
FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_messages_session
|
||||
ON agent_messages(session_id, timestamp);
|
||||
`;
|
||||
|
||||
// ── SessionDb class ──────────────────────────────────────────────────────────
|
||||
|
||||
export class SessionDb {
|
||||
private db: Database;
|
||||
|
||||
constructor() {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
this.db = new Database(DB_PATH);
|
||||
this.db.exec("PRAGMA journal_mode = WAL");
|
||||
this.db.exec("PRAGMA busy_timeout = 500");
|
||||
this.db.exec("PRAGMA foreign_keys = ON");
|
||||
this.db.exec(SESSION_SCHEMA);
|
||||
}
|
||||
|
||||
// ── Sessions ─────────────────────────────────────────────────────────────
|
||||
|
||||
saveSession(s: StoredSession): void {
|
||||
this.db
|
||||
.query(
|
||||
`INSERT INTO agent_sessions
|
||||
(project_id, session_id, provider, status, cost_usd,
|
||||
input_tokens, output_tokens, model, error, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
cost_usd = excluded.cost_usd,
|
||||
input_tokens = excluded.input_tokens,
|
||||
output_tokens = excluded.output_tokens,
|
||||
model = excluded.model,
|
||||
error = excluded.error,
|
||||
updated_at = excluded.updated_at`
|
||||
)
|
||||
.run(
|
||||
s.projectId,
|
||||
s.sessionId,
|
||||
s.provider,
|
||||
s.status,
|
||||
s.costUsd,
|
||||
s.inputTokens,
|
||||
s.outputTokens,
|
||||
s.model,
|
||||
s.error ?? null,
|
||||
s.createdAt,
|
||||
s.updatedAt
|
||||
);
|
||||
}
|
||||
|
||||
loadSession(projectId: string): StoredSession | null {
|
||||
const row = this.db
|
||||
.query<
|
||||
{
|
||||
project_id: string;
|
||||
session_id: string;
|
||||
provider: string;
|
||||
status: string;
|
||||
cost_usd: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
model: string;
|
||||
error: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
},
|
||||
[string]
|
||||
>(
|
||||
`SELECT * FROM agent_sessions
|
||||
WHERE project_id = ?
|
||||
ORDER BY updated_at DESC LIMIT 1`
|
||||
)
|
||||
.get(projectId);
|
||||
|
||||
if (!row) return null;
|
||||
return {
|
||||
projectId: row.project_id,
|
||||
sessionId: row.session_id,
|
||||
provider: row.provider,
|
||||
status: row.status,
|
||||
costUsd: row.cost_usd,
|
||||
inputTokens: row.input_tokens,
|
||||
outputTokens: row.output_tokens,
|
||||
model: row.model,
|
||||
error: row.error ?? undefined,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
listSessionsByProject(projectId: string): StoredSession[] {
|
||||
const rows = this.db
|
||||
.query<
|
||||
{
|
||||
project_id: string;
|
||||
session_id: string;
|
||||
provider: string;
|
||||
status: string;
|
||||
cost_usd: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
model: string;
|
||||
error: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
},
|
||||
[string]
|
||||
>(
|
||||
`SELECT * FROM agent_sessions
|
||||
WHERE project_id = ?
|
||||
ORDER BY updated_at DESC LIMIT 20`
|
||||
)
|
||||
.all(projectId);
|
||||
|
||||
return rows.map((r) => ({
|
||||
projectId: r.project_id,
|
||||
sessionId: r.session_id,
|
||||
provider: r.provider,
|
||||
status: r.status,
|
||||
costUsd: r.cost_usd,
|
||||
inputTokens: r.input_tokens,
|
||||
outputTokens: r.output_tokens,
|
||||
model: r.model,
|
||||
error: r.error ?? undefined,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Messages ─────────────────────────────────────────────────────────────
|
||||
|
||||
saveMessage(m: StoredMessage): void {
|
||||
this.db
|
||||
.query(
|
||||
`INSERT INTO agent_messages
|
||||
(session_id, msg_id, role, content, tool_name, tool_input,
|
||||
timestamp, cost_usd, input_tokens, output_tokens)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
|
||||
ON CONFLICT(session_id, msg_id) DO NOTHING`
|
||||
)
|
||||
.run(
|
||||
m.sessionId,
|
||||
m.msgId,
|
||||
m.role,
|
||||
m.content,
|
||||
m.toolName ?? null,
|
||||
m.toolInput ?? null,
|
||||
m.timestamp,
|
||||
m.costUsd,
|
||||
m.inputTokens,
|
||||
m.outputTokens
|
||||
);
|
||||
}
|
||||
|
||||
saveMessages(msgs: StoredMessage[]): void {
|
||||
const stmt = this.db.prepare(
|
||||
`INSERT INTO agent_messages
|
||||
(session_id, msg_id, role, content, tool_name, tool_input,
|
||||
timestamp, cost_usd, input_tokens, output_tokens)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
|
||||
ON CONFLICT(session_id, msg_id) DO NOTHING`
|
||||
);
|
||||
|
||||
const tx = this.db.transaction(() => {
|
||||
for (const m of msgs) {
|
||||
stmt.run(
|
||||
m.sessionId,
|
||||
m.msgId,
|
||||
m.role,
|
||||
m.content,
|
||||
m.toolName ?? null,
|
||||
m.toolInput ?? null,
|
||||
m.timestamp,
|
||||
m.costUsd,
|
||||
m.inputTokens,
|
||||
m.outputTokens
|
||||
);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
loadMessages(sessionId: string): StoredMessage[] {
|
||||
const rows = this.db
|
||||
.query<
|
||||
{
|
||||
session_id: string;
|
||||
msg_id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
tool_name: string | null;
|
||||
tool_input: string | null;
|
||||
timestamp: number;
|
||||
cost_usd: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
},
|
||||
[string]
|
||||
>(
|
||||
`SELECT * FROM agent_messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp ASC`
|
||||
)
|
||||
.all(sessionId);
|
||||
|
||||
return rows.map((r) => ({
|
||||
sessionId: r.session_id,
|
||||
msgId: r.msg_id,
|
||||
role: r.role,
|
||||
content: r.content,
|
||||
toolName: r.tool_name ?? undefined,
|
||||
toolInput: r.tool_input ?? undefined,
|
||||
timestamp: r.timestamp,
|
||||
costUsd: r.cost_usd,
|
||||
inputTokens: r.input_tokens,
|
||||
outputTokens: r.output_tokens,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Cleanup ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Delete sessions older than maxAgeDays for a project, keeping at most keepCount. */
|
||||
pruneOldSessions(projectId: string, keepCount = 10): void {
|
||||
this.db
|
||||
.query(
|
||||
`DELETE FROM agent_sessions
|
||||
WHERE project_id = ?1
|
||||
AND session_id NOT IN (
|
||||
SELECT session_id FROM agent_sessions
|
||||
WHERE project_id = ?1
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?2
|
||||
)`
|
||||
)
|
||||
.run(projectId, keepCount);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
export const sessionDb = new SessionDb();
|
||||
Loading…
Add table
Add a link
Reference in a new issue