Files
oh-my-pi/docs/session.md
T
2026-08-03 16:39:23 +02:00

500 lines
21 KiB
Markdown

# Session Storage and Entry Model
This document is the source of truth for how coding-agent sessions are represented, persisted, migrated, and reconstructed at runtime.
## Scope
Covers:
- Session JSONL format and versioning
- Entry taxonomy and tree semantics (`id`/`parentId` + leaf pointer)
- Migration/compatibility behavior when loading old or malformed files
- Context reconstruction (`buildSessionContext`)
- Persistence guarantees, failure behavior, truncation/blob externalization
- Storage abstractions (`FileSessionStorage`, `MemorySessionStorage`) and related utilities
Does not cover `/tree` UI rendering behavior beyond semantics that affect session data.
## Implementation Files
- [`src/session/session-manager.ts`](../packages/coding-agent/src/session/session-manager.ts) — orchestration: tree/leaf, appends, persistence, blobs, lifecycle factories
- [`src/session/session-entries.ts`](../packages/coding-agent/src/session/session-entries.ts) — entry/header types, `SessionEntry` union, `CURRENT_SESSION_VERSION`
- [`src/session/session-migrations.ts`](../packages/coding-agent/src/session/session-migrations.ts) — version migrations
- [`src/session/session-loader.ts`](../packages/coding-agent/src/session/session-loader.ts) — file load + blob-ref resolution
- [`src/session/session-context.ts`](../packages/coding-agent/src/session/session-context.ts) — `buildSessionContext`
- [`src/session/session-persistence.ts`](../packages/coding-agent/src/session/session-persistence.ts) — truncation + image blob externalization
- [`src/session/session-paths.ts`](../packages/coding-agent/src/session/session-paths.ts) — on-disk layout, dir encoding, terminal breadcrumbs
- [`src/session/session-listing.ts`](../packages/coding-agent/src/session/session-listing.ts) — discovery (list/recent/resolve)
- [`src/session/session-storage.ts`](../packages/coding-agent/src/session/session-storage.ts) — storage abstractions
- [`src/session/session-title-slot.ts`](../packages/coding-agent/src/session/session-title-slot.ts) — fixed-width current-title slot
- [`src/session/indexed-session-storage.ts`](../packages/coding-agent/src/session/indexed-session-storage.ts) — local index + ordered remote-backed storage adapter
- [`src/session/messages.ts`](../packages/coding-agent/src/session/messages.ts) — custom-message transformers
- [`src/session/blob-store.ts`](../packages/coding-agent/src/session/blob-store.ts) — content-addressed blob store
- [`src/session/history-storage.ts`](../packages/coding-agent/src/session/history-storage.ts) — prompt history (separate subsystem)
## On-Disk Layout
Default file-session location:
```text
~/.omp/agent/sessions/<scope>-<project-basename>-<sha256(canonical-cwd)>/<timestamp>_<sessionId>.jsonl
```
`<scope>` is `home`, `tmp`, or `abs`, chosen after canonicalizing cwd (so symlink aliases share a bucket). The readable basename is sanitized and capped at 80 characters; the full canonical cwd digest prevents the collisions possible with the old separator-replacement scheme.
On access, the old home-relative (`-<relative>`), temp-relative (`-tmp-<relative>`), and absolute (`--<encoded-absolute>--`) buckets are migrated into the hashed bucket best-effort. Colliding legacy buckets are split by the cwd recorded in each session header before migration.
Blob store location:
```text
~/.omp/agent/blobs/<sha256>
```
Terminal breadcrumb files are written under:
```text
~/.omp/agent/terminal-sessions/<terminal-id>
```
Breadcrumb content is original cwd and session file path, plus an optional third line `fresh`. A fresh breadcrumb preserves a `/new` boundary whose lazily-created JSONL file does not exist yet, preventing `continueRecent()` from reopening the previous session. Writes are synchronous, ordered, and best-effort.
## File Format
Session files are JSONL: one JSON object per line. Current files physically begin with a fixed-width, 256-byte `type: "title"` slot, followed by the session header and then `SessionEntry` values. Legacy files may begin directly with the header. Loaders strip the physical slot and fold its current title/source into the logical header.
- The logical first entry is always the session header (`type: "session"`).
- Remaining logical entries are `SessionEntry` values.
- Entries are append-only at runtime; branch navigation moves a pointer (`leafId`) rather than mutating existing entries.
### Header (`SessionHeader`)
```json
{
"type": "session",
"version": 3,
"id": "1f9d2a6b9c0d1234",
"timestamp": "2026-02-16T10:20:30.000Z",
"cwd": "/work/pi",
"title": "optional session title",
"titleSource": "auto",
"additionalDirectories": ["/work/shared"],
"previousSessionFiles": ["/old/location/session.jsonl"],
"providerPromptCacheKey": "optional inherited cache identity",
"parentSession": "optional lineage marker"
}
```
Notes:
- `additionalDirectories` records normalized, deduplicated workspace roots beyond `cwd`.
- `previousSessionFiles` records prior absolute locations after successful moves.
- `providerPromptCacheKey` carries an inherited provider prompt-cache identity for eligible full forks.
- `parentSession` is an opaque lineage string. Current code writes either a session id or a session path depending on flow (`fork`, `forkFrom`, `createBranchedSession`, or explicit `newSession({ parentSession })`). Treat it as metadata, not a typed foreign key.
- `titleSource` is `auto` or `user`; automatic renames cannot overwrite a user title.
### Entry Base (`SessionEntryBase`)
All non-header entries include:
```json
{
"type": "...",
"id": "8-char-id",
"parentId": "previous-or-branch-parent",
"timestamp": "2026-02-16T10:20:30.000Z"
}
```
`parentId` can be `null` for a root entry (first append, or after `resetLeaf()`).
## Entry Taxonomy
`SessionEntry` is the union of:
- `message`
- `thinking_level_change`
- `model_change`
- `service_tier_change`
- `compaction`
- `branch_summary`
- `reset_boundary`
- `custom`
- `custom_message`
- `label`
- `title_change`
- `ttsr_injection`
- `credential_pin`
- `session_init`
- `mode_change`
### `message`
Stores an `AgentMessage` directly.
```json
{
"type": "message",
"id": "a1b2c3d4",
"parentId": null,
"timestamp": "2026-02-16T10:21:00.000Z",
"message": {
"role": "assistant",
"provider": "anthropic",
"model": "claude-sonnet-4-5",
"content": [{ "type": "text", "text": "Done." }],
"usage": {
"input": 100,
"output": 20,
"cacheRead": 0,
"cacheWrite": 0,
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0,
"total": 0
}
},
"timestamp": 1760000000000
}
}
```
### `model_change`
```json
{
"type": "model_change",
"id": "b1c2d3e4",
"parentId": "a1b2c3d4",
"timestamp": "2026-02-16T10:21:30.000Z",
"model": "openai/gpt-4o",
"role": "default"
}
```
`role` is optional; missing is treated as `default` in context reconstruction.
### `service_tier_change`
```json
{
"type": "service_tier_change",
"id": "c1d2e3f4",
"parentId": "b1c2d3e4",
"timestamp": "2026-02-16T10:21:45.000Z",
"serviceTier": { "openai": "priority", "google": "flex" }
}
```
`serviceTier` is a per-family map keyed by `openai`/`anthropic`/`google` (each value `auto`/`default`/`flex`/`scale`/`priority`), or `null` when no tier is active. Legacy entries that stored a single string (`"flex"`, `"openai-only"`, `"claude-only"`, …) are normalized to this map on read.
### `thinking_level_change`
```json
{
"type": "thinking_level_change",
"id": "c1d2e3f4",
"parentId": "b1c2d3e4",
"timestamp": "2026-02-16T10:22:00.000Z",
"thinkingLevel": "high"
}
```
`configured` may additionally preserve the selector the user chose (`"auto"` or a concrete level). Readers of older entries fall back to `thinkingLevel`.
### `compaction`
```json
{
"type": "compaction",
"id": "d1e2f3a4",
"parentId": "c1d2e3f4",
"timestamp": "2026-02-16T10:23:00.000Z",
"summary": "Conversation summary",
"shortSummary": "Short recap",
"firstKeptEntryId": "a1b2c3d4",
"tokensBefore": 42000,
"details": { "readFiles": ["src/a.ts"] },
"preserveData": { "hookState": true },
"fromExtension": false
}
```
### `branch_summary`
```json
{
"type": "branch_summary",
"id": "e1f2a3b4",
"parentId": "a1b2c3d4",
"timestamp": "2026-02-16T10:24:00.000Z",
"fromId": "a1b2c3d4",
"summary": "Summary of abandoned path",
"details": { "note": "optional" },
"fromExtension": true
}
```
If branching from root (`branchFromId === null`), `fromId` is the literal string `"root"`.
### `reset_boundary`
A payload-free marker appended by `/reset`. The collapsed live transcript and rebuilt model context begin after the latest applicable boundary; full-history transcript export still retains entries before it.
### `custom`
Extension state persistence; ignored by `buildSessionContext`.
```json
{
"type": "custom",
"id": "f1a2b3c4",
"parentId": "e1f2a3b4",
"timestamp": "2026-02-16T10:25:00.000Z",
"customType": "my-extension",
"data": { "state": 1 }
}
```
### `custom_message`
Extension-provided message that does participate in LLM context. `content` can be a string or text/image content blocks, and `attribution` records whether the user or agent initiated it.
```json
{
"type": "custom_message",
"id": "a2b3c4d5",
"parentId": "f1a2b3c4",
"timestamp": "2026-02-16T10:26:00.000Z",
"customType": "my-extension",
"content": "Injected context",
"display": true,
"details": { "debug": false },
"attribution": "agent"
}
```
### `label`
```json
{
"type": "label",
"id": "b2c3d4e5",
"parentId": "a2b3c4d5",
"timestamp": "2026-02-16T10:27:00.000Z",
"targetId": "a1b2c3d4",
"label": "checkpoint"
}
```
`label: undefined` clears a label for `targetId`.
### `title_change`
Append-only audit entry for a session rename. It records `title`, `source` (`auto` or `user`), and optionally `previousTitle` and `trigger`. The current title is also updated in the fixed-width title slot so listing does not require a full-file rewrite.
### `ttsr_injection`
```json
{
"type": "ttsr_injection",
"id": "c2d3e4f5",
"parentId": "b2c3d4e5",
"timestamp": "2026-02-16T10:28:00.000Z",
"injectedRules": ["ruleA", "ruleB"]
}
```
### `credential_pin`
Records the provider and a pseudonymous SHA-256 account/scope hash used to re-pin resumed OAuth traffic to the serving account and preserve account-scoped prompt-cache reuse. It does not store the raw account identity; exported hashes remain linkable and are not anonymous.
### `session_init`
```json
{
"type": "session_init",
"id": "d2e3f4a5",
"parentId": "c2d3e4f5",
"timestamp": "2026-02-16T10:29:00.000Z",
"systemPrompt": "...",
"task": "...",
"tools": ["read", "edit"],
"outputSchema": { "type": "object" },
"outputSchemaMode": "strict",
"restrictToolNames": true,
"spawns": "*",
"readSummarize": false
}
```
### `mode_change`
```json
{
"type": "mode_change",
"id": "e2f3a4b5",
"parentId": "d2e3f4a5",
"timestamp": "2026-02-16T10:30:00.000Z",
"mode": "plan",
"data": { "planFile": "/tmp/plan.md" }
}
```
## Versioning and Migration
Current session version: `3`.
### v1 -> v2
Applied when header `version` is missing or `< 2`:
- Adds `id` and `parentId` to each non-header entry.
- Reconstructs a linear parent chain using file order.
- Migrates compaction field `firstKeptEntryIndex` -> `firstKeptEntryId` when present.
- Sets header `version = 2`.
### v2 -> v3
Applied when header `version < 3`:
- For `message` entries: rewrites legacy `message.role === "hookMessage"` to `"custom"`.
- Sets header `version = 3`.
### Migration Trigger and Persistence
- Migrations run during session load (`setSessionFile`).
- If any migration ran, the in-memory representation is marked for a full rewrite rather than rewritten immediately.
- The next persistence operation performs the full rewrite before incremental appends continue.
## Load and Compatibility Behavior
`loadEntriesFromFile(path)` behavior:
- Missing file (`ENOENT`) -> returns `[]`.
- Current files at least 8 MiB use a streaming JSONL loader; smaller or non-file storage uses a full text read.
- Non-parseable lines are handled by the lenient JSONL parser.
- The optional fixed-width title slot is removed and folded into the header.
- If the first logical entry is not a valid session header (`type !== "session"` or missing string `id`) -> returns `[]`.
`SessionManager.setSessionFile()` behavior:
- `[]` from the loader is treated as empty/nonexistent session and replaced with a new initialized session at that exact path; its header is materialized immediately.
- Valid files are loaded, migrated if needed, blob refs resolved, then indexed.
## Tree and Leaf Semantics
The underlying model is append-only tree + mutable leaf pointer:
- Every append method creates exactly one new entry whose `parentId` is current `leafId`.
- The new entry becomes the new `leafId`.
- `branch(entryId)` moves only `leafId`; existing entries remain unchanged.
- `resetLeaf()` sets `leafId = null`; next append creates a new root entry (`parentId: null`).
- `branchWithSummary()` sets leaf to branch target and appends a `branch_summary` entry.
`getEntries()` returns all non-header entries in insertion order. Existing entries are not deleted in normal operation; rewrites preserve logical history while updating representation (migrations, move, targeted rewrite helpers).
## Context Reconstruction (`buildSessionContext`)
`buildSessionContext(entries, leafId?, byId?, options?)` resolves what is sent to the model. `options.transcript: true` instead builds a display transcript. Full transcript mode preserves compactions inline; `collapseCompactedHistory` renders only the current compacted tail, and `keepDanglingToolCalls` preserves still-running tool calls during a mid-turn UI rebuild.
Algorithm:
1. Determine leaf:
- `leafId === null` -> return empty context.
- explicit `leafId` -> use that entry if found.
- otherwise fallback to last entry.
2. Walk `parentId` to root, stopping on a repeated id to bound corrupt cycles, then reverse to root->leaf.
3. Derive runtime state across the path:
- resolved and configured thinking selectors from latest `thinking_level_change`
- service tier from latest `service_tier_change`
- model map from `model_change` entries (`role ?? "default"`); assistant-message inference is legacy fallback only until an explicit default is seen
- deduplicated `injectedTtsrRules`
- mode/modeData from latest `mode_change` (default mode `"none"`)
4. Choose the emission boundary:
- a later `reset_boundary` hides everything through that boundary from model context and collapsed live transcript
- otherwise the latest compaction emits its summary plus kept/post-compaction messages (provider-native replacement history may supply the kept model context)
- full transcript export retains pre-reset history and renders compactions chronologically
5. Convert `message`, `custom_message`, and `branch_summary` entries into messages. Other entry types only affect replay state or metadata.
6. Remove dangling tool calls from replay (unless explicitly retained for a mid-turn transcript), neutralizing protected reasoning metadata on rewritten turns; drop unsafe aborted/error assistant turns and their paired tool results from model context.
## Persistence Guarantees and Failure Model
### Persist vs in-memory
- `SessionManager.create/open/continueRecent/forkFrom` -> persistent mode (`persist = true`).
- `SessionManager.inMemory` -> non-persistent mode (`persist = false`) with `MemorySessionStorage`.
### Write pipeline
Completed entries update memory and are handed to file/memory storage synchronously in the append call once the lazy file-creation gate has been crossed. There is no `fsync`, so the guarantee covers software crashes, not power loss. Streaming partial text is not persisted until the completed message is appended.
- A new ordinary session remains memory-only until it contains an assistant message or a caller invokes `ensureOnDisk()`.
- Before that gate, entries remain in memory; crossing it writes the full title slot, header, and accumulated entries.
- Afterwards, entries append incrementally.
- Saving an editor draft forces a discoverable header and stores `draft.txt` with a marker; if the draft disappears while only startup metadata remains, close removes that draft-only session. Explicit `ensureOnDisk()` sessions remain resumable.
- Concurrent completed appends supersede an in-flight atomic rewrite with an authoritative full-body rewrite so stale publication cannot clobber them.
### Durability operations
- `flush()` drains async disk/storage queues and the open writer (no `fsync`); `flushSync()` performs synchronous draining/full rewrite where supported.
- Atomic full rewrites use storage `writeTextAtomic` with a commit guard; file storage stages then renames over the target, including an EPERM-safe move-aside fallback.
- Rewrites serve renames, entry rewrites, migrations/sanitization, move/fork, and recovery. Session-title changes normally update the fixed-width title slot and append a `title_change` audit entry instead of rewriting the body.
### Error behavior
- Persistence errors are latched and rethrown by later flush/close/write operations; the first is logged once with session-file context.
- Failed atomic publication attempts authoritative repair. If storage may have published a write and repair cannot be proven durable, `SessionPersistenceIndeterminateError` fails closed with the original and recovery errors.
- Writer close propagates the first meaningful error.
## Data Size Controls and Blob Externalization
Before persisting entries:
- Strings over 500,000 characters are truncated with `"[Session persistence truncated large content]"`, except signed/encrypted provider blocks and signature fields, which must remain byte-exact for replay.
- Transient `jsonlEvents` is removed.
- If an object has both string `content` and numeric `lineCount`, line count is recomputed after truncation.
- Base64 image payloads at least 1024 characters are content-addressed in the blob store and replaced with `blob:sha256:<hash>`. This includes image content blocks, image-data payloads/URLs, and image-generation results.
- Redundant OpenAI Responses `thinkingSignature` copies are omitted when the authoritative reasoning item already exists in `providerPayload`.
On load, persisted blob references are resolved back to the inline payload shapes expected by downstream transports.
## Storage Abstractions
`SessionStorage` owns filesystem-like operations used by `SessionManager`: synchronous directory/existence/write/stat/list operations; async read, sliced read, write, guarded atomic write, rename, unlink, artifact-aware deletion, title update, writer creation, and backend drain.
Implementations and adapters:
- `FileSessionStorage`: real local files
- `MemorySessionStorage`: map/chunk-backed in-memory storage for non-persistent sessions and tests
- `IndexedSessionStorage`: shared local index plus ordered remote publication used by Redis/SQL-backed storage
`SessionStorageWriter` exposes `append`, optional `appendSync`, `flush`, optional `flushSync`, `isOpen`, `close`, and `getError`.
## Session Discovery Utilities
Discovery helpers live in `session-listing.ts`; `SessionManager` exposes project-scoped wrappers:
- `getRecentSessions(sessionDir, limit?)` -> lightweight welcome metadata, default limit 4
- `findMostRecentSession(sessionDir)` -> newest by mtime
- `listSessions(sessionDir, storage)` / `SessionManager.list(...)` -> project scope with lifecycle status
- `listSessionsReadOnly(...)` -> same metadata without backup recovery
- `listAllSessions(storage)` / `SessionManager.listAll()` -> all project scopes
- `resolveResumableSession(...)` -> local lookup then optional global fallback
Recent/most-recent scans read only a 4 KiB prefix. Full lists read that prefix plus a bounded 32 KiB tail for lifecycle status. Scans are stat-keyed and cached; large sets are processed with bounded parallel workers. Normal per-directory scans also recover the newest orphaned EPERM backup when its primary JSONL is missing. Resume matching is case-insensitive and accepts session id prefixes, full filename prefixes, or the id suffix after the timestamp.
## Related but Distinct: Prompt History Storage
`HistoryStorage` (`history-storage.ts`) is a separate SQLite subsystem for prompt recall/search, not session replay.
- DB: `~/.omp/agent/history.db`
- Table: `history(id, prompt, created_at, cwd, session_id)`
- FTS5 index: `history_fts` with trigger-maintained sync
- Deduplicates consecutive identical prompts using in-memory last-prompt cache
- Inserts are batched through an async drain queue (~100 ms delay) so prompt capture does not block turn execution
Use session files for conversation graph/state replay; use `HistoryStorage` for prompt history UX.