Hermes Agent Deep Cuts: Every Session Lives in One 200 MB SQLite File
Part of the Hermes Agent: Deep Cuts series

Hermes Agent Deep Cuts: Every Session Lives in One 200 MB SQLite File

Run this and the number will stop you:

$ hermes sessions stats
Total sessions: 255
Total messages: 30655
 cli: 148 sessions
 telegram: 14 sessions
Database size: 203.7 MB

Two hundred and fifty five conversations, thirty thousand messages, one file. Every prompt you have ever typed into Hermes, every tool call, every wall of terminal output an agent dumped back, sits in a single SQLite database at ~/.hermes/state.db, and it is searchable in a few milliseconds because of three FTS5 full-text indexes that database triggers built for you without you ever asking. That file is why --resume can pull a months old session back into context, why /session_search is fast, and why your ~/.hermes directory is suddenly half a gigabyte. Most operators treat it as an opaque blob, type du, and forget it. The interesting part is what is actually inside, and what happens when the indexes, the archive sweep, or the resume path quietly break.

The store is a schema, not a magic blob

state.db is a normal SQLite file, and you can read it with the stock sqlite3 that ships with Python, no Hermes API needed. Open it read-only and the shape becomes obvious. The star is a sessions table with 58 columns, one row per conversation, keyed by an ID that already tells you when it started:

20260915_074937_3cf8aa23

That is YYYYmmdd_HHMMSS plus six hex digits of uuid, generated at line 2826 of the agent’s runtime. The row I pulled back on this box shows the whole story at a glance: source telegram, a title that a model assigned while the session was live, 83 messages, and an ended_at that is still null because the conversation is open.

The 58 columns matter more than most people realize, because they are the substrate for features you use every day without thinking. input_tokens, output_tokens, cache_read_tokens, and estimated_cost_usd are what populate the cost and token counts in the status bar and the audit trail. billing_provider and billing_base_url record which provider billed the run. git_branch and git_repo_root let hermes sessions list --workspace answer “what did I do in this repo”. parent_session_id links a session to its compression lineage, which is how compaction keeps one logical conversation across multiple SQLite rows. profile_name separates the contexts of the people sharing the box.

Alongside it, a messages table stores every turn: role, content, and the structured tool_calls JSON that lets the store know an assistant turn produced a browser navigation or a terminal write. Rows with role='tool' carry the output and tool_name. Nothing about this is exotic. The magic is what sits beside those two tables.

The FTS layer is where the cleverness lives

Plain SQLite rows would make search a full table scan. Hermes does not scan. It maintains three FTS5 virtual tables: messages_fts, messages_fts_trigram, and messages_fts_cjk, each with its own tokenizer. I queried sqlite_master on the live database and confirmed all three exist, and the search returns in the low milliseconds:

SELECT count(*) FROM messages_fts      WHERE messages_fts MATCH 'gateway'  => 2505
SELECT count(*) FROM messages_fts_trigram WHERE messages_fts_trigram MATCH 'hermes' => 5498

Three indexes sounds like overkill until you hit the tokenizer limits. The default messages_fts uses SQLite’s unicode61, which is fine for whole words. But a trigram index, needed for fuzzy and substring matching, demands at least three characters per term, so a one or two character query falls back to a LIKE scan. For CJK text, where almost every term is one or two characters, that meant a three to six second CPU burn per query on multi-gigabyte installs. The fix is a loadable tokenizer called cjk_unicode61, contributed upstream as PR 65544, that re-emits CJK runs as overlapping bigrams so phrase semantics give you substring matching down to two characters at index speed. It lives at ~/.hermes/lib/libfts5_cjk.so and Hermes loads it at open time when both the .so and the config flag sessions.cjk_fts allow.

The indexes are not maintained by the application on every insert, because a process could crash between writing a message and updating the index. They are maintained by SQLite triggers on the messages table. Insert a non-tool message and a trigger inserts the indexed columns into messages_fts. Delete, and a delete trigger runs. Update content, and an update trigger fires a delete plus an insert. The tool rows are mostly excluded from the index, because indexing a 100 MB terminal dump is pure waste. A marker pair in state_meta, fts_rebuild_high_water and fts_rebuild_progress, tells each trigger whether a given row has been indexed, and rows above the high water stay out until a rebuild catches up.

