> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mibyanai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Session Storage

Mibyan uses a SQLite database (`~/.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 temporary `mibyan_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 leading `userMessage` 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 existing
`display_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/state.db (SQLite, WAL mode)
├── sessions              — Session metadata, token counts, billing
├── messages              — Full message history per session
├── session_model_usage   — Per-model/per-task usage attribution rows
├── messages_fts          — FTS5 virtual table (content + tool_name + tool_calls)
├── messages_fts_trigram  — FTS5 virtual table with trigram tokenizer (CJK / substring search)
├── messages_fts_cjk      — FTS5 virtual table with cjk_unicode61 tokenizer
├── state_meta            — Key/value metadata table
├── gateway_routing       — Gateway routing metadata
├── compression_locks     — Cross-process compression locking
├── async_delegations     — Async delegation bookkeeping
├── delivery_obligations  — Gateway outbox (owed replies); created lazily by gateway/delivery_ledger.py
└── schema_version        — Single-row table tracking migration state
```

`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_id` chains (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 — see `SCHEMA_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`):

```sql theme={null}
CREATE TABLE IF NOT EXISTS sessions (
    id TEXT PRIMARY KEY,
    source TEXT NOT NULL,
    user_id TEXT,
    model TEXT,
    model_config TEXT,
    system_prompt TEXT,
    parent_session_id TEXT,
    started_at REAL NOT NULL,
    ended_at REAL,
    end_reason TEXT,
    message_count INTEGER DEFAULT 0,
    tool_call_count INTEGER DEFAULT 0,
    input_tokens INTEGER DEFAULT 0,
    output_tokens INTEGER DEFAULT 0,
    cache_read_tokens INTEGER DEFAULT 0,
    cache_write_tokens INTEGER DEFAULT 0,
    reasoning_tokens INTEGER DEFAULT 0,
    billing_provider TEXT,
    billing_base_url TEXT,
    billing_mode TEXT,
    estimated_cost_usd REAL,
    actual_cost_usd REAL,
    cost_status TEXT,
    cost_source TEXT,
    pricing_version TEXT,
    title TEXT,
    api_call_count INTEGER DEFAULT 0,
    -- ... additional gateway/workspace/handoff/compression columns ...
    FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
);

CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source);
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id);
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique
    ON sessions(title) WHERE title IS NOT NULL;
```

`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 includes `effect_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`:

```sql theme={null}
CREATE TABLE IF NOT EXISTS messages (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    session_id TEXT NOT NULL REFERENCES sessions(id),
    role TEXT NOT NULL,
    content TEXT,
    tool_call_id TEXT,
    tool_calls TEXT,
    tool_name TEXT,
    timestamp REAL NOT NULL,
    token_count INTEGER,
    finish_reason TEXT,
    reasoning TEXT,
    reasoning_content TEXT,
    reasoning_details TEXT,
    codex_reasoning_items TEXT,
    codex_message_items TEXT
    -- ... additional display/compaction columns ...
);

CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp);
CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id, id);
```

Notes:

* `tool_calls` is stored as a JSON string (serialized list of tool call objects)
* `reasoning_details`, `codex_reasoning_items`, and `codex_message_items` are stored as JSON strings
* `reasoning_details` is 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 in `codex_message_items` while `content` is empty. Canonical content still takes precedence, and analysis/commentary items are not promoted to reply text.
* `reasoning` stores 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 as `content`: `content` stays empty, the text lives in `reasoning`/`reasoning_content`, and `api_content` carries it so the next request replays the answer byte-identically. History surfaces therefore show it as reasoning, not as a reply.
* `api_content` is a byte-fidelity sidecar: the exact content string sent to the API for this message when it differs from `content` (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. `NULL` means `content` was sent verbatim.
* Timestamps are Unix epoch floats (`time.time()`)
* `message_uid` is 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_messages` re-issues, export/import), so one logical message keeps one uid while its physical `id` changes. Row-addressed rewrites leave it alone. It is restored on every projection (`get_messages_as_conversation` sets it unconditionally; `_row_id` stays opt-in) and stripped from provider requests. Context engines key their own per-message state on it — see [Context Engine Plugins](/desktop/developer-guide/context-engine-plugin#stable-message-identity-message_uid).
* `absorbed_message_uids` is the merge witness: a JSON list of the `message_uid`s 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`, `NULL` on rows that absorbed nothing.
* `tool_call_uids` (assistant rows) is a JSON `{tool_call_id: uid}` map giving each entry of `tool_calls` a 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. The `tool_calls` JSON itself is never modified. Minted at the assistant row's first insert, paired onto the result at flush or, when the column is `NULL`, derived on restore from the preceding assistant row; restored as the live `_tool_call_uids` / `_tool_call_uid`.

### FTS5 Full-Text Search

```sql theme={null}
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
    content,
    tool_name,
    tool_calls,
    content='messages',
    content_rowid='id'
);
```

The FTS5 table is kept in sync via three triggers that fire on INSERT, UPDATE,
and DELETE of the `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**

