Spaces:
Paused
Paused
| # 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. | |