diff options
| author | t <t@tjp.lol> | 2026-07-07 11:26:32 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-07-07 11:26:45 -0600 |
| commit | f83578fdc9264019a1a1cef8c5484a161167d3dd (patch) | |
| tree | 888f11767f944d61e5ca8eb92fa1b2dba295a4b8 /src/session_store.zig | |
initial commit, moved libpanto over from the pantograph repo
Diffstat (limited to 'src/session_store.zig')
| -rw-r--r-- | src/session_store.zig | 188 |
1 files changed, 188 insertions, 0 deletions
diff --git a/src/session_store.zig b/src/session_store.zig new file mode 100644 index 0000000..de25e21 --- /dev/null +++ b/src/session_store.zig @@ -0,0 +1,188 @@ +//! `SessionStore`: the neutral persistence seam for the `Agent`. +//! +//! The interface is **asymmetric**: rich on write (audit/provenance-capable), +//! minimal on read (resume-oriented). The store decides how much write-side +//! richness it durably keeps. +//! +//! ## Write side (maximalist) +//! +//! `appendMessages` takes `[]PersistentMessage` — the rich, audit-oriented +//! write record. Each carries the in-memory `Message` being appended, its +//! `usage`, the **wire-format** provider identity (`api_style`, `base_url`, +//! `model`, `reasoning` — never CLI config aliases, and never any `api_key` +//! material, not even a hash), and full provenance context (the entire +//! current conversation and the tool set offered for this turn). The library +//! *offers* all of it on every append; a store keeps what it wants. The +//! built-in `FileSystemJSONLStore` deliberately ignores the `conversation` +//! and `tools_available` provenance fields. +//! +//! ## Read side (minimal) +//! +//! `load` reconstructs one linear `Conversation`; `list`/`resolve`/`latest` +//! traffic in `SessionInfo` (display/selection metadata) and `Session` +//! (an `info` + a store to proxy to). The read path never reproduces a +//! `PersistentMessage` — a store may not have kept the provenance. +//! +//! ## Store construction +//! +//! Stores own their own (unprescribed) `init`: a Postgres store takes a DSN, +//! the FS store takes a directory. Nothing in the vtable carries an +//! allocator or io — the store captured whatever it needs at its own init. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const session_mod = @import("session.zig"); +const conversation_mod = @import("conversation.zig"); +const config_mod = @import("config.zig"); +const tool_source_mod = @import("tool_source.zig"); + +pub const Conversation = conversation_mod.Conversation; +pub const Message = conversation_mod.Message; +pub const Usage = conversation_mod.Usage; +pub const APIStyle = config_mod.APIStyle; +pub const ReasoningEffort = config_mod.ReasoningEffort; +pub const ToolDecl = tool_source_mod.ToolDecl; + +/// The default filesystem-JSONL backend, re-exported under its +/// interface-facing name. Its concrete constructor (`init`/`open`) and +/// catalog helpers are backend-specific and stay on that module. +pub const FileSystemJSONLStore = @import("file_system_jsonl_store.zig").FileSystemJSONLStore; + +/// Wire-format provider identity. This is the **ground truth** of which +/// endpoint a turn was sent to — never a CLI config alias (aliases get +/// renamed; two keys for one endpoint are indistinguishable on the wire). +/// `reasoning` disambiguates otherwise-identical endpoints. No `api_key` +/// material ever appears here. Aliased from `config` to avoid a module +/// cycle (config must not import session_store). +pub const WireIdentity = config_mod.WireIdentity; + +/// The rich, audit-oriented write record. The library offers all of this on +/// every append; the store keeps what it wants. +pub const PersistentMessage = struct { + /// The in-memory message being appended (carries its own `metadata`). + message: Message, + /// Provider usage for this message (assistant turns), or null. + usage: ?Usage = null, + /// Wire-format provider identity for the turn this message belongs to. + identity: WireIdentity, + /// Full provenance: the entire current conversation at write time. The + /// FS store ignores this; an audit store may content-address it. + conversation: []const Message = &.{}, + /// Full provenance: the tool set offered for this turn. The FS store + /// ignores this; an audit store may content-address it. + tools_available: []const ToolDecl = &.{}, +}; + +/// Display/selection metadata for one session — pure data, aliased. Used by +/// `panto sessions` and resume pre-selection. The last-used wire identity is +/// updated on append (for resume), never a CLI config alias. +pub const SessionInfo = struct { + id: []const u8, + created: []const u8, + modified: []const u8, + message_count: usize, + /// May be truncated. + last_user_message: []const u8, + /// Last-used wire identity, updated on append. + api_style: APIStyle, + base_url: []const u8, + model: []const u8, + reasoning: ReasoningEffort, + + pub fn deinit(self: SessionInfo, alloc: Allocator) void { + alloc.free(self.id); + alloc.free(self.created); + alloc.free(self.modified); + alloc.free(self.last_user_message); + alloc.free(self.base_url); + alloc.free(self.model); + } +}; + +/// A session handle: pure data (a `SessionInfo`) plus a store to proxy to. +pub const Session = struct { + info: SessionInfo, + store: SessionStore, + + /// Reconstruct the conversation. The id came from `resolve`/`latest`, so + /// the conversation must exist; a `null` from the store is promoted to + /// an error. + pub fn load(self: Session) !Conversation { + return (try self.store.load(self.info.id)) orelse error.SessionNotFound; + } + + /// Append a batch of messages, proxying to the store. Takes `*Session` + /// for API symmetry and to allow future in-place `info` updates; today + /// it only updates the non-owning `api_style`/`reasoning` last-used + /// fields (the `base_url`/`model` strings stay the owned originals to + /// avoid aliasing borrowed config memory — resume picks the default + /// model rather than matching the stored wire identity, so the stale + /// display strings are harmless). + pub fn append(self: *Session, messages: []PersistentMessage) !void { + try self.store.appendMessages(self.info.id, messages); + if (messages.len > 0) { + const id = messages[messages.len - 1].identity; + self.info.api_style = id.api_style; + self.info.reasoning = id.reasoning; + } + } +}; + +/// A pluggable session-persistence backend. +pub const SessionStore = struct { + ptr: *anyopaque, + vtable: *const VTable, + + pub const VTable = struct { + /// Mint an in-memory session handle. Cannot fail: nothing hits the + /// backend until the first `appendMessages` (create-on-demand), so + /// no record exists before the first assistant message. + create: *const fn (ctx: *anyopaque) Session, + + /// List known sessions, newest first. Caller frees via + /// `freeSessionInfos`. + list: *const fn (ctx: *anyopaque) anyerror![]SessionInfo, + + /// Free a slice returned by `list`. + freeSessionInfos: *const fn (ctx: *anyopaque, infos: []SessionInfo) void, + + /// Resolve a (possibly abbreviated) id to a session, or null if no + /// match. + resolve: *const fn (ctx: *anyopaque, id: []const u8) anyerror!?Session, + + /// The most recent session, or null if none exist. + latest: *const fn (ctx: *anyopaque) anyerror!?Session, + + /// Reconstruct one linear `Conversation` for `id`, or null if absent. + /// The returned `Conversation` self-describes its allocator. + load: *const fn (ctx: *anyopaque, id: []const u8) anyerror!?Conversation, + + /// Append a batch atomically. A single append is a length-1 batch. + /// The store reads what it wants off each `PersistentMessage` and + /// is responsible for any de-duplication of provenance. + appendMessages: *const fn (ctx: *anyopaque, session_id: []const u8, messages: []PersistentMessage) anyerror!void, + }; + + pub fn create(self: SessionStore) Session { + return self.vtable.create(self.ptr); + } + pub fn list(self: SessionStore) ![]SessionInfo { + return self.vtable.list(self.ptr); + } + pub fn freeSessionInfos(self: SessionStore, infos: []SessionInfo) void { + self.vtable.freeSessionInfos(self.ptr, infos); + } + pub fn resolve(self: SessionStore, id: []const u8) !?Session { + return self.vtable.resolve(self.ptr, id); + } + pub fn latest(self: SessionStore) !?Session { + return self.vtable.latest(self.ptr); + } + pub fn load(self: SessionStore, id: []const u8) !?Conversation { + return self.vtable.load(self.ptr, id); + } + pub fn appendMessages(self: SessionStore, session_id: []const u8, messages: []PersistentMessage) !void { + return self.vtable.appendMessages(self.ptr, session_id, messages); + } +}; |