The `schema_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:

| Version | Change |
| - | - |
| 1 | Initial schema (sessions, messages, FTS5) |
| 2 | Add `finish_reason` column to messages |
| 3 | Add `title` column to sessions |
| 4 | Add unique index on `title` (NULLs allowed, non-NULL must be unique) |
| 5 | Add billing columns: `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `billing_provider`, `billing_base_url`, `billing_mode`, `estimated_cost_usd`, `actual_cost_usd`, `cost_status`, `cost_source`, `pricing_version` |
| 6 | Add reasoning columns to messages: `reasoning`, `reasoning_details`, `codex_reasoning_items` |
| 7 | Add `reasoning_content` column to messages |
| 8 | Add `api_call_count` column to sessions |
| 9 | Add `codex_message_items` column to messages for Codex Responses message id/phase replay |
| 10 | Add `messages_fts_trigram` virtual table (trigram tokenizer for CJK / substring search) and backfill existing rows |
| 11 | Re-index `messages_fts` and `messages_fts_trigram` to cover `tool_name` + `tool_calls` and switch from external-content to inline mode; drop old triggers and backfill every message row |
| 16 | Tag delegate subagent rows in `model_config` (`$._delegate_from`) so session pickers stay clean after parent deletes orphan them |
| 18 | Gateway metadata consolidation — backfill `display_name` / `origin_json` / `expiry_finalized` from `sessions.json` |
| 20 | Per-model usage attribution — seed `session_model_usage` rows from historical per-session aggregate totals |
| 22 | Task-dimension usage attribution — rebuild `session_model_usage` so the `task` column participates in the PRIMARY KEY |
| 23 | FTS storage redesign — external-content FTS tables replacing the v11 inline-mode copies (opt-in transition for existing DBs) |
| 29 | Cron sessions leave the trigram (substring/CJK) index; `messages_fts_trigram_src` view + triggers filter on `sessions.source`, one-time rebuild purges historical rows |
| 30 | Delegate-child (subagent) sessions leave the trigram index too — `source='subagent'` or the `$._delegate_from` marker (`FTS_TRIGRAM_SESSION_SQL`). Rows stay in `messages` and the standard `messages_fts` word index, so `session_search` still finds them; only the \~2.6× trigram shadow tables shrink. Same one-time rebuild as v29 |
| 31 | `messages.message_uid`, `absorbed_message_uids`, `tool_call_uids`, `tool_call_uid` (declarative column adds), a backfill of a fresh `message_uid` onto every row that predates the column (2,000-id chunks, each its own short write; one open spends about a second past its first chunk and resumes from the `message_uid_backfill_id` meta cursor on the next open, until the `message_uid_backfill` marker ends it, so a large store never holds the write lock for the whole table), and the `messages_message_uid_insert` trigger that mints one for any row inserted without it (an older build writing into the store), so a v31 store hands every row to consumers with an id (tool-call uids are minted when a row is next written; results are paired on restore) |

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 one
`state.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)

This avoids the "convoy effect" where SQLite's deterministic internal backoff
causes all competing writers to retry at the same intervals.

```
_WRITE_PATIENCE_S, _TRANSCRIPT_WRITE_PATIENCE_S, _ACTIVITY_WRITE_PATIENCE_S = 20.0, 60.0, 0.5
_WRITE_RETRY_MIN_S, _WRITE_RETRY_MAX_S = 0.020, 0.150
_WRITE_RETRY_SLOW_MIN_S, _WRITE_RETRY_SLOW_MAX_S = 0.250, 1.000
_CHECKPOINT_EVERY_N_WRITES = 50
```

When a writer exhausts its budget the turn ends with
`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

```python theme={null}
from mibyan_state import SessionDB

db = SessionDB()                           # Default: ~/.mibyan/state.db
db = SessionDB(db_path=Path("~/.mibyan/cache/scratch/test.db").expanduser())  # Custom path
```

### Create and Manage Sessions

```python theme={null}
# Create a new session
db.create_session(
    session_id="sess_abc123",
    source="cli",
    model="anthropic/claude-sonnet-4.6",
    user_id="user_1",
    parent_session_id=None,  # or previous session ID for lineage
)

# End a session
db.end_session("sess_abc123", end_reason="user_exit")

# Reopen a session (clear ended_at/end_reason)
db.reopen_session("sess_abc123")
```

