summaryrefslogtreecommitdiff
path: root/libpanto/src/session_store.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-05 11:27:33 -0600
committert <t@tjp.lol>2026-06-05 11:27:43 -0600
commit03ac69658517a0054d2a573d1e1b60c1bfaaf977 (patch)
treec38c4b009c98e96497db7482d04f803bf2ee78f9 /libpanto/src/session_store.zig
parent003908344336cc13e74618291aa9f3af137f030f (diff)
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.
Diffstat (limited to 'libpanto/src/session_store.zig')
-rw-r--r--libpanto/src/session_store.zig134
1 files changed, 134 insertions, 0 deletions
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);
+ }
+};