/** * types.ts — S49 contract module (the source of truth). * * Every implementation must satisfy these interfaces. Hosts import only this * file + the factory from index.ts. SQL schemas are private to implementations. * * Design principles (from docs/specs/s49-rev1-architecture-upgrade.md): * 1. Contract-first — the interface IS the spec. * 2. Append-only — TurnWriter.append* are the only write methods; no UPDATE. * 3. Capability-gated — asReader/asWriter/asAdmin return subset views. * 4. Ledger protocol — host PUSHES facts, PULLS views. Store never initiates. * 5. StoreSnapshot — checkpoint/restore for backup, migration, test seeding. * * PREVENT-PI-004: zero network. PREVENT-002: no SQL here (private to impls). */ // ─── Domain types ─────────────────────────────────────────────────── /** * Thrown when appendTurn is called with a (conversationId, turnIndex) pair * that already exists. Both backends must throw this in lieu of a raw * SQLite error (SqliteTurnStore) or silent success (InMemoryTurnStore). */ export class DuplicateTurnError extends Error { /** The conversation that already has this turn index. */ readonly conversationId: ConversationId; /** The duplicate turn index. */ readonly turnIndex: number; constructor(conversationId: ConversationId, turnIndex: number) { super( `Duplicate turn: conversation "${conversationId}" already has turnIndex ${turnIndex}`, ); this.name = "DuplicateTurnError"; this.conversationId = conversationId; this.turnIndex = turnIndex; } } /** Unique turn identifier (string to stay backend-agnostic). */ export type TurnId = string; /** Unique conversation identifier. */ export type ConversationId = string; /** Unique session identifier. */ export type SessionId = string; /** A single turn record — an immutable, append-only fact. */ export interface TurnEntry { conversationId: ConversationId; sessionId: SessionId; turnIndex: number; role: "user" | "assistant" | "system" | "tool"; endedAt: number; // epoch ms ctxTokens?: number; // context window tokens at end of turn ctxPercent?: number; // context window utilization 0-1 pressureBand?: "green" | "yellow" | "red"; model?: string; // model used for this turn /** S50B: the compact epoch that superseded this turn (stamped post-hoc). */ epochId?: string; /** H1: HyDE invocation telemetry for this turn (persisted to hyde_* columns). */ hyde?: TurnHydeTelemetry; /** H1: recall-quality snapshot for this turn (persisted to recall_* columns). */ recallMetrics?: TurnRecallTelemetry; /** * S49R: pi's per-session turn counter, carried alongside the * conversation-monotonic `turnIndex`. `turnIndex` restarts at 0 on resume, * so it is unsafe to use as a join key against `raw_transcript` (which is * seeded from the session counter). `sessionTurnIndex` preserves that * per-session value for the metrics join. Null on pre-migration rows. */ sessionTurnIndex?: number; } /** * H1: HyDE telemetry for a turn — a structural subset of the recall-core * `HydeInvocationInfo` that maps onto the turns `hyde_*` columns. */ export interface TurnHydeTelemetry { ran: boolean; /** Skip reason when HyDE didn't run: "disabled" | "no-llm" | "generation-failed". */ reason: string; hypotheticalDoc: string; rawHitCount: number; hydeHitCount: number; fusedHitCount: number; lift: number; generationMs: number; } /** * H1: recall-quality snapshot for a turn — a structural subset of the recall-core * `RecallMetricsSnapshot` that maps onto the turns `recall_*` columns. */ export interface TurnRecallTelemetry { score: number; pass: boolean; relevance: number; coverage: number; diversity: number; specificity: number; } /** A recall hit recorded during a turn — an immutable, append-only fact. */ export interface TurnRecallEntry { turnId: TurnId; checkpointId: string; score: number; source: "checkpoint" | "cluster_summary" | "memory"; raptorLevel?: number; } /** A conversation fork — an immutable, append-only fact. */ export interface ConversationFork { parentConversationId: ConversationId; childConversationId: ConversationId; forkTurnIndex: number; // turn in the parent where the fork happened createdAt: number; } /** Filters for querying turns. All fields optional (AND-combined). */ export interface TurnFilter { conversationId?: ConversationId; sessionId?: SessionId; sinceMs?: number; // epoch ms lower bound untilMs?: number; // epoch ms upper bound pressureBand?: string; limit?: number; offset?: number; } /** What a prune operation removed. */ export interface PruneReport { turnsRemoved: number; recallRemoved: number; branchesPreserved: number; freedBytes: number; // approximate, from file-size delta } /** Retention policy — what the admin capability allows. */ export interface RetentionPolicy { maxTurnAgeMs: number; // delete turns older than this keepMinPerConversation: number; // always keep at least N turns per conversation vacuumAfterPrune: boolean; // run VACUUM after pruning } /** A complete snapshot for backup/migration/test-seeding. */ export interface StoreSnapshot { version: 1; exportedAt: number; // epoch ms turns: TurnEntry[]; recall: TurnRecallEntry[]; forks: ConversationFork[]; } /** Aggregate stats for a conversation (materialized view, derived on read). */ export interface ConversationStats { turnCount: number; firstTurnAt: number; lastTurnAt: number; avgCtxPercent: number; pressureBands: Record; } // ─── Capability interfaces ────────────────────────────────────────── /** Read-only view — dashboards, TUI, analytics. Cannot write. */ export interface TurnReader { query(filter: TurnFilter): TurnEntry[]; getTurn(turnId: TurnId): TurnEntry | undefined; /** Resolve a turn by its (conversationId, turnIndex) coordinate (fork + recall replay). */ getTurnByIndex( conversationId: ConversationId, turnIndex: number, ): TurnEntry | undefined; listRecall(turnId: TurnId): TurnRecallEntry[]; /** Recall hits recorded for the turn at (conversationId, turnIndex) — the * replay set a fork rehydrates. Avoids needing the opaque TurnId. */ listRecallByIndex( conversationId: ConversationId, turnIndex: number, ): TurnRecallEntry[]; listForks(conversationId: ConversationId): ConversationFork[]; countTurns(conversationId: ConversationId): number; conversationStats(conversationId: ConversationId): ConversationStats; /** * S49R: the next conversation-monotonic turn index for a conversation — * `MAX(turn_index) + 1` (or 0 if the conversation has no turns yet). Used by * writers so resumed sessions (where pi's per-session `turnIndex` restarts * at 0) never collide with `UNIQUE(conversation_id, turn_index)`. */ nextTurnIndexFor(conversationId: ConversationId): number; } /** Append-only writer — compaction engine, event handlers. Cannot prune. */ export interface TurnWriter { appendTurn(entry: TurnEntry): TurnId; appendRecall(entry: TurnRecallEntry): void; ensureConversationId(sessionId: SessionId): ConversationId; forkConversation( parentId: ConversationId, forkTurnIndex: number, ): ConversationId; } /** Admin operations — prune command, DR, migration. */ export interface TurnAdmin { prune(policy: RetentionPolicy): PruneReport; vacuum(): void; checkpoint(): StoreSnapshot; restore(from: StoreSnapshot): void; clear(): void; // test-only; wipes all data /** S50B: stamp `epoch_id` on this session's turns that have none yet (links * a turn to the compact epoch that superseded it). Backfill UPDATE, so it * lives on the admin capability (the writer is append-only). Returns the * number of turns stamped. */ stampTurnsEpoch(sessionId: SessionId, epochId: string): number; } /** The composed store — hosts get a capability-gated view. */ export interface TurnStore extends TurnReader, TurnWriter, TurnAdmin { /** Return a read-only view (for dashboards, TUI, analytics). */ asReader(): TurnReader; /** Return an append-only view (for event handlers, compaction). */ asWriter(): TurnWriter; /** Return an admin view (for prune, DR, migration). */ asAdmin(): TurnAdmin; /** Close the underlying connection. For tests + graceful shutdown. */ close(): void; } // ─── Factory ─────────────────────────────────────────────────────── /** Options for creating a TurnStore. */ export interface TurnStoreOptions { stateDir: string; /** Override DB path (for tests / DR). Default: join(stateDir, "turns.db") */ dbPath?: string; /** In-memory mode (for tests). Default: false. */ inMemory?: boolean; } /** * Factory: create a TurnStore from a state directory. * * By default returns a SqliteTurnStore backed by turns.db. * When options.inMemory is true, returns an InMemoryTurnStore. */ export type TurnStoreFactory = (options: TurnStoreOptions) => TurnStore;