# Session stores

## What it does

Session stores persist `SessionEntry` records for agent sessions. The `SessionStore` contract is the runtime seam: `append(entry)`, `list(sessionId)`, and an optional `get(id)`. Everything else — branch reconstruction, compaction boundaries, redaction, pagination, retention, and multi-tenant ownership — is layered on top by the runtime or by host adapters.

This page describes the store contract, built-in helpers, and where to find the production schema reference.

## When to use it

Use `SessionStore` when you write a host adapter that keeps session entries durable across restarts. Use the built-in `createMemorySessionStore()` for tests and throwaway sessions. Use `createJsonlSessionStore()` from `@arnilo/prism/node/session-store-jsonl` only for single-process development. For production multi-tenant or multi-writer storage, implement a database-backed `SessionStore` or `ProductionPersistenceStore` using the reference schema in [Database persistence](database-persistence.md), then run [Session store conformance](session-store-conformance.md) against the adapter.

## Inputs / request

```ts
import type { SessionStore, SessionEntry } from "@arnilo/prism";
```

`SessionStore` methods:

| Method | Purpose |
| --- | --- |
| `append(entry, options?)` | Persist one `SessionEntry`. `SessionAppendOptions` can carry `expectedParentId` and an opaque `idempotencyKey`. Rejects duplicate ids within the store. |
| `list(sessionId)` | Return all entries for one session in stored order. Development fallback for branch reads. |
| `get?(id)` | Return one entry by id, if present. Optional. |
| `readBranchPath?(query)` | Optional DB-friendly branch read. Return one branch's ancestor chain as a `PersistencePage<SessionEntry>` so the runtime can avoid `list(sessionId)`. |
| `searchSessions?(query)` | Optional bounded session search (`SessionSearchQuery` → `PersistencePage<SessionSearchHit>`). SQLite/Postgres implement FTS + metadata filters; memory and JSONL scan linearly (JSONL re-reads its file per query). |

Public helpers:

| Helper | Purpose |
| --- | --- |
| `createSessionEntry(options)` | Build a `SessionEntry` with generated `id`/`timestamp` when omitted. |
| `createMemorySessionStore(initialEntries?, options?: CreateMemorySessionStoreOptions)` | Built-in in-memory `SessionStore`. `options.sessionSearchMode`: `"linear"` (default) or `"unsupported"` (throws `SessionSearchUnsupportedError`); `options.search` may override the linear scan caps (`maxLinearSessions` / `maxLinearEntries` / `maxLinearBytes`), each bounded by its `HARD_MAX_SESSION_SEARCH_LINEAR_*` value and validated at construction (`TypeError` below 1 or above the hard cap). |
| `resolveSessionSearchQuery(query)` | Validate/clamp search limits (page, query bytes, snippet, cursor, linear/FTS caps). |
| `SessionIndex` | Narrow search seam (`search(query)`); adapters may expose this instead of `SessionStore.searchSessions`. |
| `getSessionBranchEntries(entries, options)` | Return root-to-leaf entries for a leaf id (sync array path). |
| `getSessionBranchEntries(reader, query)` | Async overload for a `BranchReader` / `readBranchPath` implementation. |
| `listSessionBranches(entries)` | List every branch handle as `{ leafId, entries }`. Pair with `sessionId` for a durable `(sessionId, leafId)` branch handle. |
| `rebuildSessionContext(entries, options)` | Rebuild provider-context `messages` and `summaries` from a branch, honoring compaction entries (sync array path). |
| `rebuildSessionContext(reader, query)` | Async overload that reads one branch path without a full-session load. |

## Outputs / response / events

A `SessionStore` returns `SessionEntry` arrays. Branch helpers return deep copies. `rebuildSessionContext()` returns `{ leafId, entries, messages, summaries }` where `entries` is the raw branch, `messages` is the provider context, and `summaries` includes compaction summaries.

## Request/response example

```json
{
  "id": "entry_1",
  "sessionId": "s1",
  "timestamp": "2024-06-15T10:00:00Z",
  "kind": "message",
  "message": {
    "role": "user",
    "content": [{ "type": "text", "text": "Hello" }]
  }
}
```