Why your disk grows, and what optimize-storage does about it

Here is the gotcha that quietly costs you gigabytes. In every Hermes version up to the current one, the FTS indexes were structural in the wrong way. messages_fts stored, inline, a duplicate copy of the message content it indexed. Your conversation text lived once in messages and again inside the index. Worse, it indexed whole tool outputs, so a session that ran a few huge terminal commands ballooned the index with near-duplicate megabytes.

SQLite FTS5 has a better mode: external content. You point the virtual table at a real source table or view with content=, and FTS5 stores only the inverted index, not the text. The current layout, the v23 layout, is exactly that. The CREATE statement on this box reads:

CREATE VIRTUAL TABLE messages_fts USING fts5(
  content, tool_name, tool_calls,
  content='messages_fts_cjk_src', content_rowid='id', ...
)

The index references the source view and never duplicates the message text. Tool rows are filtered out of the source view, and only a bounded prefix of tool output is indexed. That is the whole point of hermes sessions optimize-storage. It migrates a legacy inline index to the compact v23 external-content layout, then runs VACUUM so the freed pages actually return to the operating system instead of staying reserved in the file. On large databases this reclaims a large fraction of state.db. The migration is safe to interrupt and re-run, because it resumes at a high-water marker, and it throttles itself so a running gateway stays responsive.

To see which layout your install is on, read the stored schema:

sqlite3 ~/.hermes/state.db "SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'"

If the stored CREATE contains tool_name, you are already on v23 and optimize-storage is just a segment merge and vacuum. If it does not, you are on a legacy inline index and running it will return real disk.

The commands that actually matter day to day

Most people only ever see hermes sessions stats and never look at the rest of the surface. There are seventeen subcommands, and a few of them do work you will eventually wish you had known about.

hermes sessions prune is the dangerous one done right. Bare prune defaults to deleting sessions older than 90 days, but every filter composes. You can prune only cli sessions, only sessions on a branch name, only sessions that cost more than a threshold, only sessions with a minimum token count, and you can dry run first with --dry-run to see the list before touching anything. Deleting is not a real delete of the conversation either, because of the compression lineage: pruning one session also removes its compacted ancestors, and cascades to the messages.

hermes sessions prune --source telegram --older-than 14d --min-tokens 500000 --dry-run

That line answers “which Telegram sessions over half a million tokens have gone quiet for two weeks”, without deleting anything.

hermes sessions export turns the store into a real repository. Formats are jsonl, markdown, Quarto qmd, html, and a special trace format that emits Claude Code JSONL for the Hugging Face Agent Trace Viewer, with an --upload flag that pushes it to a HF dataset when HF_TOKEN is set. The --only user-prompts filter gives you a clean one-line-per-prompt dump for fine-tuning or audits. --redact scrubs API keys, tokens, and credentials from the exported content on the way out.

hermes sessions pin is the durable keep flag. Pinning a session exempts it from the auto-archive sweep, clears its hidden flag, and pushes it to the top of every listing and the Desktop sidebar. A pinned session is never a candidate for automatic hiding.

hermes sessions import --from claude or --from codex pulls a conversation you started in Claude Code (~/.claude/projects) or Codex CLI (~/.codex/sessions) into the Hermes store so you can resume it with --resume. The foreign files are only read, never modified.

Resuming is a read from this file

When you start Hermes with hermes --resume <id> or hermes --continue, you are not serializing a blob back in. The runtime opens the store, reads the sessions row, and rebuilds the conversation history from the messages rows for that session, skipping the identity of messages it already knows, then hands that history to the model. The session ID is fixed on resume, so the resumed conversation keeps writing to the same row. The display defaults are tunable in config (resume_display, resume_exchanges, and character caps for the recap echo), which is exactly the sort of setting that looks cosmetic until you realize how much context a verbose resume replays before the model ever answers.

The gotchas that make it look broken

Four traps are waiting, and they each report as a mystery rather than an error.

