summaryrefslogtreecommitdiff
path: root/src/session_persist.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-02 15:00:44 -0600
committert <t@tjp.lol>2026-06-02 16:37:32 -0600
commit8b88b886346460b1ab29edb03ad85bc3bb565a45 (patch)
tree13b9ab56061a722641389b5fc8ac1113d09b66e5 /src/session_persist.zig
parent7268c0b8e8bcf4b325581fabe7476a394f167738 (diff)
compaction
Diffstat (limited to 'src/session_persist.zig')
-rw-r--r--src/session_persist.zig153
1 files changed, 153 insertions, 0 deletions
diff --git a/src/session_persist.zig b/src/session_persist.zig
new file mode 100644
index 0000000..d5349bc
--- /dev/null
+++ b/src/session_persist.zig
@@ -0,0 +1,153 @@
+//! Session-log persistence helpers shared between the REPL loop
+//! (`main.zig`) and slash-command handlers (e.g. `compaction.zig`).
+//!
+//! These were previously private to `main.zig`. They were lifted into
+//! their own module so that command handlers living outside the root
+//! source file can reuse them without depending on `main.zig` (which,
+//! being the executable root, can't be imported as a normal module).
+
+const std = @import("std");
+const panto = @import("panto");
+
+/// Append a single user-prompt message to the session log.
+pub fn appendUserPromptToSession(
+ alloc: std.mem.Allocator,
+ mgr: *panto.session_manager.SessionManager,
+ text: []const u8,
+ provider: []const u8,
+ model: []const u8,
+) !void {
+ const blocks = try alloc.alloc(panto.session_manager.DiskContentBlock, 1);
+ blocks[0] = .{ .text = .{ .text = try alloc.dupe(u8, text) } };
+ _ = try mgr.appendMessage(
+ .{ .role = .user, .content = blocks },
+ provider,
+ model,
+ );
+}
+
+/// After the agent loop has driven a turn to completion, persist every
+/// new in-memory message at index `>= start_index` to the session log.
+///
+/// Each in-memory message is mapped to disk:
+/// - assistant → assistant entry with provider/model/stop_reason metadata
+/// and Usage (from `per_message_usage`, one slot per assistant message in
+/// completion order).
+/// - user (with ToolResult blocks) → user entry stamped with provider/model.
+///
+/// `stop_reason` is recorded as `"stop"` for now; surfacing the real wire
+/// value requires plumbing it through the Receiver vtable (future work,
+/// separate from token plumbing).
+pub fn persistTurn(
+ alloc: std.mem.Allocator,
+ mgr: *panto.session_manager.SessionManager,
+ conv: *const panto.conversation.Conversation,
+ start_index: usize,
+ provider: []const u8,
+ model: []const u8,
+ per_message_usage: []const ?panto.session_manager.Usage,
+) !void {
+ var batch_messages: std.ArrayList(panto.session_manager.DiskMessage) = .empty;
+ defer batch_messages.deinit(alloc);
+ var batch_providers: std.ArrayList(?[]const u8) = .empty;
+ defer batch_providers.deinit(alloc);
+ var batch_models: std.ArrayList(?[]const u8) = .empty;
+ defer batch_models.deinit(alloc);
+
+ var i = start_index;
+ var assistant_seen: usize = 0;
+ while (i < conv.messages.items.len) : (i += 1) {
+ const msg = conv.messages.items[i];
+ const blocks = try alloc.alloc(panto.session_manager.DiskContentBlock, msg.content.items.len);
+ var allocated: usize = 0;
+ errdefer {
+ for (blocks[0..allocated]) |b| b.deinit(alloc);
+ alloc.free(blocks);
+ }
+ for (msg.content.items) |block| {
+ blocks[allocated] = try panto.session.contentBlockToDisk(alloc, block);
+ allocated += 1;
+ }
+ if (msg.role == .assistant and hasToolUseWithoutFollowingResults(conv, i)) {
+ for (blocks[0..allocated]) |b| b.deinit(alloc);
+ alloc.free(blocks);
+ continue;
+ }
+ switch (msg.role) {
+ .system => {
+ // Mid-turn system messages aren't a thing in pantograph today.
+ // Treat as harmless and persist verbatim, sans stamps.
+ try batch_messages.append(alloc, .{ .role = .system, .content = blocks });
+ try batch_providers.append(alloc, null);
+ try batch_models.append(alloc, null);
+ },
+ .user => {
+ try batch_messages.append(alloc, .{ .role = .user, .content = blocks });
+ try batch_providers.append(alloc, provider);
+ try batch_models.append(alloc, model);
+ },
+ .assistant => {
+ // Pair this assistant message with its usage, if the
+ // receiver captured one for this position. (A turn
+ // ending in a stream error can have fewer usage entries
+ // than assistant messages; treat as null and move on.)
+ const usage: ?panto.session_manager.Usage = if (assistant_seen < per_message_usage.len)
+ per_message_usage[assistant_seen]
+ else
+ null;
+ assistant_seen += 1;
+ try batch_messages.append(alloc, .{
+ .role = .assistant,
+ .content = blocks,
+ .provider = try alloc.dupe(u8, provider),
+ .model = try alloc.dupe(u8, model),
+ .stop_reason = try alloc.dupe(u8, "stop"),
+ .usage = usage,
+ });
+ try batch_providers.append(alloc, null);
+ try batch_models.append(alloc, null);
+ },
+ }
+ }
+ try mgr.appendMessagesAtomic(batch_messages.items, batch_providers.items, batch_models.items);
+}
+
+/// Persist a compaction to the session log. The agent rewrote the
+/// in-memory conversation to `[system..., summary, kept-suffix...]`; the
+/// superseded prefix stays on disk untouched. We append everything from
+/// the compaction summary onward as fresh entries: the summary message
+/// (carrying the `compactionSummary` block) followed by the duplicated
+/// kept-verbatim suffix. On replay, the latest summary resets effective
+/// context, so the duplicated suffix is what survives.
+///
+/// Usage is not threaded through here: the duplicated entries are replays
+/// of already-sized turns, and the summary itself isn't an assistant turn.
+pub fn persistCompaction(
+ alloc: std.mem.Allocator,
+ mgr: *panto.session_manager.SessionManager,
+ conv: *const panto.conversation.Conversation,
+ provider: []const u8,
+ model: []const u8,
+) !void {
+ const start = panto.conversation.latestCompactionIndex(conv.messages.items) orelse return;
+ try persistTurn(alloc, mgr, conv, start, provider, model, &.{});
+}
+
+pub fn hasToolUseWithoutFollowingResults(conv: *const panto.conversation.Conversation, index: usize) bool {
+ const msg = conv.messages.items[index];
+ for (msg.content.items) |block| {
+ if (block != .ToolUse) continue;
+ if (index + 1 >= conv.messages.items.len) return true;
+ const next = conv.messages.items[index + 1];
+ if (next.role != .user) return true;
+ var found = false;
+ for (next.content.items) |next_block| {
+ if (next_block == .ToolResult and std.mem.eql(u8, next_block.ToolResult.tool_use_id, block.ToolUse.id)) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) return true;
+ }
+ return false;
+}