summaryrefslogtreecommitdiff
path: root/docs/libpanto-cleanup.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/libpanto-cleanup.md')
-rw-r--r--docs/libpanto-cleanup.md711
1 files changed, 0 insertions, 711 deletions
diff --git a/docs/libpanto-cleanup.md b/docs/libpanto-cleanup.md
deleted file mode 100644
index 7c53e02..0000000
--- a/docs/libpanto-cleanup.md
+++ /dev/null
@@ -1,711 +0,0 @@
-# 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`.