//! `SessionStore`: the neutral persistence seam for the `Agent`. //! //! Session logging used to be a single concrete type (`SessionManager`, //! filesystem JSONL). This interface lets a `libpanto` consumer swap in its //! own backend — e.g. a web service backed by Postgres — without the agent //! knowing how (or whether) persistence happens. //! //! The interface follows the same `{ ptr, vtable }` shape as the other //! `libpanto` seams (`Tool`/`ToolSource`/`Provider`). It traffics in //! `DiskMessage` as the neutral in-memory representation: the default //! backend (`FSJSONLStore`) emits JSONL, but a Postgres backend would map //! `DiskMessage` to columns and never produce a byte of JSONL. //! //! ## What lives here vs. on the concrete backend //! //! On the interface (every store must do these): //! - `appendMessages` — the batch-atomic append primitive. A single //! append is a length-1 batch. //! - `loadConversation` — reconstruct one linear `Conversation` from the //! store, plus an optional dangling trailing user prompt (see //! `LoadedSession`). A store you cannot read is not a valid store. //! - `sessionId` — opaque id string. //! - `activeModel` — the provider/model last stamped on a user entry. //! //! NOT on the interface (backend-specific, stay as free functions / methods //! on the concrete type): //! - filesystem path accessors (`getSessionFile`), //! - catalog/listing/resume helpers (`listSessions`, //! `findMostRecentSession`, `resolveSessionId`) — a web backend lists //! via SQL, not by walking a directory. const std = @import("std"); const Allocator = std.mem.Allocator; const session_mod = @import("session.zig"); const conversation_mod = @import("conversation.zig"); // Re-export the disk types so the interface is self-contained: an embedder // implementing a `SessionStore` imports everything it needs from here. pub const DiskMessage = session_mod.DiskMessage; pub const DiskMessageRole = session_mod.DiskMessageRole; pub const DiskSystemMode = session_mod.DiskSystemMode; pub const DiskContentBlock = session_mod.DiskContentBlock; pub const Usage = session_mod.Usage; pub const Conversation = conversation_mod.Conversation; /// The default filesystem-JSONL backend. Defined in `session_manager.zig`; /// re-exported here under its interface-facing name. Its concrete /// constructors (`init`/`open`) and catalog helpers are backend-specific /// and stay on that module. pub const FSJSONLStore = @import("session_manager.zig").SessionManager; /// The read side's result: a reconstructed linear `Conversation` plus an /// optional dangling trailing user prompt. /// /// `dangling_user` is set when the log ends with a user entry that has no /// following assistant entry — e.g. a crash or quit right after the prompt /// was submitted and durably logged but before the model replied. The /// reconstructed `conversation` **excludes** that dangling turn, so a /// resumed agent never auto-sends it. Consumers may surface the dangling /// text (e.g. a TUI prefilling it for editing) or ignore it. pub const LoadedSession = struct { conversation: Conversation, /// Owned by the caller's allocator when present; free it when done. dangling_user: ?[]const u8 = null, pub fn deinit(self: *LoadedSession, alloc: Allocator) void { self.conversation.deinit(); if (self.dangling_user) |d| alloc.free(d); } }; /// The active provider/model, as last stamped on a user entry. pub const ActiveModel = struct { provider: []const u8, model: []const u8, }; /// A pluggable session-persistence backend. pub const SessionStore = struct { ptr: *anyopaque, vtable: *const VTable, pub const VTable = struct { /// Append a batch of messages atomically. `providers`/`models` are /// parallel arrays (one per message) carrying the top-level entry /// stamps (recorded on user entries). A single append is a /// length-1 batch. Implementations consume nothing — the caller /// retains ownership of `messages` and frees them after return. appendMessages: *const fn ( ctx: *anyopaque, messages: []DiskMessage, providers: []const ?[]const u8, models: []const ?[]const u8, ) anyerror!void, /// Reconstruct one linear `Conversation` from the store, with the /// dangling trailing user prompt (if any) split out. The returned /// `LoadedSession` owns its allocations against `alloc`. loadConversation: *const fn ( ctx: *anyopaque, alloc: Allocator, ) anyerror!LoadedSession, /// Opaque session id. Borrowed; lifetime owned by the store. sessionId: *const fn (ctx: *anyopaque) []const u8, /// Provider/model last stamped on a user entry, or null if no user /// message has been recorded yet. Borrowed slices owned by the /// store. activeModel: *const fn (ctx: *anyopaque) ?ActiveModel, }; pub fn appendMessages( self: SessionStore, messages: []DiskMessage, providers: []const ?[]const u8, models: []const ?[]const u8, ) !void { return self.vtable.appendMessages(self.ptr, messages, providers, models); } pub fn loadConversation(self: SessionStore, alloc: Allocator) !LoadedSession { return self.vtable.loadConversation(self.ptr, alloc); } pub fn sessionId(self: SessionStore) []const u8 { return self.vtable.sessionId(self.ptr); } pub fn activeModel(self: SessionStore) ?ActiveModel { return self.vtable.activeModel(self.ptr); } };