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 (content + tool_name + tool_calls)
├── messages_fts_trigram — FTS5 virtual table with trigram tokenizer (CJK / substring search)
├── state_meta — Key/value metadata table
└── 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
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,
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
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
);
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, codex_reasoning_items, and codex_message_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
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:
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: 11
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 | |lang: ru
---|--------|
| 1 | Исходная схема (сессии, сообщения, FTS5) |
| 2 | Добавить столбец finish_reason в сообщения |
| 3 | Добавить столбец title в сеансы |
| 4 | Добавьте уникальный индекс в title (разрешены значения NULL, значения, отличные от NULL, должны быть уникальными) |
| 5 | Добавьте столбцы выставления счетов: 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 | Добавляйте в сообщения столбцы аргументации: reasoning, reasoning_details, codex_reasoning_items |
| 7 | Добавить столбец reasoning_content в сообщения |
| 8 | Добавить столбец api_call_count в сеансы |
| 9 | Добавить столбец codex_message_items в сообщения для воспроизведения идентификатора сообщения/фазы ответов Кодекса |
| 10 | Добавьте виртуальную таблицу messages_fts_trigram (токенизатор триграмм для поиска CJK/подстроки) и заполните существующие строки |
| 11 | Переиндексировать messages_fts и messages_fts_trigram, чтобы охватить tool_name + tool_calls, и переключиться с внешнего контента на встроенный режим; удалить старые триггеры и заполнить каждую строку сообщения |
Декларативные добавления столбцов используют ALTER TABLE ADD COLUMN, завернутую в try/кроме, для обработки случая, когда столбец уже существует (идемпотент). Номер версии увеличивается после каждого успешного блока миграции.
Обработка конфликтов записи
Несколько процессов Hermes (шлюз + сеансы CLI + агенты рабочего дерева) совместно используют один
state.db. Класс SessionDB обрабатывает конфликты записи с помощью:
- Короткий таймаут SQLite (1 секунда) вместо 30 секунд по умолчанию.
- Повторная попытка на уровне приложения со случайным джиттером (20–150 мс, до 15 повторных попыток)
- НАЧАТЬ НЕМЕДЛЕННО транзакции, чтобы выявить конфликты блокировок в начале транзакции.
- Периодические контрольные точки WAL каждые 50 успешных записей (ПАССИВНЫЙ режим)
Это позволяет избежать «эффекта конвоя», при котором детерминированная внутренняя отсрочка SQLite заставляет всех конкурирующих авторов повторять попытки через одинаковые промежутки времени.
_WRITE_MAX_RETRIES = 15
_WRITE_RETRY_MIN_S = 0.020 # 20ms
_WRITE_RETRY_MAX_S = 0.150 # 150ms
_CHECKPOINT_EVERY_N_WRITES = 50
Общие операции
Инициализировать
from hermes_state import SessionDB
db = SessionDB() # Default: ~/.hermes/state.db
db = SessionDB(db_path=Path("/tmp/test.db")) # Custom path
Создание сеансов и управление ими
# 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")
Сохранить сообщения
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...",
)
Получить сообщения
# 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", ...}]
Названия сессий
# 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"
Полнотекстовый поиск
Метод search_messages() поддерживает синтаксис запросов FTS5 с автоматическим
очистка пользовательского ввода.
Базовый поиск
results = db.search_messages("docker deployment")
Синтаксис запроса FTS5
| Синтаксис | Пример | Значение |
|---|---|---|
| Ключевые слова | docker deployment |
Оба условия (неявное И) |
| Цитируемая фраза | "exact phrase" |
Точное фразовое соответствие |
| Логическое ИЛИ | docker OR kubernetes |
Любой термин |
| Логическое НЕ | python NOT java |
Исключить термин |
| Префикс | deploy* |
Префикс совпадения |
Поиск с фильтром
# 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"])
Формат результатов поиска
Каждый результат включает в себя:
- id, session_id, role, timestamp
- snippet — фрагмент, сгенерированный FTS5 с маркерами >>>match<<<.
- context — 1 сообщение до и после матча (содержимое сокращено до 200 символов)
- source, model, session_started — из родительской сессии
Метод _sanitize_fts5_query() обрабатывает крайние случаи:
- Удаляет несовпадающие кавычки и специальные символы.
- Термины, написанные через дефис, заключаются в кавычки (chat-send → "chat-send")
- Удаляет висячие логические операторы (hello AND → hello)
Родословная сеанса
Сессии могут образовывать цепочки через parent_session_id. Это происходит, когда контекст
сжатие запускает разделение сеанса на шлюзе.
Запрос: найти происхождение сеанса
-- 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;
Запрос: последние сеансы с предварительным просмотром
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;
Запрос: статистика использования токенов
-- 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 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")
Расположение базы данных
Путь по умолчанию: ~/.hermes/state.db
Это получено из hermes_constants.get_hermes_home(), что разрешает
~/.hermes/ по умолчанию или значение переменной среды HERMES_HOME.
Файл базы данных, файл WAL (state.db-wal) и файл общей памяти.
(state.db-shm) создаются в одном каталоге.