summaryrefslogtreecommitdiff
path: root/libpanto/src/session_store.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-07 11:35:49 -0600
committert <t@tjp.lol>2026-06-07 11:35:49 -0600
commitb14859b9726185ab873356390068e887b7f486d3 (patch)
tree6530d8ee62e7639b50c99a67d23f89e25ef572dc /libpanto/src/session_store.zig
parentd36a51358efbc48de3d5b732727455efc60acae1 (diff)
R2: redesign session store with wire-format identity
Replace the single-session SessionManager seam with a directory-backed catalog. session_manager.zig -> file_system_jsonl_store.zig; the old machinery becomes internal SessionFile, and a new FileSystemJSONLStore implements the redesigned SessionStore vtable (create/list/resolve/latest/ load/appendMessages) minting Session/SessionInfo handles. Session logs now record wire-format provider identity ({api_style, base_url, model, reasoning}) instead of a single provider string or CLI aliases; no api_key material is ever stored. Disk* content types renamed Stored*; the rich audit-oriented write record is PersistentMessage (in-memory Message + WireIdentity + provenance). Agent.init now takes a Session; persist_provider/persist_model display strings deleted (banner stays alias-based, resume picks default model). Message.metadata round-trips. Dangling-prompt recovery dropped. CLI migrated to the catalog store for run/resume/list. Clean break, no version bump (old logs wiped).
Diffstat (limited to 'libpanto/src/session_store.zig')
-rw-r--r--libpanto/src/session_store.zig252
1 files changed, 150 insertions, 102 deletions
diff --git a/libpanto/src/session_store.zig b/libpanto/src/session_store.zig
index b929c66..de25e21 100644
--- a/libpanto/src/session_store.zig
+++ b/libpanto/src/session_store.zig
@@ -1,79 +1,132 @@
//! `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 is **asymmetric**: rich on write (audit/provenance-capable),
+//! minimal on read (resume-oriented). The store decides how much write-side
+//! richness it durably keeps.
//!
-//! 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.
+//! ## Write side (maximalist)
//!
-//! ## What lives here vs. on the concrete backend
+//! `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.
//!
-//! 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.
+//! ## Read side (minimal)
//!
-//! 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.
+//! `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");
-// 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;
+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. 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 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;
-/// 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,
+/// 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;
- pub fn deinit(self: *LoadedSession, alloc: Allocator) void {
- self.conversation.deinit();
- if (self.dangling_user) |d| alloc.free(d);
- }
+/// 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 = &.{},
};
-/// The active provider/model, as last stamped on a user entry.
-pub const ActiveModel = struct {
- provider: []const u8,
+/// 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.
@@ -82,59 +135,54 @@ pub const SessionStore = struct {
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.
- ///
- /// Ownership: the store **consumes** each `DiskMessage` on success
- /// (takes ownership of its heap allocations). On error the store
- /// frees any messages it had already taken and the caller frees the
- /// rest — i.e. after this call returns the caller must not free the
- /// `DiskMessage`s regardless of outcome. (The `messages` *slice*
- /// itself remains the caller's.)
- appendMessages: *const fn (
- ctx: *anyopaque,
- messages: []DiskMessage,
- providers: []const ?[]const u8,
- models: []const ?[]const u8,
- ) anyerror!void,
+ /// 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,
- /// 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,
+ /// Resolve a (possibly abbreviated) id to a session, or null if no
+ /// match.
+ resolve: *const fn (ctx: *anyopaque, id: []const u8) anyerror!?Session,
- /// Opaque session id. Borrowed; lifetime owned by the store.
- sessionId: *const fn (ctx: *anyopaque) []const u8,
+ /// The most recent session, or null if none exist.
+ latest: *const fn (ctx: *anyopaque) anyerror!?Session,
- /// 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,
+ /// 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 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 create(self: SessionStore) Session {
+ return self.vtable.create(self.ptr);
}
-
- pub fn loadConversation(self: SessionStore, alloc: Allocator) !LoadedSession {
- return self.vtable.loadConversation(self.ptr, alloc);
+ pub fn list(self: SessionStore) ![]SessionInfo {
+ return self.vtable.list(self.ptr);
}
-
- pub fn sessionId(self: SessionStore) []const u8 {
- return self.vtable.sessionId(self.ptr);
+ pub fn freeSessionInfos(self: SessionStore, infos: []SessionInfo) void {
+ self.vtable.freeSessionInfos(self.ptr, infos);
}
-
- pub fn activeModel(self: SessionStore) ?ActiveModel {
- return self.vtable.activeModel(self.ptr);
+ 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);
}
};