summaryrefslogtreecommitdiff
path: root/libpanto/src/session_store.zig
blob: de25e218bd808604a667baf8211fe6587789e0c0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
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);
    }
};