## Implementation example

```ts
import { createMemorySessionStore, createSessionEntry } from "@arnilo/prism";

const store = createMemorySessionStore();
const entry = createSessionEntry({
  sessionId: "s1",
  kind: "message",
  message: { role: "user", content: [{ type: "text", text: "Hello" }] },
});
await store.append(entry);
const entries = await store.list("s1");
```

For a database-backed store, see the reference schema and query shapes in [Database persistence](database-persistence.md). A runnable external-app reference that implements `SessionStore` + `RunLedger` + `ProductionPersistenceStore` and self-checks with `assertSessionStoreConforms(..., { exerciseReadBranchPath: true })` lives in [`examples/external-app-db-backed.ts`](../examples/external-app-db-backed.ts).

### Atomic append and branch handles

```ts
import type { SessionAppendOptions, SessionBranchHandle } from "@arnilo/prism";

const handle: SessionBranchHandle = { sessionId: "s1", leafId: "entry_1" };
const options: SessionAppendOptions = {
  expectedParentId: handle.leafId,
  idempotencyKey: "request-42", // opaque host value; never a credential
};
await store.append(entry, options);
```

`SessionAppendConflictError` carries this stable shape:

```ts
{
  code: "session_append_conflict";
  expectedParentId?: string;
  currentLeafId?: string;
  idempotencyDuplicate?: boolean;
}
```

Recognize it with `isSessionAppendConflict(error)`, not message text. Built-in stores reject duplicate entry ids, dangling `expectedParentId` values, and `expectedParentId` pointing at another session's entry (the parent must exist in the same session — a cross-session parent would be a write no per-session branch walk could read back), and exact idempotency retries. They allow two distinct children of the same existing parent because that is a branch/fork, not parent-order corruption. Production stores may add a stricter branch-tip compare-and-swap when a host wants one-writer linear branches.

## Extension and configuration notes

- The runtime only requires `SessionStore`. Hosts opt into `ProductionPersistenceStore` for paginated reads and audit tables.
- Store adapters own id generation policy, ordering, duplicate detection, idempotency storage, and error handling.
- `AgentSession` uses `AgentSessionConfig.store` before `AgentConfig.store`; otherwise it falls back to a private memory store.
- Branch semantics are parent links plus a leaf id. External UIs should keep branch handles as `(sessionId, leafId)`; RPC exposes an additional `handleId` for active handles.
- Development stores can omit `readBranchPath`; the runtime falls back to `list(sessionId)` and the pure in-memory branch walk. Database-backed stores should implement `readBranchPath` so `entries()`, `clone()`, and context rebuild read only the selected ancestor chain.

## Session search

Bounded `SessionIndex` / `searchSessions` lists sessions by optional `workspaceRoot` (`metadata.workspaceRoot`), provider/model, label/summary, time range, ownership, and optional text `query`. SQLite/Postgres run indexed full-text search; the memory and JSONL stores scan linearly (case-sensitive substring, capped by the contract linear caps; JSONL re-reads and parses its file per query, see [Node JSONL session store](node-jsonl-session-store.md)). Hits require `sessionId` and may include `leafId` for `checkout`; never credentials or whole transcripts.

