# Sessions and stores

A session is one conversation. Its journal, an append-only list of entries in a `ChatStore`, is the session's canonical state: everything a session knows about its turns is rebuilt from it, including after a restart.

## Create and continue

```ts
import { createChat, type ChatStore, type Responder } from "june/chat";

declare const responder: Responder;
declare const store: ChatStore;

const chat = createChat({ responder, store });
const session = await chat.createSession();

const turn = session.send("What is June?");
const { text } = await turn.result;

console.log(session.id, text);
console.log(session.turns());
console.log(session.messages());
```

- `chat.createSession()` writes the session's first entry, `session.created`, and returns it. Its id is a random UUID unless you pass one: `createSession({ id })`.
- `session.send(input)` starts a turn. [Turns and streaming](https://june.dev/docs/chat/turns.md) covers what a turn does while it runs.
- `session.turns()` lists every turn, oldest first, with its status, input, output, metadata, feedback and steering, including turns that failed, were canceled or were interrupted.
- `session.messages()` lists the completed conversation as user and assistant messages.
- `session.journal()` reads the raw entries from the store. Pass `{ after: entryId }` to read only the entries after one you already have.

### Reopening a session

`chat.openSession(id)` reads the session's journal from the store and rebuilds its turns and messages from it. It doesn't resume anything that was running in the old process; only the journal is saved. It rejects with `june: no chat session <id>` when the store has no such session.

Sending to a reopened session continues the same conversation: with the default context, the next turn sees every completed turn, from this process and earlier ones.

```ts
import { createChat, fileStore, type Responder } from "june/chat";

declare const responder: Responder;

const chat = createChat({ responder, store: fileStore(".june/chat") });

const session = await chat.createSession();
await session.send("Remember the number 7.").result;

// Later, in another process:
const again = await chat.openSession(session.id);
console.log(again.messages());
await again.send("Which number was it?").result;
```

Only a persistent store makes this work across processes. With the default `memoryStore()`, a session ends with its process. [`src/examples/chat/persistent.ts`](https://github.com/johno/june/blob/main/packages/june/src/examples/chat/persistent.ts) is a complete script that starts a session, prints its id, and continues it when you pass the id back. `june chat --session <id>` and `june ask --session <id>` do the same from the [CLI](https://june.dev/docs/cli.md#june-chat).

There's no API to list sessions. Keep the ids your application needs, or, with `fileStore()`, read the directory: each session is one `<session id>.jsonl` file.

## Stores

Pick the store by how long a conversation has to last:

- **`memoryStore()`** for tests and one-off scripts. It's the default: a chat without a `store` gets a new one.
- **`fileStore(directory)`** for local persistence: the CLI, local apps, anything you'll reopen.
- **Your own `ChatStore`** for your application's storage, such as a database.

|           | `memoryStore()`                   | `fileStore(directory)`                      |
| --------- | --------------------------------- | ------------------------------------------- |
| Keeps     | journals in the process's memory  | one NDJSON file per session                 |
| Survives  | nothing; gone when the process is | crashes and restarts                        |
| Committed | once in memory                    | once written and synced with `fsync`        |
| Good for  | tests, one-off scripts            | the CLI, local apps, anything you'll reopen |

```ts
import { createChat, memoryStore, type Responder } from "june/chat";

declare const responder: Responder;

const store = memoryStore();
const chat = createChat({ responder, store });
```

### `fileStore()`

`fileStore(directory)` keeps each session at `<directory>/<session id>.jsonl`. Each line is one complete journal entry, so the files are easy to inspect with `cat`, `jq` or a text editor:

```text
.june/chat/
  4f9c…e1.jsonl
    {"version":1,"id":"…","sessionId":"4f9c…e1","at":"…","type":"session.created"}
    {"version":1,"id":"…","sessionId":"4f9c…e1","at":"…","type":"turn.accepted","turnId":"…","input":"Remember the number 7."}
    {"version":1,"id":"…","sessionId":"4f9c…e1","at":"…","type":"turn.completed","turnId":"…","output":"Got it: 7.","metadata":{"responder":{"kind":"model"},…}}
```

- **Append-only.** Entries are appended and never rewritten, including when a session is reopened.
- **Synced.** Every append, and the file a new session creates, is synced to disk before it resolves. Streamed text is never written, so it costs nothing.
- **Crashes.** A crash mid-append can leave part of a line at the end of the file. That append never resolved, so the entry was never committed: reads skip the fragment and the next append replaces it. A complete last entry that's only missing its newline, say after an editor saved the file, is kept. A malformed line anywhere else is corruption, and reading fails with the file and line number.
- **Session ids** become file names, so `fileStore()` accepts up to 128 lowercase letters, digits, `.`, `_` and `-`, starting with a letter or digit, and rejects anything else, such as `../x`. Lowercase only, so two ids can't name the same file on a case-insensitive disk.
- **Permissions.** The directory is created readable only by its owner (`0700`) and journals as `0600`, where the platform supports it. Journals aren't encrypted: they hold every input and reply in plain text, so keep them out of version control (add `.june/` to `.gitignore`) and treat them like any other local data.
- **One writer per session.** Appends from one process are serialized, but there's no locking between processes. Keep a session open for writing in one process at a time; reading from others is fine.

### Your own store

A store persists journals, for example in your database. It only ever appends and reads; it never rewrites an entry.

```ts
import type { ChatJournalEntry, ChatStore } from "june/chat";

const journals = new Map<string, ChatJournalEntry[]>();

export const store: ChatStore = {
  async create(entry) {
    if (journals.has(entry.sessionId)) throw new Error("session exists");
    journals.set(entry.sessionId, [entry]);
  },
  async append(entry) {
    journals.get(entry.sessionId)!.push(entry);
  },
  async *read(sessionId, { after } = {}) {
    const entries = journals.get(sessionId) ?? [];
    const start = after ? entries.findIndex(({ id }) => id === after) + 1 : 0;
    if (after && start === 0) throw new Error(`unknown cursor ${after}`);
    yield* entries.slice(start);
  },
};
```

- `create()` starts a journal with its `session.created` entry, and rejects if the session exists.
- `append()` resolves once the entry is committed. Turns count as accepted when it does.
- `read()` yields a session's entries in order, or none if it doesn't exist. `after` is a cursor: the `id` of the last entry the caller has, and only later entries are yielded. An unknown cursor must throw rather than replay the wrong range.

A store owns its own access control, encryption and retention. June stores what it's given and doesn't authenticate who reads a session; see [Privacy and security](https://june.dev/docs/chat/privacy.md).

## What persistence means

A store keeps the journal and nothing else. `turns()` and `messages()` are projections of it, rebuilt when a session is reopened, so whatever a store has kept is exactly what a reopened session knows.

A chat records two different kinds of thing, on purpose:

|           | Live `ChatEvent`s                                                        | `ChatJournalEntry`s                                                                                                       |
| --------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
| What      | `turn-start`, `text-delta`, `run-update`, `approval-request`, `turn-end` | `session.created`, `turn.accepted`, `turn.completed`, `turn.failed`, `turn.canceled`, `turn.steered`, `feedback.recorded` |
| How often | many per turn, e.g. one per streamed token                               | a few per turn                                                                                                            |
| Who reads | whoever iterates the turn: a CLI, a UI                                   | the `ChatStore`, and anything that reopens or analyses the session                                                        |
| Stored    | never                                                                    | always, append-only                                                                                                       |

Streamed deltas are never journaled; only the final reply is.

Every entry has `version: 1`, a unique `id`, the `sessionId`, and an ISO 8601 timestamp `at`. Entries are plain JSON. June skips entry types it doesn't know, so newer versions can add types, and refuses entries with a version it doesn't read.

### Immutable history

Nothing in a journal is edited in place. A reply is written once, in `turn.completed`, and never changes. Feedback, including a person's preferred edit, is a new `feedback.recorded` entry next to it. Steering is a `turn.steered` entry on the same turn. A retry is a new turn whose `turn.accepted` entry records `retryOf`, and the original stays as it was. Reopening a session only reads: it never rewrites what's there.

That's what makes a journal useful as evidence: the original reply, every judgment of it and every correction are all still there, in order.

## Journal entries and turn state

### Accepted input

`await turn.accepted` resolves once the configured store has committed the user's input: its `append()` for the `turn.accepted` entry has resolved. The responder only starts after that. If the store fails, `accepted` and `result` both reject and the responder never runs.

What "committed" means is the store's promise. `memoryStore()` commits an entry once it's in the in-memory journal. `fileStore()` commits it once the line is written and synced to disk with `fsync`, so an accepted turn survives a crash or power loss. A custom store should resolve `append()` only once the entry would survive whatever the application cares about.

### Outcomes

| Status        | Means                                                                       | In `messages()` and the next turn's context |
| ------------- | --------------------------------------------------------------------------- | ------------------------------------------- |
| `running`     | accepted, and being answered in this process                                | no                                          |
| `completed`   | answered; `turn.result` resolved with the reply                             | yes                                         |
| `failed`      | the responder threw or rejected; `turn.result` rejects with its error       | no                                          |
| `canceled`    | `turn.cancel(reason)` was called; `turn.result` rejects with `reason`       | no                                          |
| `interrupted` | accepted, but the journal has no outcome, e.g. the process stopped mid-turn | no                                          |

Only completed turns become context for later turns, so a prompt that was never answered doesn't reach the model as if it had been. Failed, canceled and interrupted turns stay in `turns()` and the journal. [Turns and streaming](https://june.dev/docs/chat/turns.md#when-a-turn-doesnt-complete) covers how each happens and how to retry one.

## Durability

What June Chat guarantees:

- a turn's input is committed before the turn counts as accepted and before the responder runs;
- a completed, failed or canceled turn's outcome is appended before `turn.result` settles;
- a session can be reopened from its journal, and a turn accepted without an outcome reads as `interrupted`.

What it doesn't:

- checkpointing a turn while it runs, or resuming one after the process stops;
- exactly-once execution of a responder, its model calls, or its side effects;
- more than one writer per session. Use one `Session` object per session id at a time.

If writing a turn's outcome fails, the turn reads as `interrupted`, as it would after a crash. A reply that couldn't be recorded rejects `turn.result` with the store's error; a failed or canceled turn still rejects with its own error or cancel reason. [Retry](https://june.dev/docs/chat/turns.md#retry) answers an interrupted turn again, from the start.
