//! 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; }