Graph nodes
Every conversation event — user inputs, assistant responses, tool calls, mode events, prompt snapshots — lives in graph_nodes with a tombstoned flag for soft deletion. The runtime walks active paths from the session head.
Sessions persist their runtime state through a small interface, RuntimePersistence. The default first-party implementation is SQLite-backed (lash-sqlite-store) and is what the CLI uses. Host apps wire it up by passing a SessionStoreFactory to the core builder, or by handing a per-session store directly to SessionBuilder.
RuntimePersistence is the runtime-facing trait. Most app hosts never implement it themselves — they use lash-sqlite-store via its factory.
#[async_trait]
pub trait RuntimePersistence: Send + Sync {
async fn load_session(&self, scope: SessionReadScope)
-> Result<Option<PersistedSessionRead>, StoreError>;
async fn load_node(&self, node_id: &str)
-> Result<Option<SessionNodeRecord>, StoreError>;
async fn commit_runtime_state(&self, commit: RuntimeCommit)
-> Result<RuntimeCommitResult, StoreError>;
async fn save_session_meta(&self, meta: SessionMeta) -> Result<(), StoreError>;
async fn load_session_meta(&self) -> Result<Option<SessionMeta>, StoreError>;
async fn tombstone_nodes(&self, ids: &[String]) -> Result<(), StoreError>;
async fn vacuum(&self) -> Result<VacuumReport, StoreError>;
async fn gc_unreachable(&self) -> Result<GcReport, StoreError>;
}
A SessionStoreFactory produces stores on demand — one per session — keyed by the session id, parent id (for subagents / forks), and policy:
pub trait SessionStoreFactory: Send + Sync {
fn create_store(
&self,
request: &SessionStoreCreateRequest,
) -> Result<Arc<dyn RuntimePersistence>, String>;
}
The common path is a single factory installed on the core. Every session opened from that core uses it.
use std::sync::Arc;
use lash::{LashCore, ModeId, ModePreset};
use lash_sqlite_store::SqliteSessionStoreFactory;
let store_factory = Arc::new(SqliteSessionStoreFactory::new("./.lash-data"));
let core = LashCore::builder()
.install_mode(ModePreset::rlm())
.default_mode(ModeId::rlm())
.provider(provider)
.model(model, None)
.max_context_tokens(200_000)
.store_factory(store_factory)
.build()?;
Override per session — useful for tests that want an isolated in-memory or temp-directory store:
let session = core
.session(chat_id)
.store(Arc::new(my_custom_persistence))
.open()
.await?;
If no store_factory is set, sessions run in-memory: they accept turns and produce results, but nothing is written to disk and nothing can be resumed after the process exits. There's no separate "in-memory" implementation — absence of a factory is the mechanism.
The SQLite schema is exhaustive and stable. A single session's database holds:
Every conversation event — user inputs, assistant responses, tool calls, mode events, prompt snapshots — lives in graph_nodes with a tombstoned flag for soft deletion. The runtime walks active paths from the session head.
A singleton row tracking the current logical head pointer and its revision. Optimistic concurrency uses the revision to detect conflicting writes.
Compressed snapshots of tool state, plugin state, execution state, and prompt material, keyed by content hash so identical blobs are deduplicated across resumes.
Per-turn token counts: input_tokens, output_tokens, cached_input_tokens, reasoning_tokens. The runtime aggregates these into the session usage ledger surfaced by SessionReadView.
Session id, human-readable name, model, cwd, parent session id, creation timestamp. This is what the resume picker shows.
Image / file attachments are stored as blobs and referenced from graph nodes by BlobRef. The same blob is reused by traces and exports.
Two operations on RuntimePersistence reclaim space. Both are safe to run from a background task or a CLI maintenance command.
gc_unreachable()Walks reachable blob references from the session head and active checkpoints, marks orphaned blobs for deletion, and returns a GcReport with the number of blobs removed. Run this after pruning a branch or discarding a long unused thread.
vacuum()Physically removes tombstoned graph-node rows that gc has already detached, returning a VacuumReport with the row count. Use this after a gc_unreachable pass to reclaim file size.
The agent-service example shows the canonical wire-up: a SQLite factory, sessions opened from the same core by chat id, runtime persistence handled transparently while the app keeps its own product database (chat rows, board state, frontend events) alongside.
// One factory at boot, shared across every chat.
let store_factory = Arc::new(SqliteSessionStoreFactory::new(
data_dir.join("lash-sessions"),
));
let core = LashCore::builder()
.install_mode(ModePreset::rlm())
.default_mode(ModeId::rlm())
.provider(provider)
.model(model.clone(), Some(model_variant.clone()))
.max_context_tokens(200_000)
.store_factory(store_factory)
.build()?;
// Per request: open a session keyed by the app's chat id.
let session = core.session(chat_id).rlm().open().await?;
The full source is at examples/agent-service/src/main.rs.