When a text `query` matches, the hit points at one matched entry per session (the store's best-ranked match on the indexed SQLite/Postgres paths, the first match in transcript order on the linear memory/JSONL paths): `entryId`, `runId`, and a 1-based `turn` (transcript position, `(timestamp, id)` order) locate it, `score` is the store relevance where the store has an index (higher is better; SQLite bm25 negated, Postgres `ts_rank_cd`; absent on linear stores; a non-discriminative term can legitimately score 0, so test for presence, not `> 0`), and `snippet` is bounded context around the match in that entry. Hits stay ordered by session `updatedAt` with cursor pagination, so hosts rank by `score` client-side when they want relevance order.

`SessionSearchQuery.kind` restricts which entry kinds the query may match (one kind or a list; omitted or `"any"` = all). Annotation search is `kind: ["label", "summary", "metadata", "custom"]`; `kind: "label"` without a `query` lists sessions that carry an annotation entry. Unknown kinds fail closed with `TypeError`. Indexed text is transcript message text plus label/summary - tool arguments and tool results are never indexed, so they cannot leak through search.

```ts
import { createMemorySessionStore, resolveSessionSearchQuery } from "@arnilo/prism";

const store = createMemorySessionStore([], { sessionSearchMode: "linear" });
const page = await store.searchSessions!({
  workspaceRoot: "/repo",
  query: "flake",
  kind: "any",
  limit: 20,
});
// [{ sessionId, leafId, entryId, runId, turn, score, snippet, ... }]  // score is absent on linear stores
// Opt out: createMemorySessionStore([], { sessionSearchMode: "unsupported" })
// Raise the in-process scan caps for a small but large-query session set (defaults are the contract caps):
const wide = createMemorySessionStore([], { search: { maxLinearSessions: 5_000, maxLinearEntries: 50_000 } });
```

The JSONL store exposes the same `searchSessions` contract through the same matcher, with no index:

```ts
import { createJsonlSessionStore } from "@arnilo/prism/node/session-store-jsonl";

const store = createJsonlSessionStore("./sessions.jsonl");
const page = await store.searchSessions!({ workspaceRoot: "/repo", query: "flake", limit: 20 });
// Every query reads and parses the file: O(corpus) time and memory, caps default to the linear caps.
```

Finite caps (defaults / hard): page 20/100; query string 4 KiB/16 KiB; snippet 512 B/4 KiB; cursor 1 KiB/4 KiB; memory linear sessions 1000/5000, entries 10000/50000, bytes 8 MiB/64 MiB (also the JSONL scan caps); DB FTS candidates 1000/5000. Overflow fails closed via `resolveSessionSearchQuery`.

Sizing (plan 095): SQLite FTS5 and the Postgres `tsvector` column are maintained additively at append time (no background job). On the 100k-turn fixture in `scripts/benchmark-scenarios/session-search.mjs` (stored tool output, which is never indexed), the index is 18.8% of transcript page bytes and query p95 is 38 ms (`node scripts/benchmark.mjs --scenario session-search`; ceiling 100 ms). Stores receive already-redacted entries, so the index inherits the same redaction as session reads. Unindexed stores (memory, JSONL) trade that cost for O(corpus) per query — see `examples/session-search.ts` for both paths side by side.

## Security and performance notes

- Do not store provider credentials, credential resolvers, provider instances, or unredacted secrets in session entries, append options, idempotency keys, or branch records.
- Use `AgentConfig.redactor` or `RunOptions.redactor` to redact secrets before entries reach durable stores. Stores receive already-redacted `SessionEntry` values.
- `createMemorySessionStore()` keeps O(1) duplicate/idempotency/parent checks in process-local maps; it is not durable.
- The JSONL adapter serializes appends per store instance, has no cross-process lock, and is not suitable for production multi-writer storage.
- Database-backed stores should follow the indexes and retention guidance in [Database persistence](database-persistence.md). Implement `readBranchPath` as a single branch-path query (for example a recursive CTE) and avoid loading entire large sessions into memory when only one branch is needed.

## Related APIs

- [Session store conformance](session-store-conformance.md): dependency-free adapter assertions for append/idempotency/conflict/branch invariants.
- [Migration guide](migration.md): moving from this memory/JSONL store to a database-backed adapter.
- [Session stores and branching](session-stores-and-branching.md): detailed branch helpers, compaction boundaries, and runtime branch semantics.
- [Database persistence](database-persistence.md): reference relational schema, indexes, retention, migrations, and NoSQL mapping notes.
- [Node JSONL session store](node-jsonl-session-store.md): development-only file adapter.
- [Agent/session runtime](agent-session-runtime.md): runtime sessions use stores for history, checkout, fork, and clone.
- [Public contracts](public-contracts.md): `SessionEntry`, `SessionStore`, `StoreFactory`, and production persistence contracts.
