"""Shared module-level constants for the SessionDB family of modules. Extracted verbatim from hermes_state.py so the SessionDB mixin modules (hermes_state_search / hermes_state_schema / hermes_state_portability) can reference them without importing hermes_state (which would be a cycle). hermes_state re-imports every name here for backward compatibility. """ from typing import Any from agent.skill_commands import ( SKILL_EXCERPT_JOINT, SKILL_SCAFFOLD_SQL_LIKE, describe_skill_invocation, ) # Session preview = the head of the first user message, shown wherever a # session has no title (sidebar rows, pickers, exports, the desktop's # `sessionTitle` fallback). # # A /skill invocation expands into a message that embeds the whole skill body, # so the plain head of it previews the SKILL's opening prose as if the user had # written it. Scaffolded rows therefore carry a wider excerpt so # ``_shape_preview`` can hand it to ``describe_skill_invocation`` and recover # ``/work — fix the title leak``: the whole message while it stays under the # budget, and head + tail (where the typed instruction lands) once it doesn't. _PREVIEW_HEAD_CHARS = 63 _PREVIEW_SCAFFOLD_WINDOW = 400 _PREVIEW_MAX_CHARS = 60 _PREVIEW_CONTENT_SQL = "REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' ')" _PREVIEW_SCAFFOLDED_SQL = f"m.content LIKE '{SKILL_SCAFFOLD_SQL_LIKE}'" # The shared ``_preview_raw`` SELECT expression, interpolated by every listing # query. A scaffolded row gets a wider excerpt: the whole message while it fits # the budget, else head + tail (where the typed instruction lands) spliced # around SKILL_EXCERPT_JOINT. _PREVIEW_RAW_SELECT = ( f"CASE WHEN {_PREVIEW_SCAFFOLDED_SQL}" f" AND LENGTH(m.content) > {_PREVIEW_SCAFFOLD_WINDOW * 2}" f" THEN SUBSTR({_PREVIEW_CONTENT_SQL}, 1, {_PREVIEW_SCAFFOLD_WINDOW})" f" || '{SKILL_EXCERPT_JOINT}'" f" || SUBSTR({_PREVIEW_CONTENT_SQL}, -{_PREVIEW_SCAFFOLD_WINDOW})" f" WHEN {_PREVIEW_SCAFFOLDED_SQL}" f" THEN SUBSTR({_PREVIEW_CONTENT_SQL}, 1, {_PREVIEW_SCAFFOLD_WINDOW * 2})" f" ELSE SUBSTR({_PREVIEW_CONTENT_SQL}, 1, {_PREVIEW_HEAD_CHARS}) END" ) def _shape_preview(raw: Any) -> str: """Turn a ``_preview_raw`` column into the short preview callers show.""" text = str(raw or "").strip() if not text: return "" described = describe_skill_invocation(text) text = described if described is not None else text.split(SKILL_EXCERPT_JOINT)[0] if len(text) > _PREVIEW_MAX_CHARS: return text[:_PREVIEW_MAX_CHARS] + "..." return text # A child session counts as a /branch (kept visible, never cascade-deleted) if # it carries the stable marker OR the legacy end_reason heuristic holds. _BRANCH_CHILD_SQL = ( "json_extract(COALESCE({a}.model_config, '{{}}'), '$._branched_from') IS NOT NULL" " OR EXISTS (SELECT 1 FROM sessions p" " WHERE p.id = {a}.parent_session_id" " AND p.end_reason = 'branched'" " AND {a}.started_at >= p.ended_at)" ) _COMPRESSION_CHILD_SQL = ( "EXISTS (SELECT 1 FROM sessions p" " WHERE p.id = {a}.parent_session_id" " AND p.end_reason = 'compression')" ) # Rows that surface in pickers: roots + branch children (subagent runs and # compression continuations stay hidden). _LISTABLE_CHILD_SQL = f"(s.parent_session_id IS NULL OR {_BRANCH_CHILD_SQL.format(a='s')})" def _ephemeral_child_sql(alias: str = "s") -> str: """Subagent runs (cascade-delete targets), not branches or compression tips.""" branch = _BRANCH_CHILD_SQL.format(a=alias) compression = _COMPRESSION_CHILD_SQL.format(a=alias) return ( f"({alias}.parent_session_id IS NOT NULL" f" AND NOT ({branch})" f" AND NOT ({compression}))" ) SCHEMA_VERSION = 23 # FTS storage-layout version, tracked INDEPENDENTLY of SCHEMA_VERSION in the # state_meta key ``fts_storage_version``. The main schema version advances # freely on open (so future migrations always land); the FTS *layout* only # reaches the current version when a DB is either born fresh or explicitly # optimized via ``hermes sessions optimize-storage``. A legacy DB sits at # layout 0 (marker absent) with a working inline index until the user opts in. # 1 = v23 external-content layout (content/tool_name/tool_calls, # tool-row-excluded trigram) FTS_STORAGE_VERSION = 1 # Cap on user-controlled FTS5 query input before regex/sanitizer processing. # Search queries do not need to be arbitrarily large, and bounding them keeps # sanitizer/runtime behavior predictable under adversarial input. MAX_FTS5_QUERY_CHARS = 2_048 _FTS_TRIGGERS = ( "messages_fts_insert", "messages_fts_delete", "messages_fts_update", "messages_fts_trigram_insert", "messages_fts_trigram_delete", "messages_fts_trigram_update", ) SCHEMA_SQL = """ CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, source TEXT NOT NULL, user_id TEXT, session_key TEXT, chat_id TEXT, chat_type TEXT, thread_id TEXT, display_name TEXT, origin_json TEXT, expiry_finalized INTEGER DEFAULT 0, 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, cwd TEXT, git_branch TEXT, git_repo_root TEXT, 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, handoff_state TEXT, handoff_platform TEXT, handoff_error TEXT, compression_failure_cooldown_until REAL, compression_failure_error TEXT, compression_fallback_streak INTEGER NOT NULL DEFAULT 0, compression_ineffective_count INTEGER NOT NULL DEFAULT 0, profile_name TEXT, rewind_count INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (parent_session_id) REFERENCES sessions(id) ); 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, effect_disposition 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, platform_message_id TEXT, observed INTEGER DEFAULT 0, active INTEGER NOT NULL DEFAULT 1, compacted INTEGER NOT NULL DEFAULT 0, api_content TEXT, display_kind TEXT, display_metadata TEXT ); CREATE TABLE IF NOT EXISTS session_model_usage ( session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, model TEXT NOT NULL, billing_provider TEXT NOT NULL DEFAULT '', billing_base_url TEXT NOT NULL DEFAULT '', billing_mode TEXT NOT NULL DEFAULT '', task TEXT NOT NULL DEFAULT '', api_call_count INTEGER NOT NULL DEFAULT 0, input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_write_tokens INTEGER NOT NULL DEFAULT 0, reasoning_tokens INTEGER NOT NULL DEFAULT 0, estimated_cost_usd REAL NOT NULL DEFAULT 0, actual_cost_usd REAL NOT NULL DEFAULT 0, cost_status TEXT, cost_source TEXT, first_seen REAL, last_seen REAL, PRIMARY KEY (session_id, model, billing_provider, billing_base_url, billing_mode, task) ); CREATE TABLE IF NOT EXISTS state_meta ( key TEXT PRIMARY KEY, value TEXT ); CREATE TABLE IF NOT EXISTS gateway_routing ( scope TEXT NOT NULL DEFAULT '', session_key TEXT NOT NULL, entry_json TEXT NOT NULL, updated_at REAL NOT NULL, PRIMARY KEY (scope, session_key) ); CREATE TABLE IF NOT EXISTS compression_locks ( session_id TEXT PRIMARY KEY, holder TEXT NOT NULL, acquired_at REAL NOT NULL, expires_at REAL NOT NULL ); CREATE TABLE IF NOT EXISTS async_delegations ( delegation_id TEXT PRIMARY KEY, origin_session TEXT NOT NULL, origin_ui_session_id TEXT NOT NULL DEFAULT '', parent_session_id TEXT, state TEXT NOT NULL, dispatched_at REAL NOT NULL, completed_at REAL, updated_at REAL NOT NULL, event_json TEXT, result_json TEXT, delivery_state TEXT NOT NULL DEFAULT 'pending', delivery_attempts INTEGER NOT NULL DEFAULT 0, delivered_at REAL, owner_pid INTEGER, owner_started_at INTEGER, task_json TEXT, delivery_claim TEXT, delivery_claimed_at REAL ); CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source); CREATE INDEX IF NOT EXISTS idx_sessions_source_id ON sessions(source, id); 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 INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp); CREATE INDEX IF NOT EXISTS idx_compression_locks_expires ON compression_locks(expires_at); CREATE INDEX IF NOT EXISTS idx_session_model_usage_session ON session_model_usage(session_id); CREATE INDEX IF NOT EXISTS idx_session_model_usage_model ON session_model_usage(model); CREATE INDEX IF NOT EXISTS idx_async_delegations_delivery ON async_delegations(delivery_state, completed_at); """ # Indexes that reference columns added in later schema versions must be # created AFTER _reconcile_columns() has had a chance to ADD them on # existing databases. SCHEMA_SQL above is run by sqlite executescript # which would otherwise fail on legacy DBs ("no such column: active"). DEFERRED_INDEX_SQL = """ CREATE INDEX IF NOT EXISTS idx_messages_session_active ON messages(session_id, active, timestamp); CREATE INDEX IF NOT EXISTS idx_messages_active_null ON messages(active) WHERE active IS NULL; CREATE INDEX IF NOT EXISTS idx_sessions_session_key ON sessions(session_key, started_at DESC); CREATE INDEX IF NOT EXISTS idx_sessions_gateway_peer ON sessions(source, user_id, chat_id, chat_type, thread_id, started_at DESC); CREATE INDEX IF NOT EXISTS idx_sessions_handoff_state ON sessions(handoff_state, started_at); """ # ── Deferred FTS rebuild bookkeeping (schema v23) ── # While a background index rebuild is pending, two state_meta keys define # which message rows are currently IN the FTS indexes: # # fts_rebuild_high_water H — MAX(messages.id) at the moment the old # indexes were dropped # fts_rebuild_progress P — highest id the chunked backfill has indexed # # A row is indexed iff id <= P (backfilled) OR id > H (inserted after # the drop; ids are AUTOINCREMENT so new rows are always > H and the insert # triggers index them live). Rows in (P, H] are not yet indexed. # # Every trigger below gates on that same predicate: firing an FTS5 # external-content 'delete' for a row that is NOT in the index corrupts the # index, and skipping it for a row that IS indexed leaves a stale entry. # When no rebuild is pending both keys are absent and COALESCE turns the # predicate into a tautology (id > -1 OR id <= -1), i.e. normal operation. # The two state_meta PK probes per write are negligible next to the FTS # insert itself. FTS_SQL = """ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( content, tool_name, tool_calls, content='messages', content_rowid='id' ); CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages WHEN (new.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta WHERE key = 'fts_rebuild_high_water'), -1) OR new.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta WHERE key = 'fts_rebuild_progress'), -1)) BEGIN INSERT INTO messages_fts(rowid, content, tool_name, tool_calls) VALUES (new.id, new.content, new.tool_name, new.tool_calls); END; CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages WHEN (old.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta WHERE key = 'fts_rebuild_high_water'), -1) OR old.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta WHERE key = 'fts_rebuild_progress'), -1)) BEGIN INSERT INTO messages_fts(messages_fts, rowid, content, tool_name, tool_calls) VALUES ('delete', old.id, old.content, old.tool_name, old.tool_calls); END; CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages WHEN (old.content IS NOT new.content OR old.tool_name IS NOT new.tool_name OR old.tool_calls IS NOT new.tool_calls) AND (old.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta WHERE key = 'fts_rebuild_high_water'), -1) OR old.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta WHERE key = 'fts_rebuild_progress'), -1)) BEGIN INSERT INTO messages_fts(messages_fts, rowid, content, tool_name, tool_calls) VALUES ('delete', old.id, old.content, old.tool_name, old.tool_calls); INSERT INTO messages_fts(rowid, content, tool_name, tool_calls) VALUES (new.id, new.content, new.tool_name, new.tool_calls); END; """ # Trigram FTS5 table for CJK substring search. The default unicode61 # tokenizer splits CJK characters into individual tokens, breaking phrase # matching. The trigram tokenizer creates overlapping 3-byte sequences so # substring queries work natively for any script (CJK, Thai, etc.). # # The trigram index is the most expensive index in state.db (~2.6x the size # of the text it covers), and ``role='tool'`` rows are ~90% of message bytes # while being almost entirely machine noise (base64 payloads, file dumps, # delegation transcripts). The index therefore reads through # ``messages_fts_trigram_src``, a view that excludes tool rows — they stay # fully stored in ``messages`` and fully searchable via the standard # ``messages_fts`` index; they just don't get trigram (CJK substring) # treatment. ``search_messages`` routes CJK queries that filter on # ``role='tool'`` to the LIKE fallback for the same reason. FTS_TRIGRAM_SQL = """ CREATE VIEW IF NOT EXISTS messages_fts_trigram_src AS SELECT id, role, content, tool_name, tool_calls FROM messages WHERE role <> 'tool'; CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_trigram USING fts5( content, tool_name, tool_calls, content='messages_fts_trigram_src', content_rowid='id', tokenize='trigram' ); CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_insert AFTER INSERT ON messages WHEN new.role <> 'tool' AND (new.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta WHERE key = 'fts_rebuild_high_water'), -1) OR new.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta WHERE key = 'fts_rebuild_progress'), -1)) BEGIN INSERT INTO messages_fts_trigram(rowid, content, tool_name, tool_calls) VALUES (new.id, new.content, new.tool_name, new.tool_calls); END; CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_delete AFTER DELETE ON messages WHEN old.role <> 'tool' AND (old.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta WHERE key = 'fts_rebuild_high_water'), -1) OR old.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta WHERE key = 'fts_rebuild_progress'), -1)) BEGIN INSERT INTO messages_fts_trigram(messages_fts_trigram, rowid, content, tool_name, tool_calls) VALUES ('delete', old.id, old.content, old.tool_name, old.tool_calls); END; CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_update AFTER UPDATE ON messages WHEN (old.content IS NOT new.content OR old.tool_name IS NOT new.tool_name OR old.tool_calls IS NOT new.tool_calls OR old.role IS NOT new.role) AND (old.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta WHERE key = 'fts_rebuild_high_water'), -1) OR old.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta WHERE key = 'fts_rebuild_progress'), -1)) BEGIN INSERT INTO messages_fts_trigram(messages_fts_trigram, rowid, content, tool_name, tool_calls) SELECT 'delete', old.id, old.content, old.tool_name, old.tool_calls WHERE old.role <> 'tool'; INSERT INTO messages_fts_trigram(rowid, content, tool_name, tool_calls) SELECT new.id, new.content, new.tool_name, new.tool_calls WHERE new.role <> 'tool'; END; """ _FTS_CJK_TRIGGERS = ( "messages_fts_cjk_insert", "messages_fts_cjk_delete", "messages_fts_cjk_update", ) # state_meta breadcrumb set when a tokenizer-less process had to drop the # cjk triggers to keep message writes alive: rows written from that moment # on are missing from the cjk index, so it must not serve reads until # `hermes sessions optimize-storage` rebuilds it on a capable host. FTS_CJK_STALE_KEY = "fts_cjk_stale" # ── Legacy (v22 / inline-content) FTS DDL ────────────────────────────── # Used ONLY to keep an existing pre-v23 install's search working and its # triggers repairable UNTIL the user opts into `hermes db optimize`. This is # the exact inline shape v11..v22 shipped: each virtual table stores its own # copy of ``content || tool_name || tool_calls`` and the trigram table indexes # every row (including role='tool'). We never CREATE these on a fresh install — # fresh installs are born on the v23 external-content schema above. These # constants exist so a legacy DB is never accidentally handed the v23 DDL # (which would create the external-content trigram source VIEW and leave the # DB in a mixed, broken state). `optimize_fts_storage()` is what migrates a # legacy DB to the v23 shape. LEGACY_FTS_SQL = """ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( content ); CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN INSERT INTO messages_fts(rowid, content) VALUES ( new.id, COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '') ); END; CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN DELETE FROM messages_fts WHERE rowid = old.id; END; CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN DELETE FROM messages_fts WHERE rowid = old.id; INSERT INTO messages_fts(rowid, content) VALUES ( new.id, COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '') ); END; """ LEGACY_FTS_TRIGRAM_SQL = """ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_trigram USING fts5( content, tokenize='trigram' ); CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_insert AFTER INSERT ON messages BEGIN INSERT INTO messages_fts_trigram(rowid, content) VALUES ( new.id, COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '') ); END; CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_delete AFTER DELETE ON messages BEGIN DELETE FROM messages_fts_trigram WHERE rowid = old.id; END; CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_update AFTER UPDATE ON messages BEGIN DELETE FROM messages_fts_trigram WHERE rowid = old.id; INSERT INTO messages_fts_trigram(rowid, content) VALUES ( new.id, COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '') ); END; """