セッションの保存領域
目次
- Hermes のホームとプロファイルの分離
- テストの分離ガード
- デスクトップのプロファイル分離と圧縮の世代
- Codex app-server における入力の持ち主
- ゲートウェイの例外経路における入力の持ち主
- 全体の構成
- SQLite のスキーマ
- sessions テーブル
- messages テーブル
- FTS5 の全文検索
- ダッシュボードでの閲覧と持ち主による保守
- スキーマのバージョンと移行
- 書き込み競合の扱い
- よく使う操作
- 初期化
- セッションの作成と管理
- メッセージの保存
- メッセージの取り出し
- セッションのタイトル
- 全文検索
- 基本の検索
- FTS5 のクエリ構文
- 絞り込み検索
- 検索結果の形式
- セッションの系譜
- クエリ: セッションの系譜をたどる
- クエリ: 直近のセッションと冒頭の抜粋
- クエリ: トークン使用量の統計
- 書き出しと後始末
- データベースの置き場所
Hermes Agent は SQLite のデータベース(~/.hermes/state.db)を使い、セッションの メタデータ、メッセージ履歴の全文、モデル設定を、CLI とゲートウェイの どちらのセッションでも残し続けます。以前のセッションごとの JSONL ファイル方式を置き換えたものです。
正規のセッションに書き込むのは、そのプロファイルのゲートウェイだけです。hermes chat、 TUI、Desktop(ローカル)、API サーバー、ACP、cron、Bot Chat、Kanban のワーカーはクライアントであり、 /api/ws を通じてゲートウェイのセッション管理に入力を受け付けてもらいます( ゲートウェイのローカルセッションを参照)。別プロセスで動く管理下の ワーカーは、その持ち主を通じて書き込みます(RuntimeSessionStore の RPC)。会話の記録を書くために 自分で state.db を開くことはありません。
ソースファイル: hermes_state.py(窓口)と、その兄弟にあたる hermes_state_*.py 群(スキーマ、fts、検索、圧縮、可搬性、ゲートウェイ、ほか)
Hermes のホームとプロファイルの分離
状態と設定がファイルシステムのどこにあるかを決める正式な窓口は get_hermes_home() です。 まずその文脈だけに効く上書きを見て、次に環境変数 HERMES_HOME を見て、最後にプラットフォームの既定 (macOS と Linux では ~/.hermes、Windows では %LOCALAPPDATA%/hermes)を使います。 そのため、既定のデータベースは常に get_hermes_home() / "state.db" です。呼び出す側が ~/.hermes/state.db というパスを直書きしてよいわけではありません。
名前付きのプロファイルは、互いに切り離されたディレクトリです。たとえば coder という名前のプロファイルは <default Hermes root>/profiles/coder/ を使うので、自分専用の state.db、設定、ログ、そのほかプロファイル単位の状態を持ちます。あるプロファイルのために データベースを作る、設定を読む、子プロセスを起動するといった処理では、そのプロファイルの HERMES_HOME を持ち続けるか、子へ渡さなければなりません。既定のルートに戻ってしまうと、 別のプロファイルの状態が処理に混ざります。
CLI の起動処理は、Hermes のほかの部分を import する前に _apply_profile_override() を呼びます。 --profile/-p がはっきり指定されていれば、そのプロファイルを解決し、 解決したディレクトリを HERMES_HOME に書き込みます。指定がない場合、プロファイル専用の HERMES_HOME が設定されていればそれを保ちます。そうでなければ、起動処理は既定のルートで 選ばれている「使用中のプロファイル」を使うことがあります。HOME が決めるのは、文脈の上書きも HERMES_HOME もないときに使うプラットフォームの既定だけです。 HOME を変えるのは、名前付きのプロファイルを選ぶ安全な方法ではありません。とくに、 HERMES_HOME を落とした子プロセスは、別のプロファイルが使用中でも既定のプロファイルに戻ってしまうことがあります。 子プロセスを起動する側は HERMES_HOME を明示して渡してください。
display_hermes_home() は、利用者に見せる文字列にだけ使います。解決済みのホームを、できる限り 利用者のホームディレクトリからの相対表記に整えるものです(たとえば ~/.hermes/profiles/coder)。別の解決規則を持っているわけではありません。
テストの分離ガード
テストでは、一時的な HERMES_HOME か、明示した一時的なデータベースのパスを使わなければなりません。 本番環境を守るガードは、テスト中のプロセスが、実際の既定の Hermes ルートや実在する名前付きプロファイルの下にある 本番の state.db を開こうとした時点で例外を投げます。これにより、テスト用のデータや SQLite の副作用が、 実際に使っている環境に届くのを防ぎます。
HERMES_STATE_DB_GUARD_BYPASS=1 は、本番のデータベースにどうしても触る必要がある子プロセスのための、 テスト専用の抜け道です。同じプロセス内で使う抜け道は @pytest.mark.live_system_guard_bypass です。 ふつうの Hermes コマンド、開発用のシェル、アプリの設定では、どちらの抜け道も設定しないでください。 本番のセッション履歴を守るガード(容赦なく RuntimeError を出すもの)が無効になります。 また、シェルで export すると、そのあとに走るすべての pytest に抜け道が引き継がれてしまいます。
デスクトップのプロファイル分離と圧縮の世代
名前付きのプロファイルは、それぞれ自分の $HERMES_HOME/state.db に会話の記録を持ちます。1 つの プロセス(複数のプロファイルをまとめて扱うゲートウェイ、または hermes serve)が複数のプロファイルを受け持っている場合も同じです。セッションの途中で エージェントを組み直すとき(Bot Chat の機能更新と tools.configure)は、そのセッションの データベースハンドルを保ったまま、組み立ての間そのプロファイルのホームに結び付けておく必要があります。 役目を終えるエージェントを解放するときに、後継が引き継いだハンドルを閉じてはいけません。 tools.configure は、クライアントが session_id しか渡していないときでも、動いているセッションの profile_home から設定を解決します。組み直しでは、後継を確保する前にモデル設定を用意し、そのうえで エージェントを据えて所有権をまとめて移します。用意の段階で失敗した場合、後始末の責任は既存の エージェントに残ります。明示されたプロファイルが解決できない、あるいはそのディレクトリが消えている 場合は、起動設定や履歴に触れる前に失敗します。古くなった tools.configure のセッション ID も同様に、 設定を変えずに session not found を返します。セッション ID を省いたときは、これまでどおり全体設定の 操作として扱われます。
その場での圧縮では、古い行を active=0 にして保管し、残したコンテキストを active=1 の行として 入れ直します。そのため、保護されたメッセージが、同じ内容・同じ日時のまま両方の世代に現れることが あります。これは正しい状態なので、重複と見なして保管側の行を消さないでください。実際に動いている 書き込みの重複を調べるときは active=1 を使い、履歴が巻き戻ったように見えるときは、セッション ID だけでなくデータベースのプロファイルも確かめてください。
Codex app-server における入力の持ち主
エージェントは、Codex のターンを始める前に、受け付けた利用者の入力を保存します。Codex はそのあと、 同じ入力を先頭の userMessage 通知として流してきます。実行時のつなぎ目では、Hermes は turn/start に書き出したテキストと完全に一致する場合にかぎって、その先頭の項目だけを除きます。リッチな入力を 変換したものも一致の対象です。後から来た利用者イベントや、一致しないイベントはそのまま残り、別々に 受け付けられた同じ内容のターンも残ります。合成された入力やキーを持たない入力にも同じ扱いが適用され、 プラットフォーム側のメッセージ ID には依存しません。すでに履歴にある重複を書き換えることはしません。 エージェントが「保存は自分が持っている」と伝えた場合、ゲートウェイは会話記録の書き込みを行いません。
ゲートウェイの例外経路における入力の持ち主
ゲートウェイの例外は、エージェントを組み立てる前に起きることもあれば、その入力が SQLite に届いた あとに起きることもあります。ゲートウェイは受け付けた入力に、既存の display_metadata の付随領域を 使って持ち主の目印を付け、エージェントのふだんの保存経路へ渡します。プロバイダへ送るメッセージに このメタデータが入ることはありません。プラットフォームの目印は、受信メッセージ ID をプラットフォーム・ プロファイル・スコープ・チャット・スレッドで区切って一意にします。引用や返信の解決に使う platform_message_id は元のまま変わりません。キーを持たないターンには、テキストと日時がまったく 同じでも新しい目印が振られます。
例外時の書き込み側は、その目印だけを探します。探す順番は、公表された転送先、正規の現行圧縮の後継、 そのあと圧縮の祖先です。数えるのは有効な行と圧縮の保管分で、取り消された行、観測された入力、 無関係な書き込み手は数えません。同じセッションに書き込んでいる無関係なプロセスが、このターンを 消してしまうことはありません。履歴全体の基準を取ったり、保管されたメッセージ本文を読み込んだりする 必要もありません。持ち主の読み取りに失敗したときに、当て推量で追記することは許されません。ふつうの 履歴読み取りの失敗は、これまでどおり「履歴を取得できない」という応答になります。
エージェントが持ち主になる通常の保存の流れは変わりません。これは失敗したときの書き込み手を裁くための 仕組みであって、あらゆる場合に 1 回きりの配送を保証するものでも、内容の重複を取り除くものでも、 スキーマの移行でもありません。過去の行を書き換えることはなく、目印のない過去の入力が、再配送された イベントの持ち主を示す根拠になることもありません。
全体の構成
~/.hermes/state.db (SQLite, WAL mode)
├── sessions — Session metadata, token counts, billing
├── messages — Full message history per session
├── session_model_usage — Per-model/per-task usage attribution rows
├── messages_fts — FTS5 virtual table (content + tool_name + tool_calls)
├── messages_fts_trigram — FTS5 virtual table with trigram tokenizer (CJK / substring search)
├── messages_fts_cjk — FTS5 virtual table with cjk_unicode61 tokenizer
├── state_meta — Key/value metadata table
├── gateway_routing — Gateway routing metadata
├── compression_locks — Cross-process compression locking
├── async_delegations — Async delegation bookkeeping
├── delivery_obligations — Gateway outbox (owed replies); created lazily by gateway/delivery_ledger.py
├── runtime_epoch — Session authority's owner epoch + instance id (one row)
├── session_admissions — Durable per-session input FIFO (queued → started → terminal; unknown on owner loss)
├── worker_executions — Managed worker/child/cron/kanban executions bound to a session and epoch
├── worker_receipts — Ordered, digest-checked results a managed worker reported
└── schema_version — Single-row table tracking migration statesession_admissions、worker_executions、worker_receipts は ON DELETE RESTRICT で sessions を参照します。セッションを削除または整理するときは、同じトランザクションの中で 終了済みの台帳の行を片付け、作業が動いている間や unknown のままの間は実行を拒否します。 持ち主が落ちたときに started だった入力は、次に持ち主が起動したときに unknown になり、 やり直されることはありません。その後ろの入力は、これが解決されるまで待ちます(prompt.resolve_unknown)。
hermes sessions recover を実行すると、上のうち行を持つテーブルが復旧先の データベースへコピーされます(FTS の索引と schema_version は作り直されます)。 コピー元にあれば、必要になったときだけ作られる delivery_obligations の台帳も対象になり、 その行数は sessions や messages と同じように検証されます。実行時の台帳にあたる 4 つのテーブル (runtime_epoch、session_admissions、worker_executions、worker_receipts)は、 一覧に載せたうえで対象外として報告され、コピーはされません。台帳が欠けていると、開始済みの処理を やり直したり、別の持ち主のワーカーを引き取ったりするおそれがあるためです。hermes backup による完全な復元なら、 台帳を丸ごと残し、起動時の突き合わせに使います。
設計上の要点は次のとおりです。
- WAL モードにより、読み手が複数いても書き手が 1 つなら並行して動きます(ゲートウェイの複数プラットフォーム対応)
- FTS5 の仮想テーブルで、全セッションのメッセージを高速に文字列検索できます
- セッションの系譜は
parent_session_idの連なりで表します(圧縮をきっかけにセッションが分かれたとき) - 発生元のタグ付け(
cli、telegram、discordなど)により、プラットフォーム単位で絞り込めます - バッチ実行と強化学習の軌跡はここには保存されません(別の仕組みです)
SQLite のスキーマ
sessions テーブル
以下は抜粋です。現行の列を全部見るには hermes_state_common.py の SCHEMA_SQL (適用するのは hermes_state_schema.py)を参照してください。そちらにはゲートウェイの 経路情報である session_key、chat_id、chat_type、thread_id、display_name、 origin_json、expiry_finalized、作業環境の cwd / git_branch / git_repo_root、 引き継ぎと圧縮失敗に関する列、profile_name、transport_profile(その通り道を 受け取った多重化の bot。空でも構いません)、rewind_count、archived、 auto_archived(アイドル時の掃除だけが立てる印です。再開や圧縮の続きが入ると、掃除だけによるアーカイブは解除されますが、意図して行ったアーカイブは解除されません)、 pinned も含まれます。
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,
-- ... additional gateway/workspace/handoff/compression columns ...
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;user_id は、そのセッションの向こう側にいる主体です。メッセージのアダプターは プラットフォームの送信者 ID を入れ、認証付きの hermes serve(OAuth、または ユーザー名とパスワードによる基本認証のプロバイダー)から開いた desktop やダッシュボードの セッションは、ログインを <provider>:<user id> の形(たとえば basic:alice)で入れます。 誰もいないセッション — 認証なしのループバックでの利用、subagent、cron、kanban — では空のままです。値は行を作るときに決まり、あとから推測して埋めることはありません。
messages テーブル
以下は抜粋です。実際のスキーマには effect_disposition、 platform_message_id、observed、active、compacted、api_content、 display_kind、display_metadata、message_uid、absorbed_message_uids、 tool_call_uids、tool_call_uid も含まれます。
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
-- ... additional display/compaction columns ...
);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp);
CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id, id);補足:
tool_callsは JSON の文字列として保存されます(ツール呼び出しオブジェクトの配列を直列化したもの)reasoning_details、codex_reasoning_items、codex_message_itemsも JSON の文字列で保存されますreasoning、reasoning_content、reasoning_detailsは履歴に必ず残ります。chat-completions の通信では、アシスタントの各ターンが保存済みの推論を、その経路が読む項目に載せて送り直します(agent/message_sanitization.py::route_reasoning_carriers)。既定ではどこでもreasoning_contentに載せます。これに加えて、OpenRouter 形式のゲートウェイ(OpenRouter、Kilo、Vercel AI Gateway、Nous Portal)ではreasoningとreasoning_detailsにも載せます。Novita、MiniMax の/v1、TokenHub ではreasoning_detailsに、ローカルのサーバー、Ollama、Upstage ではreasoningに載せます。決まりの厳しいスキーマ(Mistral、Groq、Cerebras、SambaNova)には推論の項目を一切付けず、Fireworks にはreasoning_contentだけを付けます。推論を持たないツール呼び出しのターンを" "で埋めるのは DeepSeek、Kimi、MiMo だけです。経路が項目名を挙げて拒否したとき("Extra inputs are not permitted, field: messages[N].reasoning_details")は、Hermes がそのプロバイダー・ホスト・モデルの組について記録し、その項目を外して 1 回だけ再試行します。この記録はセッションのあいだだけ有効で、履歴には一切手を加えません。- デスクトップの履歴の復元では、REST と JSON-RPC(
session.resume、session.activate、session.history)のどちらの表現でも、アシスタント側の付随データを残します。推論やツール呼び出しを含む行も同じです。REST は SQLite の JSON 文字列をそのまま返すことがあり、RPC は復号した項目を返しますが、デスクトップはどちらも受け付けます。Responses の最終的な返答がcodex_message_itemsにしか入っておらず、contentが空のこともあります。それでも正規の content が優先され、分析や注釈の項目が返答の本文へ格上げされることはありません。 reasoningには、生の推論テキストを出すプロバイダの場合にその本文が入ります- 推論だけで正常に終わった応答(
contentが空、finish_reason=stop、推論あり)には推論のテキストがそのまま返答として使われますが、アシスタントの行にその本文がcontentとして書かれることはありません。contentは空のままで、本文はreasoningやreasoning_contentに入り、api_contentが同じ文字列を持つので、次のリクエストでもバイト単位で同じ返答を再生できます。そのため履歴の画面では、返答ではなく推論として表示されます。 api_contentはバイト単位で忠実さを保つための控えです。このメッセージについて実際に API へ送った内容の文字列がcontentと食い違うとき(一時的なメモリやプラグインの差し込み、persist による上書きなど)に、送ったとおりの文字列を保持します。プロンプトキャッシュを崩さずに再生できるよう通信時のバイト列を残すもので、例外は単独のサロゲートだけです。これは sqlite3 がバインドできず、会話ループが送信内容から常に取り除いています。NULLならcontentをそのまま送ったという意味です。- タイムスタンプは Unix エポック秒の浮動小数点数です(
time.time()) message_uidはメッセージごとに長く保たれる ID です(16 進 32 桁、uuid4().hex)。行を最初に挿入したときに一度だけ発行し、行の複製(その場での圧縮の世代、ローテーションで生まれる子、並行する末尾の複製、replace_messagesによる再発行、エクスポートとインポート)のたびにそのまま写します。そのため物理的なidが変わっても、論理的に同じメッセージは同じ uid を持ち続けます。行を指定しての書き換えでは変わりません。どの投影でも復元され(get_messages_as_conversationは常に付け、_row_idは指定したときだけ付きます)、プロバイダへのリクエストからは取り除かれます。コンテキストエンジンは、メッセージごとの自前の状態をこの値で引きます。コンテキストエンジンプラグインを参照してください。absorbed_message_uidsは統合の記録です。ホストがこの行へ畳み込んだメッセージのmessage_uidを JSON の配列で持ちます(交互の並びを直すときのユーザー同士・アシスタント同士の統合、圧縮器による処理途中の言い直しとアンカーの畳み込み、マイクロ圧縮による隣り合うユーザー発言の統合が対象で、まとめた行は最初の構成要素の uid を引き継ぎます)。残った行を書き出すときに書き込み、その行と一緒に書き直され、実行中の_absorbed_message_uidsとして復元されます。何も取り込んでいない行ではNULLです。tool_call_uids(アシスタントの行)は JSON の{tool_call_id: uid}の対応表で、tool_callsの各項目に出現ごとの ID を与えます。プロバイダのツール呼び出し ID は重複するためです(1 つの応答の中で同じ ID を繰り返す呼び出しは uid を共有し、2 つのアシスタントのターンを畳み込んだあとは、両方が持つ ID が uid の配列、つまり呼び出し順に出現ごと 1 つずつの uid に対応します)。tool_call_uid(ツールの行)は、その結果に対応する値です。tool_callsの JSON 自体は変更しません。アシスタントの行を最初に挿入したときに発行し、書き出し時に結果の行へ対にして付けます。列がNULLの場合は、復元時に直前のアシスタントの行から導きます。実行中の_tool_call_uids/_tool_call_uidとして復元されます。
FTS5 の全文検索
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content,
tool_name,
tool_calls,
content='messages',
content_rowid='id'
);FTS5 のテーブルは、messages テーブルへの INSERT、UPDATE、DELETE で動く 3 つの トリガーによって同期されます。現在のトリガーは state_meta にある fts_rebuild_high_water / fts_rebuild_progress の目印で制御されており(背後で FTS の作り直しが進んでいても二重に索引されません)、索引対象の 3 列すべてを 扱います。正確な SQL は hermes_state_common.py の SCHEMA_SQL にあります。
ダッシュボードでの閲覧と持ち主による保守
セッションの閲覧では、state.db を読み取り専用で開きます。GET による一覧、検索、詳細、 メッセージ、統計、書き出しは、存在しないデータベースを作ったり、0 バイトのファイルを隔離したり、 古いスキーマを合わせ直したり、自動アーカイブを動かしたりすることはありません。初期化されていない 保存先や互換性のない保存先には、持ち主の起動や復旧の案内を付けて HTTP 503 を返します。 複数のプロファイルをまとめる集計では、スキーマの失敗をプロファイルごとの errors の一覧で報告します。
保存先の初期化と突き合わせは、そのプロファイルのゲートウェイが起動時に行います。 単独で動かす serve のバックエンドは、これまでどおり起動時の突き合わせ用ワーカーを持ちます。 sessions.auto_archive を設定している場合は、閲覧とは関係なく、ゲートウェイの起動時と定期的な片付け、 または単独のバックエンドの保守用タイマーで引き続き働きます。明示的に変更を加えるエンドポイントは、これまでどおり書き込みます。
SQLite の WAL を読む側も、共有メモリによる調整には加わります。mode=ro でも、空の WAL/SHM の付随ファイルを作ったり、SHM の読み取り位置を更新したりすることがあります。読み取り専用の約束が 禁じているのは、正規のスキーマやデータへの書き込み、修復、チェックポイントです。動作中の SQLite の調整用ファイルまで書き換え不可にするものではありません。
スキーマのバージョンと移行
現在のスキーマバージョン: 31
schema_version テーブルには整数が 1 つだけ入ります。単純な列の追加は _reconcile_columns() が宣言的に処理します(実際の列と SCHEMA_SQL を突き合わせ、足りない列を ADD します)。バージョンで段階を切る移行の連なりは、宣言的に書けないデータ移行や索引・FTS の変更のために取ってあります。
| バージョン | 変更内容 |
|---|---|
| 1 | 最初のスキーマ(sessions、messages、FTS5) |
| 2 | messages に finish_reason 列を追加 |
| 3 | sessions に 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 | messages に推論の列を追加: reasoning、reasoning_details、codex_reasoning_items |
| 7 | messages に reasoning_content 列を追加 |
| 8 | sessions に api_call_count 列を追加 |
| 9 | Codex Responses のメッセージ id とフェーズを再生するため、messages に codex_message_items 列を追加 |
| 10 | messages_fts_trigram の仮想テーブルを追加(CJK と部分一致の検索向けトライグラム分割器)し、既存の行を埋め直し |
| 11 | messages_fts と messages_fts_trigram を作り直して tool_name と tool_calls も対象にし、外部コンテンツ方式からインライン方式へ変更。古いトリガーを落とし、全メッセージ行を入れ直し |
| 16 | 委任した副エージェントの行を model_config 内で印付け($._delegate_from)し、親の削除で孤児になってもセッション選択画面が散らからないように |
| 18 | ゲートウェイのメタデータを一本化。display_name / origin_json / expiry_finalized を sessions.json から埋め直し |
| 20 | モデル別の使用量の割り当て。過去のセッションごとの合計値から session_model_usage の行を作成 |
| 22 | タスクの軸を加えた使用量の割り当て。task 列が PRIMARY KEY に加わるように session_model_usage を作り直し |
| 23 | FTS の保存方式の見直し。v11 のインライン方式の複製を外部コンテンツ方式の FTS テーブルに置き換え(既存のデータベースは希望した場合のみ移行) |
| 29 | cron のセッションをトライグラム(部分一致・CJK)索引から外す。messages_fts_trigram_src ビューとトリガーが sessions.source で絞り込み、一度だけの作り直しで過去の行を掃除 |
| 30 | 委任先(副エージェント)のセッションもトライグラム索引から外す。判定は source='subagent' か $._delegate_from の印(FTS_TRIGRAM_SESSION_SQL)。行は messages と通常の単語索引 messages_fts に残るので session_search では見つかり、約 2.6 倍に膨らむトライグラムの影テーブルだけが小さくなる。作り直しは v29 と同じく一度だけ |
| 31 | messages.message_uid、absorbed_message_uids、tool_call_uids、tool_call_uid(宣言的な列追加)。列ができる前からある全行に新しい message_uid を埋める(2,000 id ずつの塊に分け、塊ごとに短い書き込みにする。1 回の起動では最初の塊のあと約 1 秒だけ進め、次の起動で message_uid_backfill_id のメタ情報のカーソルから再開し、message_uid_backfill の印で終わる。そのため大きな保存領域でも表全体の書き込みロックを握り続けない)。さらに uid なしで挿入された行(古いビルドがこの保存領域へ書き込んだ場合)に 1 つ発行する messages_message_uid_insert トリガーを加える。これで v31 の保存領域は、すべての行を ID 付きで利用側へ渡す(ツール呼び出しの uid は行が次に書き込まれるときに発行し、結果は復元時に対にする) |
上の表に出てこないバージョンは、_reconcile_columns() が処理した宣言的な列追加です(バージョンを上げるだけで、データ移行はありません)。
宣言的な列追加では ALTER TABLE ADD COLUMN を try/except で包み、列がすでにある場合に備えています(何度実行しても同じ結果になります)。移行の各段階が成功するたびにバージョン番号が上がります。
書き込み競合の扱い
今も複数のプロセスが 1 つの state.db を開きます。ゲートウェイ(正規のセッションに書き込む側)、 読み取り専用で閲覧する側(ダッシュボード、hermes sessions list)、保守用のコマンド、 セッション以外のテーブルに書き込む側です。SessionDB クラスは書き込みの競合を次のように処理します。
- SQLite のタイムアウトを短く(既定の 30 秒ではなく 1 秒)
- アプリ側での再試行に制限時間を設ける。ランダムな揺らぎを持たせます(最初の 2 秒は 20〜150 ミリ秒、そのあとは 250 ミリ秒〜1 秒)。持ち時間は通常の書き込みが 20 秒、会話記録の書き込みが 60 秒(失敗するとそのターンが打ち切られるため)、記録目的だけの活動ログの書き込みが 0.5 秒です
- BEGIN IMMEDIATE のトランザクションで、ロックの競合をトランザクション開始時に表面化させる
- WAL のチェックポイントを書き込み 50 回成功ごとに実行(PASSIVE モード)
これにより、SQLite の内部的な待ち時間が決まりきっているせいで競合する書き手が 同じ間隔でいっせいに再試行してしまう「行列効果」を避けられます。
_WRITE_PATIENCE_S, _TRANSCRIPT_WRITE_PATIENCE_S, _ACTIVITY_WRITE_PATIENCE_S = 20.0, 60.0, 0.5
_WRITE_RETRY_MIN_S, _WRITE_RETRY_MAX_S = 0.020, 0.150
_WRITE_RETRY_SLOW_MIN_S, _WRITE_RETRY_SLOW_MAX_S = 0.250, 1.000
_CHECKPOINT_EVERY_N_WRITES = 50書き手が持ち時間を使い切ると、そのターンは session_persistence_failed:locked で 終わります。さらに Linux では hermes_state_lockowners が WARNING を出し、その時点で ロックを握っていたプロセスを名前付きで記録します (PID 594094 (hermes --worktree --yolo) holds WAL write lock on state.db-shm)。 これは /proc/locks から読み取ったもので、SQLite のバイト範囲 fcntl ロックは ロックの種類をオフセットで表しています(state.db-shm の 120 バイト目が WAL の書き込み、 121 がチェックポイント、123〜127 が読み取り枠。state.db 側の 1 GiB の pending バイトの ページはロールバックジャーナルの PENDING / RESERVED / SHARED です)。開いている ファイル記述子を調べるやり方ではこの区別ができません。Hermes のプロセスはどれも この DB を開いているからです。database is locked の失敗のすぐ隣に出るこの行を、 ~/.hermes/logs/errors.log で探してください。
ロックの競合は、メッセージの文面ではなく SQLite の結果コード(SQLITE_BUSY / SQLITE_LOCKED、hermes_state_errors.is_sqlite_lock_error)で見分けます。ロールバックジャーナル (delete)モードでは、FTS5 のテーブルのコンストラクタの中でロックを失うと、SQLITE_BUSY と vtable constructor failed: messages_fts という文面で届きます。これも database is locked と同じ扱いです。書き込みできる SessionDB を開くときは最大 _WRITE_PATIENCE_S まで待ちます。読み取り専用で開くときは、読み取りの持ち時間 _READ_BUSY_TIMEOUT_S(5 秒)を1回だけ待ちます。ロックがそれより長く続くと、ダッシュボードは 500 ではなく 503(busy)を返します。
よく使う操作
初期化
from hermes_state import SessionDB
db = SessionDB() # Default: ~/.hermes/state.db
db = SessionDB(db_path=Path("~/.hermes/cache/scratch/test.db").expanduser()) # 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 |
両方の語を含む(暗黙の AND) |
| 引用符で囲む | "exact phrase" |
その語順どおりに一致 |
| 論理和の OR | docker OR kubernetes |
どちらかの語を含む |
| 否定の NOT | 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、timestampsnippet— 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")データベースの置き場所
既定のパス: get_hermes_home() / "state.db" — 既定のプロファイルなら ~/.hermes/state.db、 名前付きのプロファイルなら ~/.hermes/profiles/<name>/state.db、あるいは HERMES_HOME が指す場所です(Hermes のホームとプロファイルの分離を参照)。
データベース本体、WAL ファイル(state.db-wal)、共有メモリのファイル (state.db-shm)は、いずれも同じディレクトリに作られます。