summaryrefslogtreecommitdiff
path: root/docs/phase-4.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/phase-4.md')
-rw-r--r--docs/phase-4.md582
1 files changed, 582 insertions, 0 deletions
diff --git a/docs/phase-4.md b/docs/phase-4.md
new file mode 100644
index 0000000..268467a
--- /dev/null
+++ b/docs/phase-4.md
@@ -0,0 +1,582 @@
+# Phase 4: Conversation Serialization
+
+## 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, awl replays the event log to fully rebuild conversation state. Sessions survive process restarts and can be reviewed later.
+
+## Deliverable
+
+A session persistence system where awl saves and resumes conversations. At the end of this phase, you can:
+
+- Start `awl`, hold a conversation, and find the session saved to disk.
+- Restart `awl --resume` and continue the conversation exactly where you left off.
+- Restart `awl --resume <id>` to resume a specific session.
+- Inspect the JSONL session file to see every event that occurred, including which provider/model handled each turn.
+- Have awl recover gracefully from a crash — the corrupted trailing line is removed, and the session loads from the last valid entry.
+
+## What is usable at the end
+
+| Capability | How to exercise it |
+|---|---|
+| Auto-save session | Start `awl`, converse, quit; session is in `~/.local/share/awl/sessions/` |
+| Resume most recent | `awl --resume` — continues the most recent session for the current working directory |
+| Resume specific session | `awl --resume <id>` — opens the session with the given ID (or unique prefix) |
+| List sessions | `awl sessions` — lists sessions for the current working directory |
+| Crash recovery | Kill `awl` mid-turn; restart with `--resume`; corrupted trailing line is removed, session loads from last valid entry |
+
+## What is explicitly out of scope
+
+- Compaction / context pruning (phase 6 — the format is designed to allow future entry types like `compaction` without breaking older readers, but phase 4 does not implement any form of compaction)
+- Branching / tree navigation (the format supports it via `id`/`parentId`, but no CLI commands for branching exist yet)
+- Session forking or cloning
+- Custom entry types (for extensions — future phase)
+- Labels, bookmarks, session naming
+- Session export or format migration beyond version 1
+- In-memory-only sessions (every session is persisted)
+
+---
+
+## Event Log Format
+
+Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with a `type` field. Entries form a tree structure via `id`/`parentId` fields, enabling in-place branching in future phases without creating new files.
+
+The event log is the authoritative record of a session. It captures everything that happened — not just the conversation content, but the full context of how it happened (which model was active, when it changed, etc.). On load, awl rebuilds all state from the log alone.
+
+### File Location
+
+```
+~/.local/share/awl/sessions/<encoded-cwd>/<timestamp>_<uuid>.jsonl
+```
+
+Where `<encoded-cwd>` is the working directory with `/` replaced by `--`. This groups sessions by project directory, making it easy to find sessions for a given project.
+
+The base directory respects `XDG_DATA_HOME`, falling back to `~/.local/share` if unset (per the XDG Base Directory specification).
+
+Example:
+```
+~/.local/share/awl/sessions/--Users-travis-Code-awl--/2026-04-25T17-40-15-990Z_019dc5ba-53f6-71a5-ab8f-b1f8709c2572.jsonl
+```
+
+### Crash Recovery
+
+On loading a session, if the last line of the file does not deserialize as a complete JSON object, it is deleted. The process does not refuse to start — it trims the corruption and loads from the last valid entry. This handles the common case where a process is killed mid-write.
+
+---
+
+## Entry Types
+
+### SessionHeader
+
+First line of the file. Metadata only — not part of the tree (no `id`/`parentId`).
+
+```json
+{
+ "type": "session",
+ "version": 1,
+ "id": "019dc5ba-53f6-71a5-ab8f-b1f8709c2572",
+ "timestamp": "2026-04-25T17:40:15.990Z",
+ "cwd": "/Users/travis/Code/awl",
+ "provider": "anthropic",
+ "model": "claude-sonnet-4-20250514"
+}
+```
+
+| Field | Type | Purpose |
+|---|---|---|
+| `version` | number | Format version (1 for phase 4). Future format changes increment this. |
+| `id` | string | Session UUID. |
+| `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.
+
+### EntryBase
+
+All entries (except the header) extend this base:
+
+```
+EntryBase = {
+ type: string, // entry type discriminator
+ id: string, // 8-char hex ID, random
+ parentId: string | null, // parent entry ID (null for first entry)
+ timestamp: string, // ISO 8601
+}
+```
+
+The first entry has `parentId: null`. Each subsequent entry points to its parent, forming a linked path from root to leaf. In phase 4 this is always a linear chain; the tree structure exists to support branching in future phases.
+
+### MessageEntry
+
+A message in the conversation. The `message` field contains role, content blocks, and (for assistant messages) provider metadata.
+
+**System message:**
+```json
+{
+ "type": "message",
+ "id": "s1s2s3s4",
+ "parentId": null,
+ "timestamp": "2026-04-25T14:00:00.000Z",
+ "message": {
+ "role": "system",
+ "content": [
+ { "type": "text", "text": "You are a helpful assistant." }
+ ]
+ }
+}
+```
+
+**User message (human prompt):**
+```json
+{
+ "type": "message",
+ "id": "a1b2c3d4",
+ "parentId": "s1s2s3s4",
+ "timestamp": "2026-04-25T14:00:01.000Z",
+ "message": {
+ "role": "user",
+ "content": [
+ { "type": "text", "text": "What files are in this directory?" }
+ ]
+ }
+}
+```
+
+**Assistant message (with tool call):**
+```json
+{
+ "type": "message",
+ "id": "b2c3d4e5",
+ "parentId": "a1b2c3d4",
+ "timestamp": "2026-04-25T14:00:02.000Z",
+ "message": {
+ "role": "assistant",
+ "content": [
+ { "type": "thinking", "thinking": "The user wants to list files..." },
+ { "type": "text", "text": "I'll check that for you." },
+ { "type": "toolUse", "id": "tool_abc123", "name": "bash", "input": "{\"command\":\"ls\"}" }
+ ],
+ "provider": "anthropic",
+ "model": "claude-sonnet-4-20250514",
+ "stopReason": "toolUse",
+ "usage": { "input": 1500, "output": 85 }
+ }
+}
+```
+
+**User message (tool results):**
+```json
+{
+ "type": "message",
+ "id": "c3d4e5f6",
+ "parentId": "b2c3d4e5",
+ "timestamp": "2026-04-25T14:00:03.000Z",
+ "message": {
+ "role": "user",
+ "content": [
+ { "type": "toolResult", "toolUseId": "tool_abc123", "content": "file1.txt\nfile2.txt" }
+ ]
+ }
+}
+```
+
+Note that tool results are content blocks on a user message, consistent with awl's internal model (and Anthropic's wire format). This differs from pi's approach of giving `toolResult` its own message role — in awl, tool results are content blocks that happen to live on user messages, just as they do in the Conversation model.
+
+### Content Block Serialization
+
+The on-disk content block types correspond directly to awl's internal `ContentBlock` tagged union:
+
+| Internal type | On-disk `type` | Fields |
+|---|---|---|
+| `Text` (`TextualBlock`) | `"text"` | `text: string` |
+| `Thinking` (`TextualBlock`) | `"thinking"` | `thinking: string` |
+| `ToolUse` (`ToolUseBlock`) | `"toolUse"` | `id: string`, `name: string`, `input: string` (raw JSON bytes) |
+| `ToolResult` (`ToolResultBlock`) | `"toolResult"` | `toolUseId: string`, `content: string` |
+
+Notable decisions:
+
+- **`input` is a string, not a parsed object.** Consistent with awl's internal model where tool input is stored as raw JSON bytes. The on-disk format does not parse or interpret tool schemas.
+- **`content` in `toolResult` is a string.** Consistent with awl's model where tool result content is a single `TextualBlock`. If structured content (e.g., images) is needed later, the format can evolve.
+- **`thinking` stores full thinking content.** The event log records everything that happened. Thinking blocks are never omitted.
+
+### Assistant message metadata
+
+Assistant messages carry additional fields beyond `role` and `content`:
+
+| Field | Type | Purpose |
+|---|---|---|
+| `provider` | string | Which provider handled this request |
+| `model` | string | Which model was used |
+| `stopReason` | string | Why the model stopped: `"stop"`, `"length"`, `"toolUse"`, `"error"` |
+| `usage` | object | Token counts: `{ "input": number, "output": number }` — optional, omitted if unavailable |
+
+These fields are metadata about the provider response. They are recorded in the event log but do **not** become part of the in-memory `Conversation` model. The `Conversation` stores only `role` and `content` — the fields needed for provider serialization. Metadata is accessed through the session entry layer when needed (display, review, debugging).
+
+The assistant metadata is a response-side confirmation of what the user message's `provider`/`model` already declared on the request side. It's useful for quick inspection — you can see which model produced a response without scanning backward to the preceding user message — and for detecting discrepancies (e.g., a provider routing layer returned a different model than requested).
+
+### Model tracking on user messages
+
+Every entry with `type: "message"` and `message.role: "user"` carries top-level `provider` and `model` fields recording which provider/model the request was submitted to. This includes both human-authored prompts and user messages containing tool results — both are submissions to a provider API.
+
+```json
+{
+ "type": "message",
+ "id": "a1b2c3d4",
+ "parentId": "s1s2s3s4",
+ "timestamp": "2026-04-25T14:00:01.000Z",
+ "provider": "anthropic",
+ "model": "claude-sonnet-4-20250514",
+ "message": {
+ "role": "user",
+ "content": [
+ { "type": "text", "text": "What files are in this directory?" }
+ ]
+ }
+}
+```
+
+```json
+{
+ "type": "message",
+ "id": "c3d4e5f6",
+ "parentId": "b2c3d4e5",
+ "timestamp": "2026-04-25T14:00:03.000Z",
+ "provider": "openai",
+ "model": "gpt-4o",
+ "message": {
+ "role": "user",
+ "content": [
+ { "type": "toolResult", "toolUseId": "tool_abc123", "content": "file1.txt\nfile2.txt" }
+ ]
+ }
+}
+```
+
+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.
+
+---
+
+## Tree Structure
+
+Entries form a tree via `id`/`parentId`:
+
+- First entry has `parentId: null`
+- Each subsequent entry points to its parent
+- In phase 4, the tree is always a simple linear chain (one path from root to leaf)
+- Branching creates new children from an earlier entry (future phase)
+- The "leaf" is the current position — always the last entry
+
+```
+[system] ─── [user] ─── [assistant] ─── [user (tool results)] ─── [assistant] ─┬─ [user] ← current leaf
+ │
+ └─ (future branch point)
+```
+
+The tree structure is defined now so that the on-disk format supports branching from day one. Phase 4 simply doesn't exercise it — every session is a linear chain.
+
+---
+
+## Rebuilding State from the Log
+
+On resume, awl fully rebuilds conversation state by replaying the event log:
+
+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:
+ - 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.
+
+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.
+
+### Handling incomplete turns on resume
+
+If the session was interrupted mid-turn, the log may contain an assistant message with `ToolUse` blocks but no corresponding user message with `ToolResult` blocks. On resume, awl does **not** automatically re-execute the tools. The conversation is rebuilt as-is and presented to the user. The agent loop waits for user input — the user decides how to proceed.
+
+---
+
+## Persistence Strategy
+
+**Append-only.** Entries are appended to the JSONL file as they occur. No full-file rewrite during normal operation. Each agent turn appends one or more entries:
+
+- A user message entry (when the user submits a prompt, or when tool results are assembled)
+- Provider/model stamped on user message entries (top-level fields on every user message)
+- An assistant message entry (when the provider response is complete)
+
+A single agent turn with tool calls appends multiple rounds of these entries.
+
+**File creation.** The session file is created when the session is initialized (header written). If `awl` starts and the user immediately quits without the session being initialized, no file is created.
+
+**No explicit save command.** Persistence is automatic. Every event is written to disk as it happens.
+
+---
+
+## Module Changes
+
+### New files
+
+```
+src/session.zig // On-disk entry types, JSON serialization/deserialization
+src/session_manager.zig // SessionManager: file management, append, load, tree traversal, crash recovery
+```
+
+### `session.zig`
+
+Defines the on-disk types and their JSON serialization. These are separate from the in-memory `Message`/`ContentBlock` types — the on-disk types are the schema of what goes in the JSONL file, and include metadata that doesn't belong in the Conversation model.
+
+```
+SessionHeader = struct {
+ version: u32,
+ id: []const u8, // UUID 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
+};
+
+EntryBase = struct {
+ id: []const u8, // 8-char hex, owned
+ parent_id: ?[]const u8, // owned
+ timestamp: []const u8, // ISO 8601, owned
+
+ pub fn deinit(self) void
+};
+
+SessionEntry = union(enum) {
+ message: MessageEntry,
+ pub fn base(self) EntryBase
+ pub fn deinit(self) void
+};
+
+MessageEntry = struct {
+ base: EntryBase,
+ message: DiskMessage,
+ // Present on user messages only. Null for system/assistant messages.
+ provider: ?[]const u8, // owned
+ model: ?[]const u8, // owned
+};
+
+DiskMessage = struct {
+ role: enum { system, user, assistant },
+ content: []DiskContentBlock,
+ // Assistant-only metadata (null for system/user messages):
+ provider: ?[]const u8,
+ model: ?[]const u8,
+ stop_reason: ?[]const u8,
+ usage: ?Usage,
+
+ pub fn deinit(self) void
+};
+
+DiskContentBlock = union(enum) {
+ text: DiskTextBlock,
+ thinking: DiskThinkingBlock,
+ tool_use: DiskToolUseBlock,
+ tool_result: DiskToolResultBlock,
+
+ pub fn deinit(self) void
+};
+
+DiskTextBlock = struct {
+ text: []const u8, // owned
+};
+
+DiskThinkingBlock = struct {
+ thinking: []const u8, // owned
+};
+
+DiskToolUseBlock = struct {
+ id: []const u8, // owned
+ name: []const u8, // owned
+ input: []const u8, // raw JSON bytes, owned
+};
+
+DiskToolResultBlock = struct {
+ tool_use_id: []const u8, // owned
+ content: []const u8, // owned
+};
+
+Usage = struct {
+ input: u64,
+ output: u64,
+};
+
+FileEntry = union(enum) {
+ header: SessionHeader,
+ entry: SessionEntry,
+};
+```
+
+Serialization functions:
+
+- `serializeHeader(allocator, header) ![]const u8` — header → JSON line
+- `serializeEntry(allocator, entry) ![]const u8` — entry → JSON line
+- `parseLine(allocator, line: []const u8) !FileEntry` — JSON line → header or entry
+
+Conversion functions (bridge between on-disk and in-memory types):
+
+- `contentBlockToDisk(allocator, block: ContentBlock) !DiskContentBlock` — in-memory → on-disk
+- `diskContentBlockToInternal(allocator, disk: DiskContentBlock) !ContentBlock` — on-disk → in-memory
+
+All `[]const u8` fields in on-disk types are owned copies. `deinit()` frees them. The conversion from `ContentBlock` to `DiskContentBlock` extracts the content from `TextualBlock` buffers as owned string copies. The reverse conversion creates new `TextualBlock` instances initialized with the complete content.
+
+### `session_manager.zig`
+
+```
+SessionManager = struct {
+ session_id: []const u8,
+ session_file: ?[]const u8, // path to JSONL file
+ session_dir: []const u8, // directory containing session files for this cwd
+ cwd: []const u8,
+ entries: std.ArrayList(SessionEntry),
+ by_id: std.StringHashMap(*const SessionEntry),
+ leaf_id: ?[]const u8,
+ allocator: std.mem.Allocator,
+
+ // ── Creation ──
+
+ /// Create a new session. Writes the header to disk.
+ pub fn init(allocator, cwd, provider, model) !SessionManager
+
+ /// Open an existing session file, replay the log, rebuild state.
+ pub fn open(allocator, path) !SessionManager
+
+ /// Find and open the most recent session for the given cwd.
+ /// Returns null if no sessions exist.
+ pub fn continueRecent(allocator, cwd) ?SessionManager
+
+ pub fn deinit(self) void
+
+ // ── Appending (writes to disk immediately) ──
+
+ /// Append a message entry as child of the current leaf.
+ /// Returns the new entry's id.
+ pub fn appendMessage(self, message: DiskMessage) ![]const u8
+
+
+
+ // ── Tree traversal ──
+
+ pub fn getLeafId(self) ?[]const u8
+ pub fn getEntry(self, id: []const u8) ?*const SessionEntry
+
+ /// Walk from an entry to root, returning entries in root-to-leaf order.
+ pub fn getBranch(self, from_id: ?[]const u8) []const SessionEntry
+
+ // ── Rebuilding ──
+
+ /// Rebuild a Conversation from the event log.
+ /// Walks from leaf to root, collects message entries,
+ /// converts DiskContentBlocks → ContentBlocks.
+ 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 }
+
+ // ── Session listing ──
+
+ /// List sessions for a given cwd.
+ pub fn listSessions(allocator, cwd) ![]SessionInfo
+
+ // ── Crash recovery ──
+
+ /// Remove the last line of the file if it doesn't parse as complete JSON.
+ /// Called during open().
+ fn trimCorruptedTrailingLine(file_path: []const u8) !void
+};
+
+SessionInfo = struct {
+ path: []const u8, // owned
+ id: []const u8, // owned
+ cwd: []const u8, // owned
+ created: []const u8, // ISO 8601, owned
+ modified: []const u8, // from file mtime, ISO 8601, owned
+ message_count: usize,
+};
+```
+
+### Modified files
+
+- **`agent.zig`** — Receives a `SessionManager` reference. After each message is assembled, calls `appendMessage()` with the current provider/model (user messages get stamped; assistant/system messages pass null). The agent loop is now a producer of session entries.
+- **`config.zig`** — Session directory path added. Default: `$XDG_DATA_HOME/awl/sessions/` (falling back to `~/.local/share/awl/sessions/` if `XDG_DATA_HOME` is unset). Overridable via `AWL_SESSION_DIR` environment variable.
+- **`main.zig`** — `--resume` and `--resume <id>` flags. `sessions` subcommand. On startup with `--resume`, load session, rebuild conversation, continue agent loop. Without `--resume`, create a new session.
+
+---
+
+## CLI Changes
+
+### `--resume`
+
+Resume the most recent session for the current working directory. If no sessions exist, create a new one.
+
+```
+awl --resume
+```
+
+### `--resume <id>`
+
+Resume a specific session by its ID (or a unique prefix of the ID). Errors if no session matches, or if the prefix is ambiguous.
+
+```
+awl --resume 019dc5ba
+```
+
+### `sessions` subcommand
+
+List sessions for the current working directory. Shows session ID (short), creation time, and message count.
+
+```
+awl sessions
+```
+
+Output:
+```
+019dc5ba 2026-04-25 17:40 14 messages
+019dc80a 2026-04-26 04:27 38 messages
+```
+
+---
+
+## Testing Strategy
+
+### Unit tests
+
+| What | How |
+|---|---|
+| Entry serialization round-trip | Create entries of each type, serialize to JSON, parse back, verify equivalence |
+| Content block conversion | Convert each `ContentBlock` variant to/from `DiskContentBlock`, verify content preserved |
+| Crash recovery | Write a JSONL file with a corrupted trailing line, load it, verify the line is removed and valid entries are intact |
+| Session rebuild | Create a session with system, user, assistant, tool result messages; rebuild conversation; verify messages match original content |
+| Model tracking on user messages | Create a session with multiple turns, change model mid-session, verify `provider`/`model` fields appear on every user message entry and reflect the model used for that request |
+| Tree traversal | Create entries with parent chain, walk from leaf to root, verify correct root-to-leaf order |
+
+### Integration test (manual)
+
+- Start `awl`, hold a multi-turn conversation with tool calls, quit
+- Inspect the JSONL file — verify entries are present and well-formed
+- Run `awl --resume` — verify conversation continues from where it left off
+- Change the model mid-session, send a prompt, verify the user message entry in the log carries the updated `provider`/`model` fields
+- Kill `awl` mid-turn (e.g., Ctrl+C during streaming), run `awl --resume` — verify session loads from last valid entry
+- Run `awl sessions` — verify sessions are listed with correct metadata
+
+---
+
+## Open Questions (to resolve during implementation)
+
+1. **Entry ID generation**: 8-char hex IDs give ~4 billion possibilities. Collision probability within a single session is negligible, but should we verify uniqueness against existing entries, or trust the randomness?
+2. **Timestamp precision**: ISO 8601 with millisecond precision is sufficient. Zig's `std.time` provides nanosecond precision — we format to milliseconds.
+3. **File locking**: If two `awl` processes try to append to the same session file simultaneously, lines could interleave and corrupt the file. Should we use `flock` for mutual exclusion? Or is this not worth worrying about in phase 4?
+4. **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? Or defer this optimization?
+5. **Session directory creation**: Should `awl` create the session directory tree eagerly on startup, or lazily when the first session is created?