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/null_store.zig | 77 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 libpanto/src/null_store.zig (limited to 'libpanto/src/null_store.zig') diff --git a/libpanto/src/null_store.zig b/libpanto/src/null_store.zig new file mode 100644 index 0000000..a9bd813 --- /dev/null +++ b/libpanto/src/null_store.zig @@ -0,0 +1,77 @@ +//! `NullStore`: a no-op `SessionStore` for embedders who opt out of +//! persistence (and the default backing for an `Agent` constructed without +//! an explicit store). +//! +//! Every append is dropped. `loadConversation` returns an empty +//! conversation with no dangling prompt. `activeModel` is null and the +//! session id is the empty string. The store is stateless, so a single +//! process-global instance backs every `NullStore` handle. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const session_store_mod = @import("session_store.zig"); +const conversation_mod = @import("conversation.zig"); + +const SessionStore = session_store_mod.SessionStore; +const LoadedSession = session_store_mod.LoadedSession; +const DiskMessage = session_store_mod.DiskMessage; +const ActiveModel = session_store_mod.ActiveModel; + +/// Stateless singleton context. The vtable ignores `ctx` entirely; we hand +/// out a pointer to this so the `*anyopaque` is always valid. +var singleton: u8 = 0; + +fn appendMessagesVT( + _: *anyopaque, + _: []DiskMessage, + _: []const ?[]const u8, + _: []const ?[]const u8, +) anyerror!void {} + +fn loadConversationVT(_: *anyopaque, alloc: Allocator) anyerror!LoadedSession { + return .{ + .conversation = conversation_mod.Conversation.init(alloc), + .dangling_user = null, + }; +} + +fn sessionIdVT(_: *anyopaque) []const u8 { + return ""; +} + +fn activeModelVT(_: *anyopaque) ?ActiveModel { + return null; +} + +const vtable: SessionStore.VTable = .{ + .appendMessages = appendMessagesVT, + .loadConversation = loadConversationVT, + .sessionId = sessionIdVT, + .activeModel = activeModelVT, +}; + +/// A no-op `SessionStore`. Cheap to call repeatedly; all handles share one +/// stateless context. +pub fn store() SessionStore { + return .{ .ptr = &singleton, .vtable = &vtable }; +} + +const testing = std.testing; + +test "NullStore: appends are dropped and load returns empty" { + var msg_content = try testing.allocator.alloc(session_store_mod.DiskContentBlock, 1); + msg_content[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi") } }; + var messages = [_]DiskMessage{.{ .role = .user, .content = msg_content }}; + defer messages[0].deinit(testing.allocator); + + const s = store(); + try s.appendMessages(&messages, &.{null}, &.{null}); + + var loaded = try s.loadConversation(testing.allocator); + defer loaded.deinit(testing.allocator); + try testing.expectEqual(@as(usize, 0), loaded.conversation.messages.items.len); + try testing.expect(loaded.dangling_user == null); + try testing.expect(s.activeModel() == null); + try testing.expectEqualStrings("", s.sessionId()); +} -- cgit v1.3