chore(vendor): import pi 0.85.1
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
324
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/branch-entries.ts
vendored
Normal file
324
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/branch-entries.ts
vendored
Normal file
@@ -0,0 +1,324 @@
|
||||
import type { Entry, EntryStructure, StorageBranchScan } from "@earendil-works/pi-agent-core";
|
||||
import { joinSqlFragments, type SqlQuery, sql } from "../sql.ts";
|
||||
import type { SqliteDatabase } from "../types.ts";
|
||||
import { decodeEntryRow, type EntryRow } from "./entries.ts";
|
||||
|
||||
interface BranchMembershipRow {
|
||||
branch_id: string;
|
||||
entry_seq: number;
|
||||
}
|
||||
|
||||
interface BranchMetaRow {
|
||||
branch_id: string;
|
||||
tip_entry_id: string;
|
||||
tip_seq: number;
|
||||
base_branch_id: string | null;
|
||||
base_seq: number | null;
|
||||
}
|
||||
|
||||
interface BranchSegment {
|
||||
branchId: string;
|
||||
lowerSeq: number;
|
||||
upperSeq: number;
|
||||
}
|
||||
|
||||
interface StopSeqRow {
|
||||
stop_seq: number | null;
|
||||
}
|
||||
|
||||
interface EntryStructureRow {
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
seq: number;
|
||||
type: Entry["type"];
|
||||
custom_type: string | null;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface BranchTipRow {
|
||||
branch_id: string;
|
||||
}
|
||||
|
||||
interface CompactionBoundary {
|
||||
branchId: string;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
interface CompactionBoundaryRow {
|
||||
entry_seq: number | null;
|
||||
}
|
||||
|
||||
function readBranchMembership(db: SqliteDatabase, sessionId: string, entryId: string): BranchMembershipRow {
|
||||
const row = sql`SELECT b.branch_id, b.entry_seq
|
||||
FROM branch_entries b
|
||||
JOIN branch_meta m ON m.session_id = b.session_id AND m.branch_id = b.branch_id
|
||||
WHERE b.session_id = ${sessionId}
|
||||
AND b.entry_id = ${entryId}
|
||||
AND ((m.base_seq IS NULL AND b.entry_seq > 0) OR (m.base_seq IS NOT NULL AND b.entry_seq > m.base_seq))
|
||||
AND b.entry_seq <= m.tip_seq
|
||||
ORDER BY m.tip_seq DESC, b.branch_id
|
||||
LIMIT 1`.get<BranchMembershipRow>(db);
|
||||
if (row === undefined) throw new Error(`Branch cache missing entry ${entryId}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
function readBranchMeta(db: SqliteDatabase, sessionId: string, branchId: string): BranchMetaRow {
|
||||
const row = sql`SELECT branch_id, tip_entry_id, tip_seq, base_branch_id, base_seq
|
||||
FROM branch_meta
|
||||
WHERE session_id = ${sessionId} AND branch_id = ${branchId}`.get<BranchMetaRow>(db);
|
||||
if (row === undefined) throw new Error(`Branch metadata missing for branch ${branchId}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
function insertBranchEntry(db: SqliteDatabase, sessionId: string, branchId: string, entry: Entry): void {
|
||||
sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type)
|
||||
VALUES (${sessionId}, ${branchId}, ${entry.id}, ${entry.seq}, ${entry.type})`.run(db);
|
||||
}
|
||||
|
||||
function readBranchTipForParent(db: SqliteDatabase, sessionId: string, parentId: string): BranchTipRow | undefined {
|
||||
return sql`SELECT branch_id
|
||||
FROM branch_meta
|
||||
WHERE session_id = ${sessionId} AND tip_entry_id = ${parentId}`.get<BranchTipRow>(db);
|
||||
}
|
||||
|
||||
function createRootBranchForEntry(db: SqliteDatabase, sessionId: string, entry: Entry): void {
|
||||
sql`INSERT INTO branch_meta (session_id, branch_id, tip_entry_id, tip_seq, base_branch_id, base_seq)
|
||||
VALUES (${sessionId}, ${entry.id}, ${entry.id}, ${entry.seq}, ${null}, ${null})`.run(db);
|
||||
insertBranchEntry(db, sessionId, entry.id, entry);
|
||||
}
|
||||
|
||||
function appendEntryToExistingBranch(db: SqliteDatabase, sessionId: string, branchId: string, entry: Entry): void {
|
||||
insertBranchEntry(db, sessionId, branchId, entry);
|
||||
const result = sql`UPDATE branch_meta
|
||||
SET tip_entry_id = ${entry.id}, tip_seq = ${entry.seq}
|
||||
WHERE session_id = ${sessionId} AND branch_id = ${branchId}`.run(db);
|
||||
if (result.changes !== 1) throw new Error(`Expected to update branch ${branchId}, updated ${result.changes}`);
|
||||
}
|
||||
|
||||
function readBranchSegmentsNewestFirst(db: SqliteDatabase, sessionId: string, start: string): BranchSegment[] {
|
||||
let { branch_id: branchId, entry_seq: upperSeq } = readBranchMembership(db, sessionId, start);
|
||||
const segments: BranchSegment[] = [];
|
||||
while (true) {
|
||||
const meta = readBranchMeta(db, sessionId, branchId);
|
||||
const lowerSeq = meta.base_seq ?? 0;
|
||||
segments.push({ branchId, lowerSeq, upperSeq });
|
||||
if (meta.base_branch_id === null) break;
|
||||
if (meta.base_seq === null) throw new Error(`Branch ${branchId} has base branch without base_seq`);
|
||||
branchId = meta.base_branch_id;
|
||||
upperSeq = meta.base_seq;
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
function readNewestCompactionBoundary(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
segmentsNewestFirst: readonly BranchSegment[],
|
||||
): CompactionBoundary | undefined {
|
||||
for (const segment of segmentsNewestFirst) {
|
||||
const row = sql`SELECT MAX(entry_seq) AS entry_seq
|
||||
FROM branch_entries
|
||||
WHERE session_id = ${sessionId}
|
||||
AND branch_id = ${segment.branchId}
|
||||
AND entry_seq > ${segment.lowerSeq}
|
||||
AND entry_seq <= ${segment.upperSeq}
|
||||
AND entry_type = ${"compaction"}`.get<CompactionBoundaryRow>(db);
|
||||
if (row?.entry_seq !== null && row?.entry_seq !== undefined)
|
||||
return { branchId: segment.branchId, seq: row.entry_seq };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function copyBranchEntriesAfterSeqThroughParent(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
targetBranchId: string,
|
||||
segmentsNewestFirst: readonly BranchSegment[],
|
||||
afterSeq: number,
|
||||
): void {
|
||||
for (const segment of [...segmentsNewestFirst].reverse()) {
|
||||
const lowerSeq = Math.max(segment.lowerSeq, afterSeq);
|
||||
if (segment.upperSeq <= lowerSeq) continue;
|
||||
sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type)
|
||||
SELECT ${sessionId}, ${targetBranchId}, entry_id, entry_seq, entry_type
|
||||
FROM branch_entries
|
||||
WHERE session_id = ${sessionId}
|
||||
AND branch_id = ${segment.branchId}
|
||||
AND entry_seq > ${lowerSeq}
|
||||
AND entry_seq <= ${segment.upperSeq}`.run(db);
|
||||
}
|
||||
}
|
||||
|
||||
function createDivergentBranchForEntry(db: SqliteDatabase, sessionId: string, entry: Entry): void {
|
||||
if (entry.parentId === null) throw new Error("Root entries do not create divergent branches");
|
||||
const segmentsNewestFirst = readBranchSegmentsNewestFirst(db, sessionId, entry.parentId);
|
||||
const compaction = readNewestCompactionBoundary(db, sessionId, segmentsNewestFirst);
|
||||
const branchId = entry.id;
|
||||
// A null base means this segment stores its own root-through-parent prefix.
|
||||
sql`INSERT INTO branch_meta (session_id, branch_id, tip_entry_id, tip_seq, base_branch_id, base_seq)
|
||||
VALUES (${sessionId}, ${branchId}, ${entry.id}, ${entry.seq}, ${compaction?.branchId ?? null}, ${compaction?.seq ?? null})`.run(
|
||||
db,
|
||||
);
|
||||
copyBranchEntriesAfterSeqThroughParent(db, sessionId, branchId, segmentsNewestFirst, compaction?.seq ?? 0);
|
||||
insertBranchEntry(db, sessionId, branchId, entry);
|
||||
}
|
||||
|
||||
export function appendEntryToBranchIndex(db: SqliteDatabase, sessionId: string, entry: Entry): void {
|
||||
if (entry.parentId === null) {
|
||||
createRootBranchForEntry(db, sessionId, entry);
|
||||
return;
|
||||
}
|
||||
|
||||
const branch = readBranchTipForParent(db, sessionId, entry.parentId);
|
||||
if (branch === undefined) {
|
||||
createDivergentBranchForEntry(db, sessionId, entry);
|
||||
return;
|
||||
}
|
||||
appendEntryToExistingBranch(db, sessionId, branch.branch_id, entry);
|
||||
}
|
||||
|
||||
function stopPredicates(query: StorageBranchScan): SqlQuery[] {
|
||||
const predicates: SqlQuery[] = [];
|
||||
if (query.stopAtType !== undefined) predicates.push(sql`b.entry_type = ${query.stopAtType}`);
|
||||
if (query.stopAtId !== undefined) predicates.push(sql`b.entry_id = ${query.stopAtId}`);
|
||||
return predicates;
|
||||
}
|
||||
|
||||
function readStopSeq(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
segment: BranchSegment,
|
||||
query: StorageBranchScan,
|
||||
oldestFirst: boolean,
|
||||
): number | undefined {
|
||||
const stop = stopPredicates(query);
|
||||
if (stop.length === 0) return undefined;
|
||||
const aggregate = oldestFirst ? sql`MIN(b.entry_seq)` : sql`MAX(b.entry_seq)`;
|
||||
const row = sql`SELECT ${aggregate} AS stop_seq
|
||||
FROM branch_entries b
|
||||
WHERE b.session_id = ${sessionId}
|
||||
AND b.branch_id = ${segment.branchId}
|
||||
AND b.entry_seq > ${segment.lowerSeq}
|
||||
AND b.entry_seq <= ${segment.upperSeq}
|
||||
AND (${joinSqlFragments(stop, " OR ")})`.get<StopSeqRow>(db);
|
||||
return row?.stop_seq ?? undefined;
|
||||
}
|
||||
|
||||
function branchScanPredicates(
|
||||
sessionId: string,
|
||||
segment: BranchSegment,
|
||||
query: StorageBranchScan,
|
||||
oldestFirst: boolean,
|
||||
stopSeq: number | undefined,
|
||||
): SqlQuery[] {
|
||||
const predicates: SqlQuery[] = [
|
||||
sql`b.session_id = ${sessionId}`,
|
||||
sql`b.branch_id = ${segment.branchId}`,
|
||||
sql`b.entry_seq > ${segment.lowerSeq}`,
|
||||
sql`b.entry_seq <= ${segment.upperSeq}`,
|
||||
sql`e.session_id = b.session_id`,
|
||||
];
|
||||
if (stopSeq !== undefined)
|
||||
predicates.push(oldestFirst ? sql`b.entry_seq <= ${stopSeq}` : sql`b.entry_seq >= ${stopSeq}`);
|
||||
if (query.type !== undefined) predicates.push(sql`b.entry_type = ${query.type}`);
|
||||
if (query.customType !== undefined) predicates.push(sql`e.custom_type = ${query.customType}`);
|
||||
if (query.cursor !== undefined) {
|
||||
predicates.push(oldestFirst ? sql`b.entry_seq > ${query.cursor.seq}` : sql`b.entry_seq < ${query.cursor.seq}`);
|
||||
}
|
||||
return predicates;
|
||||
}
|
||||
|
||||
function limitSql(limit: number | undefined): SqlQuery {
|
||||
return limit === undefined ? sql`` : sql`LIMIT ${Math.max(0, limit)}`;
|
||||
}
|
||||
|
||||
function scanEntrySegmentRows(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
segment: BranchSegment,
|
||||
query: StorageBranchScan,
|
||||
oldestFirst: boolean,
|
||||
stopSeq: number | undefined,
|
||||
limit: number | undefined,
|
||||
): EntryRow[] {
|
||||
const predicates = branchScanPredicates(sessionId, segment, query, oldestFirst, stopSeq);
|
||||
const order = oldestFirst ? sql`ASC` : sql`DESC`;
|
||||
return sql`SELECT e.id, e.parent_id, e.seq, e.type, e.custom_type, e.timestamp, e.payload
|
||||
FROM branch_entries b
|
||||
CROSS JOIN entries e ON e.session_id = b.session_id AND e.id = b.entry_id
|
||||
WHERE ${joinSqlFragments(predicates, " AND ")}
|
||||
ORDER BY b.entry_seq ${order} ${limitSql(limit)}`.all<EntryRow>(db);
|
||||
}
|
||||
|
||||
function decodeEntryStructureRow(row: EntryStructureRow): EntryStructure {
|
||||
return {
|
||||
id: row.id,
|
||||
parentId: row.parent_id,
|
||||
seq: row.seq,
|
||||
timestamp: row.timestamp,
|
||||
type: row.type,
|
||||
...(row.custom_type === null ? {} : { customType: row.custom_type }),
|
||||
};
|
||||
}
|
||||
|
||||
function scanStructureSegmentRows(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
segment: BranchSegment,
|
||||
query: StorageBranchScan,
|
||||
oldestFirst: boolean,
|
||||
stopSeq: number | undefined,
|
||||
limit: number | undefined,
|
||||
): EntryStructure[] {
|
||||
const predicates = branchScanPredicates(sessionId, segment, query, oldestFirst, stopSeq);
|
||||
const order = oldestFirst ? sql`ASC` : sql`DESC`;
|
||||
const rows = sql`SELECT e.id, e.parent_id, e.seq, e.type, e.custom_type, e.timestamp
|
||||
FROM branch_entries b
|
||||
CROSS JOIN entries e ON e.session_id = b.session_id AND e.id = b.entry_id
|
||||
WHERE ${joinSqlFragments(predicates, " AND ")}
|
||||
ORDER BY b.entry_seq ${order} ${limitSql(limit)}`.all<EntryStructureRow>(db);
|
||||
return rows.map(decodeEntryStructureRow);
|
||||
}
|
||||
|
||||
function scanBranchSegments<T>(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
query: StorageBranchScan,
|
||||
readSegment: (
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
segment: BranchSegment,
|
||||
query: StorageBranchScan,
|
||||
oldestFirst: boolean,
|
||||
stopSeq: number | undefined,
|
||||
limit: number | undefined,
|
||||
) => T[],
|
||||
): T[] {
|
||||
const oldestFirst = query.order === "oldestFirst";
|
||||
const segmentsNewestFirst = readBranchSegmentsNewestFirst(db, sessionId, query.start);
|
||||
const segments = oldestFirst ? [...segmentsNewestFirst].reverse() : segmentsNewestFirst;
|
||||
const limit = query.limit === undefined ? undefined : Math.max(0, query.limit);
|
||||
if (limit === 0) return [];
|
||||
|
||||
const rows: T[] = [];
|
||||
for (const segment of segments) {
|
||||
const remaining = limit === undefined ? undefined : limit - rows.length;
|
||||
if (remaining !== undefined && remaining <= 0) break;
|
||||
const stopSeq = readStopSeq(db, sessionId, segment, query, oldestFirst);
|
||||
rows.push(...readSegment(db, sessionId, segment, query, oldestFirst, stopSeq, remaining));
|
||||
if (stopSeq !== undefined) break;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function scanBranchEntries(db: SqliteDatabase, sessionId: string, query: StorageBranchScan): Entry[] {
|
||||
return scanBranchSegments(db, sessionId, query, scanEntrySegmentRows).map(decodeEntryRow);
|
||||
}
|
||||
|
||||
export function scanBranchEntryStructures(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
query: StorageBranchScan,
|
||||
): EntryStructure[] {
|
||||
return scanBranchSegments(db, sessionId, query, scanStructureSegmentRows);
|
||||
}
|
||||
161
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/entries.ts
vendored
Normal file
161
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/entries.ts
vendored
Normal file
@@ -0,0 +1,161 @@
|
||||
import type {
|
||||
BranchSummaryEntry,
|
||||
CompactionEntry,
|
||||
CustomEntry,
|
||||
Entry,
|
||||
EntryScan,
|
||||
EntryStructure,
|
||||
MessageEntry,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { joinSqlFragments, type SqlQuery, sql } from "../sql.ts";
|
||||
import type { SqliteDatabase, SqliteStatement } from "../types.ts";
|
||||
|
||||
export interface EntryRow {
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
seq: number;
|
||||
type: Entry["type"];
|
||||
custom_type: string | null;
|
||||
timestamp: number;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
type StoredEntryPayload<TEntry extends Entry> = Omit<
|
||||
TEntry,
|
||||
"id" | "parentId" | "seq" | "timestamp" | "type" | "customType"
|
||||
>;
|
||||
|
||||
function entryPayload(entry: Entry): StoredEntryPayload<Entry> {
|
||||
switch (entry.type) {
|
||||
case "message": {
|
||||
const payload: StoredEntryPayload<MessageEntry> = {
|
||||
message: entry.message,
|
||||
...(entry.terminate === undefined ? {} : { terminate: entry.terminate }),
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
case "compaction": {
|
||||
const payload: StoredEntryPayload<CompactionEntry> = {
|
||||
summary: entry.summary,
|
||||
retainedTail: entry.retainedTail,
|
||||
tokensBefore: entry.tokensBefore,
|
||||
...(entry.details === undefined ? {} : { details: entry.details }),
|
||||
...(entry.usage === undefined ? {} : { usage: entry.usage }),
|
||||
fromHook: entry.fromHook,
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
case "branch_summary": {
|
||||
const payload: StoredEntryPayload<BranchSummaryEntry> = {
|
||||
fromId: entry.fromId,
|
||||
summary: entry.summary,
|
||||
...(entry.details === undefined ? {} : { details: entry.details }),
|
||||
...(entry.usage === undefined ? {} : { usage: entry.usage }),
|
||||
fromHook: entry.fromHook,
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
case "custom": {
|
||||
const payload: StoredEntryPayload<CustomEntry> = entry.data === undefined ? {} : { data: entry.data };
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parsePayload<TEntry extends Entry>(row: EntryRow): StoredEntryPayload<TEntry> {
|
||||
return JSON.parse(row.payload) as StoredEntryPayload<TEntry>;
|
||||
}
|
||||
|
||||
const INSERT_ENTRY_SQL = `INSERT INTO entries (session_id, id, parent_id, seq, type, custom_type, timestamp, payload)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
function entryRowParams(sessionId: string, entry: Entry): unknown[] {
|
||||
return [
|
||||
sessionId,
|
||||
entry.id,
|
||||
entry.parentId,
|
||||
entry.seq,
|
||||
entry.type,
|
||||
entry.type === "custom" ? entry.customType : null,
|
||||
entry.timestamp,
|
||||
JSON.stringify(entryPayload(entry)),
|
||||
];
|
||||
}
|
||||
|
||||
export class EntryRowWriter {
|
||||
private readonly insertStatement: SqliteStatement;
|
||||
private readonly sessionId: string;
|
||||
|
||||
constructor(db: SqliteDatabase, sessionId: string) {
|
||||
this.insertStatement = db.prepare(INSERT_ENTRY_SQL);
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
insert(entry: Entry): void {
|
||||
this.insertStatement.run(...entryRowParams(this.sessionId, entry));
|
||||
}
|
||||
}
|
||||
|
||||
export function insertEntryRow(db: SqliteDatabase, sessionId: string, entry: Entry): void {
|
||||
db.prepare(INSERT_ENTRY_SQL).run(...entryRowParams(sessionId, entry));
|
||||
}
|
||||
|
||||
export function decodeEntryRow(row: EntryRow): Entry {
|
||||
const base = {
|
||||
id: row.id,
|
||||
parentId: row.parent_id,
|
||||
seq: row.seq,
|
||||
timestamp: row.timestamp,
|
||||
};
|
||||
switch (row.type) {
|
||||
case "message":
|
||||
return { ...base, type: "message", ...parsePayload<MessageEntry>(row) };
|
||||
case "compaction":
|
||||
return { ...base, type: "compaction", ...parsePayload<CompactionEntry>(row) };
|
||||
case "branch_summary":
|
||||
return { ...base, type: "branch_summary", ...parsePayload<BranchSummaryEntry>(row) };
|
||||
case "custom":
|
||||
if (row.custom_type === null) throw new Error(`Custom entry ${row.id} is missing custom_type`);
|
||||
return { ...base, type: "custom", customType: row.custom_type, ...parsePayload<CustomEntry>(row) };
|
||||
}
|
||||
}
|
||||
|
||||
export function entryStructureFromRow(row: EntryRow): EntryStructure {
|
||||
return {
|
||||
id: row.id,
|
||||
parentId: row.parent_id,
|
||||
seq: row.seq,
|
||||
timestamp: row.timestamp,
|
||||
type: row.type,
|
||||
...(row.custom_type === null ? {} : { customType: row.custom_type }),
|
||||
};
|
||||
}
|
||||
|
||||
export function readEntryRows(db: SqliteDatabase, sessionId: string, ids: readonly string[]): EntryRow[] {
|
||||
if (ids.length === 0) return [];
|
||||
const placeholders = joinSqlFragments(
|
||||
ids.map((id) => sql`${id}`),
|
||||
", ",
|
||||
);
|
||||
return sql`SELECT id, parent_id, seq, type, custom_type, timestamp, payload
|
||||
FROM entries
|
||||
WHERE session_id = ${sessionId} AND id IN (${placeholders})`.all<EntryRow>(db);
|
||||
}
|
||||
|
||||
export function readAllEntryRows(db: SqliteDatabase, sessionId: string): EntryRow[] {
|
||||
return sql`SELECT id, parent_id, seq, type, custom_type, timestamp, payload
|
||||
FROM entries WHERE session_id = ${sessionId} ORDER BY seq ASC`.all<EntryRow>(db);
|
||||
}
|
||||
|
||||
export function scanEntryRows(db: SqliteDatabase, sessionId: string, query: EntryScan): EntryRow[] {
|
||||
const filters: SqlQuery[] = [sql`session_id = ${sessionId}`];
|
||||
if (query.type !== undefined) filters.push(sql`type = ${query.type}`);
|
||||
if (query.customType !== undefined) filters.push(sql`custom_type = ${query.customType}`);
|
||||
if (query.fromSeq !== undefined) filters.push(sql`seq >= ${query.fromSeq}`);
|
||||
if (query.toSeq !== undefined) filters.push(sql`seq <= ${query.toSeq}`);
|
||||
|
||||
const order = query.order === "desc" ? sql`ORDER BY seq DESC` : sql`ORDER BY seq ASC`;
|
||||
const limit = query.limit === undefined ? sql`` : sql`LIMIT ${Math.max(0, query.limit)}`;
|
||||
return sql`SELECT id, parent_id, seq, type, custom_type, timestamp, payload
|
||||
FROM entries WHERE ${joinSqlFragments(filters, " AND ")} ${order} ${limit}`.all<EntryRow>(db);
|
||||
}
|
||||
102
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/session-row.ts
vendored
Normal file
102
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/session-row.ts
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { SessionMetadata } from "@earendil-works/pi-agent-core";
|
||||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
import { sql } from "../sql.ts";
|
||||
import type { SqliteDatabase } from "../types.ts";
|
||||
|
||||
export interface SessionRow {
|
||||
id: string;
|
||||
created_at: number;
|
||||
parent_session_id: string | null;
|
||||
storage_version: number;
|
||||
metadata: string | null;
|
||||
message_count: number;
|
||||
usage_payload: string;
|
||||
next_seq: number;
|
||||
}
|
||||
|
||||
export interface SqliteSessionMetadata extends SessionMetadata {
|
||||
/** SQLite container/shard path containing this session. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
function zeroUsage(): Usage {
|
||||
return {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
export function readSessionRow(db: SqliteDatabase, sessionId: string): SessionRow {
|
||||
const row = sql`SELECT id, created_at, parent_session_id, storage_version, metadata,
|
||||
message_count, usage_payload, next_seq
|
||||
FROM sessions
|
||||
WHERE id = ${sessionId}`.get<SessionRow>(db);
|
||||
if (row === undefined) throw new Error(`Unknown SQLite session: ${sessionId}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
export function readAllSessionRows(db: SqliteDatabase): SessionRow[] {
|
||||
return sql`SELECT id, created_at, parent_session_id, storage_version, metadata,
|
||||
message_count, usage_payload, next_seq
|
||||
FROM sessions`.all<SessionRow>(db);
|
||||
}
|
||||
|
||||
export function hasSessionRow(db: SqliteDatabase, sessionId: string): boolean {
|
||||
return sql`SELECT id FROM sessions WHERE id = ${sessionId}`.get<{ id: string }>(db) !== undefined;
|
||||
}
|
||||
|
||||
export function metadataFromSessionRow(
|
||||
path: string,
|
||||
row: SessionRow,
|
||||
currentStorageVersion: number,
|
||||
): SqliteSessionMetadata {
|
||||
if (row.storage_version > currentStorageVersion) {
|
||||
throw new Error(`SQLite session storage version ${row.storage_version} is newer than ${currentStorageVersion}`);
|
||||
}
|
||||
if (row.storage_version < currentStorageVersion) {
|
||||
throw new Error(`SQLite session storage version ${row.storage_version} requires migrations`);
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
createdAt: row.created_at,
|
||||
storageVersion: row.storage_version,
|
||||
...(row.parent_session_id === null ? {} : { parentSessionId: row.parent_session_id }),
|
||||
path,
|
||||
};
|
||||
}
|
||||
|
||||
export function insertSessionRow(
|
||||
db: SqliteDatabase,
|
||||
metadata: SqliteSessionMetadata,
|
||||
storageVersion: number,
|
||||
nextSeq: number,
|
||||
): void {
|
||||
sql`INSERT INTO sessions
|
||||
(id, created_at, parent_session_id, storage_version, metadata, message_count, usage_payload, next_seq)
|
||||
VALUES (
|
||||
${metadata.id},
|
||||
${metadata.createdAt},
|
||||
${metadata.parentSessionId ?? null},
|
||||
${storageVersion},
|
||||
${null},
|
||||
${0},
|
||||
${JSON.stringify(zeroUsage())},
|
||||
${nextSeq}
|
||||
)`.run(db);
|
||||
}
|
||||
|
||||
export function deleteSessionRows(db: SqliteDatabase, sessionId: string): void {
|
||||
sql`DELETE FROM entries WHERE session_id = ${sessionId}`.run(db);
|
||||
sql`DELETE FROM scalar_values WHERE session_id = ${sessionId}`.run(db);
|
||||
sql`DELETE FROM list_values WHERE session_id = ${sessionId}`.run(db);
|
||||
sql`DELETE FROM usage_ledger WHERE session_id = ${sessionId}`.run(db);
|
||||
sql`DELETE FROM branch_entries WHERE session_id = ${sessionId}`.run(db);
|
||||
sql`DELETE FROM branch_meta WHERE session_id = ${sessionId}`.run(db);
|
||||
const result = sql`DELETE FROM sessions WHERE id = ${sessionId}`.run(db);
|
||||
if (result.changes !== 1)
|
||||
throw new Error(`Expected to delete one SQLite session ${sessionId}, deleted ${result.changes}`);
|
||||
}
|
||||
14
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/session-sequences.ts
vendored
Normal file
14
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/session-sequences.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
import { sql } from "../sql.ts";
|
||||
import type { SqliteDatabase } from "../types.ts";
|
||||
|
||||
export function readNextSeq(db: SqliteDatabase, sessionId: string): number {
|
||||
const row = sql`SELECT next_seq FROM sessions WHERE id = ${sessionId}`.get<{ next_seq: number }>(db);
|
||||
if (row === undefined) throw new Error(`Unknown SQLite session: ${sessionId}`);
|
||||
return row.next_seq;
|
||||
}
|
||||
|
||||
export function advanceNextSeq(db: SqliteDatabase, sessionId: string, nextSeq: number): void {
|
||||
const result = sql`UPDATE sessions SET next_seq = ${nextSeq} WHERE id = ${sessionId}`.run(db);
|
||||
if (result.changes !== 1)
|
||||
throw new Error(`Expected to update one SQLite session ${sessionId}, updated ${result.changes}`);
|
||||
}
|
||||
45
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/session-stats.ts
vendored
Normal file
45
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/session-stats.ts
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { SessionStats, UsageRow } from "@earendil-works/pi-agent-core";
|
||||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
import { sql } from "../sql.ts";
|
||||
import type { SqliteDatabase } from "../types.ts";
|
||||
import { readSessionRow } from "./session-row.ts";
|
||||
|
||||
function addUsage(left: Usage, right: Usage): Usage {
|
||||
return {
|
||||
input: left.input + right.input,
|
||||
output: left.output + right.output,
|
||||
cacheRead: left.cacheRead + right.cacheRead,
|
||||
cacheWrite: left.cacheWrite + right.cacheWrite,
|
||||
...(left.cacheWrite1h === undefined && right.cacheWrite1h === undefined
|
||||
? {}
|
||||
: { cacheWrite1h: (left.cacheWrite1h ?? 0) + (right.cacheWrite1h ?? 0) }),
|
||||
...(left.reasoning === undefined && right.reasoning === undefined
|
||||
? {}
|
||||
: { reasoning: (left.reasoning ?? 0) + (right.reasoning ?? 0) }),
|
||||
totalTokens: left.totalTokens + right.totalTokens,
|
||||
cost: {
|
||||
input: left.cost.input + right.cost.input,
|
||||
output: left.cost.output + right.cost.output,
|
||||
cacheRead: left.cost.cacheRead + right.cost.cacheRead,
|
||||
cacheWrite: left.cost.cacheWrite + right.cost.cacheWrite,
|
||||
total: left.cost.total + right.cost.total,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function readSessionStats(db: SqliteDatabase, sessionId: string): SessionStats {
|
||||
const row = readSessionRow(db, sessionId);
|
||||
return {
|
||||
messageCount: row.message_count,
|
||||
usage: JSON.parse(row.usage_payload) as Usage,
|
||||
};
|
||||
}
|
||||
|
||||
export function incrementMessageCount(db: SqliteDatabase, sessionId: string): void {
|
||||
sql`UPDATE sessions SET message_count = message_count + 1 WHERE id = ${sessionId}`.run(db);
|
||||
}
|
||||
|
||||
export function addUsageToSessionStats(db: SqliteDatabase, sessionId: string, usage: UsageRow["usage"]): void {
|
||||
const current = readSessionStats(db, sessionId).usage;
|
||||
sql`UPDATE sessions SET usage_payload = ${JSON.stringify(addUsage(current, usage))} WHERE id = ${sessionId}`.run(db);
|
||||
}
|
||||
67
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/usage-ledger.ts
vendored
Normal file
67
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/usage-ledger.ts
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { UsageRow, UsageScan } from "@earendil-works/pi-agent-core";
|
||||
import { joinSqlFragments, type SqlQuery, sql } from "../sql.ts";
|
||||
import type { SqliteDatabase, SqliteStatement } from "../types.ts";
|
||||
|
||||
export interface UsageLedgerRow {
|
||||
id: string;
|
||||
seq: number;
|
||||
entry_id: string | null;
|
||||
adjustment: number;
|
||||
usage: string;
|
||||
details: string | null;
|
||||
}
|
||||
|
||||
const INSERT_USAGE_LEDGER_SQL = `INSERT INTO usage_ledger (session_id, id, seq, entry_id, adjustment, usage, details)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
function usageLedgerRowParams(sessionId: string, row: UsageRow): unknown[] {
|
||||
return [
|
||||
sessionId,
|
||||
row.id,
|
||||
row.seq,
|
||||
row.entryId ?? null,
|
||||
row.adjustment ? 1 : 0,
|
||||
JSON.stringify(row.usage),
|
||||
row.details === undefined ? null : JSON.stringify(row.details),
|
||||
];
|
||||
}
|
||||
|
||||
export class UsageLedgerRowWriter {
|
||||
private readonly insertStatement: SqliteStatement;
|
||||
private readonly sessionId: string;
|
||||
|
||||
constructor(db: SqliteDatabase, sessionId: string) {
|
||||
this.insertStatement = db.prepare(INSERT_USAGE_LEDGER_SQL);
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
insert(row: UsageRow): void {
|
||||
this.insertStatement.run(...usageLedgerRowParams(this.sessionId, row));
|
||||
}
|
||||
}
|
||||
|
||||
export function insertUsageLedgerRow(db: SqliteDatabase, sessionId: string, row: UsageRow): void {
|
||||
db.prepare(INSERT_USAGE_LEDGER_SQL).run(...usageLedgerRowParams(sessionId, row));
|
||||
}
|
||||
|
||||
export function decodeUsageLedgerRow(row: UsageLedgerRow): UsageRow {
|
||||
return {
|
||||
id: row.id,
|
||||
seq: row.seq,
|
||||
usage: JSON.parse(row.usage) as UsageRow["usage"],
|
||||
...(row.entry_id === null ? {} : { entryId: row.entry_id }),
|
||||
adjustment: row.adjustment !== 0,
|
||||
...(row.details === null ? {} : { details: JSON.parse(row.details) as UsageRow["details"] }),
|
||||
};
|
||||
}
|
||||
|
||||
export function scanUsageLedgerRows(db: SqliteDatabase, sessionId: string, query: UsageScan): UsageLedgerRow[] {
|
||||
const filters: SqlQuery[] = [sql`session_id = ${sessionId}`];
|
||||
if (query.fromSeq !== undefined) filters.push(sql`seq >= ${query.fromSeq}`);
|
||||
if (query.toSeq !== undefined) filters.push(sql`seq <= ${query.toSeq}`);
|
||||
|
||||
const order = query.order === "desc" ? sql`ORDER BY seq DESC` : sql`ORDER BY seq ASC`;
|
||||
const limit = query.limit === undefined ? sql`` : sql`LIMIT ${Math.max(0, query.limit)}`;
|
||||
return sql`SELECT id, seq, entry_id, adjustment, usage, details
|
||||
FROM usage_ledger WHERE ${joinSqlFragments(filters, " AND ")} ${order} ${limit}`.all<UsageLedgerRow>(db);
|
||||
}
|
||||
146
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/values.ts
vendored
Normal file
146
third_party/pi/packages/session-backends/sqlite-node/src/sqlite/session/values.ts
vendored
Normal file
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
type ListElement,
|
||||
type ListReadOptions,
|
||||
resolveListReadOptions,
|
||||
type StoredValue,
|
||||
type Value,
|
||||
type ValueList,
|
||||
value,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { type SqlQuery, sql } from "../sql.ts";
|
||||
import type { SqliteDatabase } from "../types.ts";
|
||||
|
||||
export interface ScalarValueRow {
|
||||
namespace: string;
|
||||
key: string;
|
||||
seq: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ListValueRow {
|
||||
seq: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function setScalarValueRow(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
namespace: string,
|
||||
key: string,
|
||||
seq: number,
|
||||
storedValue: unknown,
|
||||
): void {
|
||||
sql`INSERT INTO scalar_values (session_id, namespace, key, seq, value)
|
||||
VALUES (${sessionId}, ${namespace}, ${key}, ${seq}, ${JSON.stringify(storedValue)})
|
||||
ON CONFLICT(session_id, namespace, key) DO UPDATE SET seq = excluded.seq, value = excluded.value`.run(db);
|
||||
}
|
||||
|
||||
export function deleteScalarValueRow(db: SqliteDatabase, sessionId: string, namespace: string, key: string): void {
|
||||
sql`DELETE FROM scalar_values
|
||||
WHERE session_id = ${sessionId} AND namespace = ${namespace} AND key = ${key}`.run(db);
|
||||
}
|
||||
|
||||
export function appendListValueRow(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
namespace: string,
|
||||
key: string,
|
||||
seq: number,
|
||||
element: unknown,
|
||||
): void {
|
||||
sql`INSERT INTO list_values (session_id, namespace, key, seq, value)
|
||||
VALUES (${sessionId}, ${namespace}, ${key}, ${seq}, ${JSON.stringify(element)})`.run(db);
|
||||
}
|
||||
|
||||
export function deleteListValueRows(db: SqliteDatabase, sessionId: string, namespace: string, key: string): void {
|
||||
sql`DELETE FROM list_values
|
||||
WHERE session_id = ${sessionId} AND namespace = ${namespace} AND key = ${key}`.run(db);
|
||||
}
|
||||
|
||||
function decodeScalarValueRow<T>(address: Value<T>, row: ScalarValueRow): StoredValue<T> {
|
||||
if (row.namespace !== address.namespace || row.key !== address.key) {
|
||||
throw new Error(`Expected value ${address.namespace}:${address.key}, found ${row.namespace}:${row.key}`);
|
||||
}
|
||||
return { address, seq: row.seq, value: JSON.parse(row.value) as T };
|
||||
}
|
||||
|
||||
export function readScalarValueRow<T>(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
address: Value<T>,
|
||||
): StoredValue<T> | undefined {
|
||||
const row = sql`SELECT namespace, key, seq, value FROM scalar_values
|
||||
WHERE session_id = ${sessionId} AND namespace = ${address.namespace} AND key = ${address.key}`.get<ScalarValueRow>(
|
||||
db,
|
||||
);
|
||||
return row === undefined ? undefined : decodeScalarValueRow(address, row);
|
||||
}
|
||||
|
||||
export function readAllScalarValueRows(db: SqliteDatabase, sessionId: string): StoredValue<unknown>[] {
|
||||
return sql`SELECT namespace, key, seq, value FROM scalar_values
|
||||
WHERE session_id = ${sessionId} ORDER BY seq ASC`
|
||||
.all<ScalarValueRow>(db)
|
||||
.map((row) => ({
|
||||
address: value<unknown>(row.namespace, row.key),
|
||||
seq: row.seq,
|
||||
value: JSON.parse(row.value),
|
||||
}));
|
||||
}
|
||||
|
||||
function nextPrefixBoundary(prefix: string): string | undefined {
|
||||
if (prefix === "") return undefined;
|
||||
const codePoints = Array.from(prefix);
|
||||
for (let index = codePoints.length - 1; index >= 0; index--) {
|
||||
const codePoint = codePoints[index]?.codePointAt(0);
|
||||
if (codePoint === undefined) throw new Error("Invalid value key prefix");
|
||||
if (codePoint < 0x10ffff) {
|
||||
const nextCodePoint = codePoint >= 0xd7ff && codePoint < 0xe000 ? 0xe000 : codePoint + 1;
|
||||
return `${codePoints.slice(0, index).join("")}${String.fromCodePoint(nextCodePoint)}`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function scanScalarValueRows<T>(db: SqliteDatabase, sessionId: string, prefix: Value<T>): StoredValue<T>[] {
|
||||
const upperBound = nextPrefixBoundary(prefix.key);
|
||||
const rows =
|
||||
upperBound === undefined
|
||||
? sql`SELECT namespace, key, seq, value FROM scalar_values
|
||||
WHERE session_id = ${sessionId} AND namespace = ${prefix.namespace} AND key >= ${prefix.key}
|
||||
ORDER BY key ASC`.all<ScalarValueRow>(db)
|
||||
: sql`SELECT namespace, key, seq, value FROM scalar_values
|
||||
WHERE session_id = ${sessionId} AND namespace = ${prefix.namespace} AND key >= ${prefix.key} AND key < ${upperBound}
|
||||
ORDER BY key ASC`.all<ScalarValueRow>(db);
|
||||
return rows.map((row) => decodeScalarValueRow(value<T>(row.namespace, row.key), row));
|
||||
}
|
||||
|
||||
export function listValueReadQuery<T>(sessionId: string, address: ValueList<T>, options?: ListReadOptions): SqlQuery {
|
||||
const resolved = resolveListReadOptions(options);
|
||||
if (resolved.order === "asc") {
|
||||
return resolved.cursor === undefined
|
||||
? sql`SELECT seq, value FROM list_values
|
||||
WHERE session_id = ${sessionId} AND namespace = ${address.namespace} AND key = ${address.key}
|
||||
ORDER BY seq ASC LIMIT ${resolved.limit}`
|
||||
: sql`SELECT seq, value FROM list_values
|
||||
WHERE session_id = ${sessionId} AND namespace = ${address.namespace} AND key = ${address.key} AND seq > ${resolved.cursor.seq}
|
||||
ORDER BY seq ASC LIMIT ${resolved.limit}`;
|
||||
}
|
||||
return resolved.cursor === undefined
|
||||
? sql`SELECT seq, value FROM list_values
|
||||
WHERE session_id = ${sessionId} AND namespace = ${address.namespace} AND key = ${address.key}
|
||||
ORDER BY seq DESC LIMIT ${resolved.limit}`
|
||||
: sql`SELECT seq, value FROM list_values
|
||||
WHERE session_id = ${sessionId} AND namespace = ${address.namespace} AND key = ${address.key} AND seq < ${resolved.cursor.seq}
|
||||
ORDER BY seq DESC LIMIT ${resolved.limit}`;
|
||||
}
|
||||
|
||||
export function readListValueRows<T>(
|
||||
db: SqliteDatabase,
|
||||
sessionId: string,
|
||||
address: ValueList<T>,
|
||||
options?: ListReadOptions,
|
||||
): ListElement<T>[] {
|
||||
return listValueReadQuery(sessionId, address, options)
|
||||
.all<ListValueRow>(db)
|
||||
.map((row) => ({ seq: row.seq, value: JSON.parse(row.value) as T }));
|
||||
}
|
||||
Reference in New Issue
Block a user