9e7582aa03
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.
190 lines
5.8 KiB
TypeScript
190 lines
5.8 KiB
TypeScript
import { uuidv7 } from "@earendil-works/pi-ai";
|
|
import {
|
|
type LeafEntry,
|
|
type SessionEntryCursorOptions,
|
|
SessionError,
|
|
type SessionMetadata,
|
|
type SessionStorage,
|
|
type SessionTreeEntry,
|
|
} from "../types.ts";
|
|
|
|
function updateLabelCache(labelsById: Map<string, string>, entry: SessionTreeEntry): void {
|
|
if (entry.type !== "label") return;
|
|
const label = entry.label?.trim();
|
|
if (label) {
|
|
labelsById.set(entry.targetId, label);
|
|
} else {
|
|
labelsById.delete(entry.targetId);
|
|
}
|
|
}
|
|
|
|
function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> {
|
|
const labelsById = new Map<string, string>();
|
|
for (const entry of entries) {
|
|
updateLabelCache(labelsById, entry);
|
|
}
|
|
return labelsById;
|
|
}
|
|
|
|
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(-8);
|
|
if (!byId.has(id)) return id;
|
|
}
|
|
return uuidv7();
|
|
}
|
|
|
|
function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
|
|
return entry.type === "leaf" ? entry.targetId : entry.id;
|
|
}
|
|
|
|
export class InMemorySessionStorage<TMetadata extends SessionMetadata = SessionMetadata>
|
|
implements SessionStorage<TMetadata>
|
|
{
|
|
private readonly metadata: TMetadata;
|
|
private entries: SessionTreeEntry[];
|
|
private byId: Map<string, SessionTreeEntry>;
|
|
private labelsById: Map<string, string>;
|
|
private leafId: string | null;
|
|
|
|
constructor(options?: { entries?: SessionTreeEntry[]; metadata?: TMetadata }) {
|
|
this.entries = options?.entries ? [...options.entries] : [];
|
|
this.byId = new Map(this.entries.map((entry) => [entry.id, entry]));
|
|
this.labelsById = buildLabelsById(this.entries);
|
|
this.leafId = null;
|
|
for (const entry of this.entries) this.leafId = leafIdAfterEntry(entry);
|
|
if (this.leafId !== null && !this.byId.has(this.leafId)) {
|
|
throw new SessionError("invalid_session", `Entry ${this.leafId} not found`);
|
|
}
|
|
this.metadata = options?.metadata ?? ({ id: uuidv7(), createdAt: new Date().toISOString() } as TMetadata);
|
|
}
|
|
|
|
async getMetadata(): Promise<TMetadata> {
|
|
return this.metadata;
|
|
}
|
|
|
|
async getLeafId(): Promise<string | null> {
|
|
if (this.leafId !== null && !this.byId.has(this.leafId)) {
|
|
throw new SessionError("invalid_session", `Entry ${this.leafId} not found`);
|
|
}
|
|
return this.leafId;
|
|
}
|
|
|
|
async setLeafId(leafId: string | null): Promise<void> {
|
|
if (leafId !== null && !this.byId.has(leafId)) {
|
|
throw new SessionError("not_found", `Entry ${leafId} not found`);
|
|
}
|
|
const entry: LeafEntry = {
|
|
type: "leaf",
|
|
id: generateEntryId(this.byId),
|
|
parentId: this.leafId,
|
|
timestamp: new Date().toISOString(),
|
|
targetId: leafId,
|
|
};
|
|
this.entries.push(entry);
|
|
this.byId.set(entry.id, entry);
|
|
this.leafId = leafId;
|
|
}
|
|
|
|
async createEntryId(): Promise<string> {
|
|
return generateEntryId(this.byId);
|
|
}
|
|
|
|
async appendEntry(entry: SessionTreeEntry): Promise<void> {
|
|
this.entries.push(entry);
|
|
this.byId.set(entry.id, entry);
|
|
updateLabelCache(this.labelsById, entry);
|
|
this.leafId = leafIdAfterEntry(entry);
|
|
}
|
|
|
|
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
|
|
return this.byId.get(id);
|
|
}
|
|
|
|
async findEntries<TType extends SessionTreeEntry["type"]>(
|
|
type: TType,
|
|
): Promise<Array<Extract<SessionTreeEntry, { type: TType }>>> {
|
|
return this.entries.filter((entry): entry is Extract<SessionTreeEntry, { type: TType }> => entry.type === type);
|
|
}
|
|
|
|
async getLabel(id: string): Promise<string | undefined> {
|
|
return this.labelsById.get(id);
|
|
}
|
|
|
|
async getSessionName(): Promise<string | undefined> {
|
|
const entries = await this.findEntries("session_info");
|
|
return entries[entries.length - 1]?.name?.trim() || undefined;
|
|
}
|
|
|
|
async getSessionStats() {
|
|
let messageCount = 0;
|
|
let cachedTokens = 0;
|
|
let uncachedTokens = 0;
|
|
let totalTokens = 0;
|
|
let costTotal = 0;
|
|
for (const entry of this.entries) {
|
|
if (entry.type === "message") {
|
|
messageCount += 1;
|
|
}
|
|
const usage =
|
|
entry.type === "message"
|
|
? entry.message.role === "assistant"
|
|
? entry.message.usage
|
|
: undefined
|
|
: entry.type === "compaction" || entry.type === "branch_summary"
|
|
? entry.usage
|
|
: undefined;
|
|
if (
|
|
!usage ||
|
|
typeof usage.input !== "number" ||
|
|
typeof usage.output !== "number" ||
|
|
typeof usage.cacheRead !== "number" ||
|
|
typeof usage.cacheWrite !== "number" ||
|
|
typeof usage.cost?.total !== "number"
|
|
) {
|
|
continue;
|
|
}
|
|
cachedTokens += usage.cacheRead;
|
|
uncachedTokens += usage.input + usage.cacheWrite;
|
|
totalTokens += usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
|
|
costTotal += usage.cost.total;
|
|
}
|
|
return {
|
|
messageCount,
|
|
cachedTokens,
|
|
uncachedTokens,
|
|
totalTokens,
|
|
costTotal,
|
|
};
|
|
}
|
|
|
|
async getPathToRootOrCompaction(leafId: string | null): Promise<SessionTreeEntry[]> {
|
|
if (leafId === null) return [];
|
|
const path: SessionTreeEntry[] = [];
|
|
let stopAtEntryId: string | null = null;
|
|
let current = this.byId.get(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 = this.byId.get(current.parentId);
|
|
if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
|
|
current = parent;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
async getEntries(options?: SessionEntryCursorOptions): Promise<SessionTreeEntry[]> {
|
|
const start = options?.afterEntrySeq ?? 0;
|
|
const end = options?.limit === undefined ? undefined : start + options.limit;
|
|
return this.entries.slice(start, end);
|
|
}
|
|
}
|