From 03ac69658517a0054d2a573d1e1b60c1bfaaf977 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 5 Jun 2026 11:27:33 -0600 Subject: Add pluggable SessionStore interface with FSJSONL + Null backends Introduce a neutral persistence seam (session_store.zig) following the {ptr, vtable} shape of the other libpanto seams. The interface traffics in DiskMessage so non-JSONL backends (e.g. Postgres) can implement it. - session_store.zig: SessionStore vtable (appendMessages, loadConversation, sessionId, activeModel), LoadedSession{conversation, dangling_user}, re-exported disk types, and an FSJSONLStore alias. - session_manager.zig: add store() wrapper + loadConversation() with dangling trailing-user detection (excluded from the rebuilt conversation, returned as dangling_user). - null_store.zig: no-op backend, stateless singleton. - root.zig: export session_store + null_store. Phase 1+2 of docs/pluggable-session-store.md. Behavior-preserving; CLI not yet rewired. --- libpanto/src/session_store.zig | 134 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 libpanto/src/session_store.zig (limited to 'libpanto/src/session_store.zig') diff --git a/libpanto/src/session_store.zig b/libpanto/src/session_store.zig new file mode 100644 index 0000000..83bf136 --- /dev/null +++ b/libpanto/src/session_store.zig @@ -0,0 +1,134 @@ +//! `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); + } +}; -- cgit v1.3