- 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
322 lines
9.3 KiB
TypeScript
322 lines
9.3 KiB
TypeScript
/**
|
|
* 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 { join } from "path";
|
|
import { openDb } from "./db-utils.ts";
|
|
|
|
// ── 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() {
|
|
this.db = openDb(DB_PATH, { foreignKeys: true });
|
|
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();
|