First, hermes sessions optimize-storage versus a running gateway. The command throttles itself precisely so it can run beside a live gateway, but it takes a while on a big database and it ends with a VACUUM that briefly wants the file to itself. If you run it exactly when a cron job is mid write, the vacuum can wait or fall back. Do it in a quiet window, or pass --no-vacuum and vacuum later.

Second, the archive sweep is opt-in but silent once enabled. Under sessions: in config.yaml, auto_archive: true makes a timer archive every session idle for auto_archive_days (default 3). Archive is a soft-hide: it sets archived=1, the session stops appearing in the default listing, but the rows and messages stay in the database. A session that “disappeared” on its own is usually archived, not lost, and hermes sessions unarchive handles the recoverable cases. Pins are exempt from the sweep, which is why pin is the correct answer before you walk away from a session for a week.

Third, the archive-versus-delete distinction trips people up hardest. Deleting a session cascades to its messages and its compacted lineage, and it is permanent. Archiving never touches a message. If you are scripting cleanup, make sure you know which one you called. prune deletes. archive soft-hides. They are not synonyms.

Fourth, and most damaging, the resume silence. If the session store fails to open at startup, the chat still looks perfectly healthy. Messages render, tools run, everything feels normal. Nothing is being persisted. On your next --resume, the conversation comes back truncated or empty, and there was never an error to catch. The runtime does print a bold warning that the session will not be saved, but it is a warning, not a failure, and easy to miss in the banner. If a session ever looks short on resume, check that you have a state store at all rather than assuming the model forgot something.

How to verify it is actually working

Do not trust the reports. Read the file.

  • hermes sessions stats for the totals, then open the database read-only and confirm the row count matches: SELECT count(*) FROM sessions and SELECT count(*) FROM messages.
  • Check the FTS layout with the sqlite_master query above and confirm your index references an external source view rather than storing inline.
  • Prove search returns before trusting the index: issue a raw MATCH query directly against messages_fts as shown above, then confirm the returned row IDs still resolve to real messages rows, so you know the index is not silently stale or pointing at missing data.
  • After prune or archive, re-query the store to confirm exactly the sessions you intended changed, and that archived rows still exist with archived=1 while deleted rows are gone from both tables.
  • Before optimize-storage, record the file size. Run it. Confirm the size dropped and that hermes sessions stats still shows the same session and message totals, because a migration that changes the data would be a bug.

The whole subsystem is inspectable with stock tooling, which is the best thing about it. No proprietary format, no export-only view, no black box. state.db is a SQLite file you can open, query, and back up with tools that have existed for decades. Understanding the schema and the three FTS indexes turns three gigs of mystery into a storage problem you can actually reason about.

Sources

  • hermes sessions CLI reference for the sessions subcommand surface, prune filters, export formats, and --resume/--continue flags.
  • hermes_state_common.py: the sessions (58-column) and messages table DDL, plus the routing column set.
  • hermes_state_fts.py: the FTS5 external-content view (messages_fts_cjk_src), the cjk_unicode61 loadable tokenizer and its LIKE-scan rationale, the legacy-inline detection heuristic, and the trigger DDL.
  • hermes_state_schema.py: FTS trigger lifecycle, fts_rebuild_high_water/fts_rebuild_progress markers, bounded tool-output indexing, and orphan FTS5 shadow-table repair (#103840).
  • hermes_state_sessions.py: archive_stale_sessions, maybe_auto_archive (idle default 3 days, pin-exempt), pin as durable keep flag, and the recoverable unarchive path.
  • hermes_cli/subcommands/sessions.py: the 17-subcommand parser (list, export, prune, archive, optimize, optimize-storage, repair, recover, pin/unpin, import, rename, browse).
  • cli.py: session ID generation (%Y%m%d_%H%M%S + uuid hex), the fail-open session-store warning (#41386), and the sessions: auto-archive config read at startup.

All numbers above were measured live on 2026-09-16 against Hermes Agent v0.21.2, upstream commit d62716c7, by running the stock hermes sessions commands and read-only SQLite queries against this box’s ~/.hermes/state.db. hermes sessions stats reports the logical page count as 203.7 MB; the on-disk file is 213.6 MB because of unreturned free pages. The FTS layout checks, MATCH counts, and the archived/pinned column reads all came from real queries on this exact database.

Keep reading