# Session Storage Hermes Agent uses a SQLite database (`~/.hermes/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 file: `hermes_state.py` ## Architecture Overview ``` ~/.hermes/state.db (SQLite, WAL mode) ├── sessions — Session metadata, token counts, billing ├── messages — Full message history per session ├── messages_fts — FTS5 virtual table for full-text search └── schema_version — Single-row table tracking migration state ``` 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 ```sql 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, 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; ``` ### Messages Table ```sql 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_details TEXT, codex_reasoning_items TEXT ); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp); ``` Notes: - `tool_calls` is stored as a JSON string (serialized list of tool call objects) - `reasoning_details` and `codex_reasoning_items` are stored as JSON strings - `reasoning` stores the raw reasoning text for providers that expose it - Timestamps are Unix epoch floats (`time.time()`) ### FTS5 Full-Text Search ```sql CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( content, 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: ```sql CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); END; CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); END; CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); END; ``` ## Schema Version and Migrations Current schema version: **6** The `schema_version` table stores a single integer. On initialization, `_init_schema()` checks the current version and applies migrations sequentially: | 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` | Each migration uses `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 hermes 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 - **Application-level retry** with random jitter (20-150ms, up to 15 retries) - **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_MAX_RETRIES = 15 _WRITE_RETRY_MIN_S = 0.020 # 20ms _WRITE_RETRY_MAX_S = 0.150 # 150ms _CHECKPOINT_EVERY_N_WRITES = 50 ``` ## Common Operations ### Initialize ```python from hermes_state import SessionDB db = SessionDB() # Default: ~/.hermes/state.db db = SessionDB(db_path=Path("/tmp/test.db")) # Custom path ``` ### Create and Manage Sessions ```python # 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 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 # 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 # 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 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 # 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 -- 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 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 -- 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 # 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: `~/.hermes/state.db` This is derived from `hermes_constants.get_hermes_home()` which resolves to `~/.hermes/` by default, or the value of `HERMES_HOME` environment variable. The database file, WAL file (`state.db-wal`), and shared-memory file (`state.db-shm`) are all created in the same directory.