세션 저장
Hermes 에이전트는 SQLite 데이터베이스(~/.hermes/state.db)를 사용하여 세션을 유지합니다.
CLI와 게이트웨이 전반의 메타데이터, 전체 메시지 기록, 모델 구성
세션. 이는 이전의 세션별 JSONL 파일 접근 방식을 대체합니다.
소스 파일: hermes_state.py
아키텍처 개요
~/.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
주요 설계 결정:
- WAL 모드 동시 독자 + 작성자 1명(게이트웨이 다중 플랫폼)
- 모든 세션 메시지에 대한 빠른 텍스트 검색을 위한 FTS5 가상 테이블
parent_session_id체인을 통한 세션 계보(압축 트리거 분할)- 플랫폼 필터링을 위한 소스 태깅(
cli,telegram,discord등) - 배치 실행기와 RL 궤적은 여기에 저장되지 않습니다(별도 시스템).
SQLite 스키마
세션 테이블
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;
메시지 테이블
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);
참고:
tool_calls은 JSON 문자열(직렬화된 도구 호출 개체 목록)로 저장됩니다.reasoning_details,codex_reasoning_items및codex_message_items은 JSON 문자열로 저장됩니다.reasoning은 이를 노출하는 제공자에 대한 원시 추론 텍스트를 저장합니다.- 타임스탬프는 Unix epoch float입니다(
time.time()).
FTS5 전체 텍스트 검색
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content,
content=messages,
content_rowid=id
);
FTS5 테이블은 INSERT, UPDATE,
messages 테이블을 삭제합니다.
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;
스키마 버전 및 마이그레이션
현재 스키마 버전: 11
schema_version 테이블은 단일 정수를 저장합니다. 단순 열 추가는 _reconcile_columns()에 의해 선언적으로 처리됩니다(라이브 열을 SCHEMA_SQL과 비교하고 누락된 열을 추가함). 버전 관리 체인은 선언적으로 표현할 수 없는 데이터 마이그레이션 및 인덱스/FTS 변경을 위해 예약되어 있습니다.
| 버전 | 변경 |
|---|---|
| 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 응답 메시지 ID/단계 재생을 위한 메시지에 codex_message_items 열을 추가합니다. |
| 10 | messages_fts_trigram 가상 테이블(CJK/하위 문자열 검색용 트라이그램 토크나이저)을 추가하고 기존 행을 다시 채웁니다. |
| 11 | messages_fts 및 messages_fts_trigram을 다시 색인화하여 tool_name + tool_calls을 포함하고 외부 콘텐츠에서 인라인 모드로 전환합니다. 이전 트리거를 삭제하고 모든 메시지 행을 다시 채웁니다. |
선언적 열 추가에서는 열이 이미 존재하는 경우(멱등성)를 처리하기 위해 try/제외에 래핑된 ALTER TABLE ADD COLUMN을 사용합니다. 버전 번호는 마이그레이션이 성공적으로 차단될 때마다 올라갑니다.
쓰기 경합 처리
여러 Hermes 프로세스(게이트웨이 + CLI 세션 + 작업 트리 에이전트)가 하나를 공유합니다.
state.db. SessionDB 클래스는 다음을 사용하여 쓰기 경합을 처리합니다.
- 짧은 SQLite 시간 제한(기본 30초 대신 1초)
- 애플리케이션 수준 재시도 랜덤 지터(20~150ms, 최대 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 | 두 용어 모두(암시적 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—>>>match<<<마커가 있는 FTS5 생성 스니펫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)은 모두 동일한 디렉터리에 생성됩니다.