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:
explicit(the --db flag, when the caller actually passed one)ATTEST_DBenv var, when set -- orRSS_DB, its pre-rename name, which stays honoured so no existing cron line or MCP entry breaks- the co-located skill data dir (~/.hermes/skills/science-recommendations/data/hermes.db), but only if that file already exists
- ./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.