summaryrefslogtreecommitdiff
path: root/src/session_store.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-07-07 11:26:32 -0600
committert <t@tjp.lol>2026-07-07 11:26:45 -0600
commitf83578fdc9264019a1a1cef8c5484a161167d3dd (patch)
tree888f11767f944d61e5ca8eb92fa1b2dba295a4b8 /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.zig188
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);
+ }
+};