Memory & sessions
Three separate concerns — static config, one execution, and a conversation that outlives both.
Jonaki keeps three things strictly apart:
| What it is | Lifetime | |
|---|---|---|
| Agent config | Static, defined in code | Never mutated by a run |
RunState | One execution, keyed by runId | Ephemeral unless persisted |
| Session | Conversation history, keyed by sessionId | Spans many runs |
A store persists runs and sessions. It never persists agent config — that is code.
Remembering across runs
import { Agent, MemoryStore, run } from "@rohhitrz/jonaki";
const agent = new Agent({ name: "Assistant" });
const store = new MemoryStore();
await run(agent, "I'm Rohit", { store, session: "chat-42" });
const second = await run(agent, "What's my name?", {
store,
session: "chat-42",
});
console.log(second.output); // "You're Rohit."Prior messages are loaded before the run and the new ones appended after. Only a completed run joins the conversation, so a failed or paused run never leaves half a turn behind.
session needs store
Passing session without store throws STORE_ERROR. A session needs
somewhere to live.
Surviving a restart
MemoryStore dies with the process. For real persistence use SQLite:
import { Agent, createSQLiteStore, run } from "@rohhitrz/jonaki";
const store = await createSQLiteStore({ path: "./chat.db" });
const agent = new Agent({ name: "Assistant" });
await run(agent, "What's my name?", { store, session: "chat-42" });
store.close();Run that twice from a cold process and the second run still knows.
The three stores
MemoryStore
The default. Zero configuration. State is deep-copied through JSON on the way in and out, so callers cannot mutate what is stored.
import { MemoryStore } from "@rohhitrz/jonaki";
const store = new MemoryStore();FileStore
One JSON file per run under <dir>/runs, sessions as JSONL under
<dir>/sessions. This is the format replay reads.
import { FileStore } from "@rohhitrz/jonaki";
const store = new FileStore({ dir: "runs" });SQLiteStore
Durable, indexed, and queryable. Needs the optional better-sqlite3 peer
dependency.
import { createSQLiteStore } from "@rohhitrz/jonaki";
const store = await createSQLiteStore({ path: "./app.db" });createSQLiteStore is async
The driver is imported on demand so it can stay optional. new SQLiteStore(path)
is wrong — the constructor takes an already-open database handle.
The Store interface
Any of the three is interchangeable, and you can implement your own:
import type { Store } from "@rohhitrz/jonaki";
const store: Store = {
async saveRun(state) {},
async loadRun(runId) {
return null;
},
async appendSession(sessionId, messages) {},
async loadSession(sessionId, limit) {
return [];
},
async listRuns(filter) {
return [];
},
};Listing runs
import { run } from "@rohhitrz/jonaki";
const runs = await store.listRuns({ sessionId: "chat-42", limit: 10 });
for (const run of runs) {
console.log(run.runId, run.status, run.turn, run.usage.costUSD);
}RunSummary is an index row, not a transcript — listing runs does not load
every message.
Limiting how much history loads
import { run } from "@rohhitrz/jonaki";
await run(agent, "and now?", {
store,
session: "chat-42",
sessionLimit: 20, // only the 20 most recent messages
});The cut never separates a tool result from the assistant turn that produced it.
Context windowing
Long conversations eventually exceed the model's context. Windowing pins system messages, keeps the most recent turns, and drops the middle:
import { Agent } from "@rohhitrz/jonaki";
const agent = new Agent({
name: "Assistant",
context: { maxTokens: 8_000, keepRecent: 6 },
});Or per run:
import { run } from "@rohhitrz/jonaki";
await run(agent, "hi", { context: { maxTokens: 8_000, keepRecent: 6 } });Windowing happens in whole turns, so an assistant message carrying tool calls always travels with its tool results — a provider rejects an orphaned tool result.
The stored history is untouched; only what is sent is trimmed.
No summarization
Dropped messages are gone, not condensed. The earliest turns — often where the
user stated the task — go first. Anything that must survive belongs in
instructions, which is never dropped. Windowing is opt-in: with no
maxTokens, the full history is sent. Token counts are estimated at roughly
four characters per token, not measured with a real tokenizer, so budget with
headroom.