summaryrefslogtreecommitdiff
path: root/libpanto/src/session.zig
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto/src/session.zig')
-rw-r--r--libpanto/src/session.zig112
1 files changed, 82 insertions, 30 deletions
diff --git a/libpanto/src/session.zig b/libpanto/src/session.zig
index c1fd1c4..21193c8 100644
--- a/libpanto/src/session.zig
+++ b/libpanto/src/session.zig
@@ -129,36 +129,10 @@ pub const DiskMessage = struct {
/// Token usage reported by a provider for a single assistant turn.
///
-/// All four input categories sum to the total prompt tokens that were
-/// billed for the turn:
-/// `input + cache_read + cache_write` = total prompt tokens.
-///
-/// `reasoning` is a **subset** of `output`, not additive — it's the
-/// portion of the output that the model spent on internal reasoning
-/// (OpenAI o-series, Anthropic extended thinking). Cost is computed
-/// from `output` only; `reasoning` is tracked for display.
-///
-/// All fields are `u64` and default to 0. Providers that don't report a
-/// given category leave it at 0. Future cache-tier breakdowns (e.g.
-/// Anthropic's 5m vs 1h tiers) can be added as new fields without
-/// breaking the format.
-pub const Usage = struct {
- /// Fresh input tokens billed at the model's base input rate.
- input: u64 = 0,
- /// Output tokens billed at the model's base output rate.
- output: u64 = 0,
- /// Input tokens served from cache (typ. 0.1× base input rate on
- /// Anthropic, 0.5× on OpenAI).
- cache_read: u64 = 0,
- /// Input tokens written to a new cache entry (Anthropic only at
- /// time of writing; typ. 1.25× base input rate). OpenAI's cache
- /// hits don't bill a write premium, so this stays 0 there.
- cache_write: u64 = 0,
- /// Subset of `output` spent on reasoning. For OpenAI o-series
- /// models and Anthropic extended thinking. Display-only — cost is
- /// already accounted for via `output`.
- reasoning: u64 = 0,
-};
+/// Defined in `conversation.zig` (so in-memory `Message`s can carry it
+/// without a module cycle) and re-exported here for the on-disk types and
+/// historical call sites that import it as `session.Usage`.
+pub const Usage = conversation.Usage;
// =============================================================================
// Content blocks
@@ -169,6 +143,7 @@ pub const DiskContentBlock = union(enum) {
thinking: DiskThinkingBlock,
tool_use: DiskToolUseBlock,
tool_result: DiskToolResultBlock,
+ compaction_summary: DiskCompactionSummaryBlock,
pub fn deinit(self: DiskContentBlock, alloc: Allocator) void {
switch (self) {
@@ -176,6 +151,7 @@ pub const DiskContentBlock = union(enum) {
.thinking => |b| b.deinit(alloc),
.tool_use => |b| b.deinit(alloc),
.tool_result => |b| b.deinit(alloc),
+ .compaction_summary => |b| b.deinit(alloc),
}
}
};
@@ -219,6 +195,16 @@ pub const DiskToolResultBlock = struct {
}
};
+/// A compaction summary block: the synthetic seed text standing in for a
+/// compacted conversation prefix. Sits alone in a `user`-role message. See
+/// `conversation.CompactionSummaryBlock`.
+pub const DiskCompactionSummaryBlock = struct {
+ text: []const u8, // owned
+ pub fn deinit(self: DiskCompactionSummaryBlock, alloc: Allocator) void {
+ alloc.free(self.text);
+ }
+};
+
// =============================================================================
// File entry (header or entry)
// =============================================================================
@@ -400,6 +386,14 @@ fn writeDiskBlock(s: *std.json.Stringify, block: DiskContentBlock) !void {
try s.write(b.content);
try s.endObject();
},
+ .compaction_summary => |b| {
+ try s.beginObject();
+ try s.objectField("type");
+ try s.write("compactionSummary");
+ try s.objectField("text");
+ try s.write(b.text);
+ try s.endObject();
+ },
}
}
@@ -575,6 +569,9 @@ fn parseDiskBlock(allocator: Allocator, obj: std.json.ObjectMap) ParseError!Disk
errdefer allocator.free(tuid);
const content = try dupeStringField(allocator, obj, "content");
return .{ .tool_result = .{ .tool_use_id = tuid, .content = content } };
+ } else if (std.mem.eql(u8, t, "compactionSummary")) {
+ const text = try dupeStringField(allocator, obj, "text");
+ return .{ .compaction_summary = .{ .text = text } };
} else {
return error.UnknownBlockType;
}
@@ -643,6 +640,10 @@ pub fn contentBlockToDisk(
const text = try allocator.dupe(u8, sb.text.items);
return .{ .text = .{ .text = text } };
},
+ .CompactionSummary => |cs| {
+ const text = try allocator.dupe(u8, cs.text.items);
+ return .{ .compaction_summary = .{ .text = text } };
+ },
}
}
@@ -681,6 +682,10 @@ pub fn diskContentBlockToInternal(
const content = try conversation.textualBlockFromSlice(allocator, b.content);
return .{ .ToolResult = .{ .tool_use_id = tuid, .content = content } };
},
+ .compaction_summary => |b| {
+ const tb = try conversation.textualBlockFromSlice(allocator, b.text);
+ return .{ .CompactionSummary = .{ .text = tb } };
+ },
}
}
@@ -1031,3 +1036,50 @@ test "diskContentBlockToInternal: Thinking preserves signature" {
try testing.expectEqualStrings("reasoning...", inmem.Thinking.text.items);
try testing.expectEqualStrings("sig123", inmem.Thinking.signature.?);
}
+
+test "compactionSummary block round-trips through serialize/parse" {
+ const a = testing.allocator;
+
+ var content = try a.alloc(DiskContentBlock, 1);
+ content[0] = .{ .compaction_summary = .{ .text = try dupe(a, "earlier history summary") } };
+
+ const entry: SessionEntry = .{ .message = .{
+ .base = .{
+ .id = try dupe(a, "cafef00d"),
+ .parent_id = null,
+ .timestamp = try dupe(a, "2026-04-25T17:40:00Z"),
+ },
+ .message = .{ .role = .user, .content = content },
+ } };
+ defer entry.deinit(a);
+
+ const line = try serializeEntry(a, entry);
+ defer a.free(line);
+ try testing.expect(std.mem.indexOf(u8, line, "\"type\":\"compactionSummary\"") != null);
+
+ var fe = try parseLine(a, line);
+ defer fe.deinit(a);
+ const got = fe.entry.message;
+ try testing.expectEqual(DiskMessageRole.user, got.message.role);
+ try testing.expectEqualStrings("earlier history summary", got.message.content[0].compaction_summary.text);
+}
+
+test "compactionSummary bridges in-memory <-> disk both directions" {
+ const a = testing.allocator;
+
+ // in-memory -> disk
+ const tb = try conversation.textualBlockFromSlice(a, "S1");
+ const block: conversation.ContentBlock = .{ .CompactionSummary = .{ .text = tb } };
+ defer {
+ var mut = block;
+ mut.deinit(a);
+ }
+ const disk = try contentBlockToDisk(a, block);
+ defer disk.deinit(a);
+ try testing.expectEqualStrings("S1", disk.compaction_summary.text);
+
+ // disk -> in-memory
+ var inmem = try diskContentBlockToInternal(a, disk);
+ defer inmem.deinit(a);
+ try testing.expectEqualStrings("S1", inmem.CompactionSummary.text.items);
+}