Skip to content

db

Single-file SQLite store: relational tables + sqlite-vec vectors.

backup_db(conn, dest)

Write a consistent single-file copy of the database to dest.

cp hermes.db backup.db is the obvious thing to type and it silently loses data: get_db sets journal_mode=WAL, so recent commits live in hermes.db-wal until a checkpoint. The copy opens, looks intact, and is missing the newest clicks and items -- worse than having no backup, because it looks trustworthy. Five such copies were sitting in the real data directory when this was written.

VACUUM INTO reads through the WAL and writes one consistent, compacted file without stopping writers, which is exactly the operation an operator thought they were getting.

Refuses an existing destination: a backup that silently replaces the previous one is one keystroke from being no backup at all.

drop_vectors(path, backup)

Back the database up to backup, then drop both vec0 tables and their embedding_model rows, so the next get_db recreates them at the configured EMBED_DIMS and EMBED_MODEL. Nothing relational is touched.

Opens its own connection: get_db is the thing refusing this database, so it cannot be the way in. attest reembed is the only caller, and only after the new embedder has answered (spec 2026-10-06, decision 6).

embed_dims()

Stored embedding dimensionality (EMBED_DIMS, default 256).

Read at call time so tests and .env loading are respected. Changing it invalidates every stored vector — get_db() refuses mismatched databases.

embed_model()

Configured embedding model name (EMBED_MODEL, default embeddinggemma).

Read at call time -- see embed_dims(). This is the identity get_db() checks against embedding_model on open: same width, different model, is a silently incoherent vector space the dims guard alone cannot catch.

get_db(path)

Open (creating if absent) the SQLite store at path.

Never seeds personas. A new database is EMPTY, so the web UI's first screen is the onboarding form and an agent's first feed.list creates the reader it names. Demo personas (SEED_USERS) exist only when asked for: attest bootstrap-persona <demo name> creates the one it is given, and seed_demo_users() plants all three. Seeding on creation put the author's own persona in every stranger's database; seeding on every open resurrected personas the reader had deleted.

TASK 12 -- sqlite-vec extension load, and why it must not raise here:

Loading sqlite-vec needs conn.enable_load_extension(True/False), a method a Python built without --enable-loadable-sqlite-extensions does not have at all (measured: pyenv 3.12.4, no; uv-managed 3.12.12/3.13.11, yes -- see Task 12's incident notes). That surfaced as a bare AttributeError, which is neither OSError nor sqlite3.Error, so it passed straight through every handler in this codebase and killed EVERY attest subcommand -- including runs scan/compare and claims, which touch no vector table at all and are advertised (README, docs/guides/install.md) as needing no model server and no extension. That promise must survive this failure, so the load is best-effort: catch AttributeError alongside sqlite3.Error (a missing/incompatible sqlite-vec binary raises OperationalError) and OSError (a bad shared-library path), record the outcome on the connection, and continue opening the relational schema regardless.

Per-operation policy (the rest of Task 12): this function does not decide who may proceed without vectors -- it only makes the fact observable via vector_extension_available(conn), cheaply and per-connection, so callers can. The two-tier split follows CLAUDE.md's own framing verbatim ("the ledger and claim checking need no model; the feed does"): runs scan/compare/list/show/record and claims never check this and always proceed; ingest, library sync/search/embed, and eval check it in cli.py's open_db(need_vectors=True) and refuse with vector_extension_unavailable_message() before calling any code that would otherwise hit "no such table: item_vectors" -- itself a consequence of _ensure_vec_tables() deliberately not creating vec0 tables it cannot create correctly (see that function's docstring).

resolve_db_path(explicit)

Resolve the hermes.db path with this precedence:

  1. explicit (the --db flag, when the caller actually passed one)
  2. ATTEST_DB env var, when set -- or RSS_DB, its pre-rename name, which stays honoured so no existing cron line or MCP entry breaks
  3. the co-located skill data dir (~/.hermes/skills/science-recommendations/data/hermes.db), but only if that file already exists
  4. ./hermes.db (cwd-relative default)

seed_demo_users(conn)

Insert the three hardcoded demo personas (researcher, bench-chemist, ml-engineer).

INSERT OR IGNORE, so calling this against a database that already has these rows (or rows a researcher has since deleted and doesn't want back) is safe -- but it is still a write, and callers should call it only when they actually want demo data seeded, not on every connection open. A persona deleted via delete_persona must stay deleted; re-running this would silently resurrect it.

skill_data_db()

Where a pre-existing skill-data database lives under the agent home.

Monkeypatching SKILL_DATA_DB overrides this, because the suite must be able to point it at a path that does not exist.

vector_extension_available(conn)

Whether sqlite-vec actually loaded on THIS connection.

False on a Python built without --enable-loadable-sqlite-extensions (see get_db()'s docstring for Task 12's incident) or on any platform sqlite-vec ships no binary for (e.g. Alpine/musl). Callers that genuinely need a vec0 table -- ingest's vector insert, search, rank -- check this and refuse with vector_extension_unavailable_message() instead of hitting "no such table" or a bare AttributeError.

vector_extension_unavailable_message()

The one wording for "sqlite-vec did not load", mirroring rank.py's _embedder_unavailable_message() and ingest.py's chat/embedder -down messages: name the real cause, name what still works, name the remedy.