diff options
Diffstat (limited to 'libpanto')
| -rw-r--r-- | libpanto/src/file_system_jsonl_store.zig | 66 | ||||
| -rw-r--r-- | libpanto/src/session.zig | 35 |
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)" { |
