diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/auth_manager.zig | 4 | ||||
| -rw-r--r-- | src/config_file.zig | 105 | ||||
| -rw-r--r-- | src/debug_log.zig | 41 | ||||
| -rw-r--r-- | src/extension_loader.zig | 74 | ||||
| -rw-r--r-- | src/lua_event_bridge.zig | 10 | ||||
| -rw-r--r-- | src/lua_runtime.zig | 53 | ||||
| -rw-r--r-- | src/luarocks_runtime.zig | 12 | ||||
| -rw-r--r-- | src/main.zig | 23 | ||||
| -rw-r--r-- | src/models_toml.zig | 97 | ||||
| -rw-r--r-- | src/panto_home.zig | 43 | ||||
| -rw-r--r-- | src/self_exe.zig | 90 | ||||
| -rw-r--r-- | src/session_paths.zig | 11 | ||||
| -rw-r--r-- | src/subcommand.zig | 23 | ||||
| -rw-r--r-- | src/system_prompt.zig | 6 | ||||
| -rw-r--r-- | src/toml_layer.zig | 68 | ||||
| -rw-r--r-- | src/tui_component.zig | 11 | ||||
| -rw-r--r-- | src/tui_components.zig | 6 | ||||
| -rw-r--r-- | src/tui_event.zig | 35 |
18 files changed, 239 insertions, 473 deletions
diff --git a/src/auth_manager.zig b/src/auth_manager.zig index 8889ca8..f349801 100644 --- a/src/auth_manager.zig +++ b/src/auth_manager.zig @@ -5,7 +5,7 @@ //! //! - `api_key` sessions are already resolved at config load (literal/env); //! nothing to do here. -//! - `oauth_device` sessions are loaded from `$PANTO_HOME/auth/<name>.json`, +//! - `oauth_device` sessions are loaded from `<data home>/auth/<name>.json`, //! refreshed if the access token is near expiry, run through the optional //! secondary exchange (Copilot) if that token is stale, then turned into a //! `ResolvedCredential` (bearer + dynamic base_url + auth-derived headers). @@ -40,7 +40,7 @@ pub const AuthManager = struct { gpa: Allocator, io: Io, client: *std.http.Client, - /// `$PANTO_HOME/auth`. Borrowed for the manager's lifetime. + /// `<data home>/auth`. Borrowed for the manager's lifetime. auth_dir: []const u8, file_cfg: *const config_file.Config, /// Arena for the resolved credential strings the live config borrows. diff --git a/src/config_file.zig b/src/config_file.zig index 6d4b705..5bd2a51 100644 --- a/src/config_file.zig +++ b/src/config_file.zig @@ -54,15 +54,12 @@ const Io = std.Io; const toml = @import("toml"); const panto = @import("panto"); +const tvalue = toml.value_mod; const glob = @import("glob.zig"); const models_toml = @import("models_toml.zig"); - -// Document-building helpers live in the toml library's `value` module and -// are not re-exported at its root. `tableIterator` *is* re-exported, so we -// use `toml.tableIterator` directly but reach through `value_mod` for the -// constructors/mutators. -const tvalue = toml.value_mod; +const panto_home = @import("panto_home.zig"); +const toml_layer = @import("toml_layer.zig"); pub const APIStyle = panto.APIStyle; @@ -406,22 +403,11 @@ fn layerPaths( return .{ .base = base, .user = user, .project = project, .local = local }; } -/// `$PANTO_HOME/config.toml`, falling back to /// `${XDG_DATA_HOME:-$HOME/.local/share}/panto/config.toml`. -/// -/// `PANTO_HOME` is checked first so this always agrees with where the -/// bootstrap stages the default base config (see `panto_home.resolveHome`). pub fn baseConfigPath(allocator: Allocator, environ_map: *const std.process.Environ.Map) Error![]u8 { - if (environ_map.get("PANTO_HOME")) |home| { - return try std.fs.path.join(allocator, &.{ home, "config.toml" }); - } - if (environ_map.get("XDG_DATA_HOME")) |xdg| { - return try std.fs.path.join(allocator, &.{ xdg, "panto", "config.toml" }); - } - if (environ_map.get("HOME")) |home| { - return try std.fs.path.join(allocator, &.{ home, ".local", "share", "panto", "config.toml" }); - } - return error.NoHomeDirectory; + const home = try panto_home.homePath(allocator, environ_map); + defer allocator.free(home); + return try std.fs.path.join(allocator, &.{ home, "config.toml" }); } /// `${XDG_CONFIG_HOME:-$HOME/.config}/panto/config.toml`. @@ -461,73 +447,23 @@ pub fn loadFromPaths( defer merged.deinit(); for (paths) |path| { - const bytes = readFileAlloc(allocator, io, path) catch |err| switch (err) { + const bytes = toml_layer.readFileAlloc(allocator, io, path) catch |err| switch (err) { error.FileNotFound => continue, - else => return err, + error.ReadFailed => return error.ConfigReadFailed, + error.Canceled => return error.Canceled, + error.OutOfMemory => return error.OutOfMemory, }; defer allocator.free(bytes); const doc = parseDoc(allocator, bytes) catch return error.InvalidConfigToml; defer doc.deinit(); - try mergeTable(merged.allocator(), merged.root, doc.root); + try toml_layer.mergeTable(merged.allocator(), merged.root, doc.root); } return resolve(allocator, environ_map, merged.root); } // =========================================================================== -// Merge -// =========================================================================== - -/// Deep-merge `src` table into `dst` table (both must be `.table`). -/// Tables recurse; everything else (scalars, arrays) overwrites. Values are -/// deep-copied into `alloc` (the merged document's arena) so they outlive -/// the source document. -fn mergeTable(alloc: Allocator, dst: *toml.Value, src: *const toml.Value) Allocator.Error!void { - std.debug.assert(dst.* == .table); - if (src.* != .table) return; - - var it = toml.tableIterator(src); - while (it.next()) |entry| { - const existing = dst.get(entry.key); - if (existing != null and existing.?.* == .table and entry.value.* == .table) { - // Both sides are tables — recurse to accumulate keys. - try mergeTable(alloc, @constCast(existing.?), entry.value); - } else { - // Overwrite (or insert) with a deep copy of the source value. - const copy = try cloneValue(alloc, entry.value); - const key_copy = try alloc.dupe(u8, entry.key); - try tvalue.tableSet(alloc, dst, key_copy, copy); - } - } -} - -fn cloneValue(alloc: Allocator, src: *const toml.Value) Allocator.Error!*toml.Value { - const out = try alloc.create(toml.Value); - switch (src.*) { - .table => { - out.* = .{ .table = .{} }; - var it = toml.tableIterator(src); - while (it.next()) |entry| { - const child = try cloneValue(alloc, entry.value); - const key_copy = try alloc.dupe(u8, entry.key); - try tvalue.tableSet(alloc, out, key_copy, child); - } - }, - .array => |*a| { - out.* = .{ .array = .{} }; - for (a.items.items) |*item| { - const child = try cloneValue(alloc, item); - try tvalue.arrayAppend(alloc, out, child.*); - } - }, - .string => |s| out.* = .{ .string = try alloc.dupe(u8, s) }, - else => out.* = src.*, - } - return out; -} - -// =========================================================================== // Resolve // =========================================================================== @@ -956,26 +892,9 @@ pub fn parseModelRef(ref: []const u8) Error!ModelRef { } // =========================================================================== -// File / parse helpers +// Parse helpers // =========================================================================== -fn readFileAlloc(allocator: Allocator, io: Io, path: []const u8) (Allocator.Error || FileError)![]u8 { - const file = Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }) catch |err| switch (err) { - error.FileNotFound => return error.FileNotFound, - error.Canceled => return error.Canceled, - else => return error.ConfigReadFailed, - }; - defer file.close(io); - const len = file.length(io) catch return error.ConfigReadFailed; - const bytes = try allocator.alloc(u8, @intCast(len)); - errdefer allocator.free(bytes); - _ = file.readPositionalAll(io, bytes, 0) catch |err| switch (err) { - error.Canceled => return error.Canceled, - else => return error.ConfigReadFailed, - }; - return bytes; -} - fn parseDoc(allocator: Allocator, source: []const u8) !*toml.Document { const result = toml.parseWithError(allocator, source, .{}); switch (result) { diff --git a/src/debug_log.zig b/src/debug_log.zig index 0adbe63..84a2ac8 100644 --- a/src/debug_log.zig +++ b/src/debug_log.zig @@ -1,18 +1,17 @@ -//! Debug-build log redirection. +//! Optional log redirection. //! -//! Debug builds emit a *lot* of `std.log.debug` traffic — most usefully the -//! raw JSON of every provider API request and response. Writing that to stderr -//! makes the interactive TUI unusable (every line scribbles over the frame). +//! `PANTO_DEBUG != 0` captures `std.log` traffic — most usefully the raw JSON +//! of every provider API request and response — to a per-session file. Writing +//! that to stderr makes the interactive TUI unusable. //! //! This module installs a custom `std.Options.logFn` (wired up via -//! `std_options` in `main.zig`). In **Debug** builds it routes the entire log -//! stream to a per-session file under +//! `std_options` in `main.zig`). When enabled it routes the entire log stream +//! to a per-session file under //! -//! ($PANTO_HOME | $XDG_DATA_HOME/panto | ~/.local/share/panto)/debug/<session-id>.log +//! ($XDG_DATA_HOME/panto | ~/.local/share/panto)/debug/<session-id>.log //! -//! and writes *nothing* to the terminal, leaving the TUI pristine. In any -//! other build mode it delegates to `std.log.defaultLog` (stderr, level-gated) -//! so release behavior is unchanged. +//! and writes nothing to the terminal. Otherwise it delegates to +//! `std.log.defaultLog`. //! //! The file is opened with `O_TRUNC`, so each session starts fresh; the file //! holds exactly one session's logs and never grows across runs. (A *new* @@ -26,19 +25,16 @@ //! approach already used by `tui_terminal.zig`. const std = @import("std"); -const builtin = @import("builtin"); const Io = std.Io; const Allocator = std.mem.Allocator; const panto_home = @import("panto_home.zig"); -/// True only in Debug builds; gates the file-redirect path at comptime. -const enabled = builtin.mode == .Debug; - /// A tiny atomic spinlock serializing writes. `logFn` can be called from any /// thread but has no `Io` handle, so `Io.Mutex` (which needs one) is out; /// contention is rare and each critical section is a single bounded write. var log_lock = std.atomic.Value(bool).init(false); +var capture_enabled = std.atomic.Value(bool).init(false); fn lockLog() void { while (log_lock.cmpxchgWeak(false, true, .acquire, .monotonic) != null) { @@ -58,19 +54,19 @@ var seq: u64 = 0; /// Buffer backing the per-call `Writer`. Guarded by `log_mutex`. var writer_buf: [16 * 1024]u8 = undefined; -/// Open the per-session debug log file and arm `logFn` to write to it. No-op -/// in non-Debug builds. Best-effort: any failure leaves `log_fd == -1`, and -/// `logFn` silently drops messages (debug logging must never break startup). +/// Open the per-session debug log file and arm `logFn` to write to it when +/// `PANTO_DEBUG != 0`. Best-effort: any failure leaves normal logging in place. /// /// Safe to call once, after the session id is known. `environ_map` is used to -/// resolve `$PANTO_HOME`; `io` is used only to create the `debug/` directory. +/// resolve the data home; `io` is used only to create the `debug/` directory. pub fn init( allocator: Allocator, io: Io, environ_map: *const std.process.Environ.Map, session_id: []const u8, ) void { - if (!enabled) return; + const debug = environ_map.get("PANTO_DEBUG") orelse return; + if (debug.len == 0 or std.mem.eql(u8, debug, "0")) return; var layout = panto_home.resolve(allocator, environ_map) catch return; defer layout.deinit(); @@ -96,17 +92,18 @@ pub fn init( defer unlockLog(); log_fd = fd; seq = 0; + capture_enabled.store(true, .release); } -/// Custom `std.Options.logFn`. Debug builds capture everything to the session -/// file (terminal stays clean); other builds use the stderr default. +/// Custom `std.Options.logFn`. `PANTO_DEBUG != 0` captures everything to the +/// session file; otherwise use the stderr default. pub fn logFn( comptime level: std.log.Level, comptime scope: @EnumLiteral(), comptime format: []const u8, args: anytype, ) void { - if (!enabled) { + if (!capture_enabled.load(.acquire)) { std.log.defaultLog(level, scope, format, args); return; } diff --git a/src/extension_loader.zig b/src/extension_loader.zig index 0ab8091..87eb609 100644 --- a/src/extension_loader.zig +++ b/src/extension_loader.zig @@ -6,13 +6,13 @@ //! //! Extensions (full-featured; call `panto.ext.register_tool` from a script //! that may register many tools): -//! 1. `$PANTO_HOME/agent/extensions/` ("base") +//! 1. `<data home>/agent/extensions/` ("base") //! 2. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/extensions/` ("user") //! 3. `./.panto/extensions/` ("project") //! //! Tools (ergonomic single-tool form; the script returns one table //! shaped like the argument to `panto.ext.register_tool`): -//! 1. `$PANTO_HOME/agent/tools/` ("base") +//! 1. `<data home>/agent/tools/` ("base") //! 2. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/tools/` ("user") //! 3. `./.panto/tools/` ("project") //! @@ -124,7 +124,7 @@ pub const Kind = enum { /// /// `base_agent_dir`, when non-null, is the path under which embedded /// base tools/extensions have been staged — typically -/// `$PANTO_HOME/agent/`. Pass `null` to skip the base layer entirely +/// `<data home>/agent/`. Pass `null` to skip the base layer entirely /// (mostly useful for tests). /// /// `environ_map` is consulted for `HOME` and `XDG_CONFIG_HOME`. The @@ -372,16 +372,19 @@ fn classifyFile( if (!std.mem.endsWith(u8, entry_name, ".lua")) return null; const base = entry_name[0 .. entry_name.len - ".lua".len]; if (base.len == 0) return null; + if (base[0] == '_') return null; const script_path = try std.fs.path.join(allocator, &.{ dir_path, entry_name }); errdefer allocator.free(script_path); const name = try allocator.dupe(u8, base); errdefer allocator.free(name); + const package_root = try allocator.dupe(u8, dir_path); + errdefer allocator.free(package_root); return Found{ .name = name, .script_path = script_path, - .package_root = null, + .package_root = package_root, .source = source, .kind = kind, }; @@ -445,44 +448,42 @@ const ShadowKeyCtx = struct { }; fn applyShadowing(allocator: Allocator, list: *std.array_list.Managed(Found)) !void { - var latest: std.HashMap(ShadowKey, usize, ShadowKeyCtx, std.hash_map.default_max_load_percentage) = .init(allocator); + const keep = try allocator.alloc(bool, list.items.len); + defer allocator.free(keep); + @memset(keep, false); - for (list.items, 0..) |f, i| { - try latest.put(.{ .kind = f.kind, .name = f.name }, i); - } + { + var latest: std.HashMap(ShadowKey, usize, ShadowKeyCtx, std.hash_map.default_max_load_percentage) = .init(allocator); + defer latest.deinit(); - var keep: std.array_list.Managed(Found) = .init(allocator); - var drop: std.array_list.Managed(Found) = .init(allocator); - errdefer { - latest.deinit(); - for (keep.items) |*f| f.deinit(allocator); - keep.deinit(); - for (drop.items) |*f| f.deinit(allocator); - drop.deinit(); + for (list.items, 0..) |f, i| { + try latest.put(.{ .kind = f.kind, .name = f.name }, i); + } + + for (list.items, 0..) |f, i| { + const winner = latest.get(.{ .kind = f.kind, .name = f.name }).?; + if (winner == i) { + keep[i] = true; + } else { + std.log.debug( + "{s}: '{s}' from {s} shadowed by {s}", + .{ f.kind.label(), f.name, f.source.label(), list.items[winner].source.label() }, + ); + } + } } - try keep.ensureTotalCapacity(list.items.len); - try drop.ensureTotalCapacity(list.items.len); - for (list.items, 0..) |f, i| { - const winner = latest.get(.{ .kind = f.kind, .name = f.name }).?; - if (winner == i) { - keep.appendAssumeCapacity(f); + var write: usize = 0; + for (list.items, keep) |f, k| { + if (k) { + list.items[write] = f; + write += 1; } else { - std.log.debug( - "{s}: '{s}' from {s} shadowed by {s}", - .{ f.kind.label(), f.name, f.source.label(), list.items[winner].source.label() }, - ); - drop.appendAssumeCapacity(f); + var dropped = f; + dropped.deinit(allocator); } } - - latest.deinit(); - for (drop.items) |*f| f.deinit(allocator); - drop.deinit(); - - list.clearRetainingCapacity(); - list.appendSlice(keep.items) catch unreachable; - keep.deinit(); + list.shrinkRetainingCapacity(write); } // --------------------------------------------------------------------------- @@ -527,6 +528,7 @@ test "scanDir picks up single-file and directory-style extensions" { try makeDir(tmp.dir, "ext_root"); try makeDir(tmp.dir, "ext_root/beta"); try writeFile(tmp.dir, "ext_root/alpha.lua", "-- alpha\n"); + try writeFile(tmp.dir, "ext_root/_helper.lua", "-- helper module\n"); try writeFile(tmp.dir, "ext_root/beta/init.lua", "-- beta init\n"); try writeFile(tmp.dir, "ext_root/beta/helper.lua", "-- helper\n"); try writeFile(tmp.dir, "ext_root/.ignored.lua", "-- hidden\n"); @@ -552,7 +554,7 @@ test "scanDir picks up single-file and directory-style extensions" { }.lt); try testing.expectEqualStrings("alpha", list.items[0].name); - try testing.expect(list.items[0].package_root == null); + try testing.expect(list.items[0].package_root != null); try testing.expect(std.mem.endsWith(u8, list.items[0].script_path, "alpha.lua")); try testing.expectEqualStrings("beta", list.items[1].name); diff --git a/src/lua_event_bridge.zig b/src/lua_event_bridge.zig index 26a4646..044dd55 100644 --- a/src/lua_event_bridge.zig +++ b/src/lua_event_bridge.zig @@ -670,13 +670,7 @@ fn cacheLines(cache: *RenderCache) []const []const u8 { /// C thunk installed as `panto.ext.emit` by the runtime (`installEmit`), /// carrying the `*EventBridge` as a light-userdata upvalue. Fires a custom -/// event on the native bus so native AND Lua handlers run. The `data` -/// argument is currently surfaced to handlers only as a `.custom` payload -/// (opaque); structured custom-data marshalling can be added later. The -/// chosen component (if any handler set one) is NOT auto-mounted here — -/// imperative mounting from a bare `emit` is a future refinement; for now -/// `emit` drives handler side effects and component selection for events -/// the app fires at real component-creation boundaries. +/// event on the native bus so native and Lua handlers run. pub fn emitThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { const L = L_opt.?; const self_ptr = c.lua_touserdata(L, c.lua_upvalueindex(1)); @@ -689,7 +683,7 @@ pub fn emitThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { if (nptr == null) return c.luaL_error(L, "emit: first argument must be an event name string"); const name = nptr[0..nlen]; - var ev = Event.init(name, null, .{ .custom = .{} }); + var ev = Event.init(name, null, .{ .custom = {} }); _ = bus.emit(&ev); return 0; } diff --git a/src/lua_runtime.zig b/src/lua_runtime.zig index 35247bb..56ce0db 100644 --- a/src/lua_runtime.zig +++ b/src/lua_runtime.zig @@ -30,6 +30,7 @@ const Allocator = std.mem.Allocator; const panto = @import("panto"); const lua_bridge = @import("lua_bridge.zig"); const lua_event_bridge = @import("lua_event_bridge.zig"); +const panto_home = @import("panto_home.zig"); const ui_event = @import("tui_event.zig"); const c = lua_bridge.c; @@ -1630,18 +1631,24 @@ test "yielding handler with no event loop surfaces LuaHandlerYielded" { try testing.expect(std.mem.indexOf(u8, okText(results[0]), "LuaHandlerYielded") != null); } -// Integration test: requires a `$PANTO_HOME` with luv already -// installed. Skipped if luv isn't on disk — unit tests stay offline. +// Integration test: requires a data home with luv already installed. +// Skipped if luv isn't on disk — unit tests stay offline. test "scheduler: yielding handler is resumed by libuv" { - const home_z = std.c.getenv("PANTO_HOME") orelse return error.SkipZigTest; - const panto_home_env = std.mem.sliceTo(home_z, 0); + var env = try processDataHomeEnv(testing.allocator); + defer env.deinit(); + const data_home = panto_home.homePath(testing.allocator, &env) catch |err| switch (err) { + error.NoHomeDirectory => return error.SkipZigTest, + else => return err, + }; + defer testing.allocator.free(data_home); + // Check for `<home>/rocks/lua-<version>/lib/lua/5.4/luv.so`. const manifest = @import("manifest.zig"); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const so_path = try std.fmt.bufPrint( &path_buf, "{s}/rocks/lua-{s}/lib/lua/{s}/luv.so", - .{ panto_home_env, manifest.lua_version, manifest.lua_short_version }, + .{ data_home, manifest.lua_version, manifest.lua_short_version }, ); std.Io.Dir.cwd().access(testing.io, so_path, .{}) catch return error.SkipZigTest; @@ -1673,12 +1680,7 @@ test "scheduler: yielding handler is resumed by libuv" { defer rt.deinit(); // Bootstrap luarocks (so `require("luv")` works), then install - // the scheduler. We use the real environment so the test picks - // up the same PANTO_HOME the developer's machine has. - var env: std.process.Environ.Map = .init(testing.allocator); - defer env.deinit(); - try env.put("PANTO_HOME", panto_home_env); - + // the scheduler from the normal data-home location. const luarocks_runtime = @import("luarocks_runtime.zig"); // The bootstrap needs a panto executable path for the wrapper // script; tests don't actually invoke it, so a placeholder is @@ -1777,20 +1779,23 @@ fn findAgentToolsDir() ![]const u8 { } fn bootstrapRealRuntime(rt: *LuaRuntime) !*@import("luarocks_runtime.zig").LuarocksRuntime { - const home_z = std.c.getenv("PANTO_HOME") orelse return error.SkipZigTest; - const panto_home_env = std.mem.sliceTo(home_z, 0); + var env = try processDataHomeEnv(testing.allocator); + errdefer env.deinit(); + const data_home = panto_home.homePath(testing.allocator, &env) catch |err| switch (err) { + error.NoHomeDirectory => return error.SkipZigTest, + else => return err, + }; + defer testing.allocator.free(data_home); + const manifest = @import("manifest.zig"); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const so_path = try std.fmt.bufPrint( &path_buf, "{s}/rocks/lua-{s}/lib/lua/{s}/luv.so", - .{ panto_home_env, manifest.lua_version, manifest.lua_short_version }, + .{ data_home, manifest.lua_version, manifest.lua_short_version }, ); std.Io.Dir.cwd().access(testing.io, so_path, .{}) catch return error.SkipZigTest; - var env: std.process.Environ.Map = .init(testing.allocator); - defer env.deinit(); - try env.put("PANTO_HOME", panto_home_env); const luarocks_runtime = @import("luarocks_runtime.zig"); const luarocks_rt = try luarocks_runtime.bootstrap( testing.allocator, @@ -1799,10 +1804,24 @@ fn bootstrapRealRuntime(rt: *LuaRuntime) !*@import("luarocks_runtime.zig").Luaro rt.L, "/usr/bin/true", ); + env.deinit(); try rt.installScheduler(); return luarocks_rt; } +fn processDataHomeEnv(allocator: Allocator) !std.process.Environ.Map { + var env = std.process.Environ.Map.init(allocator); + errdefer env.deinit(); + + if (std.c.getenv("XDG_DATA_HOME")) |value| { + try env.put("XDG_DATA_HOME", std.mem.sliceTo(value, 0)); + } + if (std.c.getenv("HOME")) |value| { + try env.put("HOME", std.mem.sliceTo(value, 0)); + } + return env; +} + // Reproduction: two REAL `std.shell` calls in one batch. shell.lua // uses spawn + two pipes + a timeout timer (3+ libuv events per call) // and resumes its own coroutine from a libuv callback. This exercises diff --git a/src/luarocks_runtime.zig b/src/luarocks_runtime.zig index 7991e4e..1505b8f 100644 --- a/src/luarocks_runtime.zig +++ b/src/luarocks_runtime.zig @@ -2,7 +2,7 @@ //! //! Responsibilities at startup (per LUA_MAKEOVER.md steps 3-5 and Q1-Q5): //! -//! 1. Resolve `$PANTO_HOME` and the per-Lua-version rocks tree +//! 1. Resolve the panto data home and the per-Lua-version rocks tree //! (`panto_home.zig`). Create the directory layout if missing. //! 2. Stage Lua headers under `<tree>/include/` (from `@embedFile`) //! so luarocks can compile C rocks against them. Idempotent: a @@ -21,7 +21,7 @@ //! 7. Reconcile the batteries manifest: for each pinned rock, check //! `<tree>/lib/luarocks/rocks-<short>/<name>/<version>/` and invoke //! `luarocks install` for anything missing. (Slow path; only the -//! first run after a fresh `$PANTO_HOME` actually downloads.) +//! first run after a fresh data home actually downloads.) //! //! Step 7 needs a usable `lua` executable on PATH from luarocks's point //! of view \u2014 it shells out for rockspec build scripts. We satisfy @@ -330,7 +330,7 @@ fn ensurePantoModuleSignature( // --------------------------------------------------------------------------- /// Materialize the binary-embedded `agent/` tree under -/// `$PANTO_HOME/agent/`. Each entry's `path` is relative to the agent +/// `<data home>/agent/`. Each entry's `path` is relative to the agent /// root and may include subdirectories (`tools/read.lua` etc.); the /// parent directory is created lazily as we encounter files inside it. /// @@ -360,7 +360,7 @@ fn stageAgentTree( } } -/// The default base `config.toml`, materialized at `$PANTO_HOME/config.toml` +/// The default base `config.toml`, materialized at `<data home>/config.toml` /// on first run if absent. It declares OpenAI and Anthropic providers, each /// naming an `[auth.<name>]` session whose key comes from the conventional /// env var — so a provider's first request fails with a clear auth error @@ -463,7 +463,7 @@ const default_base_config = \\ ; -/// Materialize the default base config at `$PANTO_HOME/config.toml`, +/// Materialize the default base config at `<data home>/config.toml`, /// but only if no file exists there yet. We never overwrite — the base /// config is user-editable, and a stale-but-edited file must win over the /// shipped default. @@ -579,7 +579,7 @@ fn writeShellQuoted(w: anytype, s: []const u8) !void { /// Materialize a luarocks config-<short>.lua under `layout.sysconfdir`. /// This is the file luarocks reads from `SYSCONFDIR`; we point every /// path-typed variable at the in-tree directories so installs land in -/// `$PANTO_HOME/rocks/lua-X.Y.Z/`. +/// `<data home>/rocks/lua-X.Y.Z/`. /// /// Format reference: docs/config_file_format.md in the luarocks repo. fn writeLuarocksConfig( diff --git a/src/main.zig b/src/main.zig index 4b26a22..2c5b414 100644 --- a/src/main.zig +++ b/src/main.zig @@ -8,7 +8,6 @@ const lua_event_bridge = @import("lua_event_bridge.zig"); const extension_loader = @import("extension_loader.zig"); 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"); @@ -20,9 +19,8 @@ const command = @import("command.zig"); const command_compaction = @import("compaction.zig"); const debug_log = @import("debug_log.zig"); -/// Route the process-wide log stream. In Debug builds this redirects every -/// `std.log` call to a per-session file (keeping the TUI clean); other builds -/// keep the stderr default. See `debug_log.zig`. +/// Route the process-wide log stream. `PANTO_DEBUG!=0` redirects it to a +/// per-session file; otherwise this keeps the stderr default. pub const std_options: std.Options = .{ .logFn = debug_log.logFn }; // TUI foundation layer (Phase 1, sub-phase 1). Not yet wired into the REPL; @@ -59,7 +57,6 @@ test { _ = extension_loader; _ = panto_home; _ = luarocks_runtime; - _ = self_exe; _ = subcommand; _ = models_toml; _ = config_file; @@ -169,7 +166,7 @@ pub fn main(init: std.process.Init) !void { // Resolve the absolute path of the running panto binary. Needed // both by `panto lua` (we re-exec ourselves through a wrapper // luarocks invokes) and by the agent's bootstrap. - const panto_path = try self_exe.selfExePathAlloc(alloc); + const panto_path = try std.process.executablePathAlloc(io, alloc); defer alloc.free(panto_path); // Subcommand dispatch: `panto lua` and `panto bootstrap` short @@ -289,10 +286,10 @@ pub fn main(init: std.process.Init) !void { // `session.info` is adopted by the agent below (`Agent.init` can't fail) // and freed in the agent's `deinit`; no separate cleanup here. - // Arm the per-session debug log now that we know the session id. In Debug - // builds this opens `$PANTO_HOME/debug/<id>.log` and redirects all - // `std.log` output there; no-op in release. Best-effort — failures leave - // logging disabled rather than aborting startup. + // Arm the per-session debug log now that we know the session id. + // PANTO_DEBUG!=0 redirects `std.log` output to + // `<data home>/debug/<id>.log`. Best-effort: failures leave normal + // logging in place. debug_log.init(alloc, io, init.environ_map, session.info.id); const is_resume = cli_flags.resume_kind != .none and session.info.message_count > 0; @@ -341,7 +338,7 @@ pub fn main(init: std.process.Init) !void { // Bootstrap luarocks against the Lua runtime's lua_State — same // pipeline as `panto lua` and `panto bootstrap`. After this, // `require("luarocks.*")` works and any pinned batteries from the - // manifest are installed under $PANTO_HOME. + // manifest are installed under the panto data home. const luarocks_rt = try luarocks_runtime.bootstrap( alloc, io, @@ -352,7 +349,7 @@ pub fn main(init: std.process.Init) !void { defer luarocks_rt.deinit(); // Source the system prompt now that the base agent tree has been - // staged to `$PANTO_HOME/agent` (the bootstrap above writes the + // staged to `<data home>/agent` (the bootstrap above writes the // bundled `SYSTEM.md` there). The prompt is sourced by convention // from SYSTEM.md / APPEND_SYSTEM.md across the base/user/project // layers; the base layer is `luarocks_rt.layout.agent_dir`. @@ -392,7 +389,7 @@ pub fn main(init: std.process.Init) !void { try rt.installScheduler(); // Discover Lua extensions across three layers — base - // ($PANTO_HOME/agent), user ($XDG_CONFIG_HOME/panto or + // (<data home>/agent), user ($XDG_CONFIG_HOME/panto or // $HOME/.config/panto), and project (./.panto). Project shadows // user shadows base; tool-name collisions across surviving // entries abort startup. diff --git a/src/models_toml.zig b/src/models_toml.zig index 6527292..9b1b4f0 100644 --- a/src/models_toml.zig +++ b/src/models_toml.zig @@ -2,8 +2,7 @@ //! //! Four files are read and merged, lowest precedence first: //! -//! 1. base — `$PANTO_HOME/models.toml` -//! (or `${XDG_DATA_HOME:-$HOME/.local/share}/panto/models.toml`) +//! 1. base — `${XDG_DATA_HOME:-$HOME/.local/share}/panto/models.toml` //! 2. user — `${XDG_CONFIG_HOME:-$HOME/.config}/panto/models.toml` //! 3. project — `./.panto/models.toml` //! 4. local — `./.panto/models.local.toml` @@ -79,6 +78,8 @@ const Io = std.Io; const toml = @import("toml"); const panto = @import("panto"); const tvalue = toml.value_mod; +const panto_home = @import("panto_home.zig"); +const toml_layer = @import("toml_layer.zig"); pub const Pricing = panto.Pricing; pub const Registry = panto.PricingRegistry; @@ -161,20 +162,13 @@ pub const Models = struct { } }; -/// Resolve the base-layer path to `models.toml`: `$PANTO_HOME/models.toml`, -/// falling back to `${XDG_DATA_HOME:-$HOME/.local/share}/panto/models.toml`. +/// Resolve the base-layer path to `models.toml`: +/// `${XDG_DATA_HOME:-$HOME/.local/share}/panto/models.toml`. /// Caller owns the returned slice. pub fn basePath(allocator: Allocator, environ_map: *const std.process.Environ.Map) ![]u8 { - if (environ_map.get("PANTO_HOME")) |home| { - return try std.fs.path.join(allocator, &.{ home, "models.toml" }); - } - if (environ_map.get("XDG_DATA_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, ".local", "share", "panto", "models.toml" }); - } - return error.NoHomeDirectory; + const home = try panto_home.homePath(allocator, environ_map); + defer allocator.free(home); + return try std.fs.path.join(allocator, &.{ home, "models.toml" }); } /// Resolve the user-layer path to `models.toml`. Caller owns the returned @@ -243,15 +237,17 @@ pub fn loadFromPaths(allocator: Allocator, io: Io, paths: []const []const u8) !M defer merged.deinit(); for (paths) |path| { - const bytes = readFileAlloc(allocator, io, path) catch |err| switch (err) { + const bytes = toml_layer.readFileAlloc(allocator, io, path) catch |err| switch (err) { error.FileNotFound => continue, - else => return err, + error.ReadFailed => return error.ModelsReadFailed, + error.Canceled => return error.Canceled, + error.OutOfMemory => return error.OutOfMemory, }; defer allocator.free(bytes); const doc = parseDoc(allocator, bytes) catch return error.InvalidModelsToml; defer doc.deinit(); - try mergeTable(merged.allocator(), merged.root, doc.root); + try toml_layer.mergeTable(merged.allocator(), merged.root, doc.root); } var models: Models = .{ @@ -269,20 +265,6 @@ pub fn loadFromPath(allocator: Allocator, io: Io, path: []const u8) !Models { return loadFromPaths(allocator, io, &.{path}); } -fn readFileAlloc(allocator: Allocator, io: Io, path: []const u8) ![]u8 { - const file = Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }) catch |err| switch (err) { - error.FileNotFound => return error.FileNotFound, - else => return error.ModelsReadFailed, - }; - defer file.close(io); - - const len = try file.length(io); - const bytes = try allocator.alloc(u8, @intCast(len)); - errdefer allocator.free(bytes); - _ = try file.readPositionalAll(io, bytes, 0); - return bytes; -} - fn parseDoc(allocator: Allocator, source: []const u8) !*toml.Document { const result = toml.parseWithError(allocator, source, .{}); return switch (result) { @@ -299,48 +281,6 @@ fn parseDoc(allocator: Allocator, source: []const u8) !*toml.Document { }; } -fn mergeTable(alloc: Allocator, dst: *toml.Value, src: *const toml.Value) Allocator.Error!void { - std.debug.assert(dst.* == .table); - if (src.* != .table) return; - - var it = toml.tableIterator(src); - while (it.next()) |entry| { - const existing = dst.get(entry.key); - if (existing != null and existing.?.* == .table and entry.value.* == .table) { - try mergeTable(alloc, @constCast(existing.?), entry.value); - } else { - const copy = try cloneValue(alloc, entry.value); - const key_copy = try alloc.dupe(u8, entry.key); - try tvalue.tableSet(alloc, dst, key_copy, copy); - } - } -} - -fn cloneValue(alloc: Allocator, src: *const toml.Value) Allocator.Error!*toml.Value { - const out = try alloc.create(toml.Value); - switch (src.*) { - .table => { - out.* = .{ .table = .{} }; - var it = toml.tableIterator(src); - while (it.next()) |entry| { - const child = try cloneValue(alloc, entry.value); - const key_copy = try alloc.dupe(u8, entry.key); - try tvalue.tableSet(alloc, out, key_copy, child); - } - }, - .array => |*a| { - out.* = .{ .array = .{} }; - for (a.items.items) |*item| { - const child = try cloneValue(alloc, item); - try tvalue.arrayAppend(alloc, out, child.*); - } - }, - .string => |s| out.* = .{ .string = try alloc.dupe(u8, s) }, - else => out.* = src.*, - } - return out; -} - /// Parse a TOML string into the given registries. Useful for tests. pub fn parseInto(models: *Models, source: []const u8) !void { const doc = try parseDoc(models.defs.allocator, source); @@ -623,17 +563,6 @@ test "ModelRegistry.get: returns null for unknown alias" { try testing.expect(models.defs.get("openai", "sonnet") == null); } -test "basePath: PANTO_HOME wins" { - const a = testing.allocator; - var env: std.process.Environ.Map = .init(a); - defer env.deinit(); - try env.put("PANTO_HOME", "/tmp/panto-home"); - try env.put("XDG_DATA_HOME", "/ignored"); - const got = try basePath(a, &env); - defer a.free(got); - try testing.expectEqualStrings("/tmp/panto-home/models.toml", got); -} - test "basePath: XDG_DATA_HOME falls back before HOME" { const a = testing.allocator; var env: std.process.Environ.Map = .init(a); diff --git a/src/panto_home.zig b/src/panto_home.zig index 5d111d6..bc715f0 100644 --- a/src/panto_home.zig +++ b/src/panto_home.zig @@ -1,10 +1,10 @@ -//! Filesystem layout resolution for `$PANTO_HOME` and the per-Lua +//! Filesystem layout resolution for the panto data home and the per-Lua //! version rocks tree. //! -//! $PANTO_HOME = $XDG_DATA_HOME/panto -//! (or $HOME/.local/share/panto if XDG_DATA_HOME unset) +//! data home = $XDG_DATA_HOME/panto +//! (or $HOME/.local/share/panto if XDG_DATA_HOME unset) //! -//! $PANTO_HOME/ +//! data home/ //! rocks/ //! lua-5.4.7/ ← current tree //! include/ ← Lua headers staged by bootstrap @@ -28,18 +28,18 @@ const Io = std.Io; /// fields are owned by the same allocator passed to `resolve`. pub const Layout = struct { allocator: Allocator, - /// `$PANTO_HOME` itself. + /// The panto data home. home: []u8, - /// `$PANTO_HOME/agent/` — the "base" extension/tool tree, + /// `<data home>/agent/` — the "base" extension/tool tree, /// populated at bootstrap from files embedded into the panto /// binary. Searched after user/project layers for tools and /// extensions; project shadows user shadows base. agent_dir: []u8, - /// `$PANTO_HOME/auth/` — persisted provider auth tokens, one + /// `<data home>/auth/` — persisted provider auth tokens, one /// `<auth-name>.json` per OAuth session. Files are written owner-only; /// treat them like passwords. auth_dir: []u8, - /// `$PANTO_HOME/rocks/lua-<lua_version>/` — the versioned tree. + /// `<data home>/rocks/lua-<lua_version>/` — the versioned tree. tree: []u8, /// `<tree>/include/` — where Lua headers are staged. include_dir: []u8, @@ -74,17 +74,15 @@ pub const Layout = struct { }; /// Resolve every path the runtime cares about. Environment-driven: -/// - `PANTO_HOME` (explicit override) /// - `XDG_DATA_HOME` (XDG default) /// - `HOME` (fallback) /// -/// Returns `error.NoHomeDirectory` if none of those are available and -/// no `PANTO_HOME` was set. +/// Returns `error.NoHomeDirectory` if neither is available. pub fn resolve( allocator: Allocator, environ_map: *const std.process.Environ.Map, ) !Layout { - const home = try resolveHome(allocator, environ_map); + const home = try homePath(allocator, environ_map); errdefer allocator.free(home); const agent_dir = try std.fs.path.join(allocator, &.{ home, "agent" }); @@ -140,15 +138,12 @@ pub fn resolve( }; } -/// Resolve `$PANTO_HOME` honoring overrides in the documented order. +/// Resolve the panto data home. Returns owned bytes. /// Returns owned bytes. -fn resolveHome( +pub fn homePath( allocator: Allocator, environ_map: *const std.process.Environ.Map, ) ![]u8 { - if (environ_map.get("PANTO_HOME")) |explicit| { - return allocator.dupe(u8, explicit); - } if (environ_map.get("XDG_DATA_HOME")) |xdg| { return std.fs.path.join(allocator, &.{ xdg, "panto" }); } @@ -184,20 +179,6 @@ fn makePathRecursive(io: Io, path: []const u8) !void { const testing = std.testing; -test "resolve: PANTO_HOME explicit override wins" { - var env: std.process.Environ.Map = .init(testing.allocator); - defer env.deinit(); - try env.put("PANTO_HOME", "/tmp/some/home"); - try env.put("XDG_DATA_HOME", "/should/be/ignored"); - - var layout = try resolve(testing.allocator, &env); - defer layout.deinit(); - - try testing.expectEqualStrings("/tmp/some/home", layout.home); - try testing.expect(std.mem.indexOf(u8, layout.tree, "/tmp/some/home/rocks/lua-") != null); - try testing.expect(std.mem.endsWith(u8, layout.share_lua_dir, "/share/lua/" ++ manifest.lua_short_version)); -} - test "resolve: XDG_DATA_HOME is honored before HOME" { var env: std.process.Environ.Map = .init(testing.allocator); defer env.deinit(); diff --git a/src/self_exe.zig b/src/self_exe.zig deleted file mode 100644 index 6eda92d..0000000 --- a/src/self_exe.zig +++ /dev/null @@ -1,90 +0,0 @@ -//! Resolve the absolute path of the currently running executable. -//! -//! Used to find the panto binary so we can launch ourselves as `panto -//! lua` from inside the bootstrap (the embedded luarocks shells out to -//! its configured `LUA` interpreter, which we point at a wrapper script -//! that exec's panto's `lua` subcommand). -//! -//! Zig's standard library doesn't expose a portable `selfExePath` in -//! the current API, so this module wraps the per-OS syscall. -//! -//! Supported: -//! - macOS / iOS / tvOS / watchOS: `_NSGetExecutablePath` + realpath -//! - Linux: `readlink("/proc/self/exe")` -//! - FreeBSD / NetBSD / DragonFly: `readlink("/proc/curproc/file")` -//! -//! Other targets currently return `error.SelfExePathUnavailable`. - -const std = @import("std"); -const builtin = @import("builtin"); - -const Allocator = std.mem.Allocator; - -pub const ResolveError = error{ - SelfExePathUnavailable, - SelfExePathTooLong, - SelfExePathReadFailed, - OutOfMemory, -}; - -/// Return the absolute, symlink-resolved path of the current process's -/// executable. Caller owns the returned slice. -pub fn selfExePathAlloc(allocator: Allocator) ResolveError![]u8 { - var buf: [std.fs.max_path_bytes]u8 = undefined; - const path = try selfExePath(&buf); - return allocator.dupe(u8, path); -} - -/// Fill `buf` with the absolute path. Returns the slice that was used. -pub fn selfExePath(buf: []u8) ResolveError![]u8 { - return switch (builtin.os.tag) { - .macos, .ios, .tvos, .watchos => try macosSelfExePath(buf), - .linux => try readLinkSelfExe(buf, "/proc/self/exe"), - .freebsd, .netbsd, .dragonfly => try readLinkSelfExe(buf, "/proc/curproc/file"), - else => return ResolveError.SelfExePathUnavailable, - }; -} - -fn readLinkSelfExe(buf: []u8, link_path: []const u8) ResolveError![]u8 { - const link_z = std.posix.toPosixPath(link_path) catch return ResolveError.SelfExePathTooLong; - const n = posixReadlink(&link_z, buf) catch return ResolveError.SelfExePathReadFailed; - return buf[0..n]; -} - -/// Thin wrapper around the POSIX `readlink` syscall using libc directly, -/// since std.posix's surface here has been in flux. libc is linked into -/// panto already (Lua needs it), so this stays cheap. -extern "c" fn readlink(path: [*:0]const u8, buf: [*]u8, bufsize: usize) isize; - -fn posixReadlink(path: [*:0]const u8, buf: []u8) !usize { - const n = readlink(path, buf.ptr, buf.len); - if (n < 0) return error.SelfExePathReadFailed; - return @intCast(n); -} - -extern "c" fn _NSGetExecutablePath(buf: [*]u8, bufsize: *u32) c_int; - -fn macosSelfExePath(buf: []u8) ResolveError![]u8 { - var stage: [std.fs.max_path_bytes]u8 = undefined; - var size: u32 = @intCast(stage.len); - if (_NSGetExecutablePath(&stage, &size) != 0) return ResolveError.SelfExePathTooLong; - // _NSGetExecutablePath populates `size` on overflow; on success - // we need to find the NUL ourselves. - const len = std.mem.indexOfScalar(u8, &stage, 0) orelse return ResolveError.SelfExePathTooLong; - - // The returned path may include `..` and symlinks; canonicalize so - // downstream consumers don't have to. realpath(3) on POSIX, both - // libc-resident on macOS and Linux. - const path_z = std.posix.toPosixPath(stage[0..len]) catch return ResolveError.SelfExePathTooLong; - - // libc realpath: NULL second arg => malloc; we want stack buffer. - var real_buf: [std.fs.max_path_bytes]u8 = undefined; - if (libc_realpath(&path_z, &real_buf) == null) return ResolveError.SelfExePathReadFailed; - const real_len = std.mem.indexOfScalar(u8, &real_buf, 0) orelse return ResolveError.SelfExePathTooLong; - if (real_len > buf.len) return ResolveError.SelfExePathTooLong; - @memcpy(buf[0..real_len], real_buf[0..real_len]); - return buf[0..real_len]; -} - -extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*]u8; -const libc_realpath = realpath; diff --git a/src/session_paths.zig b/src/session_paths.zig index 162085e..170c8fb 100644 --- a/src/session_paths.zig +++ b/src/session_paths.zig @@ -11,6 +11,7 @@ //! gives a flat directory name per project, easy to spot in `ls`. const std = @import("std"); +const panto_home = @import("panto_home.zig"); const Allocator = std.mem.Allocator; /// Resolve the absolute sessions directory for the given cwd. Caller owns @@ -46,13 +47,9 @@ pub fn resolveSessionsBase( 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; + const home = try panto_home.homePath(allocator, environ_map); + defer allocator.free(home); + return try std.fs.path.join(allocator, &.{ home, "sessions" }); } /// Encode a working directory into a flat directory name. Caller owns. diff --git a/src/subcommand.zig b/src/subcommand.zig index 9fdcc4c..0c04ce8 100644 --- a/src/subcommand.zig +++ b/src/subcommand.zig @@ -22,7 +22,6 @@ 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 config_file = @import("config_file.zig"); const auth_manager = @import("auth_manager.zig"); @@ -131,9 +130,9 @@ fn printHelp(io: Io) !void { \\ \\Environment: \\ OPENAI_API_KEY, ANTHROPIC_API_KEY Consumed by the default providers. + \\ PANTO_DEBUG Write std.log output to <data home>/debug/<session>.log. \\ 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(); @@ -142,7 +141,7 @@ fn printHelp(io: Io) !void { pub const BootstrapOptions = struct { /// Wipe the per-Lua-version tree before reinstalling everything. /// Surfaced as `panto bootstrap --force`. Equivalent to deleting - /// `$PANTO_HOME/rocks/lua-X.Y.Z/` by hand and then running + /// the data-home `rocks/lua-X.Y.Z/` tree by hand and then running /// `panto bootstrap`. force: bool = false, }; @@ -155,7 +154,7 @@ extern "c" fn panto_lua_pmain(L: *c.lua_State, argc: c_int, argv: [*]?[*:0]u8) c /// Drop into the embedded Lua standalone interpreter, with the /// luarocks runtime bootstrap completed so `require("luarocks.*")` -/// and rocks installed under `$PANTO_HOME` are visible. +/// and rocks installed under the panto data home are visible. /// /// argv is rewritten so the interpreter sees `lua [...args]` rather /// than `panto lua [...args]` — matching upstream behavior. The first @@ -1178,16 +1177,6 @@ fn authLogin( } // --------------------------------------------------------------------------- -// `panto lua` argv plumbing — sketched against the older Args API for -// reference (kept here so the design notes survive the implementation). -// --------------------------------------------------------------------------- -// -// Because we own the `lua_State` end-to-end, the subcommand can also -// expose extra panto-specific globals to user code (e.g. surface the -// resolved $PANTO_HOME) without disturbing upstream `lua.c` behavior. -// Step out of scope for the current makeover; add when needed. - -// --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1197,8 +1186,6 @@ const testing = std.testing; // controllable from a unit test. The behavior is exercised by // integration runs of the panto binary. We test the smaller pieces. // -// Suppress dead-code warnings for `self_exe` (it's used by main, not -// by tests in this module). test "providerModelsURL appends /models once" { const url = try providerModelsURL(testing.allocator, "https://api.example.com/v1/"); defer testing.allocator.free(url); @@ -1255,7 +1242,3 @@ test "parseProviderModelsFilter: keyed models object" { test "parseProviderModelsFilter: unrecognized json returns null" { try testing.expect((try parseProviderModelsFilter(testing.allocator, "{\"ok\":true}")) == null); } - -test { - _ = self_exe; -} diff --git a/src/system_prompt.zig b/src/system_prompt.zig index e2b2907..b2b01b1 100644 --- a/src/system_prompt.zig +++ b/src/system_prompt.zig @@ -6,7 +6,7 @@ //! CLI already uses, in precedence order base → user → project (project //! highest): //! -//! base = $PANTO_HOME/agent/ (= $XDG_DATA_HOME/panto/agent/) +//! base = <data home>/agent/ (= $XDG_DATA_HOME/panto/agent/) //! user = ${XDG_CONFIG_HOME:-$HOME/.config}/panto/ //! project = ./.panto/ //! @@ -30,7 +30,7 @@ const Io = std.Io; /// /// In normal operation this is never used: the base layer ships a bundled /// `agent/SYSTEM.md` (embedded in the binary and staged to -/// `$PANTO_HOME/agent/SYSTEM.md` at bootstrap), so a seed is always found. +/// `<data home>/agent/SYSTEM.md` at bootstrap), so a seed is always found. /// This constant only matters if that staged file is missing or /// unreadable. pub const default_seed = "You are a helpful assistant."; @@ -83,7 +83,7 @@ pub const Resolved = struct { /// Resolve the system-prompt blocks from the three config layers. /// -/// `base_dir` is the base layer directory (`$PANTO_HOME/agent`). The user +/// `base_dir` is the base layer directory (`<data home>/agent`). The user /// layer is derived from `XDG_CONFIG_HOME`/`HOME`; the project layer is /// `cwd()/.panto`. Allocations are made in `arena` (caller owns it). pub fn resolve( diff --git a/src/toml_layer.zig b/src/toml_layer.zig new file mode 100644 index 0000000..a2c1942 --- /dev/null +++ b/src/toml_layer.zig @@ -0,0 +1,68 @@ +const std = @import("std"); +const toml = @import("toml"); + +const Allocator = std.mem.Allocator; +const Io = std.Io; +const tvalue = toml.value_mod; + +pub const ReadError = Allocator.Error || Io.Cancelable || error{ FileNotFound, ReadFailed }; + +pub fn readFileAlloc(allocator: Allocator, io: Io, path: []const u8) ReadError![]u8 { + const file = Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }) catch |err| switch (err) { + error.FileNotFound => return error.FileNotFound, + error.Canceled => return error.Canceled, + else => return error.ReadFailed, + }; + defer file.close(io); + + const len = file.length(io) catch return error.ReadFailed; + const bytes = try allocator.alloc(u8, @intCast(len)); + errdefer allocator.free(bytes); + _ = file.readPositionalAll(io, bytes, 0) catch |err| switch (err) { + error.Canceled => return error.Canceled, + else => return error.ReadFailed, + }; + return bytes; +} + +pub fn mergeTable(alloc: Allocator, dst: *toml.Value, src: *const toml.Value) Allocator.Error!void { + std.debug.assert(dst.* == .table); + if (src.* != .table) return; + + var it = toml.tableIterator(src); + while (it.next()) |entry| { + const existing = dst.get(entry.key); + if (existing != null and existing.?.* == .table and entry.value.* == .table) { + try mergeTable(alloc, @constCast(existing.?), entry.value); + } else { + const copy = try cloneValue(alloc, entry.value); + const key_copy = try alloc.dupe(u8, entry.key); + try tvalue.tableSet(alloc, dst, key_copy, copy); + } + } +} + +fn cloneValue(alloc: Allocator, src: *const toml.Value) Allocator.Error!*toml.Value { + const out = try alloc.create(toml.Value); + switch (src.*) { + .table => { + out.* = .{ .table = .{} }; + var it = toml.tableIterator(src); + while (it.next()) |entry| { + const child = try cloneValue(alloc, entry.value); + const key_copy = try alloc.dupe(u8, entry.key); + try tvalue.tableSet(alloc, out, key_copy, child); + } + }, + .array => |*a| { + out.* = .{ .array = .{} }; + for (a.items.items) |*item| { + const child = try cloneValue(alloc, item); + try tvalue.arrayAppend(alloc, out, child.*); + } + }, + .string => |s| out.* = .{ .string = try alloc.dupe(u8, s) }, + else => out.* = src.*, + } + return out; +} diff --git a/src/tui_component.zig b/src/tui_component.zig index b213e3f..2a2aaa0 100644 --- a/src/tui_component.zig +++ b/src/tui_component.zig @@ -1,14 +1,9 @@ //! The core component contract for the TUI. //! -//! Dispatch is a vtable of function pointers over `*anyopaque` (decided in -//! plan §4.5; a tagged union was rejected so out-of-tree extensions can define -//! their own components later without editing a central enum). +//! Dispatch is a vtable of function pointers over `*anyopaque`. //! -//! The render engine (later sub-phase) holds a list of `Component`s, asks each -//! to `render(width)` into lines, and uses `firstLineChanged` to do a -//! differential repaint. This file defines only the interface plus a small -//! reusable cache/dirty mixin; it implements no concrete components and no -//! engine. +//! The render engine holds a list of `Component`s, asks each to render, and +//! uses `firstLineChanged` to do a differential repaint. const std = @import("std"); diff --git a/src/tui_components.zig b/src/tui_components.zig index 3944ffa..837db0d 100644 --- a/src/tui_components.zig +++ b/src/tui_components.zig @@ -2307,10 +2307,8 @@ pub const CompactionSummary = struct { // =========================================================================== /// Format the header line for a tool call, given the tool's name and -/// Render the framework-default tool header. The Zig core intentionally does -/// not special-case extension tool names here: extension-provided tools own -/// their display by registering `panto.ext.on("tool"/...)` handlers and -/// calling `event:setComponent(...)` for their own names. +/// Render the framework-default tool header. Extension-provided tools own +/// their display by registering event handlers and setting their component. /// /// Allocation: caller-owned; the returned slice is from `buf`. fn formatToolHeader(name: []const u8, input_json: []const u8, buf: []u8) []const u8 { diff --git a/src/tui_event.zig b/src/tui_event.zig index c32db39..3f59ba2 100644 --- a/src/tui_event.zig +++ b/src/tui_event.zig @@ -73,16 +73,6 @@ //! own default and returns that boundary's own chosen component. Parallel tool //! calls each get their own. //! -//! ## Bridge friendliness (§7.6) -//! -//! Dispatch is a vtable of function pointers over `*anyopaque`, matching the -//! `Component` vtable in `tui_component.zig`. A Lua-backed (or future C-ABI) -//! handler implements the same `Handler` callback shape; a Lua-defined -//! component implements the same `Component` vtable across the bridge. Nothing -//! here knows or cares whether a handler/component is native or bridged. The -//! Lua side is implemented in a LATER sub-phase; this module is Zig-only and -//! must not depend on the Lua machinery. - const std = @import("std"); const component = @import("tui_component.zig"); @@ -93,8 +83,7 @@ const Component = component.Component; // =========================================================================== /// A registered event handler. Vtable-style: a `callback` function pointer over -/// an opaque `ctx`, so a native closure, a Lua-backed handler, or a future -/// C-ABI handler all plug into the same shape (§7.6). +/// an opaque `ctx`. /// /// The callback receives the live `*Event`; it inspects `payload`, reads the /// current component with `event.getComponent()`, and optionally replaces it @@ -114,16 +103,8 @@ pub const Handler = struct { // Payload — structured per-event data (§7.2) // =========================================================================== -/// Structured data carried by an event, surfaced to handlers as typed fields -/// (the §7.2 `event.tool_name`, `event.args`, … shape). A tagged union keeps -/// the per-event fields explicit and bridge-friendly (the Lua bridge maps each -/// variant's fields onto the `event` object's properties). -/// -/// New built-in event types add a variant here; extension-defined events use -/// `.custom` with an opaque pointer the emitter and handler agree on. Borrowed -/// slices are valid only for the duration of the `emit` call (handlers must -/// copy anything they retain), mirroring the streaming-event borrow contract -/// elsewhere in panto. +/// Structured data carried by an event, surfaced to handlers as typed fields. +/// Borrowed slices are valid only for the duration of the `emit` call. pub const Payload = union(enum) { /// `session_start`: the welcome/banner boundary. session_start: SessionStart, @@ -146,9 +127,8 @@ pub const Payload = union(enum) { tool: Tool, /// `compaction`: a compaction-summary boundary. compaction: Compaction, - /// An extension-defined event. The emitter and handler agree on the - /// meaning of `data`; panto does not interpret it. - custom: Custom, + /// An extension-defined event with no structured payload. + custom: void, pub const SessionStart = struct { version: []const u8 = "", @@ -217,9 +197,6 @@ pub const Payload = union(enum) { pub const Compaction = struct { summary: []const u8 = "", }; - pub const Custom = struct { - data: ?*anyopaque = null, - }; }; // =========================================================================== @@ -399,7 +376,7 @@ test "emit with zero handlers returns the seeded default unchanged" { try testing.expectEqual(@as(*anyopaque, def.comp().ptr), out.?.ptr); // And a null default passes through as null. - const none = bus.fire("nope", null, .{ .custom = .{} }); + const none = bus.fire("nope", null, .{ .custom = {} }); try testing.expect(none == null); } |
