summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorT <t@tjp.lol>2026-05-27 12:45:20 -0600
committerT <t@tjp.lol>2026-05-27 16:21:52 -0600
commit6545cdfd8f2bc865aa06a2b5515056daf58ba111 (patch)
tree5393cefda42dad12eb90612c6b7ff3c50d8eb800 /docs
parentb1a155273662d7dc2ea1fe0cfc43c1741ed30b6d (diff)
session files
Diffstat (limited to 'docs')
-rw-r--r--docs/archive/phase-4.md (renamed from docs/phase-4.md)50
1 files changed, 24 insertions, 26 deletions
diff --git a/docs/phase-4.md b/docs/archive/phase-4.md
index 9dac510..e211e1f 100644
--- a/docs/phase-4.md
+++ b/docs/archive/phase-4.md
@@ -2,7 +2,9 @@
## Goal
-Persistent session storage as an append-only event log. Every event in a session — messages, which provider/model handled each request — is recorded to disk as it happens. On load, pantograph replays the event log to fully rebuild conversation state. Sessions survive process restarts and can be reviewed later.
+Persistent session storage as an append-only event log. Every event in a session — messages, which provider/model handled each request — is recorded to disk as it happens. On load, pantograph reads the log and rebuilds the in-memory conversation. Sessions survive process restarts and can be reviewed later.
+
+Unlike systems that derive state by folding a stream of typed events (model-change entries, thinking-level-change entries, etc.), pantograph's load is mechanically simple: walk the entries, instantiate one in-memory `Message` per `message` entry, copy provider/model stamps as-is. There is no event replay or state derivation — the leaf entry's stamp _is_ the active model. The format leaves room for richer event types in the future (compaction, branching), but phase 4 doesn't introduce any.
## Deliverable
@@ -105,9 +107,7 @@ First (and only) header line of the file. Metadata only — not part of the tree
"version": 1,
"id": "019dc5ba-53f6-71a5-ab8f-b1f8709c2572",
"timestamp": "2026-04-25T17:40:15.990Z",
- "cwd": "/Users/travis/Code/pantograph",
- "provider": "anthropic",
- "model": "claude-sonnet-4-20250514"
+ "cwd": "/Users/travis/Code/pantograph"
}
```
@@ -117,10 +117,8 @@ First (and only) header line of the file. Metadata only — not part of the tree
| `id` | string | Session UUIDv7. |
| `timestamp` | string | ISO 8601 timestamp of session creation. |
| `cwd` | string | Working directory where the session was started. |
-| `provider` | string | Initial provider (`"openai"` or `"anthropic"`). |
-| `model` | string | Initial model name. |
-The `provider` and `model` fields serve as the baseline for model tracking. If no user messages have been submitted yet, the header's values are the active provider/model.
+There are no provider/model fields on the header. The session file isn't created until the first assistant message flushes, at which point a user message is already buffered — so every persisted session contains at least one user-message stamp, and that stamp is the authoritative initial model.
### Versioning and migration
@@ -289,7 +287,7 @@ Every entry with `type: "message"` and `message.role: "user"` carries top-level
}
```
-The model history is self-evident from the log: scan user messages in order and you see exactly which provider/model handled each request. The header's `provider`/`model` fields serve as the baseline for the first turn — if no user messages have been submitted yet, the header's values are the active model.
+The model history is self-evident from the log: scan user messages in order and you see exactly which provider/model handled each request. The first user message's stamp is the session's initial model.
---
@@ -315,21 +313,22 @@ The tree structure is defined now so that the on-disk format supports branching
## Rebuilding State from the Log
-On resume, pantograph fully rebuilds conversation state by replaying the event log:
+On resume, pantograph reads the log top-to-bottom and rebuilds the in-memory state directly:
1. **Read the file line by line.** Parse each line as a JSON object.
-2. **Crash recovery.** If the last line doesn't parse as a complete JSON object, delete it from the file.
-3. **Extract the header.** First line must be a `session` header. Extract version, id, cwd, initial provider/model.
-4. **Build the entry index.** Map entry `id` → entry, track `leafId` (the last entry's id).
-5. **Walk the tree.** From leaf to root, collect entries in root-to-leaf order.
-6. **Reconstruct the Conversation.** For each `message` entry on the path:
+2. **Crash recovery.** If a line fails to parse, truncate the file at the start of that line (see "Crash Recovery" above).
+3. **Extract the header.** First line must be a `session` header. Extract version, id, cwd.
+4. **Build the entry index.** Map entry `id` → entry, track `leafId` (the last entry's id). In phase 4 entries form a linear chain, so the read order _is_ the path from root to leaf.
+5. **Reconstruct the Conversation.** For each `message` entry, in order:
- Construct a `Message { role, content }` from the entry's `message` field.
- For each content block in the JSON, create the corresponding `ContentBlock` variant.
- `TextualBlock` fields (Text, Thinking) are initialized with their complete content in a single append — no streaming involved.
- `ToolUseBlock.input` is initialized with the raw JSON string from the `input` field.
- `ToolResultBlock.content` is initialized with the string from the `content` field.
-7. **Reconstruct the active model.** Walk the path and find the last user message entry with `provider`/`model` fields. Those fields (or the header defaults if no user message exists) determine the active provider/model.
-8. **Result:** A `Conversation` ready for the agent loop, plus the active provider/model for configuration.
+6. **Reconstruct the active model.** Walk entries leaf-to-root and find the last user-message entry with `provider`/`model` fields. Those fields determine the active provider/model. A valid persisted session always contains at least one user message (the deferred-file-creation rule guarantees it).
+7. **Result:** A `Conversation` ready for the agent loop, plus the active provider/model for configuration.
+
+There is no "event replay" — entries map 1:1 to in-memory messages, and the active model is read directly off the leaf's stamp. Compaction (phase 6) will introduce a single derived-state entry (`compaction` summarizes a prefix of the log), but that's still not event-folding; it's a special entry whose content stands in for the prefix it summarizes.
The Conversation stores only `role` and `content` per message — the data needed for provider serialization. Entry metadata (provider, model, usage, stopReason, timestamps) lives in the session entry layer and is accessible for display or review without polluting the Conversation model.
@@ -381,8 +380,6 @@ SessionHeader = struct {
id: []const u8, // UUIDv7 string, owned
timestamp: []const u8, // ISO 8601, owned
cwd: []const u8, // owned
- provider: []const u8, // owned
- model: []const u8, // owned
pub fn deinit(self) void
};
@@ -491,7 +488,7 @@ SessionManager = struct {
/// Create a new session. Allocates a UUIDv7, computes the target file
/// path, but does NOT write to disk yet (see deferred-file-creation).
- pub fn init(allocator, cwd, provider, model) !SessionManager
+ pub fn init(allocator, cwd) !SessionManager
/// Open an existing session file, replay the log, rebuild state.
/// Truncates from the first corrupted line onward.
@@ -531,9 +528,10 @@ SessionManager = struct {
pub fn rebuildConversation(self) !Conversation
/// Determine the active provider/model by walking the path
- /// and finding the last user message with provider/model fields,
- /// falling back to header defaults.
- pub fn activeModel(self) struct { provider: []const u8, model: []const u8 }
+ /// and finding the last user message with provider/model fields.
+ /// Returns null only for sessions with no user messages, which can
+ /// only occur in-memory before the first flush.
+ pub fn activeModel(self) ?struct { provider: []const u8, model: []const u8 }
// ── Session listing ──
@@ -638,8 +636,8 @@ The following were open questions in an earlier draft. Recording the resolutions
4. **File locking**: Not needed. The crash-recovery rule (truncate from first corruption) handles interleaved writes safely: any garbled tail is discarded on next open. Two pantograph processes resuming the same session simultaneously is rare and self-corrects to whichever process wrote last cleanly.
5. **Session directory creation**: Lazy. Created on first flush (alongside the file itself). Avoids creating empty directory trees when pantograph is invoked transiently (e.g., `panto --help`, `panto sessions` on a fresh install).
-## Open Questions (to resolve during implementation)
+## Resolved at implementation time
-1. **Large session files**: A long coding session can produce thousands of entries and megabytes of JSONL. Replaying the entire log on every resume could become slow. Should we cache the rebuilt `Conversation` state (e.g., as a checkpoint line at the end of the file) and only replay new entries on subsequent loads? **Defer**: measure first; add only if real sessions hit a noticeable load delay.
-2. **`fsync` cadence**: We flush after every completed entry (per the persistence strategy). Do we also `fsync` the file handle, or trust the kernel's writeback? `fsync` is safer but slower. Likely settle on `fsync` after assistant messages and tool-result user messages (the entries that matter for resume), not after every streaming-derived write — but phase 4 only writes once per completed entry, so a single `fsync` per `appendMessage` is probably fine.
-3. **Concurrent listing**: Use `Io.concurrent` with what concurrency cap? Pi uses 10. Probably mirror that until we have data.
+- **Large session files / replay cost**: not a concern — there's no event replay, just a 1:1 entry-to-message rebuild. Bounded by what we'd already send to the provider on every turn. The remediation for genuinely-too-many-tokens is compaction (phase 6), not a session-load optimization.
+- **`fsync` cadence**: `fsync` after every completed entry. Cheap on common filesystems, and we'd rather take the crash safety than the throughput.
+- **Concurrent listing**: deferred. Single-threaded for phase 4. Disks are fast, the listing path is rarely-traversed, and the public `listSessions` API already accepts a progress callback so we can parallelize later without breaking the signature.