The source-level JSON-serializability invariant was only a preflight: the Session constructor copied the seed array but shared every event/data object with the caller, and append() stored the caller's `data` reference verbatim. A post-create/post-append mutation could rewrite the durable log or reintroduce a non-JSON-serializable value AFTER validation, so session.events could diverge from what was validated / what a backend can persist. - ctor deep-clones each seed event after validation (not just the array). - append() stores structuredClone(data) (serializability already checked, so the clone is safe); the returned event carries the same snapshot. Regression tests: mutating the original seed / the passed append object after the call leaves session.events unchanged. Adapted the dev-freeze invariants test to assert on the logged clone (append no longer freezes the caller's input). Documented isJsonValue's exact scope (own enumerable string keys, matching JSON.stringify) and synced the README create() signature with meta.createdAt.
54 lines
3.9 KiB
Markdown
54 lines
3.9 KiB
Markdown
# dsh-session
|
|
|
|
Event-sourced session log and in-memory store. A `Session` is the append-only source of truth for an agent's whole interaction history — the LLM message history is *derived* from it.
|
|
|
|
## Service: `SessionStore` (ctx key: `sessions`)
|
|
|
|
Creates and holds event-sourced `Session` instances. Persistence is intentionally not implemented here — plugins subscribe to `session/event` and flush on `session/flush`.
|
|
|
|
### Public API
|
|
|
|
- `ctx.sessions.create(id?: string, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` to preserve it. Disposed with the calling fiber.
|
|
- `ctx.sessions.get(id: string): Session | undefined`
|
|
- `ctx.sessions.list(): Session[]`
|
|
|
|
### Events
|
|
|
|
| Event | Mode | Purpose |
|
|
|---|---|---|
|
|
| `session/created` | emit | A session was created |
|
|
| `session/event` | emit | An event was appended (sync, fire-and-forget) |
|
|
| `session/flush` | parallel | Awaited durability checkpoint (persistence plugins drain buffers here) |
|
|
|
|
### Class: `Session`
|
|
|
|
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
|
|
|
- `session.append(type, data): SessionEvent` — synchronous, never blocks on I/O. **Throws** if `data` is not losslessly JSON-serializable (BigInt, function, symbol, undefined, non-finite number, circular ref, or an exotic object like Map/Set/Date) — the event log is the durable source of truth, so this invariant is enforced at the source (exported as `isJsonValue` for backends to reuse on their replay/fork entry points).
|
|
- `session.deriveMessages(): Message[]` — derive the LLM message history from the event log. Raw `assistant/chunk` events are skipped; `context/message` and `steering/message` render as tagged synthetic user messages.
|
|
- `session.events`, `session.seq`, `session.id`
|
|
- `session.header: SessionHeader` — immutable creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`). Kept out of the event log (a storage concern, not replayable state); a minimal v1 header is synthesized for bare `Session` construction.
|
|
|
|
### Metadata types (`types.ts`)
|
|
|
|
- `SessionHeader` — immutable, written once: `{ version, id, createdAt, cwd?, parentSession? }`.
|
|
- `SessionSummary` — mutable, updateable without touching the log: `{ updatedAt, title?, firstPrompt? }`.
|
|
- `SessionMeta = SessionHeader & SessionSummary` — owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export these rather than own them (which would force a package cycle).
|
|
|
|
### Session event vocabulary (`types.ts`)
|
|
|
|
The append-only log: `turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/message`, `assistant/chunk`, `tool/call`, `tool/result`, `steering/message`, `context/message`, `usage`, `error`.
|
|
|
|
Merge-extensible via `SessionEventMap` — a compaction plugin adds `compaction/marker`, etc.
|
|
|
|
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings).
|
|
|
|
### Extension points
|
|
|
|
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`/`SessionSummary`/`SessionMeta`, `session.header`) is what such a backend stores beside the log.
|
|
- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log.
|
|
|
|
### What is NOT here (TODO)
|
|
|
|
- **Session branching/tree** (pi-style entry tree) — defered unless needed beyond seed-based forking.
|