summaryrefslogtreecommitdiff
path: root/libpanto
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto')
-rw-r--r--libpanto/src/file_system_jsonl_store.zig66
-rw-r--r--libpanto/src/session.zig35
2 files changed, 61 insertions, 40 deletions
diff --git a/libpanto/src/file_system_jsonl_store.zig b/libpanto/src/file_system_jsonl_store.zig
index 979b35e..1d56ae9 100644
--- a/libpanto/src/file_system_jsonl_store.zig
+++ b/libpanto/src/file_system_jsonl_store.zig
@@ -210,11 +210,11 @@ pub const SessionFile = struct {
allocator: Allocator,
io: Io,
session_dir: []const u8,
- cwd: []const u8,
+ metadata: ?[]const u8,
) !SessionFile {
const id = try newUuidV7(allocator, io);
defer allocator.free(id);
- return initWithId(allocator, io, session_dir, cwd, id);
+ return initWithId(allocator, io, session_dir, metadata, id);
}
/// Like `init`, but uses a caller-supplied session id (duped here)
@@ -224,7 +224,7 @@ pub const SessionFile = struct {
allocator: Allocator,
io: Io,
session_dir: []const u8,
- cwd: []const u8,
+ metadata: ?[]const u8,
session_id: []const u8,
) !SessionFile {
const dir = try allocator.dupe(u8, session_dir);
@@ -236,8 +236,8 @@ pub const SessionFile = struct {
const timestamp = try isoTimestamp(allocator, io);
errdefer allocator.free(timestamp);
- const cwd_copy = try allocator.dupe(u8, cwd);
- errdefer allocator.free(cwd_copy);
+ const metadata_copy: ?[]const u8 = if (metadata) |m| try allocator.dupe(u8, m) else null;
+ errdefer if (metadata_copy) |m| allocator.free(m);
const filename = try std.fmt.allocPrint(allocator, "{s}.jsonl", .{id});
defer allocator.free(filename);
@@ -253,7 +253,7 @@ pub const SessionFile = struct {
.version = CURRENT_VERSION,
.id = id,
.timestamp = timestamp,
- .cwd = cwd_copy,
+ .metadata = metadata_copy,
},
.entries = .empty,
.by_id = std.StringHashMap(usize).init(allocator),
@@ -419,8 +419,8 @@ pub const SessionFile = struct {
// ---------- Accessors ----------
- pub fn getCwd(self: *const SessionFile) []const u8 {
- return self.header.cwd;
+ pub fn getMetadata(self: *const SessionFile) ?[]const u8 {
+ return self.header.metadata;
}
pub fn getSessionId(self: *const SessionFile) []const u8 {
@@ -1157,24 +1157,28 @@ pub fn resolveSessionId(
/// separate user-prompt and assistant-turn appends of a single turn.
///
/// `dir` is the already-resolved sessions directory (the panto CLI derives
-/// the per-cwd grouping; the store itself is cwd-agnostic). `cwd` is stamped
-/// into new session headers for display/provenance only.
+/// the per-cwd grouping; the store itself is cwd-agnostic). Optional
+/// `metadata` is stamped into new session headers for display/provenance only.
pub const FileSystemJSONLStore = struct {
allocator: Allocator,
io: Io,
dir: []u8, // owned: the sessions directory
- cwd: []u8, // owned: recorded in new session headers
+ metadata: ?[]u8, // owned: recorded in new session headers
open: std.StringHashMap(*SessionFile),
- pub fn init(allocator: Allocator, io: Io, dir: []const u8, cwd: []const u8) !FileSystemJSONLStore {
+ pub fn init(allocator: Allocator, io: Io, dir: []const u8) !FileSystemJSONLStore {
+ return initWithMetadata(allocator, io, dir, null);
+ }
+
+ pub fn initWithMetadata(allocator: Allocator, io: Io, dir: []const u8, metadata: ?[]const u8) !FileSystemJSONLStore {
const dir_copy = try allocator.dupe(u8, dir);
errdefer allocator.free(dir_copy);
- const cwd_copy = try allocator.dupe(u8, cwd);
+ const metadata_copy: ?[]u8 = if (metadata) |m| try allocator.dupe(u8, m) else null;
return .{
.allocator = allocator,
.io = io,
.dir = dir_copy,
- .cwd = cwd_copy,
+ .metadata = metadata_copy,
.open = std.StringHashMap(*SessionFile).init(allocator),
};
}
@@ -1188,7 +1192,7 @@ pub const FileSystemJSONLStore = struct {
}
self.open.deinit();
self.allocator.free(self.dir);
- self.allocator.free(self.cwd);
+ if (self.metadata) |m| self.allocator.free(m);
}
/// Borrow (opening if needed) the `SessionFile` for `id`, caching it.
@@ -1213,7 +1217,7 @@ pub const FileSystemJSONLStore = struct {
sf.* = if (exists)
try SessionFile.open(self.allocator, self.io, full)
else
- try SessionFile.initWithId(self.allocator, self.io, self.dir, self.cwd, id);
+ try SessionFile.initWithId(self.allocator, self.io, self.dir, self.metadata, id);
errdefer sf.deinit();
const key = try self.allocator.dupe(u8, id);
@@ -1504,7 +1508,7 @@ test "SessionFile.init: does not create file yet" {
testing.allocator,
io,
sessions,
- "/some/cwd",
+ "{\"cwd\":\"/some/cwd\"}",
);
defer mgr.deinit();
@@ -1529,7 +1533,7 @@ test "SessionFile: full flow — buffer, flush on assistant, append, resume" {
testing.allocator,
io,
sessions,
- "/proj/foo",
+ "{\"cwd\":\"/proj/foo\"}",
);
defer mgr.deinit();
@@ -1599,7 +1603,7 @@ test "SessionFile: full flow — buffer, flush on assistant, append, resume" {
defer resumed.deinit();
try testing.expect(resumed.isFlushed());
try testing.expectEqual(@as(usize, 5), resumed.getEntries().len);
- try testing.expectEqualStrings("/proj/foo", resumed.getCwd());
+ try testing.expectEqualStrings("{\"cwd\":\"/proj/foo\"}", resumed.getMetadata().?);
// Continue the conversation.
const u_three = try testing.allocator.alloc(StoredContentBlock, 1);
@@ -1616,7 +1620,7 @@ test "SessionFile: assistant message tags the message metadata and the entry lea
const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
defer testing.allocator.free(sessions);
- var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, null);
defer mgr.deinit();
const u_blocks = try testing.allocator.alloc(StoredContentBlock, 1);
@@ -1645,7 +1649,7 @@ test "SessionFile: activeStamp is null before any user message, then tracks the
const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
defer testing.allocator.free(sessions);
- var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, null);
defer mgr.deinit();
// No user messages yet — there is no "active" model on disk yet.
@@ -1671,7 +1675,7 @@ test "SessionFile: rebuildConversation reconstructs system/user/assistant turn"
const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
defer testing.allocator.free(sessions);
- var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, null);
defer mgr.deinit();
const sys = try testing.allocator.alloc(StoredContentBlock, 1);
@@ -1707,7 +1711,7 @@ test "SessionFile: crash recovery truncates corrupted trailing line" {
// Build a valid session first.
const session_file: []u8 = blk: {
- var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, null);
defer mgr.deinit();
const u = try testing.allocator.alloc(StoredContentBlock, 1);
u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "ping") } };
@@ -1760,7 +1764,7 @@ test "listSessions: returns most recent first, with counts" {
// Create two sessions.
for (0..2) |i| {
- var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, null);
defer mgr.deinit();
const u = try testing.allocator.alloc(StoredContentBlock, 1);
u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u") } };
@@ -1799,7 +1803,7 @@ test "findMostRecentSession: picks lexicographically greatest" {
defer if (second_file) |s| testing.allocator.free(s);
for (0..2) |i| {
- var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, null);
defer mgr.deinit();
const u = try testing.allocator.alloc(StoredContentBlock, 1);
u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u") } };
@@ -1824,7 +1828,7 @@ test "SessionFile: tool-use round-trip — assistant w/ ToolUse, user w/ ToolRes
defer testing.allocator.free(sessions);
const session_file: []u8 = blk: {
- var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, null);
defer mgr.deinit();
const u = try testing.allocator.alloc(StoredContentBlock, 1);
@@ -1895,7 +1899,7 @@ test "SessionFile: linear chain — each entry's parent_id is the previous entry
const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
defer testing.allocator.free(sessions);
- var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, null);
defer mgr.deinit();
// Three rounds: sys, user, asst, user, asst.
@@ -1936,7 +1940,7 @@ test "resolveSessionId: unique prefix → match, ambiguous → error" {
defer testing.allocator.free(sessions);
// Create one session.
- var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, null);
defer mgr.deinit();
const u = try testing.allocator.alloc(StoredContentBlock, 1);
u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u") } };
@@ -1964,7 +1968,7 @@ test "compaction summary round-trips through persist + resume + rebuild" {
defer testing.allocator.free(sessions);
const session_file: []u8 = blk: {
- var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, null);
defer mgr.deinit();
// System + an old turn that will be superseded.
@@ -2024,7 +2028,7 @@ test "loadConversation: trailing user prompt is split out as dangling, excluded
const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
defer testing.allocator.free(sessions);
- var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, null);
defer mgr.deinit();
// A completed user/assistant round, then a trailing user prompt with no
@@ -2056,7 +2060,7 @@ test "FileSystemJSONLStore catalog: create → append → load round-trips" {
const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
defer testing.allocator.free(sessions);
- var catalog = try FileSystemJSONLStore.init(testing.allocator, io, sessions, "/c");
+ var catalog = try FileSystemJSONLStore.init(testing.allocator, io, sessions);
defer catalog.deinit();
const store = catalog.store();
diff --git a/libpanto/src/session.zig b/libpanto/src/session.zig
index 492f94b..843989a 100644
--- a/libpanto/src/session.zig
+++ b/libpanto/src/session.zig
@@ -87,12 +87,14 @@ pub const SessionHeader = struct {
version: u32,
id: []const u8, // UUIDv7 string, owned
timestamp: []const u8, // ISO 8601, owned
- cwd: []const u8, // owned
+ /// Opaque session-wide metadata bag. Round-trips verbatim; `libpanto`
+ /// never interprets it. The panto CLI records `{ "cwd": ... }` here.
+ metadata: ?[]const u8 = null, // owned
pub fn deinit(self: SessionHeader, alloc: Allocator) void {
alloc.free(self.id);
alloc.free(self.timestamp);
- alloc.free(self.cwd);
+ if (self.metadata) |m| alloc.free(m);
}
};
@@ -308,8 +310,12 @@ pub fn serializeHeader(allocator: Allocator, header: SessionHeader) ![]u8 {
try s.write(header.id);
try s.objectField("timestamp");
try s.write(header.timestamp);
- try s.objectField("cwd");
- try s.write(header.cwd);
+ if (header.metadata) |md| {
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, md, .{});
+ defer parsed.deinit();
+ try s.objectField("metadata");
+ try s.write(parsed.value);
+ }
try s.endObject();
return try aw.toOwnedSlice();
@@ -568,13 +574,24 @@ fn parseHeaderFromObject(allocator: Allocator, obj: std.json.ObjectMap) ParseErr
errdefer allocator.free(id);
const timestamp = try dupeStringField(allocator, obj, "timestamp");
errdefer allocator.free(timestamp);
- const cwd = try dupeStringField(allocator, obj, "cwd");
- errdefer allocator.free(cwd);
+ const metadata: ?[]const u8 = blk: {
+ if (obj.get("metadata")) |mv| {
+ break :blk try std.json.Stringify.valueAlloc(allocator, mv, .{});
+ }
+ if (obj.get("cwd")) |cv| {
+ if (cv != .string) return error.MissingField;
+ const cwd_json = try std.json.Stringify.valueAlloc(allocator, cv, .{});
+ defer allocator.free(cwd_json);
+ break :blk try std.fmt.allocPrint(allocator, "{{\"cwd\":{s}}}", .{cwd_json});
+ }
+ break :blk null;
+ };
+ errdefer if (metadata) |m| allocator.free(m);
return .{
.version = version,
.id = id,
.timestamp = timestamp,
- .cwd = cwd,
+ .metadata = metadata,
};
}
@@ -956,7 +973,7 @@ test "serialize/parse header round-trip" {
.version = 1,
.id = try dupe(a, "019dc5ba-53f6-71a5-ab8f-b1f8709c2572"),
.timestamp = try dupe(a, "2026-04-25T17:40:15.990Z"),
- .cwd = try dupe(a, "/Users/travis/Code/pantograph"),
+ .metadata = try dupe(a, "{\"cwd\":\"/Users/travis/Code/pantograph\"}"),
};
defer header.deinit(a);
@@ -968,7 +985,7 @@ test "serialize/parse header round-trip" {
try testing.expect(fe == .header);
try testing.expectEqual(@as(u32, 1), fe.header.version);
try testing.expectEqualStrings(header.id, fe.header.id);
- try testing.expectEqualStrings(header.cwd, fe.header.cwd);
+ try testing.expectEqualStrings(header.metadata.?, fe.header.metadata.?);
}
test "serialize/parse user message entry round-trip (with provider/model stamp)" {