summaryrefslogtreecommitdiff
path: root/docs/archive/libpanto-cleanup.md
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-07 22:36:06 -0600
committert <t@tjp.lol>2026-06-08 12:25:55 -0600
commita5881243f9bb4642f90fa94179f7a5bff23e4657 (patch)
treeb225b85a8d21b6056e642390913b12ce950d740d /docs/archive/libpanto-cleanup.md
parent75dedacfbeab1fea281d1bce124b920dcf878844 (diff)
tui plan
Diffstat (limited to 'docs/archive/libpanto-cleanup.md')
-rw-r--r--docs/archive/libpanto-cleanup.md711
1 files changed, 711 insertions, 0 deletions
diff --git a/docs/archive/libpanto-cleanup.md b/docs/archive/libpanto-cleanup.md
new file mode 100644
index 0000000..7c53e02
--- /dev/null
+++ b/docs/archive/libpanto-cleanup.md
@@ -0,0 +1,711 @@
+# Plan: `libpanto` public API cleanup
+
+## Goal
+
+Define a small, deliberate public API for `libpanto` — the surface that the
+language bindings (`libpanto-c`, `libpanto-go`, `libpanto-py`, see
+`docs/libpanto-bindings.md`) and any Zig embedder wrap. Today the public
+surface is *accidental*: `root.zig` re-exports ~18 modules wholesale, so every
+`pub` declaration (≈180 of them) is reachable, including types that are `pub`
+only to cross Zig file boundaries (`SSEParser`, `EventQueue`, the `Disk*` wire
+types, provider-loop internals). The smaller the surface, the better.
+
+This is a **prerequisite for Phase 1** of the bindings work: you cannot wrap a
+C ABI cleanly over an API whose boundary is undefined.
+
+> **Updated during implementation (public.zig landed):** `public.zig` is now
+> the module root (`build.zig` points `panto` at it) and `root.zig` is
+> deleted; its `refAllDecls` test contract moved into `public.zig`. The
+> curated façade (behavioral wrappers `Agent`/`Stream`/`Conversation`,
+> `ResultParts`, the data aliases) is in place. `public.zig` *also* carries a
+> clearly-marked **transitional** block re-exporting the internal module
+> namespaces (`agent`, `conversation`, `session_store`, etc.) and the
+> freestanding `textResult`/`ownedTextResult`/`freeResultParts` that the
+> `panto` CLI still imports; Phase 4 trims these as the CLI migrates onto the
+> curated surface. `Stream.Phase` was made `pub` so `State` can alias it.
+
+## Guiding principle: the public API is additive, expressed in one file
+
+We do **not** rewrite the internals. Almost everything we want already exists.
+The cleanup is overwhelmingly *additive*:
+
+- A new file, **`public.zig`**, is the sole exported root. `build.zig` points
+ the `panto` module at `public.zig` instead of `root.zig`. Internal modules
+ keep their `pub` decls (still needed for cross-file linking) but are simply
+ **never re-exported**. The public surface becomes an explicit allowlist in
+ one file, not "whatever happens to be `pub`."
+- Where we want a different *name* or a *narrower* shape than the internal
+ type, `public.zig` provides a thin façade (see below). The internal
+ implementation is untouched.
+- Net-new convenience functions (e.g. `panto.init` wrapping the existing
+ `initHttp`) live in `public.zig` and call through to the unchanged internals.
+
+The result: most of this plan is "write `public.zig`," with a **single genuine
+internal refactor** (tool registry off `Config`, see Refactor R1) plus a
+**session-format change** we've wanted anyway (Refactor R2).
+
+## The façade mechanism (how a type exposes 1/5 of its fields)
+
+Zig has no `private` keyword. The idiomatic way to hide internal fields is a
+**wrapper type holding one pointer** to the internal struct, re-exposing only
+the methods/fields we want:
+
+```zig
+// public.zig
+const internal = @import("agent.zig");
+
+pub const Agent = struct {
+ inner: *internal.Agent,
+ pub fn run(self: Agent, msg: UserMessage) !Stream {
+ return .{ .inner = try self.inner.run(.{ .text = msg.text }) };
+ }
+ // ...one forwarder per public method
+};
+```
+
+Consumers hold `Agent` (the wrapper); its only field is `inner`, whose type
+(`internal.Agent`) is not re-exported, so the internal fields are unreachable.
+Renames are free: `pub const State = internal.Stream.Phase;` or an accessor
+`pub fn state(self: Stream) State { return self.inner.phase; }` renames without
+touching `agent.zig`.
+
+### Three buckets — wrap, handle, or alias
+
+> **Updated during implementation:** `Conversation` moved out of the
+> behavioral-wrap bucket. Its internal interface was pared down to exactly
+> the intended public surface (constructors + the `add*`/`replace*`
+> builders; `messages`/`allocator` are fine as public data fields for a Zig
+> object), so `public.zig` now **aliases** `conversation.Conversation`
+> straight through instead of pointer-wrapping it. `Agent.conversation()`
+> returns a borrowed `*Conversation` for in-place surgery. Only `Agent` and
+> `Stream` remain true façades — they have genuinely-internal fields
+> (`config`/`registry`/`session`/`open_stream_fn`/... and
+> `queue`/`response`/`pending_error`/...) plus API-shaping renames
+> (`Stream.phase`→`state`, `Phase`→`State`) that an alias can't express.
+> `addAssistantMessageWithUsage` was merged into
+> `addAssistantMessage(blocks, ?usage)` on the real type (the separate
+> method deleted), so the alias already has the intended one-method shape.
+
+> **Updated during implementation (impl cleanups synced the internals to the
+> public names):** the public-API shaping we'd done in the façade was pushed
+> *into* the internal types, so the wrappers collapsed to trivial forwarders
+> (and two of the three behavioral types became plain aliases):
+> - **`Stream` is now aliased** (`pub const Stream = agent_mod.Stream`).
+> `Stream.phase`→`state` and `Phase`→`State` renamed in the impl; `state`
+> is the one intended-public field (the transparent state machine), and
+> the rest (`_agent`/`_queue`/`_response`/`_start`/`_persisted`/
+> `_pending_error`) are underscore-prefixed internal state. `Agent.run`
+> returns `*Stream`.
+> - **Compaction renamed in the impl:** the pure no-persist transform is now
+> the private `_compactInPlace`; `compactAndPersist`→`compact` (the public
+> name, persists by default).
+> - **System-prompt split in the impl:** `addSystemMessage(text, mode)`
+> became `addSystemMessage(text)` + `setSystemPrompt(text)` (mode is an
+> internal `_persistSystemMessage` detail).
+> - **`UserMessage` moved to module scope** (off `Agent`); `public.UserMessage`
+> aliases it.
+> - **Pure-internal `Agent` fields underscore-prefixed:** `_open_stream_fn`
+> (the test seam), `_auto_compacted`, `_retry_prng`. The method-gated
+> state (`config`/`registry`/`session`/`conversation`) stays unprefixed
+> — it backs `setConfig`/`registerTool*`/`conversation()`/`sessionId()`
+> and is heavily referenced; the door is the method.
+>
+> Result: the `Agent` façade is now **pure 1:1 forwarders** (no renames or
+> merges left). It remains a wrapper only because `init` heap-pins the inner
+> (making the handle copyable / move-safe) and `conversation()`/`sessionId()`
+> are accessors. `Conversation` and `Stream` are aliases.
+
+| bucket | rule | types |
+| --- | --- | --- |
+| **behavioral (thin wrapper)** | heap-pin the inner; every method a 1:1 forwarder | `Agent` |
+| **behavioral (pared + aliased)** | trim the real type to the public surface, then alias | `Conversation`, `Stream` |
+| **data (read/output)** | **alias** straight through; inspected field-by-field | `Event` (+ nested), `Usage`, `Pricing`, `SessionInfo` |
+| **data (constructed)** | **alias** straight through; Zig users build them with `ArrayList` directly | `ContentBlock`, `Message`, the block types, `Config` family, `ResultPart` |
+
+**Why pointer-wrap all three behavioral types** (not value-wrap):
+
+- A value wrapper containing `internal.Agent` inline makes "is the inner
+ movable?" an invariant you must keep true forever. It already isn't safe to
+ move after `run()`: `Stream` holds `*Agent`. Nothing stops a Zig embedder
+ from `var a2 = a1;`.
+- Pointer-wrapping makes the invariant **structural**: the inner is
+ heap-pinned at `init`, the wrapper is a cheap copyable handle, "don't move
+ the Agent" stops being a rule anyone can violate.
+- The indirection cost is one dereference per *coarse* call (`run`, `compact`,
+ `registerTool` — all do I/O or allocation). Unmeasurable.
+- The C ABI heap-boxes the handle regardless, so the "save an `init` malloc"
+ argument for value-wrapping is illusory.
+
+**Handle types are minted per-call for free.** `Agent.conversation()` returns
+a `Conversation` wrapper that copies one pointer (`&self.inner.conversation`)
+— no rebuild, no copy of conversation data. A `Conversation` handle borrows
+state owned by its `Agent` and has the agent's lifetime.
+
+**Why alias the constructed data types** rather than build slice-based
+façades: for a *Zig* API, `ArrayList` is well understood and exposing it is
+fine. Conversation surgery (custom compaction, context management) wants the
+real types, transparently. The slice-based constructor ergonomics that a C
+caller needs are `libpanto-c`'s job — and building them once there covers the
+majority of language bindings. (`libpanto-py` is Zig too, so `ArrayList` is
+natural there as well.)
+
+---
+
+> **Updated during implementation (CLI migration landed):** the `panto` CLI
+> now consumes the curated `public.zig` surface for all data types
+> (`Config`/`ProviderConfig`/`ReasoningEffort`/..., `Message`/`MessageRole`/
+> `effectiveSystemBlocks`, `Event`, `Pricing`/`PricingRegistry`,
+> `Session`/`SessionStore`/`WireIdentity`/`PersistentMessage`,
+> `FileSystemJSONLStore`, `ContentBlockType`), for process lifecycle
+> (`panto.init`/`panto.deinit`), and for result-part ergonomics
+> (`ResultParts.fromText`/`fromTextOwned`/`deinit`, replacing the freestanding
+> `textResult`/`ownedTextResult`/`freeResultParts`).
+>
+> **Holes plugged (escape hatch removed).** The deep-embedder gap is closed;
+> the CLI now uses **only** the curated `public.zig` surface (zero
+> `panto.agent`/`panto.conversation` reaches), and the transitional
+> internal-namespace re-exports are deleted. The additions that closed it:
+> - **`Agent.init` / `Agent.deinit`** on the public façade (heap-pin the
+> inner; the handle is a cheap copyable value passed by-value everywhere).
+> - **`Agent.addSystemMessage(text)`** (`.append`) and
+> **`Agent.setSystemPrompt(text)`** (`.replace`) — the two `SystemMode`s.
+> - **`CompactionConfig.compaction_prompt`** owns the compaction system
+> prompt (auto-compaction reads it; the old `Agent.compaction_system_prompt`
+> field is deleted). **`Agent.compact(override_system_prompt: ?[]const u8,
+> extra)`** falls back to it when the override is null.
+> - **`ConversationData`** = the owned conversation value type (what
+> `Session.load` returns and `Agent.init` adopts), distinct from the
+> borrowed `Conversation` handle returned by `Agent.conversation()`.
+> - **`Agent.sessionId()`** accessor.
+>
+> `compactAndPersist` was an internal detail — the public `Agent.compact()`
+> *is* it (renamed, persists by default); the CLI `/compact` command now
+> calls `agent.compact(...)`.
+
+## The two jobs the API must serve
+
+Everything below is derived from two user jobs, not from "the CLI happens to
+call it."
+
+1. **Run an agent loop** — configure a provider, register tools, submit a
+ turn, consume the pull event stream, persist.
+2. **Construct & operate on conversations** — build a `Conversation` by hand,
+ insert/modify/delete content blocks, implement custom compaction or other
+ context-management surgery.
+
+If something serves neither job, it stays internal.
+
+---
+
+## Target surface
+
+### Process lifecycle
+
+```zig
+pub fn init(allocator: std.mem.Allocator, io: Io) void // wraps initHttp
+pub fn deinit() void // wraps deinitHttp
+```
+
+`httpClient` is **not** exposed (internal plumbing). `initHttp`/`deinitHttp`
+stay `pub` internally but are reached only through `init`/`deinit`.
+
+### Config (data, aliased)
+
+```zig
+pub const Config = config.Config; // see Refactor R1 — registry leaves it
+pub const ProviderConfig = config.ProviderConfig;
+pub const OpenAIChatConfig = config.OpenAIChatConfig;
+pub const AnthropicMessagesConfig = config.AnthropicMessagesConfig;
+pub const APIStyle = config.APIStyle;
+pub const ReasoningEffort = config.ReasoningEffort;
+pub const CompactionConfig = config.CompactionConfig; // now owns `compaction_prompt`
+pub const RetryConfig = config.RetryConfig;
+```
+
+### Agent (behavioral, pointer-wrapped)
+
+```zig
+pub const Agent = struct {
+ inner: *internal.Agent,
+
+ pub fn init(allocator, io, config: *const Config, store: SessionStore, maybe_conversation: ?Conversation) !Agent;
+ pub fn deinit(self: Agent) void;
+ // Note: no registry parameter (R1). A fresh Agent starts with an empty
+ // tool set; populate it with the register* methods below.
+
+ pub fn registerTool(self: Agent, tool: Tool) !void; // R1: registry now on Agent
+ pub fn registerToolSource(self: Agent, src: ToolSource) !void;
+ pub fn setConfig(self: Agent, config: *const Config) void; // swap provider/model between turns
+
+ pub fn run(self: Agent, message: UserMessage) !Stream;
+ // override_system_prompt falls back to config.compaction.compaction_prompt:
+ pub fn compact(self: Agent, override_system_prompt: ?[]const u8, extra: ?[]const u8) !CompactionResult; // persists
+
+ pub fn addSystemMessage(self: Agent, text: []const u8) !void; // .append, persists
+ pub fn setSystemPrompt(self: Agent, text: []const u8) !void; // .replace, persists
+
+ pub fn conversation(self: Agent) Conversation; // borrowed handle
+ pub fn sessionId(self: Agent) []const u8;
+};
+
+pub const UserMessage = struct { text: []const u8 }; // top-level, NOT nested in Agent
+pub const CompactionResult = agent.Agent.CompactionResult; // aliased data
+```
+
+Changes from today:
+
+- **`registerTool` / `registerToolSource` move onto `Agent`** (Refactor R1).
+ The tool set is no longer part of `Config`; swapping provider/model no
+ longer means rebuilding the whole tool list.
+- **`UserMessage` is top-level**, defined in `public.zig` (translated to the
+ internal `agent.Agent.UserMessage` inside `run`).
+- **`Agent.addSystemMessage` / `setSystemPrompt` are kept on `Agent`** (they
+ persist through the turn-path, which a bare `Conversation` mutation does
+ not). *Updated during implementation:* the plan originally proposed
+ dropping `Agent.addSystemMessage` in favor of `Conversation`-only system
+ messages, but the CLI's system-prompt seeding/reconciliation needs the
+ agent to *persist* the system entry with its `SystemMode`, so the two
+ agent methods stay (façade `addSystemMessage`/`setSystemPrompt`).
+- **`compactAndPersist` → `compact`** (persists by default). The pure
+ no-persist transform stays a private internal helper.
+- **`registry()` / `httpClient`-style accessors not exposed.**
+
+### Stream (behavioral, pointer-wrapped)
+
+```zig
+pub const Stream = struct {
+ inner: *internal.Stream,
+ pub fn next(self: Stream) !?Event;
+ pub fn deinit(self: Stream) void;
+ pub fn state(self: Stream) State; // renamed from internal `phase`
+};
+pub const State = internal.Stream.Phase; // renamed: Phase -> State, transparent state machine
+```
+
+`next`/`deinit` are the streaming contract from `docs/libpanto-bindings.md`
+(`!?Event`: value = progress incl. terminal, `null` = exhausted, `error` =
+failure). `state` is newly *exposed* (was a private field) and renamed to make
+the state machine transparent.
+
+### Event (data, aliased)
+
+```zig
+pub const Event = stream.Event; // union(enum), nested structs aliased with it
+```
+
+Aliased wholesale — `Event` and its nested structs (`BlockStart`,
+`ToolDetails`, `ContentDelta`, `BlockComplete`, `MessageComplete`,
+`ToolDispatchStart`, `ToolDispatchComplete`) are read-only output, inspected
+field-by-field. The nested-struct-inside-`Event` organization is good and
+stays. (Wrapping a tagged union by hand would be miserable and pointless.)
+
+Supporting data types referenced by `Event`:
+
+```zig
+pub const ContentBlockType = provider.ContentBlockType;
+pub const ProviderRetryInfo = provider.ProviderRetryInfo; // payload of provider_retry
+```
+
+### Conversation construction (data, aliased — Job 2)
+
+The full block/message/conversation surface, aliased straight through so Zig
+users can build and operate on conversations with the real `ArrayList`-backed
+types:
+
+```zig
+pub const Conversation = struct { // behavioral handle (pointer)
+ inner: *internal.Conversation,
+ // metadata: ?[]const u8 — conversation-level user bag, fixed at creation
+ // (init), persisted in the session header. JSON-string contract (below).
+ pub fn metadata(self) ?[]const u8; // read the header bag
+ pub fn addUserMessage(self, text) !void;
+ pub fn addAssistantMessage(self, blocks: []const ContentBlock, usage: ?Usage) !void; // merged
+ pub fn addSystemMessage(self, text) !void;
+ pub fn replaceSystemMessage(self, text) !void;
+ pub fn addCompactionSummary(self, text) !void;
+ pub fn messages(self) []Message; // direct access for surgery
+ // deinit only when standalone-owned; a borrowed handle from Agent does not own
+};
+
+// Block/message data types — aliased, constructed directly with ArrayList:
+pub const ContentBlock = conversation.ContentBlock;
+pub const Message = conversation.Message; // carries `metadata: ?[]const u8` (JSON bag)
+pub const MessageRole = conversation.MessageRole;
+pub const TextualBlock = conversation.TextualBlock;
+pub const ThinkingBlock = conversation.ThinkingBlock;
+pub const ToolUseBlock = conversation.ToolUseBlock;
+pub const ToolResultBlock = conversation.ToolResultBlock;
+pub const ResultPartStored = conversation.ResultPartStored;
+pub const StoredMediaPart = conversation.StoredMediaPart;
+pub const SystemBlock = conversation.SystemBlock;
+pub const SystemMode = conversation.SystemMode;
+pub const CompactionSummaryBlock = conversation.CompactionSummaryBlock;
+pub const Usage = conversation.Usage;
+pub const effectiveSystemBlocks = conversation.effectiveSystemBlocks; // replay helper
+```
+
+#### Per-message and per-conversation metadata (the JSON bag)
+
+To support custom history strategies (e.g. a "live compaction" scheme that
+tracks a parallel side-conversation against a smaller model and dynamically
+rebuilds history each turn), `libpanto` carries two opaque metadata bags it
+never interprets:
+
+- **`Message.metadata: ?[]const u8 = null`** — attached per message, round-trips
+ through persistence: set it before a turn commits, read it off the `Message`
+ after `load`. (e.g. a live message stores the ids of the side-conversation
+ summary messages that represent it.)
+- **`Conversation.metadata: ?[]const u8`** — conversation-scoped, **fixed at
+ creation time** (`Conversation.init`), stored in the session **header**.
+ (e.g. the pointer to the whole side-conversation.) It is set once and not
+ mutated, which is exactly what an append-only header supports.
+
+**Contract:** both are pure `[]const u8` throughout `libpanto`'s in-memory and
+wire modeling — the library treats them as opaque bytes and never parses them.
+The documented requirement on *users* is that, when present, the bytes are
+valid JSON (so a store may store them as a JSON column and tools may
+deserialize them). `libpanto` does not validate this.
+
+Changes:
+
+- **`addAssistantMessageWithUsage` → `addAssistantMessage(blocks, ?Usage)`** —
+ one method; pass `null` for no usage. The separate method is deleted.
+- **System messages live on `Conversation` only** (not `Agent`). The old
+ `Agent.addSystemMessage` also persisted; persistence is now strictly the
+ `Agent` turn-path's job. A bare `Conversation` mutation is in-memory until a
+ turn commits. This is sound for the append-only log because **every
+ `PersistentMessage` carries the full current `Conversation`** (see Sessions),
+ so whatever state a turn was built on is captured at write time regardless of
+ prior in-memory surgery. System-prompt changes specifically are expressed via
+ `replaceSystemMessage` (the `.replace` `SystemMode`), which the log records
+ faithfully.
+
+### Tools (data + small façade — Job 1)
+
+```zig
+pub const Tool = tool.Tool;
+pub const ToolSource = tool_source.ToolSource;
+pub const ToolDecl = tool.ToolDecl;
+pub const ToolCall = tool_source.Call;
+pub const ToolCallResult = tool_source.CallResult;
+pub const MediaPart = tool.MediaPart;
+
+// Result-part ergonomics — replace the freestanding trio with a thin struct
+// wrapper around the slice (a bare `[]ResultPart` alias can't carry methods):
+pub const ResultPart = tool.ResultPart;
+pub const ResultParts = struct {
+ items: []ResultPart,
+ pub fn fromText(alloc, text: []const u8) !ResultParts; // was textResult
+ pub fn fromTextOwned(alloc, text: []u8) !ResultParts; // was ownedTextResult
+ pub fn deinit(self: ResultParts, alloc) void; // was freeResultParts
+};
+```
+
+Changes:
+
+- **Drop the freestanding `textResult` / `ownedTextResult` /
+ `freeResultParts`** in favor of a `ResultParts` struct wrapping the slice,
+ with `fromText` / `fromTextOwned` constructors and a `deinit` method.
+- **`ToolRegistry` is hidden.** It's an internal organization structure; after
+ R1 the only public way to populate an agent's tool set is
+ `Agent.registerTool` / `registerToolSource`. Users no longer pre-build a
+ registry to seed `Agent.init`.
+
+> **Deferred:** removing a tool from an active `Agent`. Today the workaround is
+> to build a fresh `Agent` with the same config + conversation and re-register
+> the desired tools. A future `Agent.unregisterTool(name)` convenience is out
+> of scope for this project.
+
+### Pricing (data, aliased — Job 1)
+
+```zig
+pub const Pricing = pricing.Pricing;
+pub const PricingRegistry = pricing.Registry;
+// cost becomes a method:
+// Pricing.cost(self, usage: Usage) ?u64 (was freestanding costMicroCents(usage, pricing))
+```
+
+Change: **`costMicroCents` → `Pricing.cost(usage)`** — namespaced on the rate
+card (pricing is the rates, usage is the input).
+
+### Sessions (Refactor R2 — redesigned)
+
+A clean re-think. The interface is **asymmetric**: rich on write
+(audit/provenance-capable), minimal on read (resume-oriented). The store
+decides how much write-side richness it durably keeps.
+
+```zig
+pub const PersistentMessage = struct { // the rich, audit-oriented write type
+ message: Message, // the message being appended (carries its own metadata)
+ usage: ?Usage,
+ // wire-format provider identity (no CLI aliases, and NO api_key material
+ // of any kind — not even a hash; keys never enter libpanto's records):
+ api_style: APIStyle,
+ base_url: []const u8,
+ model: []const u8,
+ reasoning: ReasoningEffort,
+ // full provenance context, captured every append:
+ conversation: []const Message, // the entire current conversation at write time
+ tools_available: []const ToolDecl, // the tool set offered for this turn
+};
+```
+
+```zig
+pub const SessionStore = struct { // vtable interface; impls own their own init (DSN, dir, ...)
+ ptr: *anyopaque,
+ vtable: *const VTable,
+ pub const VTable = struct {
+ create: *const fn (ctx: *anyopaque) Session,
+ list: *const fn (ctx: *anyopaque) anyerror![]SessionInfo,
+ freeSessionInfos:*const fn (ctx: *anyopaque, infos: []SessionInfo) void,
+ resolve: *const fn (ctx: *anyopaque, id: []const u8) anyerror!?Session,
+ latest: *const fn (ctx: *anyopaque) anyerror!?Session,
+ load: *const fn (ctx: *anyopaque, id: []const u8) anyerror!?Conversation,
+ appendMessages: *const fn (ctx: *anyopaque, session_id: []const u8, messages: []PersistentMessage) anyerror!void,
+ };
+ // thin proxies for each
+};
+
+pub const NullStore = ...; // persists nothing; empty/no-op impl of every method
+pub const FileSystemJSONLStore = ...; // renamed from SessionManager; directory-backed JSONL
+
+pub const SessionInfo = struct { // pure data, aliased; for display/selection (`panto sessions`)
+ id: []const u8,
+ created: []const u8,
+ modified: []const u8,
+ message_count: usize,
+ last_user_message: []const u8, // may be truncated
+ // last-used wire identity, updated on append (for resume pre-selection):
+ api_style: APIStyle,
+ base_url: []const u8,
+ model: []const u8, // wire model name, NOT a CLI config alias
+ reasoning: ReasoningEffort, // disambiguates otherwise-identical aliases
+};
+
+pub const Session = struct { // pure data: SessionInfo + a store to proxy to
+ info: SessionInfo,
+ store: SessionStore,
+ pub fn load(self: Session) !Conversation; // by value; null->error (id came from resolve/latest)
+ pub fn append(self: *Session, messages: []PersistentMessage) !void; // *Session: updates info.{api_style,base_url,model,reasoning}
+};
+```
+
+Design decisions:
+
+- **No allocator/io in vtable signatures.** A store captures whatever it needs
+ at its own (unprescribed) `init` — a Postgres store takes a DSN, the FS store
+ takes a directory. Returned `Conversation` self-describes its allocator;
+ `[]SessionInfo` is freed via the `freeSessionInfos` vtable method (rather
+ than struct-wrapping the slice to carry an allocator).
+- **`create` returns `Session` (not `!Session`)** — minting an in-memory handle
+ can't fail. Create-on-demand: nothing hits the backend until the first
+ `appendMessages`, preserving the "no record before the first assistant
+ message" invariant.
+- **`Session.load` returns `!Conversation` by value, optional dropped** — the
+ id came from `resolve`/`latest`, so the conversation must exist; a `null`
+ from the store is promoted to an error. (`Conversation` is a small movable
+ struct; the `ArrayList` buffer is the only heap part. No `*Conversation`.)
+- **No `activeModel` vtable method.** The last-used wire identity lives on
+ `SessionInfo` and is updated by `Session.append` after it proxies to the
+ store — hence `append` takes `*Session`.
+- **All session identity is wire-format**, never CLI config aliases:
+ `{api_style, base_url, model, reasoning}`. (See R2 for the format change.)
+
+#### The write/read asymmetry, and provenance
+
+- **Write (`appendMessages` / `PersistentMessage`) is maximalist** — the rich,
+ audit-oriented type. It carries full wire identity, usage, content, the
+ **entire current conversation**, and the **tool set offered** for the turn.
+ The library **offers** all of it on every append; the store keeps what it
+ wants. This makes `libpanto` provenance-*capable* for compliance-concerned
+ consumers who implement their own store.
+- **`api_key` material never enters a record** — not the key, not a hash of it.
+ Library-level guarantee, not a per-store choice. (Hashing keys is a
+ deliberate non-goal: it's needless risk in `libpanto` core.)
+- **Read (`load`, `SessionInfo`) is minimal** — exactly what resume and
+ selection need. The vtable never demands a store reproduce provenance on
+ read, because a store may not have kept it. The read path deserializes
+ `Conversation` + `SessionInfo`, never a `PersistentMessage`.
+- **`FileSystemJSONLStore` deliberately stays minimal.** It ignores the
+ `conversation` and `tools_available` provenance fields and simply appends the
+ latest `message` (plus wire identity + usage). The rich fields exist for
+ *other* stores; the built-in FS store is not an audit store.
+
+> **Audit-store implementation note (for `SessionStore` docs).** A serious
+> audit log should **not** store the full `tools_available` (and likely not the
+> full `conversation`) verbatim on every record — that re-serializes the whole
+> tool manifest each turn. The recommended design is **content-addressing**:
+> hash each tool's `(name, description, schema_json)`, keep a deduplicated tool
+> table keyed by that hash, and store only the hash list on each record,
+> normalizing the log by joining records against the tool table. The same
+> applies to large repeated context. `libpanto` hands you the raw data each
+> append; efficient de-duplication is the store's responsibility.
+
+#### Provider/model pre-selection on load
+
+The session log stores **wire facts only** (`{api_style, base_url, model,
+reasoning}`), never config aliases. Pre-selecting "the provider/model they left
+off with" is a **lookup against current config at load time**, not a stored
+pointer:
+
+- A helper `match(config, session_info) -> []candidate` walks configured
+ providers/aliases and returns every alias whose `(api_style, base_url, model,
+ reasoning)` equals the session stamp.
+- 1 candidate → pre-select. 0 → readable, no pre-selection (prompt / read-only).
+ N (e.g. two API keys for the same endpoint, or two reasoning levels) →
+ embedder picks (default / first / prompt).
+
+Storing the alias instead would freeze stale config state (aliases get renamed)
+and *still* fail the N-match case (two keys are indistinguishable on the wire).
+Recording `reasoning` as a wire field is what splits otherwise-identical
+aliases. The CLI owns the pick; `libpanto` may provide `match` as a helper over
+`Config`.
+
+---
+
+## Refactors (the non-additive work)
+
+Everything else is `public.zig`. These two are genuine internal edits.
+
+### R1 — tool registry off `Config`, onto `Agent`
+
+Today `Config.registry: *const ToolRegistry` and `Agent.run` reads
+`config.registry`. Consequence: swapping config (provider/model change) forces
+rebuilding the tool set. Move tool-set ownership to `Agent`:
+
+- `Agent` owns/holds the `ToolRegistry` (or borrows one supplied at `init`).
+- `Agent.registerTool` / `registerToolSource` mutate it.
+- `Config` carries only provider/model/retry/compaction; `setConfig` swaps
+ those without touching tools.
+- The agent loop reads the registry from `self`, not `self.config`.
+
+This is the one change a façade can't paper over — it relocates *where the
+running loop reads the tool set from*.
+
+### R2 — session-log format: wire-format identity, no config aliases
+
+> **Updated during implementation (R2 landed):**
+> - `session_manager.zig` → **`file_system_jsonl_store.zig`**; the old
+> `SessionManager` single-session machinery is now the internal
+> `SessionFile`, and a new directory-backed catalog struct
+> **`FileSystemJSONLStore`** implements the redesigned `SessionStore`
+> vtable (`create`/`list`/`freeSessionInfos`/`resolve`/`latest`/`load`/
+> `appendMessages`). It caches open `SessionFile`s by id so the
+> buffer-until-first-assistant write discipline survives the separate
+> user/assistant appends of one turn.
+> - The on-disk *content* types keep the name **`Stored*`** (e.g.
+> `StoredMessage`, `StoredContentBlock`) to avoid colliding with the rich
+> store write record `PersistentMessage` (which wraps an in-memory
+> `Message` + `WireIdentity` + provenance). `Disk*` → `Stored*`/
+> `Persistent*` accordingly.
+> - Wire identity on disk is a `WireStamp { api_style, base_url, model,
+> reasoning }` on each `MessageEntry` (null on system entries), replacing
+> the single `provider` string. The agent derives it from
+> `Config.provider.wireIdentity()`; **`persist_provider`/`persist_model`
+> display strings are deleted** — the CLI banner stays alias-based and
+> resume picks the default model (the `match` helper stays deferred).
+> - **`Agent.init` now takes a `Session`** (minted via `store.create()` or
+> resolved via `resolve`/`latest`) rather than a raw `SessionStore`; the
+> agent adopts and frees `session.info` in `deinit`.
+> - **Dangling-prompt recovery dropped** (resolved decision below).
+> - `PersistentMessage` carries the provenance fields (`conversation`,
+> `tools_available`); the agent currently passes an **empty
+> `tools_available`** (the FS store ignores it; a real tool-manifest
+> snapshot is a follow-up). `Message.metadata` round-trips through the
+> `Stored*` layer; `Conversation.metadata` is not yet wired.
+
+The on-disk session format records **wire-format** provider/model identity
+(`api_style`, `base_url`, `model`, `reasoning`), replacing today's single
+`provider` string and any CLI-alias coupling. `Disk*` types are renamed to
+`Persistent*` (e.g. `DiskMessage` → `PersistentMessage`), and
+`PersistentMessage` carries its own `provider`/`model` wire identity rather
+than the parallel `providers[]`/`models[]` slices the current
+`appendMessages` takes.
+
+**Clean break, no version bump.** Pantograph has effectively a single user
+(the author), so existing logs are simply wiped rather than migrated. No
+compat shim, and `CURRENT_VERSION` need not change.
+
+---
+
+## Internals that leave the public surface (derived deletions)
+
+Once `public.zig` is the allowlist, these stop being reachable (they stay `pub`
+internally for linking, just un-re-exported):
+
+- **`provider.zig`**: `ProviderStream`, `openStream`, `OpenStreamFn`,
+ `ProviderDiagnostic`, `ProviderError`, `isContextOverflowBody`,
+ `classifyHttpStatus`, `parseRetryAfterMs`, `retryAfterFromHead`,
+ `isRetryableProviderError`. (`ContentBlockType`, `ProviderRetryInfo` stay —
+ referenced by `Event`.)
+- **`stream.zig`**: `EventQueue` (internal plumbing; only `Event` escapes).
+- **`sse.zig`**: `SSEParser` (pure internal).
+- **`session.zig`**: the serialization layer — `serializeHeader`,
+ `serializeEntry`, `parseLine`, `contentBlockToDisk`,
+ `diskContentBlockToInternal`, `SessionHeader`, `SessionEntry`, `EntryBase`,
+ `MessageEntry`, `FileEntry`, `ParseError`, `CURRENT_VERSION`. The
+ `Persistent*` (ex-`Disk*`) content types are referenced by
+ `PersistentMessage` on the write path; expose only what `appendMessages`
+ needs.
+- **`turn_persist.zig`**: `persistTurn`, `persistCompaction`,
+ `hasToolUseWithoutFollowingResults` (internal to the agent/session loop).
+- **`compaction.zig`**: `computeSplit`, `serializeTranscript`,
+ `buildRequestBody`, `messageTokenEstimate`, `Split`, `word_to_token_factor`,
+ `latestSummaryText` (internals; `Agent.compact` is the entry).
+- **`tool_registry.zig`**: name-encoding helpers (`validateName`, `encodeName`,
+ `decodeName`, `max_wire_name_len`, `NameError`, `Entry`, `ToolView`,
+ iterators) — `ToolRegistry` itself stays, its internals don't.
+- **`config.zig`**: `httpClient` (and `initHttp`/`deinitHttp` only reachable via
+ `init`/`deinit`).
+- **`image.zig`**: `process`, `maybeResize`, `detectCodec`, `detectMediaType`,
+ `codecForMediaType`, `Codec`, `Processed`, `max_dim` — used internally at
+ tool-result assembly; not a Job-1/Job-2 concern. **Hidden.** Note the
+ user-visible *behavior* must still be documented: tool-returned images have a
+ maximum longer-side dimension and `libpanto` resizes larger ones down before
+ storage/serialization (see the deferred config-setting item above).
+- **`session_manager.zig` freestanding utils**: `newUuidV7`, `isoTimestamp`
+ (id/timestamp minting — internal to whichever store wants them).
+
+---
+
+## Out of scope (deliberately deferred)
+
+- **A built-in audit/provenance *store*.** Provenance *capability* is in scope
+ — `PersistentMessage` is rich (full conversation + tool manifest + wire
+ identity) so a consumer's store can capture it. But `libpanto` does not ship
+ an audit store; `FileSystemJSONLStore` stays minimal. The content-addressing
+ design above is a recommendation in the `SessionStore` docs, not code we
+ write here.
+- **Slice-based block/message constructors for non-Zig callers.** That's
+ `libpanto-c`'s job (covers Go and other cgo/cffi consumers in one place).
+- **The provider-config `match` helper UI.** `libpanto` may ship `match`; the
+ pick policy (default/first/prompt) is the embedder's.
+- **Configurable image max-dimension.** `libpanto` resizes oversized images
+ down to a maximum longer-side dimension (today a hard-coded `max_dim` in
+ `image.zig`). This should become a `libpanto` config setting (single int,
+ longer side), with the `panto` CLI supplying the value instead of the value
+ living in the library. Deferred; for now the behavior is documented (see
+ below) but the constant stays put.
+- **`Agent.unregisterTool`.** Removing a tool from a live agent; today rebuild
+ a fresh `Agent`. Deferred convenience.
+
+---
+
+## Resolved decisions (formerly open)
+
+0. **Dangling-prompt recovery — dropped (R2).** The old `LoadedSession`
+ split out a trailing crash-orphaned user prompt so resume wouldn't
+ auto-resend it. `Session.load()` returns a plain `Conversation`; a
+ trailing user message round-trips like any other.
+1. **Conversation-level persistence — Agent-only.** A bare `Conversation`
+ mutation is in-memory; persistence is strictly the `Agent` turn-path's job.
+ Append-only provenance is preserved because every `PersistentMessage`
+ carries the full current conversation at write time.
+2. **`image.zig` — hidden.** Resize behavior is documented; a configurable
+ max-dimension is deferred (see Out of Scope).
+3. **R2 migration — clean break, no version bump, no shim.** Wipe old logs.
+4. **`ToolRegistry` — hidden.** `Agent.registerTool` / `registerToolSource` is
+ the only public path; no pre-built registry seeds `Agent.init`.
+ `Agent.unregisterTool` deferred (see Out of Scope).
+5. **`ResultParts` — thin struct wrapper** around `[]ResultPart`, carrying
+ `fromText` / `fromTextOwned` / `deinit`.