본문으로 건너뛰기

세션 저장

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_itemscodex_message_items은 JSON 문자열로 저장됩니다.
  • reasoning은 이를 노출하는 제공자에 대한 원시 추론 텍스트를 저장합니다.
  • 타임스탬프는 Unix epoch float입니다(time.time()).
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 열 추가
4title에 고유 인덱스 추가(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 열 추가
9Codex 응답 메시지 ID/단계 재생을 위한 메시지에 codex_message_items 열을 추가합니다.
10messages_fts_trigram 가상 테이블(CJK/하위 문자열 검색용 트라이그램 토크나이저)을 추가하고 기존 행을 다시 채웁니다.
11messages_ftsmessages_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"정확한 구문 일치
부울 ORdocker OR kubernetes두 용어 모두
부울 NOTpython 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>>>match<<< 마커가 있는 FTS5 생성 스니펫
  • context — 일치 전후의 메시지 1개(콘텐츠는 200자로 잘림)
  • source, model, session_started — 상위 세션에서

_sanitize_fts5_query() 메소드는 극단적인 경우를 처리합니다.

  • 일치하지 않는 따옴표 및 특수 문자를 제거합니다.
  • 하이픈으로 연결된 용어를 따옴표로 묶습니다(chat-send"chat-send").
  • 매달려 있는 부울 연산자를 제거합니다(hello ANDhello).

세션 계보

세션은 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)은 모두 동일한 디렉터리에 생성됩니다.