jonaki

Memory & sessions

Three separate concerns — static config, one execution, and a conversation that outlives both.

Jonaki keeps three things strictly apart:

What it isLifetime
Agent configStatic, defined in codeNever mutated by a run
RunStateOne execution, keyed by runIdEphemeral unless persisted
SessionConversation history, keyed by sessionIdSpans 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.

On this page