Skip to content

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

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 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.

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 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.

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)
Keepsjournals in the process's memoryone NDJSON file per session
Survivesnothing; gone when the process iscrashes and restarts
Committedonce in memoryonce written and synced with fsync
Good fortests, one-off scriptsthe CLI, local apps, anything you'll reopen
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:

.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.

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.

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 ChatEventsChatJournalEntrys
Whatturn-start, text-delta, run-update, approval-request, turn-endsession.created, turn.accepted, turn.completed, turn.failed, turn.canceled, turn.steered, feedback.recorded
How oftenmany per turn, e.g. one per streamed tokena few per turn
Who readswhoever iterates the turn: a CLI, a UIthe ChatStore, and anything that reopens or analyses the session
Storedneveralways, 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

StatusMeansIn messages() and the next turn's context
runningaccepted, and being answered in this processno
completedanswered; turn.result resolved with the replyyes
failedthe responder threw or rejected; turn.result rejects with its errorno
canceledturn.cancel(reason) was called; turn.result rejects with reasonno
interruptedaccepted, but the journal has no outcome, e.g. the process stopped mid-turnno

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 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 answers an interrupted turn again, from the start.