~/.mibyan/state.db) to persist session
metadata, full message history, and model configuration across CLI and gateway
sessions. This replaces the earlier per-session JSONL file approach.
Source files: mibyan_state.py (facade) plus the mibyan_state_*.py siblings (schema, fts, search, compression, portability, gateway, …)
Mibyan home and profile isolation
get_mibyan_home() is the authoritative filesystem resolver for state and
configuration. It uses a context-local override first, then the mibyan_HOME
environment variable, and finally the platform default (~/.mibyan on macOS
and Linux; %LOCALAPPDATA%/mibyan on Windows). Consequently, the default
database is always get_mibyan_home() / "state.db", not a path that callers
should hard-code as ~/.mibyan/state.db.
Named profiles are isolated directories: a profile named coder, for example,
uses <default Mibyan root>/profiles/coder/ and therefore has its own
state.db, configuration, logs, and other profile-scoped state. A process that
creates a database, reads configuration, or starts a child process for a
profile must retain or pass that profile’s mibyan_HOME; falling back to the
default root mixes the wrong profile’s state into the operation.
The CLI bootstrap calls _apply_profile_override() before importing the rest
of Mibyan. An explicit --profile/-p resolves that profile and writes the
resolved directory to mibyan_HOME. Without an explicit selector, a
profile-specific mibyan_HOME is preserved; otherwise the bootstrap can use
the default root’s active-profile selection. HOME only determines the
platform default used when no context override or mibyan_HOME is available.
Changing HOME is not a safe way to select a named profile. In particular, a
subprocess that drops mibyan_HOME can fall back to the default profile even
when another profile is active, so subprocess spawners should pass
mibyan_HOME explicitly.
Use display_mibyan_home() only for user-facing text. It formats the resolved
home relative to the user’s home directory when possible (for example,
~/.mibyan/profiles/coder); it does not provide a separate resolution rule.
Test isolation guard
Tests must use a temporarymibyan_HOME or an explicit temporary database
path. The live-system guard raises before a test-context process opens a
production state.db under the real default Mibyan root or a real named
profile, preventing fixture data or SQLite side effects from reaching a live
installation.
mibyan_STATE_DB_GUARD_BYPASS=1 is a test-only escape hatch for a spawned
child process that genuinely must access the live database. The equivalent
in-process escape hatch is @pytest.mark.live_system_guard_bypass. Do not set
either bypass in normal Mibyan commands, development shells, or application
configuration: it disables the guard (a hard RuntimeError) that protects live
session history, and a shell that exports it hands the bypass to every later
pytest run.
Desktop profile isolation and compaction generations
Each named profile stores its transcript in its own$mibyan_HOME/state.db,
including when one mibyan serve process serves several profiles. In-session
agent rebuilds (Bot Chat capability refresh and tools.configure) must retain
that session’s database handle and bind its profile home during construction.
Releasing the outgoing agent must not close the handle inherited by its replacement.
tools.configure resolves configuration from the live session’s profile_home,
even when the client supplies only session_id. Rebuilds prepare model configuration
before allocating a replacement, then install the agent and transfer ownership
together; preparation failure leaves the existing agent responsible for teardown.
Explicit profiles that cannot be resolved or whose directory has disappeared fail
before accessing launch configuration or history. A stale tools.configure
session ID likewise returns session not found without changing configuration;
omitting the session ID still supports the global settings operation.
In-place compaction archives old rows with active=0 and inserts the retained
context as active=1 rows. A protected message can therefore legitimately appear
in both generations with identical content and timestamp. Do not delete these
archive rows as duplicates. Diagnose duplicate live writes using active=1,
and check the database’s profile as well as the session ID when investigating
history that appears to revert.
Codex app-server input ownership
The agent persists an accepted user input before starting its Codex turn. Codex then projects that input as a leadinguserMessage notification. At the runtime
splice boundary, Mibyan excludes only that leading item when it exactly matches
the text serialized into turn/start, including rich-input coercion. Later or
nonmatching user events remain intact, as do separately accepted identical turns.
This also applies to synthetic/keyless input; it does not depend on a platform
message ID. Existing historical duplicates are not rewritten. The gateway skips
its transcript write when the agent reports that it owns persistence.
Gateway exception-path input ownership
A gateway exception can occur before agent construction or after its input reaches SQLite. The gateway gives the accepted input an owner marker in the existingdisplay_metadata sidecar and passes it through the agent’s normal persistence
path. Provider messages never contain this metadata. Platform markers namespace
the inbound message ID by platform, profile, scope, chat, and thread; the original
platform_message_id remains unchanged for quote/reply resolution. Keyless turns
receive a fresh marker, even for identical text and timestamps.
The exception writer probes only for that marker, following the published reroute
and canonical live compression successor, then compression ancestors. Active rows
and compaction archives count; undone rows, observed input, and unrelated writers
do not. An unrelated process writing the same session cannot suppress this turn.
No whole-history baseline or archived message-body allocation is needed. Failed
ownership reads do not authorize a speculative append; ordinary history-read
failures retain the existing history-unavailable response.
Normal agent-owned persistence is unchanged. This is failure-writer arbitration,
not universal exactly-once delivery, content deduplication, or a schema migration.
Historical rows are not rewritten; unmarked historical inputs cannot establish
ownership for a redelivered event.
Architecture Overview
mibyan sessions recover copies the row-bearing tables above into the
recovered database (FTS indexes and schema_version are regenerated), including
the lazily-created delivery_obligations ledger when the source has one — its
row count is verified like sessions/messages.
Key design decisions:
- WAL mode for concurrent readers + one writer (gateway multi-platform)
- FTS5 virtual table for fast text search across all session messages
- Session lineage via
parent_session_idchains (compression-triggered splits) - Source tagging (
cli,telegram,discord, etc.) for platform filtering - Batch runner and RL trajectories are NOT stored here (separate systems)
SQLite Schema
Sessions Table
Abridged — seeSCHEMA_SQL in mibyan_state_common.py (applied by mibyan_state_schema.py) for the full current column list
(which also includes gateway routing metadata such as session_key, chat_id,
chat_type, thread_id, display_name, origin_json, expiry_finalized,
workspace fields cwd / git_branch / git_repo_root, handoff and
compression-failure fields, profile_name, transport_profile (the multiplex
bot that received the lane, nullable), rewind_count, archived,
auto_archived (set only by the idle sweep; a resume or compression
continuation clears a sweep-only archive, never a deliberate one), and
pinned):
user_id is the principal on the other end of the session: messaging adapters
store the platform sender id, and desktop / dashboard sessions opened through
an authenticated mibyan serve (OAuth or the basic username/password provider)
store the login as <provider>:<user id> (for example basic:alice). Sessions
with nobody behind them — anonymous loopback use, subagent, cron, kanban
— keep it empty. The value is set when the row is created and never inferred
later.
Messages Table
Abridged — the full schema also includeseffect_disposition,
platform_message_id, observed, active, compacted, api_content,
display_kind, display_metadata, message_uid, absorbed_message_uids,
tool_call_uids, and tool_call_uid:
tool_callsis stored as a JSON string (serialized list of tool call objects)reasoning_details,codex_reasoning_items, andcodex_message_itemsare stored as JSON stringsreasoning_detailsis always kept in history; on the chat-completions wire it is replayed only to OpenRouter and the Nous Portal (every other chat-completions route gets a copy without it, since strict schemas reject the field)- Desktop history hydration retains assistant sidecars in both REST and JSON-RPC (
session.resume,session.activate,session.history) projections, including rows with reasoning and tool calls. REST may return the SQLite JSON string while RPC returns decoded items; Desktop accepts both. A final Responses reply may live only incodex_message_itemswhilecontentis empty. Canonical content still takes precedence, and analysis/commentary items are not promoted to reply text. reasoningstores the raw reasoning text for providers that expose it- A reasoning-only clean stop (empty
content,finish_reason=stop, reasoning present) is answered with the reasoning text, but the assistant row is never written with that text ascontent:contentstays empty, the text lives inreasoning/reasoning_content, andapi_contentcarries it so the next request replays the answer byte-identically. History surfaces therefore show it as reasoning, not as a reply. api_contentis a byte-fidelity sidecar: the exact content string sent to the API for this message when it differs fromcontent(ephemeral memory/plugin injections, persist overrides). It preserves the wire bytes for prompt-cache-stable replay — stored as sent, except lone surrogates, which sqlite3 cannot bind and which the conversation loop scrubs from every outgoing payload anyway.NULLmeanscontentwas sent verbatim.- Timestamps are Unix epoch floats (
time.time()) message_uidis the durable per-message id (32 hex,uuid4().hex): minted once at a row’s first insert and copied by every clone (in-place compaction generations, rotation children, concurrent-tail clones,replace_messagesre-issues, export/import), so one logical message keeps one uid while its physicalidchanges. Row-addressed rewrites leave it alone. It is restored on every projection (get_messages_as_conversationsets it unconditionally;_row_idstays opt-in) and stripped from provider requests. Context engines key their own per-message state on it — see Context Engine Plugins.absorbed_message_uidsis the merge witness: a JSON list of themessage_uids the host folded into this row (alternation repair’s user and assistant merges, the compressor’s in-flight restatement and anchor folds, micro-compaction’s adjacent-user merge; the composite keeps the first constituent’s uid). Written when the survivor is flushed, rewritten with it, restored as the live_absorbed_message_uids,NULLon rows that absorbed nothing.tool_call_uids(assistant rows) is a JSON{tool_call_id: uid}map giving each entry oftool_callsa per-occurrence id, because provider tool-call ids repeat (calls repeating an id inside one response share its uid; after two assistant turns are folded, an id both name maps to a list of uids, one per occurrence in call order);tool_call_uid(tool rows) is the matching value for the result. Thetool_callsJSON itself is never modified. Minted at the assistant row’s first insert, paired onto the result at flush or, when the column isNULL, derived on restore from the preceding assistant row; restored as the live_tool_call_uids/_tool_call_uid.
FTS5 Full-Text Search
messages table. The current triggers are gated on the
fts_rebuild_high_water / fts_rebuild_progress markers in state_meta (so a
background FTS rebuild can proceed without double-indexing) and cover all three
indexed columns — see SCHEMA_SQL in mibyan_state_common.py for the exact SQL.
Schema Version and Migrations
Current schema version: 31 Theschema_version table stores a single integer. Simple column additions are handled declaratively by _reconcile_columns() (which diffs live columns against SCHEMA_SQL and ADDs any missing ones). The version-gated chain is reserved for data migrations and index/FTS changes that can’t be expressed declaratively:
Versions not listed above were declarative column additions handled by
_reconcile_columns() (version bump only, no data migration).
Declarative column adds use ALTER TABLE ADD COLUMN wrapped in try/except to handle the column-already-exists case (idempotent). The version number is bumped after each successful migration block.
Write Contention Handling
Multiple mibyan processes (gateway + CLI sessions + worktree agents) share onestate.db. The SessionDB class handles write contention with:
- Short SQLite timeout (1 second) instead of the default 30s
- Time-budgeted application-level retry with random jitter (20-150ms for the first 2s, then 250ms-1s): 20s for routine writes, 60s for transcript writes (their failure aborts the turn), 0.5s for observation-only activity writes
- BEGIN IMMEDIATE transactions to surface lock contention at transaction start
- Periodic WAL checkpoints every 50 successful writes (PASSIVE mode)
session_persistence_failed:locked and, on Linux, mibyan_state_lockowners
logs a WARNING naming the process that held the lock at that moment
(PID 594094 (mibyan --worktree --yolo) holds WAL write lock on state.db-shm),
read from /proc/locks — SQLite’s byte-range fcntl locks encode the lock kind
in their offset (state.db-shm byte 120 = WAL write, 121 = checkpoint,
123-127 = read slots; the 1 GiB pending-byte page on state.db = rollback-journal
PENDING/RESERVED/SHARED). The open-descriptor scan cannot make this distinction
because every Mibyan process has the DB open. Look for that line in
~/.mibyan/logs/errors.log next to the database is locked failure.
Lock contention is recognised by SQLite result code (SQLITE_BUSY /
SQLITE_LOCKED, mibyan_state_errors.is_sqlite_lock_error), not by message
text. In rollback-journal (delete) mode a lock lost inside FTS5’s table
constructor arrives as SQLITE_BUSY with the text vtable constructor failed: messages_fts; it is treated like database is locked. Opening a writable
SessionDB waits up to _WRITE_PATIENCE_S; a read-only open waits its
_READ_BUSY_TIMEOUT_S (5 s) read budget once. If the lock outlasts that, the dashboard
answers 503 (busy), not 500.
Common Operations
Initialize
Create and Manage Sessions
Store Messages
Retrieve Messages
Session Titles
Full-Text Search
Thesearch_messages() method supports FTS5 query syntax with automatic
sanitization of user input.
Basic Search
FTS5 Query Syntax
Filtered Search
Search Results Format
Each result includes:id,session_id,role,timestampsnippet— FTS5-generated snippet with>>>match<<<markerscontext— 1 message before and after the match (content truncated to 200 chars)source,model,session_started— from the parent session
_sanitize_fts5_query() method handles edge cases:
- Strips unmatched quotes and special characters
- Wraps hyphenated terms in quotes (
chat-send→"chat-send") - Removes dangling boolean operators (
hello AND→hello)
Session Lineage
Sessions can form chains viaparent_session_id. This happens when context
compression triggers a session split in the gateway.
Query: Find Session Lineage
Query: Recent Sessions with Preview
Query: Token Usage Statistics
Export and Cleanup
Database Location
Default path:get_mibyan_home() / "state.db" — ~/.mibyan/state.db for the
default profile, ~/.mibyan/profiles/<name>/state.db for a named profile, or
wherever mibyan_HOME points (see Mibyan home and profile isolation).
The database file, WAL file (state.db-wal), and shared-memory file
(state.db-shm) are all created in the same directory.
