feat: sqlite session storage (#6594)
This PR: - Adds retainedTail to compaction entries in the new agent harness so we don't have to walk up the tree for the 2000 tokens before compaction, - Changes getPathToRoot to getPathToRootOrCompaction to only load until last compaction, as unnecessary to access all nodes where it is called, - Adds a SQLite storage backend, in a separate packages/session-backend-sqlite, with a migration system and schemas as per on-site discussions: sessions to match session header messages (except for metadata, which I couldn't understand what it's used for or where it gets written, so I omitted it), session_entries for shared entry types as columns plus payload as a json for what remains, session_sequences to represent the append-only, serialized nature of the jsonl files, branch_entries to attribute nodes to branches (relationship one-to-many), and session_materialized with the session info (see /session in TUI) to act as a "cache" or quick-access for costs, message count, token info, labels, session name, and model-thinking-level config (e.g. for fast resume). - This is compatible with the new agent harness Session abstraction.
This commit is contained in:
committed by
GitHub
parent
54fad505b9
commit
9e7582aa03
@@ -0,0 +1,4 @@
|
||||
export * from "./migrations.ts";
|
||||
export * from "./repo.ts";
|
||||
export * from "./storage/index.ts";
|
||||
export * from "./types.ts";
|
||||
@@ -0,0 +1,50 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { SqliteDatabase } from "./types.ts";
|
||||
|
||||
export interface SqliteMigration {
|
||||
id: string;
|
||||
order: number;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
async function loadMigrationSql(relativePath: string): Promise<string> {
|
||||
return readFile(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8");
|
||||
}
|
||||
|
||||
export async function loadMigrations(): Promise<SqliteMigration[]> {
|
||||
return [
|
||||
{
|
||||
id: "001_initial.sql",
|
||||
order: 1,
|
||||
sql: await loadMigrationSql("./migrations/001_initial.sql"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function ensureMigrationsTable(db: SqliteDatabase): Promise<void> {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
export async function applyMigrations(db: SqliteDatabase): Promise<void> {
|
||||
await ensureMigrationsTable(db);
|
||||
const migrations = await loadMigrations();
|
||||
const appliedRows = await db.prepare("SELECT id FROM migrations ORDER BY applied_at, id").all<{ id: string }>();
|
||||
const applied = new Set(appliedRows.map((row) => row.id));
|
||||
|
||||
for (const migration of migrations) {
|
||||
if (applied.has(migration.id)) continue;
|
||||
await db.transaction(async () => {
|
||||
await db.exec(migration.sql);
|
||||
await db
|
||||
.prepare("INSERT INTO migrations (id, applied_at) VALUES (?, ?)")
|
||||
.run(migration.id, new Date().toISOString());
|
||||
});
|
||||
applied.add(migration.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL,
|
||||
cwd TEXT NOT NULL,
|
||||
parent_session_id TEXT NULL,
|
||||
metadata TEXT NULL,
|
||||
active_leaf_id TEXT NULL
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_entries (
|
||||
session_id TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
entry_seq INTEGER NOT NULL,
|
||||
parent_id TEXT NULL,
|
||||
type TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
PRIMARY KEY (session_id, id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_session_entries_session_seq ON session_entries(session_id, entry_seq);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_entries_session_parent ON session_entries(session_id, parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_entries_session_type ON session_entries(session_id, type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_sequences (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
next_seq INTEGER NOT NULL
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS branch_entries (
|
||||
session_id TEXT NOT NULL,
|
||||
branch_id TEXT NOT NULL,
|
||||
entry_id TEXT NOT NULL,
|
||||
entry_seq INTEGER NOT NULL,
|
||||
PRIMARY KEY (session_id, branch_id, entry_id)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_branch_entries_session_branch ON branch_entries(session_id, branch_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_branch_entries_session_branch_seq ON branch_entries(session_id, branch_id, entry_seq);
|
||||
CREATE INDEX IF NOT EXISTS idx_branch_entries_session_entry ON branch_entries(session_id, entry_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_materialized (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_materialized (
|
||||
session_id TEXT NOT NULL,
|
||||
entry_seq INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
PRIMARY KEY (session_id, entry_seq, type)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entry_materialized_session_type_seq ON entry_materialized(session_id, type, entry_seq);
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { Session, SessionStorage, SessionTreeEntry } from "@earendil-works/pi-agent-core";
|
||||
import {
|
||||
createSessionId,
|
||||
getEntriesToFork,
|
||||
getFileSystemResultOrThrow,
|
||||
SessionError,
|
||||
toSession,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { applyMigrations } from "./migrations.ts";
|
||||
import { SqliteSessionStorage } from "./storage/index.ts";
|
||||
import { rowToMetadata, type SessionRow } from "./storage/sessions.ts";
|
||||
import type {
|
||||
SqliteDatabase,
|
||||
SqliteDatabaseFactory,
|
||||
SqliteSessionCreateOptions,
|
||||
SqliteSessionListOptions,
|
||||
SqliteSessionMetadata,
|
||||
SqliteSessionRepoApi,
|
||||
SqliteSessionRepoEnv,
|
||||
} from "./types.ts";
|
||||
|
||||
function getParentPath(path: string): string {
|
||||
const normalized = path.replace(/[\\/]+$/, "");
|
||||
const lastSlash = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\"));
|
||||
if (lastSlash < 0) return ".";
|
||||
if (lastSlash === 0) return normalized.slice(0, 1);
|
||||
return normalized.slice(0, lastSlash);
|
||||
}
|
||||
|
||||
async function configureSqliteDatabase(db: SqliteDatabase): Promise<void> {
|
||||
await db.exec("PRAGMA journal_mode=WAL");
|
||||
await db.exec("PRAGMA synchronous=FULL");
|
||||
await db.exec("PRAGMA busy_timeout=5000");
|
||||
}
|
||||
|
||||
async function cleanupSessionStorage(storage: SessionStorage): Promise<void> {
|
||||
const maybeClosable = storage as SessionStorage & { cleanup?: () => Promise<void> };
|
||||
if (typeof maybeClosable.cleanup === "function") {
|
||||
await maybeClosable.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export class SqliteSessionRepo implements SqliteSessionRepoApi {
|
||||
private readonly env: SqliteSessionRepoEnv;
|
||||
private readonly sqlite: SqliteDatabaseFactory;
|
||||
private readonly databasePathInput: string;
|
||||
private databasePath: string | undefined;
|
||||
|
||||
constructor(options: { env: SqliteSessionRepoEnv; sqlite: SqliteDatabaseFactory; databasePath: string }) {
|
||||
this.env = options.env;
|
||||
this.sqlite = options.sqlite;
|
||||
this.databasePathInput = options.databasePath;
|
||||
}
|
||||
|
||||
private async getDatabasePath(): Promise<string> {
|
||||
if (!this.databasePath) {
|
||||
this.databasePath = getFileSystemResultOrThrow(
|
||||
await this.env.absolutePath(this.databasePathInput),
|
||||
`Failed to resolve SQLite sessions database ${this.databasePathInput}`,
|
||||
);
|
||||
}
|
||||
return this.databasePath;
|
||||
}
|
||||
|
||||
private async ensureDatabaseDir(): Promise<void> {
|
||||
const path = await this.getDatabasePath();
|
||||
const directory = getParentPath(path);
|
||||
getFileSystemResultOrThrow(
|
||||
await this.env.createDir(directory, { recursive: true }),
|
||||
`Failed to create SQLite sessions directory ${directory}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async openDatabase(): Promise<SqliteDatabase> {
|
||||
await this.ensureDatabaseDir();
|
||||
const db = await this.sqlite.open(await this.getDatabasePath());
|
||||
try {
|
||||
await configureSqliteDatabase(db);
|
||||
await applyMigrations(db);
|
||||
return db;
|
||||
} catch (error) {
|
||||
await db.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async create(options: SqliteSessionCreateOptions): Promise<Session<SqliteSessionMetadata>> {
|
||||
const db = await this.openDatabase();
|
||||
try {
|
||||
const id = options.id ?? createSessionId();
|
||||
const storage = await SqliteSessionStorage.create(db, await this.getDatabasePath(), {
|
||||
cwd: options.cwd,
|
||||
sessionId: id,
|
||||
parentSessionId: options.parentSessionId,
|
||||
metadata: options.metadata,
|
||||
});
|
||||
return toSession(storage);
|
||||
} catch (error) {
|
||||
await db.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async open(metadata: SqliteSessionMetadata): Promise<Session<SqliteSessionMetadata>> {
|
||||
if (
|
||||
!getFileSystemResultOrThrow(await this.env.exists(metadata.path), `Failed to check database ${metadata.path}`)
|
||||
) {
|
||||
throw new SessionError("not_found", `Session not found: ${metadata.id}`);
|
||||
}
|
||||
const db = await this.openDatabase();
|
||||
try {
|
||||
const storage = await SqliteSessionStorage.open(db, metadata);
|
||||
return toSession(storage);
|
||||
} catch (error) {
|
||||
await db.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async list(options: SqliteSessionListOptions = {}): Promise<SqliteSessionMetadata[]> {
|
||||
const path = await this.getDatabasePath();
|
||||
if (!getFileSystemResultOrThrow(await this.env.exists(path), `Failed to check database ${path}`)) {
|
||||
return [];
|
||||
}
|
||||
const db = await this.openDatabase();
|
||||
try {
|
||||
const rows = options.cwd
|
||||
? await db
|
||||
.prepare(
|
||||
"SELECT id, created_at, metadata, cwd, parent_session_id, active_leaf_id FROM sessions WHERE cwd = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.all<SessionRow>(options.cwd)
|
||||
: await db
|
||||
.prepare(
|
||||
"SELECT id, created_at, metadata, cwd, parent_session_id, active_leaf_id FROM sessions ORDER BY created_at DESC",
|
||||
)
|
||||
.all<SessionRow>();
|
||||
return rows.map((row) => rowToMetadata(row, path));
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async delete(metadata: SqliteSessionMetadata): Promise<void> {
|
||||
const db = await this.openDatabase();
|
||||
try {
|
||||
await db.transaction(async () => {
|
||||
await db.prepare("DELETE FROM branch_entries WHERE session_id = ?").run(metadata.id);
|
||||
await db.prepare("DELETE FROM session_entries WHERE session_id = ?").run(metadata.id);
|
||||
await db.prepare("DELETE FROM entry_materialized WHERE session_id = ?").run(metadata.id);
|
||||
await db.prepare("DELETE FROM session_materialized WHERE session_id = ?").run(metadata.id);
|
||||
await db.prepare("DELETE FROM session_sequences WHERE session_id = ?").run(metadata.id);
|
||||
const result = await db.prepare("DELETE FROM sessions WHERE id = ?").run(metadata.id);
|
||||
if (result.changes === 0) {
|
||||
throw new SessionError("not_found", `Session not found: ${metadata.id}`);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async fork(
|
||||
sourceMetadata: SqliteSessionMetadata,
|
||||
options: SqliteSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string },
|
||||
): Promise<Session<SqliteSessionMetadata>> {
|
||||
const source = await this.open(sourceMetadata);
|
||||
let forkedEntries: SessionTreeEntry[];
|
||||
try {
|
||||
forkedEntries = await getEntriesToFork(source.getStorage(), options);
|
||||
} finally {
|
||||
await cleanupSessionStorage(source.getStorage());
|
||||
}
|
||||
const db = await this.openDatabase();
|
||||
try {
|
||||
const id = options.id ?? createSessionId();
|
||||
const storage = await SqliteSessionStorage.create(db, await this.getDatabasePath(), {
|
||||
cwd: options.cwd,
|
||||
sessionId: id,
|
||||
parentSessionId: options.parentSessionId ?? sourceMetadata.id,
|
||||
metadata: options.metadata ?? sourceMetadata.metadata,
|
||||
});
|
||||
for (const entry of forkedEntries) {
|
||||
await storage.appendEntry(entry);
|
||||
}
|
||||
return toSession(storage);
|
||||
} catch (error) {
|
||||
await db.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { SessionTreeEntry } from "@earendil-works/pi-agent-core";
|
||||
import type { SqliteDatabase } from "../types.ts";
|
||||
import { decodeEntry, type SessionEntryRow } from "./session-entries.ts";
|
||||
import { invalidSession } from "./shared.ts";
|
||||
|
||||
export interface BranchEntryRow {
|
||||
entry_id: string;
|
||||
entry_seq: number;
|
||||
}
|
||||
|
||||
export async function getMaterializedBranchPathOrCompaction(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
branchId: string,
|
||||
byId: Map<string, SessionTreeEntry>,
|
||||
): Promise<SessionTreeEntry[]> {
|
||||
const branchRows = await db
|
||||
.prepare(
|
||||
"SELECT entry_id, entry_seq FROM branch_entries WHERE session_id = ? AND branch_id = ? ORDER BY entry_seq",
|
||||
)
|
||||
.all<BranchEntryRow>(sessionId, branchId);
|
||||
if (branchRows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const entryIds = branchRows.map((row) => row.entry_id);
|
||||
const placeholders = entryIds.map(() => "?").join(", ");
|
||||
const entryRows = await db
|
||||
.prepare(
|
||||
`SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? AND id IN (${placeholders})`,
|
||||
)
|
||||
.all<SessionEntryRow>(sessionId, ...entryIds);
|
||||
const entryRowsById = new Map(entryRows.map((row) => [row.id, row]));
|
||||
const entries: SessionTreeEntry[] = [];
|
||||
for (const branchRow of branchRows) {
|
||||
// leaf entries are navigation markers used to mark which branch became active;
|
||||
// they are not part of the model/context path reconstructed from branch_entries.
|
||||
const cached = byId.get(branchRow.entry_id);
|
||||
if (cached) {
|
||||
if (cached.type !== "leaf") {
|
||||
entries.push(cached);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const entryRow = entryRowsById.get(branchRow.entry_id);
|
||||
if (!entryRow) throw invalidSession(`missing entry row for branch entry ${branchRow.entry_id}`);
|
||||
try {
|
||||
const entry = decodeEntry(entryRow);
|
||||
byId.set(entry.id, entry);
|
||||
if (entry.type !== "leaf") {
|
||||
entries.push(entry);
|
||||
}
|
||||
} catch {
|
||||
throw invalidSession(`invalid entry row for branch entry ${branchRow.entry_id}`);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import type {
|
||||
LeafEntry,
|
||||
SessionEntryCursorOptions,
|
||||
SessionStorage,
|
||||
SessionTreeEntry,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { SessionError } from "@earendil-works/pi-agent-core";
|
||||
import { uuidv7 } from "@earendil-works/pi-ai";
|
||||
import type { SqliteDatabase, SqliteSessionMetadata } from "../types.ts";
|
||||
import { getMaterializedBranchPathOrCompaction } from "./branch-entries.ts";
|
||||
import { decodeEntry, encodeEntry, type SessionEntryRow } from "./session-entries.ts";
|
||||
import {
|
||||
applyEntryToMaterializedState,
|
||||
createEmptyMaterializedState,
|
||||
type EntryMaterializedRow,
|
||||
entryMaterializedValues,
|
||||
materializedStateFromRows,
|
||||
materializedStateValues,
|
||||
type SessionMaterializedRow,
|
||||
type SessionMaterializedState,
|
||||
serializeSummary,
|
||||
sessionStatsFromMaterializedState,
|
||||
} from "./session-materialized.ts";
|
||||
import { advanceSequence, getNextSequence } from "./session-sequences.ts";
|
||||
import { rowToMetadata, type SessionRow } from "./sessions.ts";
|
||||
import { generateEntryId, invalidSession, leafIdAfterEntry } from "./shared.ts";
|
||||
|
||||
async function decodeEntryRows(entryRows: SessionEntryRow[]): Promise<{
|
||||
entries: SessionTreeEntry[];
|
||||
leafId: string | null;
|
||||
}> {
|
||||
const entries: SessionTreeEntry[] = [];
|
||||
let leafId: string | null = null;
|
||||
for (const entryRow of entryRows) {
|
||||
try {
|
||||
const entry = decodeEntry(entryRow);
|
||||
entries.push(entry);
|
||||
leafId = leafIdAfterEntry(entry);
|
||||
} catch {
|
||||
// Keep JSONL-like permissive resume behavior: skip malformed entries.
|
||||
}
|
||||
}
|
||||
return { entries, leafId };
|
||||
}
|
||||
|
||||
async function loadEntryRowsByIds(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
entryIds: string[],
|
||||
): Promise<Map<string, SessionEntryRow>> {
|
||||
if (entryIds.length === 0) return new Map<string, SessionEntryRow>();
|
||||
const placeholders = entryIds.map(() => "?").join(", ");
|
||||
const rows = await db
|
||||
.prepare(
|
||||
`SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? AND id IN (${placeholders})`,
|
||||
)
|
||||
.all<SessionEntryRow>(sessionId, ...entryIds);
|
||||
return new Map(rows.map((row) => [row.id, row]));
|
||||
}
|
||||
|
||||
async function loadActiveBranchId(db: SqliteDatabase, sessionId: string): Promise<string | null> {
|
||||
// branch_entries includes leaf navigation entries for the active branch, so the
|
||||
// newest branch_entries row identifies the branch that was most recently made active.
|
||||
const row = await db
|
||||
.prepare(
|
||||
"SELECT branch_id FROM branch_entries WHERE session_id = ? ORDER BY entry_seq DESC, branch_id DESC LIMIT 1",
|
||||
)
|
||||
.get<{ branch_id: string }>(sessionId);
|
||||
return row?.branch_id ?? null;
|
||||
}
|
||||
|
||||
async function hasExistingChild(db: SqliteDatabase, sessionId: string, parentId: string | null): Promise<boolean> {
|
||||
const row =
|
||||
parentId === null
|
||||
? await db
|
||||
.prepare("SELECT 1 AS found FROM session_entries WHERE session_id = ? AND parent_id IS NULL LIMIT 1")
|
||||
.get<{ found: number }>(sessionId)
|
||||
: await db
|
||||
.prepare("SELECT 1 AS found FROM session_entries WHERE session_id = ? AND parent_id = ? LIMIT 1")
|
||||
.get<{ found: number }>(sessionId, parentId);
|
||||
return row !== undefined;
|
||||
}
|
||||
|
||||
async function loadSqliteStorage(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
): Promise<{
|
||||
row: SessionRow;
|
||||
leafId: string | null;
|
||||
activeBranchId: string | null;
|
||||
materializedState: SessionMaterializedState;
|
||||
}> {
|
||||
const row = await db
|
||||
.prepare("SELECT id, created_at, metadata, cwd, parent_session_id, active_leaf_id FROM sessions WHERE id = ?")
|
||||
.get<SessionRow>(sessionId);
|
||||
if (!row) throw new SessionError("not_found", `Session not found: ${sessionId}`);
|
||||
|
||||
const leafId = row.active_leaf_id;
|
||||
const materializedRow = await db
|
||||
.prepare("SELECT session_id, payload FROM session_materialized WHERE session_id = ?")
|
||||
.get<SessionMaterializedRow>(sessionId);
|
||||
if (!materializedRow) throw invalidSession(`missing materialized row for session ${sessionId}`);
|
||||
const entryMaterializedRows = await db
|
||||
.prepare(
|
||||
"SELECT session_id, entry_seq, type, payload FROM entry_materialized WHERE session_id = ? ORDER BY entry_seq, type",
|
||||
)
|
||||
.all<EntryMaterializedRow>(sessionId);
|
||||
return {
|
||||
row,
|
||||
leafId,
|
||||
activeBranchId: await loadActiveBranchId(db, sessionId),
|
||||
materializedState: materializedStateFromRows(materializedRow, entryMaterializedRows),
|
||||
};
|
||||
}
|
||||
|
||||
export class SqliteSessionStorage implements SessionStorage<SqliteSessionMetadata> {
|
||||
private readonly db: SqliteDatabase;
|
||||
private readonly metadata: SqliteSessionMetadata;
|
||||
private byId: Map<string, SessionTreeEntry>;
|
||||
private labelsById: Map<string, string>;
|
||||
private currentLeafId: string | null;
|
||||
private activeBranchId: string | null;
|
||||
private materializedState: SessionMaterializedState;
|
||||
|
||||
private async getPathToRootOrCompactionEntries(leafId: string | null): Promise<SessionTreeEntry[]> {
|
||||
if (leafId === null) return [];
|
||||
const path: SessionTreeEntry[] = [];
|
||||
let stopAtEntryId: string | null = null;
|
||||
let current = await this.getEntry(leafId);
|
||||
if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`);
|
||||
while (current) {
|
||||
path.unshift(current);
|
||||
if (stopAtEntryId !== null && current.id === stopAtEntryId) break;
|
||||
if (current.type === "compaction") {
|
||||
if (current.retainedTail) break;
|
||||
stopAtEntryId = current.firstKeptEntryId ?? null;
|
||||
}
|
||||
if (!current.parentId) break;
|
||||
const parent = await this.getEntry(current.parentId);
|
||||
if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
|
||||
current = parent;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private async materializeBranch(leafId: string | null): Promise<void> {
|
||||
const branchId = uuidv7();
|
||||
// Rebuild the branch path only when branch membership changes: branch switch
|
||||
// (leaf navigation) or a new fork from a parent that already has a child.
|
||||
// Linear appends stay cheap and extend the active branch incrementally.
|
||||
const path = await this.getPathToRootOrCompactionEntries(leafId);
|
||||
const entryRowsById = await loadEntryRowsByIds(
|
||||
this.db,
|
||||
this.metadata.id,
|
||||
path.map((entry) => entry.id),
|
||||
);
|
||||
for (const entry of path) {
|
||||
const entryRow = entryRowsById.get(entry.id);
|
||||
if (!entryRow) throw invalidSession(`missing entry row for session ${this.metadata.id} entry ${entry.id}`);
|
||||
await this.db
|
||||
.prepare("INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq) VALUES (?, ?, ?, ?)")
|
||||
.run(this.metadata.id, branchId, entry.id, entryRow.entry_seq);
|
||||
}
|
||||
this.activeBranchId = branchId;
|
||||
}
|
||||
|
||||
private async appendToActiveBranch(entryId: string, parentId: string | null): Promise<void> {
|
||||
if (!this.activeBranchId) {
|
||||
await this.materializeBranch(parentId);
|
||||
}
|
||||
// After a branch is materialized/resynced, subsequent linear appends only add the
|
||||
// new tip entry. We do not rebuild the full branch on every append.
|
||||
if (!this.activeBranchId) {
|
||||
throw invalidSession(`active branch missing for session ${this.metadata.id}`);
|
||||
}
|
||||
const entryRow = await this.db
|
||||
.prepare("SELECT entry_seq FROM session_entries WHERE session_id = ? AND id = ?")
|
||||
.get<{ entry_seq: number }>(this.metadata.id, entryId);
|
||||
if (!entryRow) throw invalidSession(`missing entry row for session ${this.metadata.id} entry ${entryId}`);
|
||||
await this.db
|
||||
.prepare("INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq) VALUES (?, ?, ?, ?)")
|
||||
.run(this.metadata.id, this.activeBranchId, entryId, entryRow.entry_seq);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
db: SqliteDatabase,
|
||||
metadata: SqliteSessionMetadata,
|
||||
entries: SessionTreeEntry[] | null,
|
||||
leafId: string | null,
|
||||
activeBranchId: string | null,
|
||||
materializedState: SessionMaterializedState,
|
||||
) {
|
||||
this.db = db;
|
||||
this.metadata = metadata;
|
||||
this.byId = new Map((entries ?? []).map((entry) => [entry.id, entry]));
|
||||
this.materializedState = materializedState;
|
||||
this.labelsById = materializedState.labelsById;
|
||||
this.currentLeafId = leafId;
|
||||
this.activeBranchId = activeBranchId;
|
||||
}
|
||||
|
||||
static async open(db: SqliteDatabase, metadata: SqliteSessionMetadata): Promise<SqliteSessionStorage> {
|
||||
const loaded = await loadSqliteStorage(db, metadata.id);
|
||||
return new SqliteSessionStorage(
|
||||
db,
|
||||
rowToMetadata(loaded.row, metadata.path),
|
||||
null,
|
||||
loaded.leafId,
|
||||
loaded.activeBranchId,
|
||||
loaded.materializedState,
|
||||
);
|
||||
}
|
||||
|
||||
static async create(
|
||||
db: SqliteDatabase,
|
||||
path: string,
|
||||
options: {
|
||||
cwd: string;
|
||||
sessionId: string;
|
||||
parentSessionId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<SqliteSessionStorage> {
|
||||
const createdAt = new Date().toISOString();
|
||||
await db
|
||||
.prepare(
|
||||
"INSERT INTO sessions (id, created_at, metadata, cwd, parent_session_id, active_leaf_id) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
options.sessionId,
|
||||
createdAt,
|
||||
options.metadata === undefined ? null : JSON.stringify(options.metadata),
|
||||
options.cwd,
|
||||
options.parentSessionId ?? null,
|
||||
null,
|
||||
);
|
||||
await db.prepare("INSERT INTO session_sequences (session_id, next_seq) VALUES (?, ?)").run(options.sessionId, 1);
|
||||
await db
|
||||
.prepare("INSERT INTO session_materialized (session_id, payload) VALUES (?, ?)")
|
||||
.run(...materializedStateValues(options.sessionId, createEmptyMaterializedState()));
|
||||
return new SqliteSessionStorage(
|
||||
db,
|
||||
{
|
||||
id: options.sessionId,
|
||||
createdAt,
|
||||
cwd: options.cwd,
|
||||
path,
|
||||
parentSessionId: options.parentSessionId,
|
||||
metadata: options.metadata,
|
||||
},
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
createEmptyMaterializedState(),
|
||||
);
|
||||
}
|
||||
|
||||
async getMetadata(): Promise<SqliteSessionMetadata> {
|
||||
return this.metadata;
|
||||
}
|
||||
|
||||
async getLeafId(): Promise<string | null> {
|
||||
return this.currentLeafId;
|
||||
}
|
||||
|
||||
async setLeafId(leafId: string | null): Promise<void> {
|
||||
if (leafId !== null && !(await this.getEntry(leafId))) {
|
||||
throw new SessionError("not_found", `Entry ${leafId} not found`);
|
||||
}
|
||||
const entry: LeafEntry = {
|
||||
type: "leaf",
|
||||
id: await this.createEntryId(),
|
||||
parentId: this.currentLeafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
targetId: leafId,
|
||||
};
|
||||
await this.appendEntry(entry);
|
||||
}
|
||||
|
||||
async createEntryId(): Promise<string> {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const id = generateEntryId(this.byId);
|
||||
const existing = await this.db
|
||||
.prepare("SELECT 1 AS found FROM session_entries WHERE session_id = ? AND id = ? LIMIT 1")
|
||||
.get<{ found: number }>(this.metadata.id, id);
|
||||
if (!existing) return id;
|
||||
}
|
||||
return uuidv7();
|
||||
}
|
||||
|
||||
async appendEntry(entry: SessionTreeEntry): Promise<void> {
|
||||
const encoded = encodeEntry(entry);
|
||||
const previousMaterializedState: SessionMaterializedState = {
|
||||
...this.materializedState,
|
||||
labelsById: new Map(this.materializedState.labelsById),
|
||||
modelThinkingConfigs: [...this.materializedState.modelThinkingConfigs],
|
||||
currentModel: this.materializedState.currentModel ? { ...this.materializedState.currentModel } : null,
|
||||
};
|
||||
const previousById = new Map(this.byId);
|
||||
const previousLeafId = this.currentLeafId;
|
||||
const previousActiveBranchId = this.activeBranchId;
|
||||
try {
|
||||
applyEntryToMaterializedState(this.materializedState, entry);
|
||||
await this.db.transaction(async () => {
|
||||
const parentHadExistingChild = await hasExistingChild(this.db, this.metadata.id, entry.parentId);
|
||||
const nextSeq = await getNextSequence(this.db, this.metadata.id);
|
||||
await this.db
|
||||
.prepare(
|
||||
"INSERT INTO session_entries (session_id, id, entry_seq, parent_id, type, timestamp, payload) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(this.metadata.id, entry.id, nextSeq, entry.parentId, entry.type, entry.timestamp, encoded.payload);
|
||||
await advanceSequence(this.db, this.metadata.id, nextSeq);
|
||||
await this.db
|
||||
.prepare("UPDATE session_materialized SET payload = ? WHERE session_id = ?")
|
||||
.run(serializeSummary(this.materializedState), this.metadata.id);
|
||||
for (const materializedEntry of entryMaterializedValues(entry)) {
|
||||
await this.db
|
||||
.prepare("INSERT INTO entry_materialized (session_id, entry_seq, type, payload) VALUES (?, ?, ?, ?)")
|
||||
.run(this.metadata.id, nextSeq, materializedEntry.type, materializedEntry.payload);
|
||||
}
|
||||
this.byId.set(entry.id, entry);
|
||||
this.currentLeafId = leafIdAfterEntry(entry);
|
||||
await this.db
|
||||
.prepare("UPDATE sessions SET active_leaf_id = ? WHERE id = ?")
|
||||
.run(this.currentLeafId, this.metadata.id);
|
||||
if (entry.type === "leaf") {
|
||||
this.activeBranchId = null;
|
||||
await this.materializeBranch(entry.targetId);
|
||||
await this.appendToActiveBranch(entry.id, entry.parentId);
|
||||
} else {
|
||||
if (parentHadExistingChild) {
|
||||
await this.materializeBranch(entry.parentId);
|
||||
}
|
||||
await this.appendToActiveBranch(entry.id, entry.parentId);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
this.materializedState = previousMaterializedState;
|
||||
this.labelsById = previousMaterializedState.labelsById;
|
||||
this.byId = previousById;
|
||||
this.currentLeafId = previousLeafId;
|
||||
this.activeBranchId = previousActiveBranchId;
|
||||
if (error instanceof SessionError) throw error;
|
||||
throw new SessionError("storage", `Failed to append SQLite session entry ${entry.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
|
||||
const cached = this.byId.get(id);
|
||||
if (cached) return cached;
|
||||
const row = await this.db
|
||||
.prepare(
|
||||
"SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? AND id = ?",
|
||||
)
|
||||
.get<SessionEntryRow>(this.metadata.id, id);
|
||||
if (!row) return undefined;
|
||||
try {
|
||||
const entry = decodeEntry(row);
|
||||
this.byId.set(entry.id, entry);
|
||||
return entry;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async findEntries<TType extends SessionTreeEntry["type"]>(
|
||||
type: TType,
|
||||
): Promise<Array<Extract<SessionTreeEntry, { type: TType }>>> {
|
||||
const rows = await this.db
|
||||
.prepare(
|
||||
"SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? AND type = ? ORDER BY entry_seq",
|
||||
)
|
||||
.all<SessionEntryRow>(this.metadata.id, type);
|
||||
const entries: Array<Extract<SessionTreeEntry, { type: TType }>> = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const entry = decodeEntry(row) as Extract<SessionTreeEntry, { type: TType }>;
|
||||
this.byId.set(entry.id, entry);
|
||||
entries.push(entry);
|
||||
} catch {
|
||||
// Keep JSONL-like permissive resume behavior: skip malformed entries.
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async getLabel(id: string): Promise<string | undefined> {
|
||||
return this.labelsById.get(id);
|
||||
}
|
||||
|
||||
async getSessionName(): Promise<string | undefined> {
|
||||
return this.materializedState.name;
|
||||
}
|
||||
|
||||
async getSessionStats() {
|
||||
return sessionStatsFromMaterializedState(this.materializedState);
|
||||
}
|
||||
|
||||
async getPathToRootOrCompaction(leafId: string | null): Promise<SessionTreeEntry[]> {
|
||||
if (leafId === null) return [];
|
||||
if (leafId === this.currentLeafId) {
|
||||
if (!this.activeBranchId) {
|
||||
throw invalidSession(`missing active branch for session ${this.metadata.id} leaf ${leafId}`);
|
||||
}
|
||||
return getMaterializedBranchPathOrCompaction(this.db, this.metadata.id, this.activeBranchId, this.byId);
|
||||
}
|
||||
return this.getPathToRootOrCompactionEntries(leafId);
|
||||
}
|
||||
|
||||
async getEntries(options?: SessionEntryCursorOptions): Promise<SessionTreeEntry[]> {
|
||||
const limit = options?.limit;
|
||||
if (limit !== undefined) {
|
||||
const beforeOrAtEntrySeq =
|
||||
options?.afterEntrySeq ??
|
||||
(
|
||||
await this.db
|
||||
.prepare("SELECT entry_seq FROM session_entries WHERE session_id = ? ORDER BY entry_seq DESC LIMIT 1")
|
||||
.get<{ entry_seq: number }>(this.metadata.id)
|
||||
)?.entry_seq;
|
||||
if (beforeOrAtEntrySeq === undefined) {
|
||||
return [];
|
||||
}
|
||||
const rows = await this.db
|
||||
.prepare(
|
||||
"SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? AND entry_seq <= ? ORDER BY entry_seq DESC LIMIT ?",
|
||||
)
|
||||
.all<SessionEntryRow>(this.metadata.id, beforeOrAtEntrySeq, limit);
|
||||
const entries = (await decodeEntryRows(rows)).entries;
|
||||
for (const entry of entries) {
|
||||
this.byId.set(entry.id, entry);
|
||||
}
|
||||
return entries.reverse();
|
||||
}
|
||||
const rows = await this.db
|
||||
.prepare(
|
||||
"SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? ORDER BY entry_seq",
|
||||
)
|
||||
.all<SessionEntryRow>(this.metadata.id);
|
||||
const entries = (await decodeEntryRows(rows)).entries;
|
||||
for (const entry of entries) {
|
||||
this.byId.set(entry.id, entry);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
await this.db.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { SessionTreeEntry, SessionTreeEntryBase } from "@earendil-works/pi-agent-core";
|
||||
import { invalidEntry, isRecord } from "./shared.ts";
|
||||
|
||||
export interface SessionEntryRow {
|
||||
session_id: string;
|
||||
id: string;
|
||||
entry_seq: number;
|
||||
parent_id: string | null;
|
||||
type: SessionTreeEntry["type"];
|
||||
timestamp: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export type EncodedEntry = {
|
||||
payload: string;
|
||||
};
|
||||
|
||||
type EntryPayload<TEntry extends SessionTreeEntry> = Omit<TEntry, keyof SessionTreeEntryBase | "type">;
|
||||
|
||||
type MessagePayload = EntryPayload<Extract<SessionTreeEntry, { type: "message" }>>;
|
||||
type ThinkingLevelChangePayload = EntryPayload<Extract<SessionTreeEntry, { type: "thinking_level_change" }>>;
|
||||
type ModelChangePayload = EntryPayload<Extract<SessionTreeEntry, { type: "model_change" }>>;
|
||||
type ActiveToolsChangePayload = EntryPayload<Extract<SessionTreeEntry, { type: "active_tools_change" }>>;
|
||||
type CompactionPayload = EntryPayload<Extract<SessionTreeEntry, { type: "compaction" }>>;
|
||||
type BranchSummaryPayload = EntryPayload<Extract<SessionTreeEntry, { type: "branch_summary" }>>;
|
||||
type CustomPayload = EntryPayload<Extract<SessionTreeEntry, { type: "custom" }>>;
|
||||
type CustomMessagePayload = EntryPayload<Extract<SessionTreeEntry, { type: "custom_message" }>>;
|
||||
type LabelPayload = EntryPayload<Extract<SessionTreeEntry, { type: "label" }>>;
|
||||
type SessionInfoPayload = EntryPayload<Extract<SessionTreeEntry, { type: "session_info" }>>;
|
||||
type LeafPayload = EntryPayload<Extract<SessionTreeEntry, { type: "leaf" }>>;
|
||||
|
||||
function parsePayload(row: SessionEntryRow): unknown {
|
||||
try {
|
||||
return JSON.parse(row.payload);
|
||||
} catch (error) {
|
||||
throw invalidEntry(`entry ${row.id} payload is not valid JSON`, error instanceof Error ? error : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function isTextImageContentArray(value: unknown): boolean {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(item) =>
|
||||
isRecord(item) && typeof item.type === "string" && (item.type !== "text" || typeof item.text === "string"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function validateSessionTreeEntry(entry: SessionTreeEntry): void {
|
||||
if (typeof entry.id !== "string" || !entry.id) throw invalidEntry("entry is missing id");
|
||||
if (entry.parentId !== null && typeof entry.parentId !== "string") {
|
||||
throw invalidEntry(`entry ${entry.id} has invalid parentId`);
|
||||
}
|
||||
if (typeof entry.timestamp !== "string" || !entry.timestamp) {
|
||||
throw invalidEntry(`entry ${entry.id} is missing timestamp`);
|
||||
}
|
||||
|
||||
switch (entry.type) {
|
||||
case "message":
|
||||
if (!isRecord(entry.message) || typeof entry.message.role !== "string") {
|
||||
throw invalidEntry(`entry ${entry.id} is missing message payload`);
|
||||
}
|
||||
break;
|
||||
case "thinking_level_change":
|
||||
if (typeof entry.thinkingLevel !== "string") throw invalidEntry(`entry ${entry.id} is missing thinkingLevel`);
|
||||
break;
|
||||
case "model_change":
|
||||
if (typeof entry.provider !== "string" || typeof entry.modelId !== "string") {
|
||||
throw invalidEntry(`entry ${entry.id} has invalid model_change payload`);
|
||||
}
|
||||
break;
|
||||
case "active_tools_change":
|
||||
if (
|
||||
!Array.isArray(entry.activeToolNames) ||
|
||||
entry.activeToolNames.some((value) => typeof value !== "string")
|
||||
) {
|
||||
throw invalidEntry(`entry ${entry.id} has invalid active_tools_change payload`);
|
||||
}
|
||||
break;
|
||||
case "compaction":
|
||||
if (
|
||||
typeof entry.summary !== "string" ||
|
||||
typeof entry.firstKeptEntryId !== "string" ||
|
||||
typeof entry.tokensBefore !== "number" ||
|
||||
(entry.retainedTail !== undefined && !Array.isArray(entry.retainedTail))
|
||||
) {
|
||||
throw invalidEntry(`entry ${entry.id} has invalid compaction payload`);
|
||||
}
|
||||
break;
|
||||
case "branch_summary":
|
||||
if (typeof entry.fromId !== "string" || typeof entry.summary !== "string") {
|
||||
throw invalidEntry(`entry ${entry.id} has invalid branch_summary payload`);
|
||||
}
|
||||
break;
|
||||
case "custom":
|
||||
if (typeof entry.customType !== "string") throw invalidEntry(`entry ${entry.id} has invalid custom payload`);
|
||||
break;
|
||||
case "custom_message":
|
||||
if (
|
||||
typeof entry.customType !== "string" ||
|
||||
typeof entry.display !== "boolean" ||
|
||||
!(typeof entry.content === "string" || isTextImageContentArray(entry.content))
|
||||
) {
|
||||
throw invalidEntry(`entry ${entry.id} has invalid custom_message payload`);
|
||||
}
|
||||
break;
|
||||
case "label":
|
||||
if (typeof entry.targetId !== "string" || (entry.label !== undefined && typeof entry.label !== "string")) {
|
||||
throw invalidEntry(`entry ${entry.id} has invalid label payload`);
|
||||
}
|
||||
break;
|
||||
case "session_info":
|
||||
if (entry.name !== undefined && typeof entry.name !== "string") {
|
||||
throw invalidEntry(`entry ${entry.id} has invalid session_info payload`);
|
||||
}
|
||||
break;
|
||||
case "leaf":
|
||||
if (entry.targetId !== null && typeof entry.targetId !== "string") {
|
||||
throw invalidEntry(`entry ${entry.id} has invalid leaf payload`);
|
||||
}
|
||||
break;
|
||||
default: {
|
||||
const exhaustive: never = entry;
|
||||
throw invalidEntry(`unknown entry type ${(exhaustive as { type?: string }).type ?? "unknown"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function entryToPayload<TEntry extends SessionTreeEntry>(entry: TEntry): EntryPayload<TEntry> {
|
||||
const { type: _type, id: _id, parentId: _parentId, timestamp: _timestamp, ...payload } = entry;
|
||||
return payload as EntryPayload<TEntry>;
|
||||
}
|
||||
|
||||
export function encodeEntry(entry: SessionTreeEntry): EncodedEntry {
|
||||
validateSessionTreeEntry(entry);
|
||||
return { payload: JSON.stringify(entryToPayload(entry)) };
|
||||
}
|
||||
|
||||
export function decodeEntry(row: SessionEntryRow): SessionTreeEntry {
|
||||
const payload = parsePayload(row);
|
||||
if (!isRecord(payload)) throw invalidEntry(`entry ${row.id} payload is not an object`);
|
||||
const base = {
|
||||
id: row.id,
|
||||
parentId: row.parent_id,
|
||||
timestamp: row.timestamp,
|
||||
};
|
||||
|
||||
switch (row.type) {
|
||||
case "message": {
|
||||
if (!("message" in payload)) throw invalidEntry(`entry ${row.id} is missing message payload`);
|
||||
const messagePayload = payload as MessagePayload;
|
||||
return { ...base, type: "message", ...messagePayload };
|
||||
}
|
||||
case "thinking_level_change":
|
||||
if (typeof payload.thinkingLevel !== "string") throw invalidEntry(`entry ${row.id} is missing thinkingLevel`);
|
||||
return { ...base, type: "thinking_level_change", ...(payload as ThinkingLevelChangePayload) };
|
||||
case "model_change":
|
||||
if (typeof payload.provider !== "string" || typeof payload.modelId !== "string") {
|
||||
throw invalidEntry(`entry ${row.id} has invalid model_change payload`);
|
||||
}
|
||||
return { ...base, type: "model_change", ...(payload as ModelChangePayload) };
|
||||
case "active_tools_change":
|
||||
if (
|
||||
!Array.isArray(payload.activeToolNames) ||
|
||||
payload.activeToolNames.some((value) => typeof value !== "string")
|
||||
) {
|
||||
throw invalidEntry(`entry ${row.id} has invalid active_tools_change payload`);
|
||||
}
|
||||
return { ...base, type: "active_tools_change", ...(payload as ActiveToolsChangePayload) };
|
||||
case "compaction":
|
||||
if (
|
||||
typeof payload.summary !== "string" ||
|
||||
typeof payload.firstKeptEntryId !== "string" ||
|
||||
typeof payload.tokensBefore !== "number" ||
|
||||
(payload.retainedTail !== undefined && !Array.isArray(payload.retainedTail))
|
||||
) {
|
||||
throw invalidEntry(`entry ${row.id} has invalid compaction payload`);
|
||||
}
|
||||
return { ...base, type: "compaction", ...(payload as CompactionPayload) };
|
||||
case "branch_summary":
|
||||
if (typeof payload.fromId !== "string" || typeof payload.summary !== "string") {
|
||||
throw invalidEntry(`entry ${row.id} has invalid branch_summary payload`);
|
||||
}
|
||||
return { ...base, type: "branch_summary", ...(payload as BranchSummaryPayload) };
|
||||
case "custom":
|
||||
if (typeof payload.customType !== "string") throw invalidEntry(`entry ${row.id} has invalid custom payload`);
|
||||
return { ...base, type: "custom", ...(payload as CustomPayload) };
|
||||
case "custom_message":
|
||||
if (
|
||||
typeof payload.customType !== "string" ||
|
||||
typeof payload.display !== "boolean" ||
|
||||
!("content" in payload)
|
||||
) {
|
||||
throw invalidEntry(`entry ${row.id} has invalid custom_message payload`);
|
||||
}
|
||||
return { ...base, type: "custom_message", ...(payload as CustomMessagePayload) };
|
||||
case "label":
|
||||
if (typeof payload.targetId !== "string") throw invalidEntry(`entry ${row.id} has invalid label payload`);
|
||||
if (payload.label !== undefined && typeof payload.label !== "string") {
|
||||
throw invalidEntry(`entry ${row.id} has invalid label payload`);
|
||||
}
|
||||
return { ...base, type: "label", ...(payload as LabelPayload) };
|
||||
case "session_info":
|
||||
if (payload.name !== undefined && typeof payload.name !== "string") {
|
||||
throw invalidEntry(`entry ${row.id} has invalid session_info payload`);
|
||||
}
|
||||
return { ...base, type: "session_info", ...(payload as SessionInfoPayload) };
|
||||
case "leaf":
|
||||
if (payload.targetId !== null && typeof payload.targetId !== "string") {
|
||||
throw invalidEntry(`entry ${row.id} has invalid leaf payload`);
|
||||
}
|
||||
return { ...base, type: "leaf", ...(payload as LeafPayload) };
|
||||
default:
|
||||
throw invalidEntry(`unknown entry type ${row.type}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import type { SessionStats, SessionTreeEntry, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { invalidSession, isRecord } from "./shared.ts";
|
||||
|
||||
export interface SessionMaterializedRow {
|
||||
session_id: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface EntryMaterializedRow {
|
||||
session_id: string;
|
||||
entry_seq: number;
|
||||
type: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface ModelThinkingConfig {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
}
|
||||
|
||||
export interface SessionMaterializedState {
|
||||
name: string | undefined;
|
||||
messageCount: number;
|
||||
cachedTokens: number;
|
||||
uncachedTokens: number;
|
||||
totalTokens: number;
|
||||
costTotal: number;
|
||||
labelsById: Map<string, string>;
|
||||
modelThinkingConfigs: ModelThinkingConfig[];
|
||||
currentModel: { provider: string; modelId: string } | null;
|
||||
currentThinkingLevel: ThinkingLevel | null;
|
||||
}
|
||||
|
||||
interface SessionMaterializedSummary {
|
||||
name?: string;
|
||||
messageCount: number;
|
||||
cachedTokens: number;
|
||||
uncachedTokens: number;
|
||||
totalTokens: number;
|
||||
costTotal: number;
|
||||
currentModel?: { provider: string; modelId: string } | null;
|
||||
currentThinkingLevel?: ThinkingLevel | null;
|
||||
}
|
||||
|
||||
function compareModelThinkingConfig(left: ModelThinkingConfig, right: ModelThinkingConfig): number {
|
||||
return (
|
||||
left.provider.localeCompare(right.provider) ||
|
||||
left.modelId.localeCompare(right.modelId) ||
|
||||
left.thinkingLevel.localeCompare(right.thinkingLevel)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeModelThinkingConfigs(configs: readonly ModelThinkingConfig[]): ModelThinkingConfig[] {
|
||||
const unique = new Map<string, ModelThinkingConfig>();
|
||||
for (const config of configs) {
|
||||
unique.set(`${config.provider}\u0000${config.modelId}\u0000${config.thinkingLevel}`, config);
|
||||
}
|
||||
return [...unique.values()].sort(compareModelThinkingConfig);
|
||||
}
|
||||
|
||||
function addModelThinkingConfig(
|
||||
state: SessionMaterializedState,
|
||||
provider: string,
|
||||
modelId: string,
|
||||
thinkingLevel: ThinkingLevel,
|
||||
): void {
|
||||
state.modelThinkingConfigs = normalizeModelThinkingConfigs([
|
||||
...state.modelThinkingConfigs,
|
||||
{ provider, modelId, thinkingLevel },
|
||||
]);
|
||||
}
|
||||
|
||||
export function isThinkingLevel(value: unknown): value is ThinkingLevel {
|
||||
return (
|
||||
value === "off" ||
|
||||
value === "minimal" ||
|
||||
value === "low" ||
|
||||
value === "medium" ||
|
||||
value === "high" ||
|
||||
value === "xhigh"
|
||||
);
|
||||
}
|
||||
|
||||
function getAssistantUsage(message: unknown):
|
||||
| {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
costTotal: number;
|
||||
}
|
||||
| undefined {
|
||||
if (!isRecord(message) || message.role !== "assistant") return undefined;
|
||||
if (typeof message.provider !== "string" || typeof message.model !== "string") return undefined;
|
||||
if (!isRecord(message.usage) || !isRecord(message.usage.cost)) return undefined;
|
||||
const { input, output, cacheRead, cacheWrite } = message.usage;
|
||||
const costTotal = message.usage.cost.total;
|
||||
if (
|
||||
typeof input !== "number" ||
|
||||
typeof output !== "number" ||
|
||||
typeof cacheRead !== "number" ||
|
||||
typeof cacheWrite !== "number" ||
|
||||
typeof costTotal !== "number"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
provider: message.provider,
|
||||
modelId: message.model,
|
||||
input,
|
||||
output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
costTotal,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyMaterializedState(): SessionMaterializedState {
|
||||
return {
|
||||
name: undefined,
|
||||
messageCount: 0,
|
||||
cachedTokens: 0,
|
||||
uncachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
costTotal: 0,
|
||||
labelsById: new Map<string, string>(),
|
||||
modelThinkingConfigs: [],
|
||||
currentModel: null,
|
||||
currentThinkingLevel: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyEntryToMaterializedState(state: SessionMaterializedState, entry: SessionTreeEntry): void {
|
||||
switch (entry.type) {
|
||||
case "session_info":
|
||||
state.name = entry.name?.trim() || undefined;
|
||||
break;
|
||||
case "label": {
|
||||
const label = entry.label?.trim();
|
||||
if (label) {
|
||||
state.labelsById.set(entry.targetId, label);
|
||||
} else {
|
||||
state.labelsById.delete(entry.targetId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "model_change":
|
||||
state.currentModel = { provider: entry.provider, modelId: entry.modelId };
|
||||
if (state.currentThinkingLevel) {
|
||||
addModelThinkingConfig(state, entry.provider, entry.modelId, state.currentThinkingLevel);
|
||||
}
|
||||
break;
|
||||
case "thinking_level_change":
|
||||
if (!isThinkingLevel(entry.thinkingLevel)) break;
|
||||
state.currentThinkingLevel = entry.thinkingLevel;
|
||||
if (state.currentModel) {
|
||||
addModelThinkingConfig(state, state.currentModel.provider, state.currentModel.modelId, entry.thinkingLevel);
|
||||
}
|
||||
break;
|
||||
case "message": {
|
||||
state.messageCount += 1;
|
||||
const usage = getAssistantUsage(entry.message);
|
||||
if (!usage) break;
|
||||
state.cachedTokens += usage.cacheRead;
|
||||
state.uncachedTokens += usage.input + usage.cacheWrite;
|
||||
state.totalTokens += usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
|
||||
state.costTotal += usage.costTotal;
|
||||
state.currentModel = { provider: usage.provider, modelId: usage.modelId };
|
||||
if (state.currentThinkingLevel) {
|
||||
addModelThinkingConfig(state, usage.provider, usage.modelId, state.currentThinkingLevel);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "compaction":
|
||||
case "branch_summary": {
|
||||
const usage = entry.usage;
|
||||
if (
|
||||
!isRecord(usage) ||
|
||||
!isRecord(usage.cost) ||
|
||||
typeof usage.input !== "number" ||
|
||||
typeof usage.output !== "number" ||
|
||||
typeof usage.cacheRead !== "number" ||
|
||||
typeof usage.cacheWrite !== "number" ||
|
||||
typeof usage.cost.total !== "number"
|
||||
) {
|
||||
break;
|
||||
}
|
||||
state.cachedTokens += usage.cacheRead;
|
||||
state.uncachedTokens += usage.input + usage.cacheWrite;
|
||||
state.totalTokens += usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
|
||||
state.costTotal += usage.cost.total;
|
||||
break;
|
||||
}
|
||||
case "active_tools_change":
|
||||
case "custom":
|
||||
case "custom_message":
|
||||
case "leaf":
|
||||
break;
|
||||
default: {
|
||||
const exhaustive: never = entry;
|
||||
void exhaustive;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeSummary(state: SessionMaterializedState): string {
|
||||
const summary: SessionMaterializedSummary = {
|
||||
name: state.name,
|
||||
messageCount: state.messageCount,
|
||||
cachedTokens: state.cachedTokens,
|
||||
uncachedTokens: state.uncachedTokens,
|
||||
totalTokens: state.totalTokens,
|
||||
costTotal: state.costTotal,
|
||||
currentModel: state.currentModel,
|
||||
currentThinkingLevel: state.currentThinkingLevel,
|
||||
};
|
||||
return JSON.stringify(summary);
|
||||
}
|
||||
|
||||
function parseSummary(json: string): SessionMaterializedSummary {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch (error) {
|
||||
throw invalidSession(
|
||||
`materialized session summary is not valid JSON`,
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
}
|
||||
if (!isRecord(parsed) || Array.isArray(parsed)) {
|
||||
throw invalidSession("materialized session summary is not an object");
|
||||
}
|
||||
const currentModel = parsed.currentModel;
|
||||
const currentThinkingLevel = parsed.currentThinkingLevel;
|
||||
if (
|
||||
(parsed.name !== undefined && typeof parsed.name !== "string") ||
|
||||
typeof parsed.messageCount !== "number" ||
|
||||
typeof parsed.cachedTokens !== "number" ||
|
||||
typeof parsed.uncachedTokens !== "number" ||
|
||||
typeof parsed.totalTokens !== "number" ||
|
||||
typeof parsed.costTotal !== "number" ||
|
||||
(currentModel !== undefined &&
|
||||
currentModel !== null &&
|
||||
(!isRecord(currentModel) ||
|
||||
typeof currentModel.provider !== "string" ||
|
||||
typeof currentModel.modelId !== "string")) ||
|
||||
(currentThinkingLevel !== undefined && currentThinkingLevel !== null && !isThinkingLevel(currentThinkingLevel))
|
||||
) {
|
||||
throw invalidSession("materialized session summary has invalid fields");
|
||||
}
|
||||
return {
|
||||
name: parsed.name?.trim() || undefined,
|
||||
messageCount: parsed.messageCount,
|
||||
cachedTokens: parsed.cachedTokens,
|
||||
uncachedTokens: parsed.uncachedTokens,
|
||||
totalTokens: parsed.totalTokens,
|
||||
costTotal: parsed.costTotal,
|
||||
currentModel:
|
||||
currentModel && isRecord(currentModel)
|
||||
? { provider: currentModel.provider as string, modelId: currentModel.modelId as string }
|
||||
: (currentModel ?? undefined),
|
||||
currentThinkingLevel: (currentThinkingLevel as ThinkingLevel | null | undefined) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseEntryMaterializedPayload(row: EntryMaterializedRow): unknown {
|
||||
try {
|
||||
return JSON.parse(row.payload);
|
||||
} catch (error) {
|
||||
throw invalidSession(
|
||||
`materialized entry row ${row.entry_seq} is not valid JSON`,
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function materializedStateFromRows(
|
||||
summaryRow: SessionMaterializedRow,
|
||||
entryRows: EntryMaterializedRow[],
|
||||
): SessionMaterializedState {
|
||||
const summary = parseSummary(summaryRow.payload);
|
||||
const state: SessionMaterializedState = {
|
||||
name: summary.name,
|
||||
messageCount: summary.messageCount,
|
||||
cachedTokens: summary.cachedTokens,
|
||||
uncachedTokens: summary.uncachedTokens,
|
||||
totalTokens: summary.totalTokens,
|
||||
costTotal: summary.costTotal,
|
||||
labelsById: new Map<string, string>(),
|
||||
modelThinkingConfigs: [],
|
||||
currentModel: summary.currentModel ?? null,
|
||||
currentThinkingLevel: summary.currentThinkingLevel ?? null,
|
||||
};
|
||||
for (const row of entryRows) {
|
||||
const payload = parseEntryMaterializedPayload(row);
|
||||
if (!isRecord(payload)) throw invalidSession(`materialized entry row ${row.entry_seq} is not an object`);
|
||||
if (row.type === "label") {
|
||||
if (typeof payload.targetId !== "string") {
|
||||
throw invalidSession(`materialized label row ${row.entry_seq} is missing targetId`);
|
||||
}
|
||||
if (payload.label !== null && payload.label !== undefined && typeof payload.label !== "string") {
|
||||
throw invalidSession(`materialized label row ${row.entry_seq} has invalid label`);
|
||||
}
|
||||
const label = typeof payload.label === "string" ? payload.label.trim() : "";
|
||||
if (label) {
|
||||
state.labelsById.set(payload.targetId, label);
|
||||
} else {
|
||||
state.labelsById.delete(payload.targetId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (row.type !== "label") {
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function sessionStatsFromMaterializedState(state: SessionMaterializedState): SessionStats {
|
||||
return {
|
||||
messageCount: state.messageCount,
|
||||
cachedTokens: state.cachedTokens,
|
||||
uncachedTokens: state.uncachedTokens,
|
||||
totalTokens: state.totalTokens,
|
||||
costTotal: state.costTotal,
|
||||
};
|
||||
}
|
||||
|
||||
export function materializedStateValues(
|
||||
sessionId: string,
|
||||
state: SessionMaterializedState,
|
||||
): [sessionId: string, payload: string] {
|
||||
return [sessionId, serializeSummary(state)];
|
||||
}
|
||||
|
||||
export function entryMaterializedValues(
|
||||
entry: SessionTreeEntry,
|
||||
): Array<{ type: EntryMaterializedRow["type"]; payload: string }> {
|
||||
switch (entry.type) {
|
||||
case "label":
|
||||
return [
|
||||
{
|
||||
type: "label",
|
||||
payload: JSON.stringify({ targetId: entry.targetId, label: entry.label ?? null }),
|
||||
},
|
||||
];
|
||||
case "model_change":
|
||||
case "thinking_level_change":
|
||||
case "message":
|
||||
return [];
|
||||
case "active_tools_change":
|
||||
case "branch_summary":
|
||||
case "compaction":
|
||||
case "custom":
|
||||
case "custom_message":
|
||||
case "leaf":
|
||||
case "session_info":
|
||||
return [];
|
||||
default: {
|
||||
const exhaustive: never = entry;
|
||||
void exhaustive;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { SqliteDatabase } from "../types.ts";
|
||||
import { invalidSession } from "./shared.ts";
|
||||
|
||||
export async function getNextSequence(db: SqliteDatabase, sessionId: string): Promise<number> {
|
||||
const sequenceRow = await db
|
||||
.prepare("SELECT next_seq FROM session_sequences WHERE session_id = ?")
|
||||
.get<{ next_seq: number }>(sessionId);
|
||||
if (!sequenceRow) {
|
||||
throw invalidSession(`missing sequence row for session ${sessionId}`);
|
||||
}
|
||||
return sequenceRow.next_seq;
|
||||
}
|
||||
|
||||
export async function advanceSequence(db: SqliteDatabase, sessionId: string, nextSeq: number): Promise<void> {
|
||||
await db.prepare("UPDATE session_sequences SET next_seq = ? WHERE session_id = ?").run(nextSeq + 1, sessionId);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { SessionError } from "@earendil-works/pi-agent-core";
|
||||
import type { SqliteSessionMetadata } from "../types.ts";
|
||||
|
||||
export interface SessionRow {
|
||||
id: string;
|
||||
created_at: string;
|
||||
metadata: string | null;
|
||||
cwd: string;
|
||||
parent_session_id: string | null;
|
||||
active_leaf_id: string | null;
|
||||
}
|
||||
|
||||
function parseMetadata(metadata: string | null, sessionId: string): Record<string, unknown> | undefined {
|
||||
if (metadata === null) return undefined;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(metadata);
|
||||
} catch (error) {
|
||||
throw new SessionError(
|
||||
"invalid_session",
|
||||
`Invalid SQLite session ${sessionId}: metadata is not valid JSON`,
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new SessionError("invalid_session", `Invalid SQLite session ${sessionId}: metadata must be an object`);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function rowToMetadata(row: SessionRow, path: string): SqliteSessionMetadata {
|
||||
return {
|
||||
id: row.id,
|
||||
createdAt: row.created_at,
|
||||
cwd: row.cwd,
|
||||
path,
|
||||
parentSessionId: row.parent_session_id ?? undefined,
|
||||
metadata: parseMetadata(row.metadata, row.id),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { SessionTreeEntry } from "@earendil-works/pi-agent-core";
|
||||
import { SessionError } from "@earendil-works/pi-agent-core";
|
||||
import { uuidv7 } from "@earendil-works/pi-ai";
|
||||
|
||||
export function generateEntryId(byId: { has(id: string): boolean }): string {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
// The uuidv7 prefix is timestamp-derived and nearly constant between calls,
|
||||
// so short ids must come from the random tail.
|
||||
const id = uuidv7().slice(0, 8);
|
||||
if (!byId.has(id)) return id;
|
||||
}
|
||||
return uuidv7();
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export function invalidSession(message: string, cause?: Error): SessionError {
|
||||
return new SessionError("invalid_session", `Invalid SQLite session: ${message}`, cause);
|
||||
}
|
||||
|
||||
export function invalidEntry(message: string, cause?: Error): SessionError {
|
||||
return new SessionError("invalid_entry", `Invalid SQLite session entry: ${message}`, cause);
|
||||
}
|
||||
|
||||
export function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
|
||||
return entry.type === "leaf" ? entry.targetId : entry.id;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { FileSystem, SessionCreateOptions, SessionMetadata, SessionRepo } from "@earendil-works/pi-agent-core";
|
||||
|
||||
/** Result of a prepared SQLite statement execution. */
|
||||
export interface SqliteRunResult {
|
||||
/** Number of rows changed by the statement. */
|
||||
changes: number;
|
||||
/** Inserted row id when the backend exposes one. */
|
||||
lastInsertRowid?: number;
|
||||
}
|
||||
|
||||
/** Prepared SQLite statement capability used by the SQLite session backend. */
|
||||
export interface SqliteStatement {
|
||||
run(...params: unknown[]): Promise<SqliteRunResult>;
|
||||
get<TRow extends object>(...params: unknown[]): Promise<TRow | undefined>;
|
||||
all<TRow extends object>(...params: unknown[]): Promise<TRow[]>;
|
||||
}
|
||||
|
||||
/** SQLite database capability used by the SQLite session backend. */
|
||||
export interface SqliteDatabase {
|
||||
exec(sql: string): Promise<void>;
|
||||
prepare(sql: string): SqliteStatement;
|
||||
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SqliteDatabaseFactory {
|
||||
open(path: string): Promise<SqliteDatabase>;
|
||||
}
|
||||
|
||||
export interface SqliteSessionMetadata extends SessionMetadata {
|
||||
cwd: string;
|
||||
path: string;
|
||||
parentSessionId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SqliteSessionCreateOptions extends SessionCreateOptions {
|
||||
cwd: string;
|
||||
parentSessionId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SqliteSessionListOptions {
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface SqliteSessionBackendOptions {
|
||||
kind: "sqlite";
|
||||
databasePath: string;
|
||||
}
|
||||
|
||||
export interface SqliteSessionRepoApi
|
||||
extends SessionRepo<SqliteSessionMetadata, SqliteSessionCreateOptions, SqliteSessionListOptions> {}
|
||||
|
||||
export type SqliteSessionRepoEnv = Pick<FileSystem, "absolutePath" | "createDir" | "exists">;
|
||||
Reference in New Issue
Block a user