mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
docs: deep quality pass — expand 10 thin pages, fix specific issues (#4134)
Developer guide stubs expanded to full documentation: - trajectory-format.md: 56→233 lines (JSONL format, ShareGPT example, normalization rules, reasoning markup, replay code) - session-storage.md: 66→388 lines (SQLite schema, migration table, FTS5 search syntax, lineage queries, Python API examples) - context-compression-and-caching.md: 72→321 lines (dual compression system, config defaults, 4-phase algorithm, before/after example, prompt caching mechanics, cache-aware patterns) - tools-runtime.md: 65→246 lines (registry API, dispatch flow, availability checking, error wrapping, approval flow) - prompt-assembly.md: 89→246 lines (concrete assembled prompt example, SOUL.md injection, context file discovery table) User-facing pages expanded: - docker.md: 62→224 lines (volumes, env forwarding, docker-compose, resource limits, troubleshooting) - updating.md: 79→167 lines (update behavior, version checking, rollback instructions, Nix users) - skins.md: 80→206 lines (all color/spinner/branding keys, built-in skin descriptions, full custom skin YAML template) Hub pages improved: - integrations/index.md: 25→82 lines (web search backends table, TTS/browser providers, quick config example) - features/overview.md: added Integrations section with 6 missing links Specific fixes: - configuration.md: removed duplicate Gateway Streaming section - mcp.md: removed internal "PR work" language - plugins.md: added inline minimal plugin example (self-contained) 13 files changed, ~1700 lines added. Docusaurus build verified clean.
This commit is contained in:
parent
54b876a5c9
commit
5b0243e6ad
13 changed files with 1735 additions and 174 deletions
|
|
@ -1,66 +1,388 @@
|
|||
---
|
||||
sidebar_position: 8
|
||||
title: "Session Storage"
|
||||
description: "How Hermes stores sessions in SQLite, maintains lineage, and exposes recall/search"
|
||||
---
|
||||
|
||||
# Session Storage
|
||||
|
||||
Hermes uses a SQLite-backed session store as the main source of truth for historical conversation state.
|
||||
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.
|
||||
|
||||
Primary files:
|
||||
Source file: `hermes_state.py`
|
||||
|
||||
- `hermes_state.py`
|
||||
- `gateway/session.py`
|
||||
- `tools/session_search_tool.py`
|
||||
|
||||
## Main database
|
||||
## Architecture Overview
|
||||
|
||||
The primary store lives at:
|
||||
|
||||
```text
|
||||
~/.hermes/state.db
|
||||
```
|
||||
~/.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
|
||||
```
|
||||
|
||||
It contains:
|
||||
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)
|
||||
|
||||
- sessions
|
||||
- messages
|
||||
- metadata such as token counts and titles
|
||||
- lineage relationships
|
||||
- full-text search indexes
|
||||
|
||||
## What is stored per session
|
||||
## SQLite Schema
|
||||
|
||||
Examples of important session metadata:
|
||||
### Sessions Table
|
||||
|
||||
- session ID
|
||||
- source/platform
|
||||
- title
|
||||
- created/updated timestamps
|
||||
- token counts
|
||||
- tool call counts
|
||||
- stored system prompt snapshot
|
||||
- parent session ID after compression splits
|
||||
```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)
|
||||
);
|
||||
|
||||
## Lineage
|
||||
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;
|
||||
```
|
||||
|
||||
When Hermes compresses a conversation, it can continue in a new session ID while preserving ancestry via `parent_session_id`.
|
||||
### Messages Table
|
||||
|
||||
This means resuming/searching can follow session families instead of treating each compressed shard as unrelated.
|
||||
```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
|
||||
);
|
||||
|
||||
## Gateway vs CLI persistence
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp);
|
||||
```
|
||||
|
||||
- CLI uses the state DB directly for resume/history/search
|
||||
- gateway keeps active-session mappings and may also maintain additional platform transcript/state files
|
||||
- some legacy JSON/JSONL artifacts still exist for compatibility, but SQLite is the main historical store
|
||||
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()`)
|
||||
|
||||
## Session search
|
||||
### FTS5 Full-Text Search
|
||||
|
||||
The `session_search` tool uses the session DB's search features to retrieve and summarize relevant past work.
|
||||
```sql
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
||||
content,
|
||||
content=messages,
|
||||
content_rowid=id
|
||||
);
|
||||
```
|
||||
|
||||
## Related docs
|
||||
The FTS5 table is kept in sync via three triggers that fire on INSERT, UPDATE,
|
||||
and DELETE of the `messages` table:
|
||||
|
||||
- [Gateway Internals](./gateway-internals.md)
|
||||
- [Prompt Assembly](./prompt-assembly.md)
|
||||
- [Context Compression & Prompt Caching](./context-compression-and-caching.md)
|
||||
```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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue