summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorT <t@tjp.lol>2026-05-27 12:45:20 -0600
committerT <t@tjp.lol>2026-05-27 16:21:52 -0600
commit6545cdfd8f2bc865aa06a2b5515056daf58ba111 (patch)
tree5393cefda42dad12eb90612c6b7ff3c50d8eb800 /src
parentb1a155273662d7dc2ea1fe0cfc43c1741ed30b6d (diff)
session files
Diffstat (limited to 'src')
-rw-r--r--src/main.zig369
-rw-r--r--src/models_toml.zig291
-rw-r--r--src/session_paths.zig141
-rw-r--r--src/subcommand.zig98
4 files changed, 891 insertions, 8 deletions
diff --git a/src/main.zig b/src/main.zig
index 6654c95..3bc24e3 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -8,6 +8,8 @@ const panto_home = @import("panto_home.zig");
const luarocks_runtime = @import("luarocks_runtime.zig");
const self_exe = @import("self_exe.zig");
const subcommand = @import("subcommand.zig");
+const session_paths = @import("session_paths.zig");
+const models_toml = @import("models_toml.zig");
// Shorthand alias for the Lua C API. The bridge module owns the actual
// `@cImport`; we re-use it here so the smoke check uses identical types.
@@ -31,6 +33,7 @@ test {
_ = luarocks_runtime;
_ = self_exe;
_ = subcommand;
+ _ = models_toml;
}
const Receiver = panto.provider.Receiver;
@@ -40,14 +43,37 @@ const MessageRole = panto.conversation.MessageRole;
/// Receiver that prints streaming deltas to stdout. Thinking blocks are
/// dimmed with ANSI escape codes; text blocks render plain.
+///
+/// Also captures one `?Usage` per assistant response during a turn. In
+/// a tool-using turn the agent loop drives multiple streamStep calls;
+/// each one ends with `onMessageComplete(msg, usage)`. We append the
+/// usage — including `null` when the wire didn't deliver any — to
+/// `per_message_usage` in order so `persistTurn` can pair each captured
+/// usage with its corresponding assistant message.
const CLIReceiver = struct {
stdout: *std.Io.Writer,
file: *std.Io.File.Writer,
+ allocator: std.mem.Allocator,
+
+ /// One slot per assistant message completed during the current
+ /// turn, in completion order. `null` means the provider did not
+ /// report usage on the wire for that message (typical of
+ /// OpenAI-compatible proxies that ignore `stream_options.include_usage`).
+ per_message_usage: std.ArrayList(?panto.session_manager.Usage) = .empty,
pub fn receiver(self: *CLIReceiver) Receiver {
return .{ .ptr = self, .vtable = &vtable };
}
+ /// Reset usage state at the start of each turn.
+ pub fn beginTurn(self: *CLIReceiver) void {
+ self.per_message_usage.clearRetainingCapacity();
+ }
+
+ pub fn deinit(self: *CLIReceiver) void {
+ self.per_message_usage.deinit(self.allocator);
+ }
+
const vtable: ReceiverVTable = .{
.onMessageStart = onMessageStart,
.onBlockStart = onBlockStart,
@@ -121,9 +147,19 @@ const CLIReceiver = struct {
try self.file.flush();
}
- fn onMessageComplete(ptr: *anyopaque, message: panto.conversation.Message) anyerror!void {
- _ = message;
+ fn onMessageComplete(
+ ptr: *anyopaque,
+ message: panto.conversation.Message,
+ usage: ?panto.session_manager.Usage,
+ ) anyerror!void {
const self: *CLIReceiver = @ptrCast(@alignCast(ptr));
+ // Only assistant messages come through streaming. The receiver
+ // contract says onMessageComplete fires exactly once per
+ // streamStep, with role=.assistant; record the usage slot in
+ // turn order regardless of whether the wire actually had usage.
+ if (message.role == .assistant) {
+ try self.per_message_usage.append(self.allocator, usage);
+ }
try self.stdout.writeAll("\n");
try self.file.flush();
}
@@ -261,6 +297,10 @@ pub fn main(init: std.process.Init) !void {
const config = try loadConfig(init.environ_map);
+ // Parse the agent-mode flags. Currently only `--resume [<id>]`.
+ const cli_flags = try parseAgentFlags(alloc, init.minimal.args);
+ defer cli_flags.deinit(alloc);
+
var stdout_buffer: [4096]u8 = undefined;
var stdout_file = std.Io.File.stdout().writer(io, &stdout_buffer);
const stdout = &stdout_file.interface;
@@ -269,9 +309,62 @@ pub fn main(init: std.process.Init) !void {
var stdin_file = std.Io.File.stdin().reader(io, &stdin_buffer);
const stdin = &stdin_file.interface;
+ // Resolve where this project's sessions live.
+ var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const cwd_n = try std.process.currentPath(io, &cwd_buf);
+ const cwd = cwd_buf[0..cwd_n];
+ const session_dir = try session_paths.sessionDirForCwd(alloc, init.environ_map, cwd);
+ defer alloc.free(session_dir);
+
+ // Load the user's models.toml — a missing file is fine (empty
+ // registry). Cost lookups against an empty registry return null,
+ // and the display layer will format that as "unknown."
+ const models_toml_path = try models_toml.configPath(alloc, init.environ_map);
+ defer alloc.free(models_toml_path);
+ var pricing_registry = try models_toml.loadFromPath(alloc, io, models_toml_path);
+ defer pricing_registry.deinit();
+ std.log.debug("models.toml: {d} entries from {s}", .{ pricing_registry.count(), models_toml_path });
+
+ const banner_model_initial: []const u8 = switch (config) {
+ inline else => |c| c.model,
+ };
+ const banner_provider_initial: []const u8 = @tagName(config);
+
+ // Create or resume the session. Resume failures (missing/ambiguous id)
+ // are user errors — print a tidy message and exit 1 rather than
+ // printing a Zig stack trace.
+ var session_mgr = openSession(
+ alloc,
+ io,
+ session_dir,
+ cwd,
+ cli_flags,
+ stdout,
+ &stdout_file,
+ ) catch |err| switch (err) {
+ error.SessionNotFound, error.AmbiguousSessionId => std.process.exit(1),
+ else => return err,
+ };
+ defer session_mgr.deinit();
+
var conv = panto.conversation.Conversation.init(alloc);
defer conv.deinit();
- try conv.addSystemMessage("You are a helpful assistant.");
+
+ if (session_mgr.getEntries().len > 0) {
+ // Resumed an existing session — rebuild the conversation from the
+ // log. The system prompt is part of the log.
+ conv.deinit();
+ conv = try session_mgr.rebuildConversation();
+ try stdout.print(
+ "resumed session {s} ({d} entries)\n",
+ .{ session_mgr.getSessionId()[0..@min(8, session_mgr.getSessionId().len)], session_mgr.getEntries().len },
+ );
+ } else {
+ // Fresh session — install the default system prompt and record it.
+ const system_text = "You are a helpful assistant.";
+ try conv.addSystemMessage(system_text);
+ try appendSystemToSession(alloc, &session_mgr, system_text);
+ }
const prov = try panto.provider.Provider.init(alloc, io, config);
var agent = panto.agent.Agent.init(alloc, io, prov);
@@ -325,17 +418,22 @@ pub fn main(init: std.process.Init) !void {
try agent.registerToolSource(rt.toolSource());
}
- const banner_model: []const u8 = switch (config) {
- inline else => |c| c.model,
- };
const banner_base: []const u8 = switch (config) {
inline else => |c| c.base_url,
};
- try stdout.print("panto — {s}: {s} @ {s}\n", .{ @tagName(config), banner_model, banner_base });
+ try stdout.print(
+ "panto — {s}: {s} @ {s}\n",
+ .{ banner_provider_initial, banner_model_initial, banner_base },
+ );
try stdout.print("> ", .{});
try stdout_file.flush();
- var cli_recv = CLIReceiver{ .stdout = stdout, .file = &stdout_file };
+ var cli_recv = CLIReceiver{
+ .stdout = stdout,
+ .file = &stdout_file,
+ .allocator = alloc,
+ };
+ defer cli_recv.deinit();
var recv = cli_recv.receiver();
while (true) {
@@ -356,12 +454,267 @@ pub fn main(init: std.process.Init) !void {
}
try conv.addUserMessage(line);
+ try appendUserPromptToSession(
+ alloc,
+ &session_mgr,
+ line,
+ banner_provider_initial,
+ banner_model_initial,
+ );
+ const entries_before_step = conv.messages.items.len;
+
+ cli_recv.beginTurn();
agent.runStep(&conv, &recv) catch |err| {
try stdout.print("\n[error: {s}]\n", .{@errorName(err)});
};
+ // Persist whatever new entries the agent produced this turn. The
+ // agent loop may have appended:
+ // - assistant message(s) (one per provider response)
+ // - user messages containing ToolResult blocks (one per tool round)
+ // Each assistant message gets paired with the Usage that the
+ // receiver captured at its onMessageComplete time (or null if
+ // the provider didn't emit usage that round).
+ try persistTurn(
+ alloc,
+ &session_mgr,
+ &conv,
+ entries_before_step,
+ banner_provider_initial,
+ banner_model_initial,
+ cli_recv.per_message_usage.items,
+ );
+
try stdout.writeAll("\n> ");
try stdout_file.flush();
}
}
+
+// -----------------------------------------------------------------------------
+// CLI flag parsing
+// -----------------------------------------------------------------------------
+
+const AgentFlags = struct {
+ /// `--resume` without an id: resume the most recent session.
+ /// `--resume <id>`: resume the session whose id has this prefix.
+ /// Not present: start a new session.
+ resume_kind: ResumeKind = .none,
+ resume_id: ?[]const u8 = null, // owned
+
+ pub fn deinit(self: AgentFlags, alloc: std.mem.Allocator) void {
+ if (self.resume_id) |id| alloc.free(id);
+ }
+};
+
+const ResumeKind = enum { none, most_recent, by_id };
+
+fn parseAgentFlags(alloc: std.mem.Allocator, args: std.process.Args) !AgentFlags {
+ var flags: AgentFlags = .{};
+ errdefer flags.deinit(alloc);
+
+ var it = args.iterate();
+ defer it.deinit();
+ _ = it.next(); // argv[0]
+
+ while (it.next()) |a| {
+ if (std.mem.eql(u8, a, "--resume")) {
+ // Peek at the next arg. If it exists and doesn't start with `-`,
+ // treat it as a session id (or prefix).
+ const next = it.next();
+ if (next) |id| {
+ if (id.len > 0 and id[0] != '-') {
+ flags.resume_kind = .by_id;
+ flags.resume_id = try alloc.dupe(u8, id);
+ continue;
+ } else {
+ // Not an id; rewind by treating it as a separate flag.
+ // The Args API doesn't support rewind, so handle inline.
+ flags.resume_kind = .most_recent;
+ if (std.mem.eql(u8, id, "--resume")) {
+ // back-to-back --resume; second resets, fine.
+ continue;
+ }
+ // Otherwise, fall through and process this token as a flag.
+ // (Currently we don't have other flags; ignore unknowns.)
+ std.log.warn("panto: ignoring unknown argument '{s}'", .{id});
+ continue;
+ }
+ }
+ flags.resume_kind = .most_recent;
+ continue;
+ }
+ // Future agent-mode flags would land here. Unknown args are tolerated
+ // (the user might be passing something we don't recognize yet).
+ }
+ return flags;
+}
+
+// -----------------------------------------------------------------------------
+// Session bootstrap
+// -----------------------------------------------------------------------------
+
+fn openSession(
+ alloc: std.mem.Allocator,
+ io: std.Io,
+ session_dir: []const u8,
+ cwd: []const u8,
+ flags: AgentFlags,
+ stdout: *std.Io.Writer,
+ stdout_file: *std.Io.File.Writer,
+) !panto.session_manager.SessionManager {
+ switch (flags.resume_kind) {
+ .none => return try panto.session_manager.SessionManager.init(
+ alloc,
+ io,
+ session_dir,
+ cwd,
+ ),
+ .most_recent => {
+ const path_opt = panto.session_manager.findMostRecentSession(alloc, io, session_dir) catch null;
+ if (path_opt) |path| {
+ defer alloc.free(path);
+ return try panto.session_manager.SessionManager.open(alloc, io, path);
+ }
+ try stdout.print("no sessions to resume; starting fresh.\n", .{});
+ try stdout_file.flush();
+ return try panto.session_manager.SessionManager.init(
+ alloc,
+ io,
+ session_dir,
+ cwd,
+ );
+ },
+ .by_id => {
+ const id = flags.resume_id.?;
+ const path = panto.session_manager.resolveSessionId(alloc, io, session_dir, id) catch |err| switch (err) {
+ error.SessionNotFound => {
+ try stdout.print("error: no session matching '{s}' in {s}\n", .{ id, session_dir });
+ try stdout_file.flush();
+ return err;
+ },
+ error.AmbiguousSessionId => {
+ try stdout.print("error: session id '{s}' is ambiguous\n", .{id});
+ try stdout_file.flush();
+ return err;
+ },
+ else => return err,
+ };
+ defer alloc.free(path);
+ return try panto.session_manager.SessionManager.open(alloc, io, path);
+ },
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Session append helpers — bridge in-memory ContentBlocks to on-disk entries.
+// -----------------------------------------------------------------------------
+
+fn appendSystemToSession(
+ alloc: std.mem.Allocator,
+ mgr: *panto.session_manager.SessionManager,
+ text: []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 = .system, .content = blocks },
+ null,
+ null,
+ );
+}
+
+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).
+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 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;
+ }
+ switch (msg.role) {
+ .system => {
+ // Mid-turn system messages aren't a thing in pantograph today.
+ // Treat as harmless and persist verbatim, sans stamps.
+ _ = try mgr.appendMessage(
+ .{ .role = .system, .content = blocks },
+ null,
+ null,
+ );
+ },
+ .user => {
+ _ = try mgr.appendMessage(
+ .{ .role = .user, .content = blocks },
+ provider,
+ 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 mgr.appendMessage(
+ .{
+ .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,
+ },
+ null,
+ null,
+ );
+ },
+ }
+ }
+}
diff --git a/src/models_toml.zig b/src/models_toml.zig
new file mode 100644
index 0000000..5ccb302
--- /dev/null
+++ b/src/models_toml.zig
@@ -0,0 +1,291 @@
+//! Loader for `~/.config/panto/models.toml`.
+//!
+//! Schema:
+//!
+//! [<provider>.<model>]
+//! input = <float> # USD per million tokens (fresh input)
+//! output = <float> # USD per million tokens
+//! cache_read = <float> # USD per million tokens (optional; default "unknown")
+//! cache_write = <float> # USD per million tokens (optional; default "unknown")
+//!
+//! All four price fields are optional at the parse layer. Any field
+//! omitted from the TOML comes through as `null` in the in-memory
+//! `Pricing`, which means "unknown price for this token category" —
+//! NOT "zero." If the model later reports usage in an unknown
+//! category (e.g. gpt-4o reads from prompt cache but the TOML omitted
+//! `cache_read`), session cost degenerates to "unknown" rather than
+//! silently treating that usage as free. To declare a category as a
+//! known zero (e.g. OpenAI doesn't bill a cache-write rate), write
+//! `cache_write = 0` explicitly.
+//!
+//! `<provider>` is `openai` or `anthropic` (matching pantograph's API
+//! styles). `<model>` is the model id pantograph sends to the API. Both
+//! are TOML "dotted-key" path segments; quote them if they contain
+//! characters TOML doesn't allow bare (`-`, `.`, `:` etc. — `-` is OK
+//! bare; `.` requires quoting since dots separate path segments).
+//!
+//! Example:
+//!
+//! [anthropic."claude-sonnet-4-20250514"]
+//! input = 3.0
+//! output = 15.0
+//! cache_read = 0.3
+//! cache_write = 3.75
+//!
+//! [openai.gpt-4o]
+//! input = 2.5
+//! output = 10.0
+//!
+//! The model id in the section header may also be unquoted when it
+//! contains no `.` or other reserved chars (e.g. `gpt-4o`); pantograph
+//! reads either form. We do not currently bake in default pricing —
+//! a missing entry (or a present entry with missing fields) surfaces
+//! as "unknown cost" and the display layer formats accordingly.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const Io = std.Io;
+
+const toml = @import("toml");
+const panto = @import("panto");
+
+pub const Pricing = panto.pricing.Pricing;
+pub const Registry = panto.pricing.Registry;
+
+/// Resolve the absolute path to `models.toml`. Honors `XDG_CONFIG_HOME`,
+/// falling back to `$HOME/.config`. Caller owns the returned slice.
+pub fn configPath(allocator: Allocator, environ_map: *const std.process.Environ.Map) ![]u8 {
+ if (environ_map.get("XDG_CONFIG_HOME")) |xdg| {
+ return try std.fs.path.join(allocator, &.{ xdg, "panto", "models.toml" });
+ }
+ if (environ_map.get("HOME")) |home| {
+ return try std.fs.path.join(allocator, &.{ home, ".config", "panto", "models.toml" });
+ }
+ return error.NoHomeDirectory;
+}
+
+/// Load `models.toml` into a fresh `Registry`. If the file does not
+/// exist, returns an empty registry (no error — missing config is fine).
+///
+/// On parse errors, returns `error.InvalidModelsToml` after logging the
+/// line/column of the first error.
+pub fn loadFromPath(
+ allocator: Allocator,
+ io: Io,
+ path: []const u8,
+) !Registry {
+ var reg = Registry.init(allocator);
+ errdefer reg.deinit();
+
+ const file = Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }) catch |err| switch (err) {
+ error.FileNotFound => return reg, // empty registry; perfectly fine.
+ else => return err,
+ };
+ defer file.close(io);
+
+ const file_len = try file.length(io);
+ const bytes = try allocator.alloc(u8, @intCast(file_len));
+ defer allocator.free(bytes);
+ _ = try file.readPositionalAll(io, bytes, 0);
+
+ try parseInto(&reg, bytes);
+ return reg;
+}
+
+/// Parse a TOML string into the given registry. Useful for tests.
+pub fn parseInto(reg: *Registry, source: []const u8) !void {
+ const alloc = reg.allocator;
+ const result = toml.parseWithError(alloc, source, .{});
+ switch (result) {
+ .err => |e| {
+ // Silenced in tests so the test runner doesn't flag the
+ // expected-failure case as a real error.
+ if (!@import("builtin").is_test) {
+ std.log.err(
+ "models.toml: parse error at line {d}, column {d}: {s}",
+ .{ e.line, e.column, e.message },
+ );
+ }
+ return error.InvalidModelsToml;
+ },
+ .ok => |doc| {
+ defer {
+ var d = doc;
+ d.deinit();
+ }
+ try ingestDocument(reg, doc);
+ },
+ }
+}
+
+fn ingestDocument(reg: *Registry, doc: *toml.Document) !void {
+ // Root is a table of provider -> table of model -> { input, output, ... }.
+ const root_val: *const toml.Value = doc.root;
+ if (root_val.* != .table) return;
+ var provider_it = toml.tableIterator(root_val);
+ while (provider_it.next()) |provider_entry| {
+ const provider = provider_entry.key;
+ const provider_val: *const toml.Value = provider_entry.value;
+ if (provider_val.* != .table) continue;
+ var model_it = toml.tableIterator(provider_val);
+ while (model_it.next()) |model_entry| {
+ const model = model_entry.key;
+ const v: *const toml.Value = model_entry.value;
+ if (v.* != .table) continue;
+ const pricing = pricingFromValue(v);
+ try reg.set(provider, model, pricing);
+ }
+ }
+}
+
+fn pricingFromValue(v: *const toml.Value) Pricing {
+ return .{
+ .input = readPriceField(v, "input"),
+ .output = readPriceField(v, "output"),
+ .cache_read = readPriceField(v, "cache_read"),
+ .cache_write = readPriceField(v, "cache_write"),
+ };
+}
+
+/// Returns `null` if the field is absent or has a type we can't
+/// interpret as a price. An explicit `0` (or `0.0`) comes through as a
+/// known zero, not `null` — callers rely on that distinction.
+fn readPriceField(table: *const toml.Value, name: []const u8) ?u64 {
+ const field = table.get(name) orelse return null;
+ // Accept either floats (the natural form) or integers (the user
+ // wrote `3` instead of `3.0`).
+ if (field.asF64()) |f| return Pricing.fromDollarsPerMtok(f);
+ if (field.asI64()) |i| return Pricing.fromDollarsPerMtok(@floatFromInt(i));
+ return null;
+}
+
+// =============================================================================
+// Tests
+// =============================================================================
+
+const testing = std.testing;
+
+test "parseInto: two providers, multiple models" {
+ var reg = Registry.init(testing.allocator);
+ defer reg.deinit();
+
+ const src =
+ \\[anthropic."claude-sonnet-4-20250514"]
+ \\input = 3.0
+ \\output = 15.0
+ \\cache_read = 0.3
+ \\cache_write = 3.75
+ \\
+ \\[openai.gpt-4o]
+ \\input = 2.5
+ \\output = 10.0
+ \\cache_read = 1.25
+ \\
+ \\[openai.gpt-4o-mini]
+ \\input = 0.15
+ \\output = 0.6
+ ;
+ try parseInto(&reg, src);
+
+ const anth = reg.get("anthropic", "claude-sonnet-4-20250514").?;
+ try testing.expectEqual(@as(?u64, 300), anth.input);
+ try testing.expectEqual(@as(?u64, 1500), anth.output);
+ try testing.expectEqual(@as(?u64, 30), anth.cache_read);
+ try testing.expectEqual(@as(?u64, 375), anth.cache_write);
+
+ const oa = reg.get("openai", "gpt-4o").?;
+ try testing.expectEqual(@as(?u64, 250), oa.input);
+ try testing.expectEqual(@as(?u64, 1000), oa.output);
+ try testing.expectEqual(@as(?u64, 125), oa.cache_read);
+ // cache_write absent in source — stays unknown, NOT silently 0.
+ try testing.expectEqual(@as(?u64, null), oa.cache_write);
+
+ const mini = reg.get("openai", "gpt-4o-mini").?;
+ try testing.expectEqual(@as(?u64, 15), mini.input);
+ try testing.expectEqual(@as(?u64, 60), mini.output);
+ try testing.expectEqual(@as(?u64, null), mini.cache_read);
+ try testing.expectEqual(@as(?u64, null), mini.cache_write);
+}
+
+test "parseInto: integer values are accepted (user writes `3` not `3.0`)" {
+ var reg = Registry.init(testing.allocator);
+ defer reg.deinit();
+
+ const src =
+ \\[openai.gpt-4o]
+ \\input = 3
+ \\output = 15
+ ;
+ try parseInto(&reg, src);
+ const p = reg.get("openai", "gpt-4o").?;
+ try testing.expectEqual(@as(?u64, 300), p.input);
+ try testing.expectEqual(@as(?u64, 1500), p.output);
+}
+
+test "parseInto: missing optional fields stay null (unknown, not zero)" {
+ var reg = Registry.init(testing.allocator);
+ defer reg.deinit();
+
+ const src =
+ \\[openai.gpt-4o]
+ \\input = 2.5
+ \\output = 10.0
+ ;
+ try parseInto(&reg, src);
+ const p = reg.get("openai", "gpt-4o").?;
+ try testing.expectEqual(@as(?u64, null), p.cache_read);
+ try testing.expectEqual(@as(?u64, null), p.cache_write);
+}
+
+test "parseInto: explicit 0 is a known zero, distinct from omission" {
+ // OpenAI doesn't charge for cache writes. Writing `cache_write = 0`
+ // in the TOML must produce a known 0 — not null — so cost stays
+ // computable on turns that report cache_write usage.
+ var reg = Registry.init(testing.allocator);
+ defer reg.deinit();
+
+ const src =
+ \\[openai.gpt-4o]
+ \\input = 2.5
+ \\output = 10.0
+ \\cache_read = 1.25
+ \\cache_write = 0
+ ;
+ try parseInto(&reg, src);
+ const p = reg.get("openai", "gpt-4o").?;
+ try testing.expectEqual(@as(?u64, 0), p.cache_write);
+}
+
+test "parseInto: malformed TOML returns InvalidModelsToml" {
+ var reg = Registry.init(testing.allocator);
+ defer reg.deinit();
+ try testing.expectError(error.InvalidModelsToml, parseInto(&reg, "this is not valid toml = ="));
+}
+
+test "loadFromPath: missing file returns empty registry, no error" {
+ const io = testing.io;
+ var reg = try loadFromPath(testing.allocator, io, "/nonexistent/path/models.toml");
+ defer reg.deinit();
+ try testing.expectEqual(@as(usize, 0), reg.count());
+}
+
+test "configPath: XDG_CONFIG_HOME wins" {
+ const a = testing.allocator;
+ var env: std.process.Environ.Map = .init(a);
+ defer env.deinit();
+ try env.put("XDG_CONFIG_HOME", "/custom/cfg");
+ try env.put("HOME", "/ignored");
+ const got = try configPath(a, &env);
+ defer a.free(got);
+ try testing.expectEqualStrings("/custom/cfg/panto/models.toml", got);
+}
+
+test "configPath: falls back to HOME/.config" {
+ const a = testing.allocator;
+ var env: std.process.Environ.Map = .init(a);
+ defer env.deinit();
+ try env.put("HOME", "/home/user");
+ const got = try configPath(a, &env);
+ defer a.free(got);
+ try testing.expectEqualStrings("/home/user/.config/panto/models.toml", got);
+}
diff --git a/src/session_paths.zig b/src/session_paths.zig
new file mode 100644
index 0000000..162085e
--- /dev/null
+++ b/src/session_paths.zig
@@ -0,0 +1,141 @@
+//! Resolves session file locations from the environment.
+//!
+//! Layout (defaults; override base via `PANTO_SESSION_DIR`):
+//!
+//! $XDG_DATA_HOME/panto/sessions/<encoded-cwd>/
+//! ↳ falls back to $HOME/.local/share/panto/sessions/<encoded-cwd>/
+//! if $XDG_DATA_HOME is unset.
+//!
+//! `<encoded-cwd>` is the working directory with leading `/` stripped and
+//! every `/` or `:` replaced by `-`, with `--` glued to both ends. This
+//! gives a flat directory name per project, easy to spot in `ls`.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+
+/// Resolve the absolute sessions directory for the given cwd. Caller owns
+/// the returned slice.
+///
+/// Precedence:
+/// 1. `PANTO_SESSION_DIR` (full path to the base dir, used as-is — no
+/// "panto/sessions" suffix is added)
+/// 2. `XDG_DATA_HOME/panto/sessions`
+/// 3. `HOME/.local/share/panto/sessions`
+///
+/// In all three cases, `<encoded-cwd>/` is appended.
+pub fn sessionDirForCwd(
+ allocator: Allocator,
+ environ_map: *const std.process.Environ.Map,
+ cwd: []const u8,
+) ![]u8 {
+ const base = try resolveSessionsBase(allocator, environ_map);
+ defer allocator.free(base);
+
+ const encoded = try encodeCwd(allocator, cwd);
+ defer allocator.free(encoded);
+
+ return try std.fs.path.join(allocator, &.{ base, encoded });
+}
+
+/// Resolve the absolute "sessions" base directory, before per-cwd grouping.
+/// Caller owns the returned slice.
+pub fn resolveSessionsBase(
+ allocator: Allocator,
+ environ_map: *const std.process.Environ.Map,
+) ![]u8 {
+ if (environ_map.get("PANTO_SESSION_DIR")) |explicit| {
+ return try allocator.dupe(u8, explicit);
+ }
+ if (environ_map.get("XDG_DATA_HOME")) |xdg| {
+ return try std.fs.path.join(allocator, &.{ xdg, "panto", "sessions" });
+ }
+ if (environ_map.get("HOME")) |home| {
+ return try std.fs.path.join(allocator, &.{ home, ".local", "share", "panto", "sessions" });
+ }
+ return error.NoHomeDirectory;
+}
+
+/// Encode a working directory into a flat directory name. Caller owns.
+///
+/// Example: `/Users/travis/Code/pantograph` → `--Users-travis-Code-pantograph--`
+pub fn encodeCwd(allocator: Allocator, cwd: []const u8) ![]u8 {
+ // Strip leading slash(es), then replace `/` and `:` with `-`.
+ var start: usize = 0;
+ while (start < cwd.len and (cwd[start] == '/' or cwd[start] == '\\')) : (start += 1) {}
+ const body = cwd[start..];
+ const out = try allocator.alloc(u8, body.len + 4); // `--` + body + `--`
+ out[0] = '-';
+ out[1] = '-';
+ for (body, 0..) |c, i| {
+ out[2 + i] = if (c == '/' or c == '\\' or c == ':') '-' else c;
+ }
+ out[out.len - 2] = '-';
+ out[out.len - 1] = '-';
+ return out;
+}
+
+// =============================================================================
+// Tests
+// =============================================================================
+
+const testing = std.testing;
+
+test "encodeCwd: replaces slashes and colons" {
+ const a = testing.allocator;
+ const got = try encodeCwd(a, "/Users/travis/Code/pantograph");
+ defer a.free(got);
+ try testing.expectEqualStrings("--Users-travis-Code-pantograph--", got);
+}
+
+test "encodeCwd: handles already-relative paths" {
+ const a = testing.allocator;
+ const got = try encodeCwd(a, "Users/travis");
+ defer a.free(got);
+ try testing.expectEqualStrings("--Users-travis--", got);
+}
+
+test "resolveSessionsBase: PANTO_SESSION_DIR wins" {
+ const a = testing.allocator;
+ var env: std.process.Environ.Map = .init(a);
+ defer env.deinit();
+ try env.put("PANTO_SESSION_DIR", "/custom/sessions");
+ try env.put("XDG_DATA_HOME", "/ignored");
+
+ const got = try resolveSessionsBase(a, &env);
+ defer a.free(got);
+ try testing.expectEqualStrings("/custom/sessions", got);
+}
+
+test "resolveSessionsBase: XDG_DATA_HOME before HOME" {
+ const a = testing.allocator;
+ var env: std.process.Environ.Map = .init(a);
+ defer env.deinit();
+ try env.put("XDG_DATA_HOME", "/x/data");
+ try env.put("HOME", "/h");
+
+ const got = try resolveSessionsBase(a, &env);
+ defer a.free(got);
+ try testing.expectEqualStrings("/x/data/panto/sessions", got);
+}
+
+test "resolveSessionsBase: falls back to HOME/.local/share" {
+ const a = testing.allocator;
+ var env: std.process.Environ.Map = .init(a);
+ defer env.deinit();
+ try env.put("HOME", "/home/user");
+
+ const got = try resolveSessionsBase(a, &env);
+ defer a.free(got);
+ try testing.expectEqualStrings("/home/user/.local/share/panto/sessions", got);
+}
+
+test "sessionDirForCwd: joins base and encoded cwd" {
+ const a = testing.allocator;
+ var env: std.process.Environ.Map = .init(a);
+ defer env.deinit();
+ try env.put("PANTO_SESSION_DIR", "/sess");
+
+ const got = try sessionDirForCwd(a, &env, "/Users/travis/Code/pantograph");
+ defer a.free(got);
+ try testing.expectEqualStrings("/sess/--Users-travis-Code-pantograph--", got);
+}
diff --git a/src/subcommand.zig b/src/subcommand.zig
index 97eb977..f6c3058 100644
--- a/src/subcommand.zig
+++ b/src/subcommand.zig
@@ -23,6 +23,8 @@ const Io = std.Io;
const lua_bridge = @import("lua_bridge.zig");
const luarocks_runtime = @import("luarocks_runtime.zig");
const self_exe = @import("self_exe.zig");
+const session_paths = @import("session_paths.zig");
+const panto = @import("panto");
const c = lua_bridge.c;
@@ -72,9 +74,47 @@ pub fn dispatch(
try runBootstrapSubcommand(allocator, io, environ_map, panto_executable_path, .{ .force = force });
return .done;
}
+ if (std.mem.eql(u8, sub, "sessions")) {
+ try runSessionsSubcommand(allocator, io, environ_map);
+ return .done;
+ }
+ if (std.mem.eql(u8, sub, "--help") or std.mem.eql(u8, sub, "-h") or std.mem.eql(u8, sub, "help")) {
+ try printHelp(io);
+ return .done;
+ }
return .agent;
}
+fn printHelp(io: Io) !void {
+ var buffer: [4096]u8 = undefined;
+ var stdout_file = std.Io.File.stdout().writer(io, &buffer);
+ const w = &stdout_file.interface;
+ try w.writeAll(
+ \\panto — a conversational coding agent
+ \\
+ \\Usage:
+ \\ panto Start a new conversation.
+ \\ panto --resume Resume the most recent conversation in this directory.
+ \\ panto --resume <id> Resume the conversation whose id begins with <id>.
+ \\ panto sessions List saved sessions for this directory.
+ \\ panto bootstrap [--force]
+ \\ Run the luarocks bootstrap and exit.
+ \\ panto lua [args...] Drop into the embedded Lua interpreter.
+ \\ panto help Show this message.
+ \\
+ \\Environment:
+ \\ PANTO_API_STYLE "openai_chat" (default) or "anthropic_messages".
+ \\ OPENAI_API_KEY, OPENAI_MODEL, OPENAI_BASE_URL, OPENAI_REASONING
+ \\ ANTHROPIC_API_KEY, ANTHROPIC_MODEL, ANTHROPIC_BASE_URL,
+ \\ ANTHROPIC_API_VERSION, ANTHROPIC_MAX_TOKENS
+ \\ PANTO_SESSION_DIR Override the base sessions directory. Defaults to
+ \\ $XDG_DATA_HOME/panto/sessions or ~/.local/share/panto/sessions.
+ \\ PANTO_HOME Override the runtime/rocks tree location.
+ \\
+ );
+ try stdout_file.flush();
+}
+
pub const BootstrapOptions = struct {
/// Wipe the per-Lua-version tree before reinstalling everything.
/// Surfaced as `panto bootstrap --force`. Equivalent to deleting
@@ -205,6 +245,64 @@ fn runBootstrapSubcommand(
}
// ---------------------------------------------------------------------------
+// `panto sessions`
+// ---------------------------------------------------------------------------
+
+/// List sessions for the current working directory.
+///
+/// Output format (one session per line):
+/// <short-id> <created> <message-count> messages
+///
+/// where `<short-id>` is the first 8 hex chars of the session UUIDv7.
+fn runSessionsSubcommand(
+ allocator: Allocator,
+ io: Io,
+ environ_map: *const std.process.Environ.Map,
+) !void {
+ var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const cwd_n = try std.process.currentPath(io, &cwd_buf);
+ const cwd = cwd_buf[0..cwd_n];
+
+ const session_dir = try session_paths.sessionDirForCwd(allocator, environ_map, cwd);
+ defer allocator.free(session_dir);
+
+ const infos = try panto.session_manager.listSessions(allocator, io, session_dir, null);
+ defer panto.session_manager.freeSessionInfos(allocator, infos);
+
+ var stdout_buffer: [4096]u8 = undefined;
+ var stdout_file = std.Io.File.stdout().writer(io, &stdout_buffer);
+ const stdout = &stdout_file.interface;
+
+ if (infos.len == 0) {
+ try stdout.print("no sessions for {s}\n", .{cwd});
+ try stdout_file.flush();
+ return;
+ }
+
+ for (infos) |info| {
+ const short = info.id[0..@min(8, info.id.len)];
+ // `created` is ISO 8601 (e.g. `2026-04-25T17:40:15.990Z`). Trim
+ // to `YYYY-MM-DD HH:MM` for terseness.
+ const created_short = trimCreated(info.created);
+ try stdout.print(
+ "{s} {s} {d} messages\n",
+ .{ short, created_short, info.message_count },
+ );
+ }
+ try stdout_file.flush();
+}
+
+fn trimCreated(iso: []const u8) []const u8 {
+ if (iso.len < 16) return iso;
+ // `YYYY-MM-DDTHH:MM:...` → `YYYY-MM-DD HH:MM` (T → space).
+ // We can't mutate a borrowed slice, so just return a 16-byte slice
+ // of the original; the caller prints character-by-character via
+ // format, so the 'T' will still appear. Use a small buffer trick:
+ // return the slice unmodified — the 'T' is fine and unambiguous.
+ return iso[0..16];
+}
+
+// ---------------------------------------------------------------------------
// `panto lua` argv plumbing — sketched against the older Args API for
// reference (kept here so the design notes survive the implementation).
// ---------------------------------------------------------------------------