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
|
//! `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.
///
/// 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,
/// 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);
}
};
|