Files
oh-my-pi/packages/coding-agent/src/session/history-storage.ts
T
can1357 a83175c94c refactor: migrated imports to unified package root and consolidated skill discovery logic
- Consolidated @oh-my-pi/pi-utils subpath imports into single package root import across 100+ files.
- Moved tryParseJson utility from local web scrapers module to @oh-my-pi/pi-utils package for centralized JSON parsing.
- Renamed loadSkillsFromDir to scanSkillsFromDir and refactored skill discovery to use fs.promises.readdir instead of glob-based approach.
- Replaced custom parseJSON with tryParseJson across discovery modules for consistent error handling.
- Removed emitCustomToolSessionEvent method and cleanupSshResources function, consolidating shutdown logic into dispose method.
- Updated glob pattern construction to use GlobBuilder with literal_separator(true) for improved path handling.
2026-02-23 20:59:17 +01:00

170 lines
4.5 KiB
TypeScript

import { Database, type Statement } from "bun:sqlite";
import * as fs from "node:fs";
import * as path from "node:path";
import { getAgentDir, logger } from "@oh-my-pi/pi-utils";
export interface HistoryEntry {
id: number;
prompt: string;
created_at: number;
cwd?: string;
}
type HistoryRow = {
id: number;
prompt: string;
created_at: number;
cwd: string | null;
};
export class HistoryStorage {
#db: Database;
static #instance?: HistoryStorage;
// Prepared statements
#insertStmt: Statement;
#recentStmt: Statement;
#searchStmt: Statement;
#lastPromptStmt: Statement;
// In-memory cache of last prompt to avoid sync DB reads on add
#lastPromptCache: string | null = null;
private constructor(dbPath: string) {
this.#ensureDir(dbPath);
this.#db = new Database(dbPath);
const hasFts = this.#db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='history_fts'").get();
this.#db.exec(`
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA busy_timeout=5000;
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
prompt TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
cwd TEXT
);
CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC);
CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5(prompt, content='history', content_rowid='id');
CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
INSERT INTO history_fts(rowid, prompt) VALUES (new.id, new.prompt);
END;
`);
if (!hasFts) {
try {
this.#db.run("INSERT INTO history_fts(history_fts) VALUES('rebuild')");
} catch (error) {
logger.warn("HistoryStorage FTS rebuild failed", { error: String(error) });
}
}
this.#insertStmt = this.#db.prepare("INSERT INTO history (prompt, cwd) VALUES (?, ?)");
this.#recentStmt = this.#db.prepare(
"SELECT id, prompt, created_at, cwd FROM history ORDER BY created_at DESC, id DESC LIMIT ?",
);
this.#searchStmt = this.#db.prepare(
"SELECT h.id, h.prompt, h.created_at, h.cwd FROM history_fts f JOIN history h ON h.id = f.rowid WHERE history_fts MATCH ? ORDER BY h.created_at DESC, h.id DESC LIMIT ?",
);
this.#lastPromptStmt = this.#db.prepare("SELECT prompt FROM history ORDER BY id DESC LIMIT 1");
const last = this.#lastPromptStmt.get() as { prompt?: string } | undefined;
this.#lastPromptCache = last?.prompt ?? null;
}
static open(dbPath: string = path.join(getAgentDir(), "history.db")): HistoryStorage {
if (!HistoryStorage.#instance) {
HistoryStorage.#instance = new HistoryStorage(dbPath);
}
return HistoryStorage.#instance;
}
add(prompt: string, cwd?: string): void {
const trimmed = prompt.trim();
if (!trimmed) return;
if (this.#lastPromptCache === trimmed) return;
this.#lastPromptCache = trimmed;
setImmediate(() => {
try {
this.#insertStmt.run(trimmed, cwd ?? null);
} catch (error) {
logger.error("HistoryStorage add failed", { error: String(error) });
}
});
}
getRecent(limit: number): HistoryEntry[] {
const safeLimit = this.#normalizeLimit(limit);
if (safeLimit === 0) return [];
try {
const rows = this.#recentStmt.all(safeLimit) as HistoryRow[];
return rows.map(row => this.#toEntry(row));
} catch (error) {
logger.error("HistoryStorage getRecent failed", { error: String(error) });
return [];
}
}
search(query: string, limit: number): HistoryEntry[] {
const safeLimit = this.#normalizeLimit(limit);
if (safeLimit === 0) return [];
const ftsQuery = this.#buildFtsQuery(query);
if (!ftsQuery) return [];
try {
const rows = this.#searchStmt.all(ftsQuery, safeLimit) as HistoryRow[];
return rows.map(row => this.#toEntry(row));
} catch (error) {
logger.error("HistoryStorage search failed", { error: String(error) });
return [];
}
}
#ensureDir(dbPath: string): void {
const dir = path.dirname(dbPath);
fs.mkdirSync(dir, { recursive: true });
}
#normalizeLimit(limit: number): number {
if (!Number.isFinite(limit)) return 0;
const clamped = Math.max(0, Math.floor(limit));
return Math.min(clamped, 1000);
}
#buildFtsQuery(query: string): string | null {
const tokens = query
.trim()
.split(/\s+/)
.map(token => token.trim())
.filter(Boolean);
if (tokens.length === 0) return null;
return tokens
.map(token => {
const escaped = token.replace(/"/g, '""');
return `"${escaped}"*`;
})
.join(" ");
}
#toEntry(row: HistoryRow): HistoryEntry {
return {
id: row.id,
prompt: row.prompt,
created_at: row.created_at,
cwd: row.cwd ?? undefined,
};
}
}