SQLite Transcript Store
Concurrent, indexed gateway session persistence — now the default
SqliteTranscriptStore keeps every gateway session as one row in a WAL-preferring SQLite database (falls back to DELETE on hostile filesystems) — the default backend whenever session.persist is on. See Hardened SQLite Storage for the fallback behaviour.
Nothing changes for a single-user Python Agent in a script — session APIs are identical. Only the gateway changes: with session.persist: true, transcripts now live in one sessions.db with concurrent readers and indexed routing instead of per-session JSON files guarded by a FileLock.
What changed by default
With session.persist: true, the gateway now builds SqliteTranscriptStore unless you opt out with session.store: file. And session.persist itself now defaults to true — see Gateway Session Persistence.
| Setting | Before | After |
|---|---|---|
Gateway backend (with session.persist: true) | DefaultSessionStore (JSON-per-session + FileLock) | SqliteTranscriptStore (WAL SQLite, one row per session) |
| Layout on disk | ~/.praisonai/sessions/*.json | ~/.praisonai/sessions/sessions.db |
| Concurrent-writer safety | Per-file FileLock | BEGIN IMMEDIATE transaction (RESERVED lock) |
list / search / gateway routing | os.listdir + JSON-parse-every-file | Indexed SELECT |
| Legacy JSON on upgrade | N/A | One-time auto-import into sessions.db on first open |
Injected session_store=… | Untouched | Untouched — new default only applies when the gateway builds the store |
The change is backward-compatible for API callers (public methods are identical) but visible on disk. External tools pointed at *.json files should either read sessions.db or set session.store: file.
Quick Start
- Default (YAML) — SQLite is on
yaml # gateway.yaml session: persist: true store: sqlite # NEW — sqlite (default) | file persist_path: ~/.praisonai/sessions/session.persist: trueis enough —store: sqliteis the default. Transcripts land in~/.praisonai/sessions/sessions.db. - Keep the legacy JSON layout
yaml # gateway.yaml session: persist: true store: file # restore pre-#3409 JSON-per-session behaviour persist_path: ~/.praisonai/sessions/Use
store: filewhen external tooling grep-scans the individual*.jsontranscripts. - Python embedder using the store directly
python from praisonaiagents import Agent from praisonaiagents.session import SqliteTranscriptStore store = SqliteTranscriptStore(db_path="~/.praisonai/sessions/sessions.db") agent = Agent(name="Assistant", instructions="Be helpful", session_store=store)An injected store always wins over the
session.storeconfig field.
Agent scripts need no changes — the gateway persists sessions for you:
from praisonaiagents import Agent
agent = Agent(name="Assistant", instructions="Be helpful")
# The gateway persists this agent's session via SqliteTranscriptStore
# — no code change, no new imports required in agent scripts.How It Works
Two gateway processes handling the same session serialise inside SQLite, not on a directory scan — the read-modify-write window the old FileLock could lose is closed by BEGIN IMMEDIATE.
Every read-modify-write (add_message and friends) runs its SELECT + INSERT OR REPLACE inside one BEGIN IMMEDIATE transaction, so a second gateway process cannot slip a read between this one’s read and its write.
| Behaviour | Detail |
|---|---|
| One-time JSON migration | On first open of a non-:memory: DB, any *.json transcripts in session_dir are imported once — only if the DB is empty. Files are read in sorted order so results are deterministic. Unreadable, malformed-JSON, and invalid-UTF-8 files are skipped and their bytes preserved untouched — a single bad file never aborts the migration. A summary logs at INFO. |
| Cross-process append safety | BEGIN IMMEDIATE grabs the RESERVED write lock and holds it to COMMIT, replacing the old cross-process FileLock. The process-local lock alone is not sufficient. |
| Search, no FTS5 | search() uses an indexed LIKE candidate query then reuses the parent’s scoring, bookends and lineage-dedup — results match DefaultSessionStore.search(). One bad transcript on disk (unreadable, malformed JSON, or invalid UTF-8) is skipped — search keeps returning valid matches. See Cross-Session Recall → Resilience to Corrupt Transcripts. FTS5 is not required. |
| Archived-history recall | search() scans the shared archived + active projection, so compacted turns stay searchable and hits carry the archived: bool flag — matching the other stores (Issue #5031). See Cross-Session Recall. |
:memory: support | db_path=":memory:" gives an ephemeral in-process store for tests; the legacy-JSON migration is a no-op. |
Which backend to pick
Configuration Options
SqliteTranscriptStore constructor:
| Arg | Type | Default | Description |
|---|---|---|---|
session_dir | Optional[str] | inherited | Retained for parent-API compat and to derive a default db_path. No JSON files are written here. |
db_path | Optional[str] | <session_dir>/sessions.db | Path to the SQLite file. ":memory:" for an ephemeral store; ~ is expanded. |
**kwargs | — | — | Forwarded to DefaultSessionStore — retention, active_window, max_messages, lock_timeout. |
SessionConfig.store (gateway YAML):
| Field | Type | Default | Description | |
|---|---|---|---|---|
store | `”sqlite” \ | “file”` | "sqlite" | Gateway transcript backend. "sqlite" uses SqliteTranscriptStore; "file" restores DefaultSessionStore (JSON-per-session + FileLock). |
Selection rules:
session.persist: false→ no store, regardless ofstore:.session.persist: true+store: sqlite(default) →SqliteTranscriptStore.session.persist: true+store: file→DefaultSessionStore.- An injected
session_store=…always wins — the field only applies when the gateway builds the store itself. - If
sqlite3is unavailable, the gateway falls back to the JSON store and logs a warning.
PRAGMAs applied on first connect (you don’t need to touch these):
| PRAGMA | Value | Why |
|---|---|---|
journal_mode | WAL | Readers proceed concurrently with a writer |
synchronous | NORMAL | Durability vs. throughput trade — safe with WAL |
busy_timeout | lock_timeout × 1000 ms | Uses the parent’s lock_timeout (default 5 s) |
Schema (one row per session — inspect with the sqlite3 CLI if needed):
CREATE TABLE sessions (
session_id TEXT PRIMARY KEY,
data TEXT NOT NULL, -- JSON payload (whole SessionData)
agent_name TEXT,
gateway_session_id TEXT,
agent_id TEXT,
updated_at TEXT
);
CREATE INDEX idx_sessions_gateway ON sessions(gateway_session_id);
CREATE INDEX idx_sessions_agent ON sessions(agent_id);
CREATE INDEX idx_sessions_agent_name ON sessions(agent_name);
CREATE INDEX idx_sessions_updated ON sessions(updated_at);Portable export/import
SqliteTranscriptStore implements the same PortableSessionStoreProtocol as DefaultSessionStore and SqliteSessionStore, but every portable operation reads and writes database rows — never JSON sidecars.
from praisonaiagents.session import SqliteTranscriptStore
store = SqliteTranscriptStore(db_path="~/.praisonai/sessions/sessions.db")
payload = store.export_all() # scans DB rows, not *.json
payload = store.export_session("s1", include_lineage=True) # follows DB lineage
report = store.import_sessions(payload, overwrite=True) # writes a DB row| Operation | What it reads/writes |
|---|---|
export_all() | Scans every row in sessions.db. Stray *.json files in session_dir are ignored. |
export_session(id, include_lineage=True) | Follows metadata.lineage_id / root_session_id / thread_id across DB rows so compacted / rotated continuations round-trip as one logical session. |
import_sessions(payload, overwrite=…) | Writes each session as a DB row — no sidecar. Honours overwrite=False end-to-end: a conflicting row lands in ImportReport.skipped with reason "already exists (use overwrite)". |
import_sessions(…) against a read-only DB (PRAGMA query_only=ON or similar) | Surfaces as a failed write in ImportReport.skipped — never a false success. |
The restore path reuses the inherited _save_imported_session orchestration, so the no-window-truncation restore contract still holds: a destination configured with a small truncating active_window / retention="truncate" will not drop exported history on import.
See Gateway Session Portability for the CLI, Python helpers, lineage flags, and ImportReport schema — the same surface works unchanged against the SQLite transcript backend.
Common Patterns
Default gateway config — SQLite is on with just persist: true:
session:
persist: trueExplicit legacy fallback — for tooling that scans JSON transcripts:
session:
persist: true
store: fileCustom db_path on a shared volume — WAL + BEGIN IMMEDIATE give cross-process append-safety on a real filesystem:
from praisonaiagents.session import SqliteTranscriptStore
store = SqliteTranscriptStore(db_path="/mnt/shared/praisonai/sessions.db")Best Practices
Don't panic when *.json files disappear
On the first boot after the upgrade, legacy JSON transcripts are imported into sessions.db once, then the JSON files stop being written to. Nothing was lost. To keep the JSON layout, set session.store: file before the first boot.
One bad legacy file won't abort the migration
A legacy *.json file that is unreadable, is not valid JSON, or is not valid UTF-8 (e.g. a truncated write, or a dump in a different encoding) is skipped during the one-time migration and its bytes are preserved on disk — valid transcripts still import. Files are read in sorted order, so this holds even when the bad file would be processed first. Fixed in PraisonAI PR #5555 (Issue #5553).
Don't run sqlite3 maintenance while the gateway is up
WAL + BEGIN IMMEDIATE are safe for concurrent gateway writers, but ad-hoc VACUUM/REINDEX from a separate sqlite3 process during traffic will fight for the RESERVED lock. Do maintenance in a quiet window.
Prefer the config field to injection
If you set session.store in YAML and pass session_store=…, the injected store wins and the config field is ignored. This is intentional — embedders control lifecycle — but easy to be surprised by. Log the resolved store class on startup.
This is not FTS
SqliteTranscriptStore.search() uses LIKE — good for hundreds of sessions, not tens of thousands. For scalable cross-session recall, use SqliteSessionStore (a different class — see the note above).
Encrypt transcripts at rest
This store writes message content to disk in plaintext. Wrap it in EncryptedSessionStore to encrypt content and metadata before they land on the filesystem — see Encrypted Session Store.
sessions.db.corrupt-* files are not deletable-on-sight
They are your only forensic copy of a malformed database. Rotate or archive them off-host if you need long-term retention (retention cap is 3 per file). See Hardened SQLite Storage → Integrity Guard.
Export/import operates on DB rows, not sidecars
export_all, export_session, and import_sessions on this store operate on rows in sessions.db. Stray *.json files in the same directory are not scanned on export, and imports never silently write a sidecar — a read-only DB is reported as a failed write, not a false success. See Portable export/import.