diff options
| author | T <t@tjp.lol> | 2026-05-27 12:45:20 -0600 |
|---|---|---|
| committer | T <t@tjp.lol> | 2026-05-27 16:21:52 -0600 |
| commit | 6545cdfd8f2bc865aa06a2b5515056daf58ba111 (patch) | |
| tree | 5393cefda42dad12eb90612c6b7ff3c50d8eb800 /src | |
| parent | b1a155273662d7dc2ea1fe0cfc43c1741ed30b6d (diff) | |
session files
Diffstat (limited to 'src')
| -rw-r--r-- | src/main.zig | 369 | ||||
| -rw-r--r-- | src/models_toml.zig | 291 | ||||
| -rw-r--r-- | src/session_paths.zig | 141 | ||||
| -rw-r--r-- | src/subcommand.zig | 98 |
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(®, 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(®, 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(®, 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(®, 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(®, 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(®, "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). // --------------------------------------------------------------------------- |