### Store Messages

```python theme={null}
msg_id = db.append_message(
    session_id="sess_abc123",
    role="assistant",
    content="Here's the answer...",
    tool_calls=[{"id": "call_1", "function": {"name": "terminal", "arguments": "{}"}}],
    token_count=150,
    finish_reason="stop",
    reasoning="Let me think about this...",
)
```

### Retrieve Messages

```python theme={null}
# Raw messages with all metadata
messages = db.get_messages("sess_abc123")

# OpenAI conversation format (for API replay)
conversation = db.get_messages_as_conversation("sess_abc123")
# Returns: [{"role": "user", "content": "..."}, {"role": "assistant", ...}]
```

### Session Titles

```python theme={null}
# Set a title (must be unique among non-NULL titles)
db.set_session_title("sess_abc123", "Fix Docker Build")

# Resolve by title (returns most recent in lineage)
session_id = db.resolve_session_by_title("Fix Docker Build")

# Auto-generate next title in lineage
next_title = db.get_next_title_in_lineage("Fix Docker Build")
# Returns: "Fix Docker Build #2"
```

## Full-Text Search

The `search_messages()` method supports FTS5 query syntax with automatic
sanitization of user input.

### Basic Search

```python theme={null}
results = db.search_messages("docker deployment")
```

### FTS5 Query Syntax

| Syntax | Example | Meaning |
| - | - | - |
| Keywords | `docker deployment` | Both terms (implicit AND) |
| Quoted phrase | `"exact phrase"` | Exact phrase match |
| Boolean OR | `docker OR kubernetes` | Either term |
| Boolean NOT | `python NOT java` | Exclude term |
| Prefix | `deploy*` | Prefix match |

### Filtered Search

```python theme={null}
# Search only CLI sessions
results = db.search_messages("error", source_filter=["cli"])

# Exclude gateway sessions
results = db.search_messages("bug", exclude_sources=["telegram", "discord"])

# Search only user messages
results = db.search_messages("help", role_filter=["user"])
```

### Search Results Format

Each result includes:

* `id`, `session_id`, `role`, `timestamp`
* `snippet` — FTS5-generated snippet with `>>>match<<<` markers
* `context` — 1 message before and after the match (content truncated to 200 chars)
* `source`, `model`, `session_started` — from the parent session

The `_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 via `parent_session_id`. This happens when context
compression triggers a session split in the gateway.

### Query: Find Session Lineage

```sql theme={null}
-- Find all ancestors of a session
WITH RECURSIVE lineage AS (
    SELECT * FROM sessions WHERE id = ?
    UNION ALL
    SELECT s.* FROM sessions s
    JOIN lineage l ON s.id = l.parent_session_id
)
SELECT id, title, started_at, parent_session_id FROM lineage;

-- Find all descendants of a session
WITH RECURSIVE descendants AS (
    SELECT * FROM sessions WHERE id = ?
    UNION ALL
    SELECT s.* FROM sessions s
    JOIN descendants d ON s.parent_session_id = d.id
)
SELECT id, title, started_at FROM descendants;
```

### Query: Recent Sessions with Preview

```sql theme={null}
SELECT s.*,
    COALESCE(
        (SELECT SUBSTR(m.content, 1, 63)
         FROM messages m
         WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
         ORDER BY m.timestamp, m.id LIMIT 1),
        ''
    ) AS preview,
    COALESCE(
        (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
        s.started_at
    ) AS last_active
FROM sessions s
ORDER BY s.started_at DESC
LIMIT 20;
```

### Query: Token Usage Statistics

```sql theme={null}
-- Total tokens by model
SELECT model,
       COUNT(*) as session_count,
       SUM(input_tokens) as total_input,
       SUM(output_tokens) as total_output,
       SUM(estimated_cost_usd) as total_cost
FROM sessions
WHERE model IS NOT NULL
GROUP BY model
ORDER BY total_cost DESC;

-- Sessions with highest token usage
SELECT id, title, model, input_tokens + output_tokens AS total_tokens,
       estimated_cost_usd
FROM sessions
ORDER BY total_tokens DESC
LIMIT 10;
```

## Export and Cleanup

```python theme={null}
# Export a single session with messages
data = db.export_session("sess_abc123")

# Export all sessions (with messages) as list of dicts
all_data = db.export_all(source="cli")

# Delete old sessions (only ended sessions)
deleted_count = db.prune_sessions(older_than_days=90)
deleted_count = db.prune_sessions(older_than_days=30, source="telegram")

# Clear messages but keep the session record
db.clear_messages("sess_abc123")

# Delete session and all messages
db.delete_session("sess_abc123")
```

## 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](#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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.