Generated banner for Engrava
Generated logo for Engrava

Engrava

Memory frameworkOfficial Sovantica project

Created by Sovantica

Published in sovantica/engrava. Publisher page

12 GitHub stars

What it is

The memory database for AI agents - graph memory, hybrid search, tamper-evident thought/edge journal.
— the description by Sovantica

AutoGineer overview, from the brain’s own files

Engrava is an in-process memory database designed for AI agents, built on SQLite. It provides a structured way to store and retrieve agent thoughts using a graph-based model, where thoughts are linked by typed edges. The system supports hybrid search, combining vector similarity, full-text search (FTS5), recency, and priority. It includes built-in mechanisms for memory consolidation (dreaming), memory hygiene (archiving and garbage collection), and a tamper-evident journal for tracking mutations. Engrava operates as a library with no external service dependencies in its core path, allowing agents to maintain persistent, queryable state locally.

How it works

Memory model: Hybrid

AutoGineer overview, from the brain’s own files

Engrava uses a SQLite database to store thoughts and their relationships. It organizes data into thoughts and typed edges, which can be queried using the MindQL language. The system supports hybrid search by combining vector embeddings, keyword search, and metadata signals. It manages memory through a lifecycle that includes archiving and garbage collection, and it can consolidate memories into reflections. The system is extensible via hooks and provides an MCP server for integration with various AI agent frameworks.

Folders
FolderRoleFiles
.githubagent-config16
docsdocs37
examplesexamples13
scriptsscripts24
srcsource-code89
teststests201
.githooksfolder1

The agent reads

Requirements

Runtimes: Python >=3.11

Databases: SQLite

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Google Antigravity, Kiro, OpenCode, Amp, Roo Code, Cline, JetBrains Junie, Devin (ex-Windsurf)

Set it up

  1. This brain runs code automatically: 2 scripts. Review .githooks/commit-msg, Makefile before opening the folder in Claude Code. No step below turns a hook on for you.

    • .githooks/commit-msg: runs on git commit-msg
    • Makefile: echo "$(BLUE)>> Installing engrava + dev dependencies$(RESET)"; $(PIP) install --upgrade pip setuptools wheel; $(PIP) install -e ".[dev]"; bash scripts/install_hooks.sh; echo "$(GREEN)>> Done. Run 'make check' to verify quality gate.$(RESET)"
  2. Not scanned by AutoGineerIts dependencies are installed by pip or uv when you run it and were not scanned by AutoGineer (5 declared). https://pypi.org/project/engrava/
  3. Created by Sovantica. Published in sovantica/engrava. Licence: MIT License.

Usage

AutoGineer overview, from the brain’s own files

  • Persistent memory for autonomous AI agents
  • Personal knowledge base management
  • Conversation storage with semantic retrieval
  • Research notes with associative linking
  • Memory-backed agent development
  • Audit-trailed thought and edge tracking
“Store a new observation about a project requirement.”
The agent uses the remember() method to persist the thought, which is then indexed for future hybrid search and graph traversal.
“Retrieve relevant context for a coding task.”
The agent uses the recall() method, which performs a hybrid search across FTS5, vector, and graph signals to return ranked, contextually relevant thoughts.
“Query the memory graph for all observations with high priority.”
The agent executes a MindQL query: FIND thoughts WHERE thought_type = 'OBSERVATION' AND priority = 'P1'.
“Consolidate old memories to free up space.”
The agent triggers the DreamingExtension to cluster related thoughts and compute reflections, or uses the memory hygiene loop to archive low-signal thoughts.

Documentation by Sovantica

Engrava

The memory database for AI agents.

A queryable memory graph with bi-temporal valid-time predicates — in an in-process Python library over one SQLite file, with no generative-model call in the core write path.

Image: CI Image: PyPI Image: Python Image: License: MIT

Engrava is a standalone, in-process database for AI agent memory. Built on SQLite, it provides thought CRUD, edge-based knowledge graphs, embedding-based similarity search, full-text search (FTS5/BM25), and a declarative extension system — all in a single package with zero external service dependencies.

Benchmark results are published, and the runs are reproducible from a separate repository. Every published run is Group A — memory_pipeline_llms: [], no language model anywhere in the memory layer's built-in path. That is a property of the architecture's default signals and hooks rather than a measurement, unless a custom Dreaming signal or Memory Hygiene hook is configured to call one.

  • Benchmark results — the live table: every published row with the comparability segment it belongs to.

  • engrava-benchmark — the runner. Clone it and reproduce a result against the package from PyPI. MIT.

  • Discussions — questions; reproduction questions belong in the Q&A category.

Use Cases

  • AI agent persistent memory

  • Personal knowledge base

  • Conversation storage with semantic search

  • Research notes with associative linking

  • Any application that needs a thought-graph with embeddings

Quick Start

Installation
pip install engrava

Optional extras:

pip install 'engrava[vec]'                # sqlite-vec vector search backend
pip install 'engrava[embeddings-local]'   # sentence-transformers embeddings (local model)
pip install 'engrava[embeddings-openai]'  # OpenAI-compatible embeddings API
pip install 'engrava[embeddings-ollama]'  # Ollama local embeddings server
pip install 'engrava[embeddings-hf]'      # HuggingFace Inference API embeddings

Dreaming/consolidation and the knowledge graph need no extra — they are part of the base install.

embeddings-local carries a large first-run download. It pulls sentence-transformers and torch, plus a further, separate model download (~88 MB for all-MiniLM-L6-v2, cached under ~/.cache/huggingface/hub) on first use. On Linux, the PyPI build of torch also pulls the NVIDIA CUDA libraries as dependencies: several gigabytes, most of it usable only with an NVIDIA GPU. Without one, install the CPU-only build first (see Configuration: No NVIDIA GPU for the exact command and numbers). engrava-mcp[local] is the exact same download, reached through the server package instead of this one. If you do not need semantic search in-process, pip install engrava alone (no extra, no download, no model) already gives you keyword search, the graph, and MindQL — see Configuration → Quick-start profiles for that and the Ollama-backed alternative that keeps the model out of this process entirely.

Basic Usage

Store a memory and search for it in two calls — no IDs to generate, no record to assemble:

import asyncio

import aiosqlite

from engrava import SqliteEngravaCore


async def main() -> None:
    # SqliteEngravaCore wraps an open aiosqlite connection.
    async with aiosqlite.connect(":memory:") as conn:
        conn.row_factory = aiosqlite.Row
        store = SqliteEngravaCore(conn)
        await store.ensure_schema()

        await store.remember("Python is great for AI agents")
        await store.remember("SQLite needs no server")

        result = await store.recall("what language is good for agents?")
        for thought_id, score in result.results:
            thought = await store.get_thought(thought_id)
            if thought is not None:
                print(f"{thought.essence}  (score: {score:.3f})")


asyncio.run(main())

remember() stores the text as a thought (generating its ID for you) and returns the stored ThoughtRecord; recall() runs the same hybrid search as search_hybrid() and returns the ranked results. This store has no SearchConfig of its own, and it still ranks with the same default weights — FTS 0.30, vector 0.55, recency 0.10, priority 0.05, graph 0.00 (opt-in) — as a store built with one; see Hybrid Search. For full control — setting priority, thought type, metadata, or the cognitive cycle on a write — build a ThoughtRecord yourself and call create_thought().

From here, link thoughts with typed edges, query them with MindQL, or run the full ingest → dream → search tour in the Quick Start guide.

Configuration-Driven Setup
from engrava import SqliteEngravaCore

# from_config opens and OWNS the connection — use it as an async context manager.
async with await SqliteEngravaCore.from_config("engrava.yaml") as store:
    # The schema is already applied by from_config.
    thought = await store.get_thought("some-id")

See docs/configuration.md for the full YAML schema.

Upgrading

Automatic schema migration runs on first connection. See the upgrade guide for compatibility notes, backup guidance, and troubleshooting steps.

Features

Thought CRUD

Create, read, update, and archive thoughts with full lifecycle management. All models are frozen Pydantic objects — mutations happen via evolve().

Edge-Based Knowledge Graph

Link thoughts with typed, weighted edges. Edge types include ASSOCIATED, DEPENDS_ON, DERIVED_FROM, CONSOLIDATED_FROM (created by dreaming), and CONTESTED_BY.

Store embeddings alongside thoughts and search them with the built-in NumPy cosine backend or the optional sqlite-vec backend (pip install 'engrava[vec]'). Pluggable embedding providers:

ProviderExtraBackend
SentenceTransformerProviderembeddings-localLocal model via sentence-transformers
OpenAICompatibleProviderembeddings-openaiAny OpenAI-compatible API
OllamaProviderembeddings-ollamaLocal Ollama server
HuggingFaceProviderembeddings-hfHuggingFace Inference API
CallbackProvider(built-in)Custom callable
Full-Text Search (FTS5)

SQLite FTS5 virtual table with BM25 ranking. Hybrid search combines vector similarity, text relevance, recency, priority, and graph connectivity. Signals that cannot run for a query are skipped and the remaining weights are redistributed.

MindQL Query Language

Declarative query language for the thought-graph:

FIND thoughts WHERE thought_type = 'OBSERVATION' AND priority = 'P1' LIMIT 10
COUNT thoughts WHERE lifecycle_status = 'ACTIVE'
SELECT thought_id, essence FROM thought WHERE thought_type = 'BELIEF'

Extensible with custom commands through MindQLExecutor or an ExtensionManifest; the lifecycle hook registry is reserved and is not consulted by the core executor.

Extension System

Plug into the thought lifecycle via EngravaHooksProtocol. Subclass DefaultEngravaHooks when you only need selected active methods:

from engrava import DefaultEngravaHooks, ThoughtRecord

class MyHooks(DefaultEngravaHooks):
    async def on_store(self, thought: ThoughtRecord) -> ThoughtRecord:
        # Observe or enrich the object returned after persistence.
        return thought

    async def decay_function(
        self, thought: ThoughtRecord, elapsed_cycles: int
    ) -> float:
        # Supply a decay multiplier to an enabled Memory Hygiene pass.
        return 1.0

Core currently invokes on_store, on_retrieve, and decay_function. on_store runs after the source thought's row is inserted; changing its return value does not rewrite the persisted row. score_function and mindql_extension_registry() remain reserved protocol methods and are not called by core.

Dreaming / Memory Consolidation

Built-in DreamingExtension for periodic memory consolidation — scores thoughts via its default signals (no LLM calls), promotes high-value entries, and creates REFLECTION thoughts by clustering semantically related thoughts and computing centroid embeddings through a deterministic structural function, not an LLM. A custom signal you register with DreamingExtension runs whatever code it contains. Available since 0.3.0.

→ See docs/benchmarks.md for reproducible evidence (synthetic benchmark suite runnable in ~5 minutes).

Forgetting / Memory Hygiene

The subtractive half of memory maintenance, paired with Dreaming: an opt-in loop whose built-in scoring makes no LLM calls, that archives cold, low-signal thoughts — the default action, reversible via restore_thought — and, as a separately opted-in step, garbage-collects them (not reversible) once both restore windows have elapsed under their non-zero defaults — a cycle count and a wall-clock duration, either of which can be configured to 0 to disable that window. OFF by default; once enabled, archived thoughts drop out of default retrieval and can be restored (restore_thought / include_archived).

→ See docs/memory-hygiene.md for the loop, protection, restore windows, and the honest deletion posture.

Tamper-Evident Thought/Edge Journal

Opt-in hash-chain journal that records thought and edge mutations (plus action status/verification_status transitions) as SHA-256-linked, before/after entries — a tamper-evident thought/edge journal, not a whole-database audit (embeddings and action creation are not covered). Off by default, one config flag to enable. Query history with store.journal.get_entries(...) and validate the chain with store.verify_journal(), which audits whatever chain is on disk independent of the current journal.enabled state — the store.journal writer handle does not exist while journaling is off, but the chain earlier sessions wrote is still in journal_entry and still needs verifying.

→ See docs/audit-trail.md for enabling, querying, verification, and the security model (what "tamper-evident" does and does not guarantee).

Multi-Service Isolation

Run multiple independent databases under one EngravaManager:

from pathlib import Path

from engrava import EngravaManager

async with EngravaManager(data_dir=Path("./data")) as mgr:
    agent_a = await mgr.get_store("agent-a")
    agent_b = await mgr.get_store("agent-b")
    # Completely isolated databases
MCP Server

Want Engrava as a memory server for your agent? The MCP server ships as its own package, engrava-mcp — a native stdio server (no HTTP shim) with read tools, optional write tools, attachable engrava:// resources, and guided prompts, for any MCP client (Claude Desktop, Claude Code, Cursor, Windsurf, VS Code):

uvx engrava-mcp        # or: pip install engrava-mcp

engrava-mcp pulls engrava in transitively, so installing it also gives you the import engrava library. See the engrava-mcp package for install, client configuration, the full tool/resource/prompt reference, and read-only mode.

uvx vs. a persistent install. uvx engrava-mcp (equivalently uv tool run engrava-mcp) installs into "an ephemeral virtual environment in the uv cache directory" per uv's own --help text — fine for the lexical/network profiles above, which add nothing heavier than httpx. Once you are on the local profile (engrava-mcp[local], the same sentence-transformers + torch download as engrava[embeddings-local]), uv tool install 'engrava-mcp[local]' is the better fit: it installs once into a persistent environment — the same uvx engrava-mcp invocation then reuses that installed environment instead of resolving a fresh ephemeral one — and a later uv tool upgrade engrava-mcp re-pays only the changed packages, not the whole dependency tree.

CLI

engrava --db mydata.db info          # Database stats
engrava --db mydata.db query "FIND thoughts WHERE thought_type = 'OBSERVATION' LIMIT 5"
engrava --db mydata.db snapshot -o backup.jsonl
engrava --db mydata.db restore -i backup.jsonl
engrava --db mydata.db gc            # Collect ARCHIVED thoughts + their edges/embeddings/actions
engrava --db mydata.db migrate       # Ensure schema is up-to-date
engrava --db mydata.db export -o portable.json

gc physically deletes ARCHIVED thoughts together with every edge touching one on either end — including edges whose other end is still live — their embeddings and the actions sourced from them, then reconciles the vector index by removing every vec0 row no embedding row owns; on a vec0-indexed store where sqlite-vec cannot be loaded — most commonly because engrava[vec] is not installed — a pass that is about to delete stops before deleting anything and exits 1 rather than stranding those vectors in an index nothing can then reach them through.

engrava info's --format json output carries every field of the metrics snapshot exposed by await store.metrics(), with the snapshot's own schema version renamed metrics_schema_version, plus two fields the snapshot itself does not carry, db_path and database_schema_version — see Upgrade Guide. The default text output is a shorter summary of that same data, not the full snapshot.

See the CLI reference for every command and option.

Architecture

  • SQLite with WAL mode for concurrent reads

  • Frozen Pydantic models — immutable domain objects

  • Async-first — all I/O via aiosqlite

  • Hook-based extension — zero monkey-patching

  • Template method pattern — subclass SqliteEngravaCore for extended schemas

  • Zero external services — everything runs locally in-process

Documentation

  • Documentation Index — choose a path by task: learn, build, operate, extend, or migrate

  • Core Concepts — the mental model (thought, edge, reflection, cycle, …) — start here

  • The Bi-temporal Model — the optional valid-time axis: query a fact as of any instant, invalidate without deleting

  • Positioning — when Engrava is (and isn't) the right tool, and how it compares

  • Quick Start — 5-minute setup guide

  • Tutorial — build a small notes memory end to end

  • Recipes — copy-paste snippets for common tasks (store a turn, retrieve context, TTL, dedup, …)

  • Building a memory-backed agent — the end-to-end agent turn loop (ingest → retrieve → generate → consolidate)

  • Migrating from another memory system — concept mapping, porting calls, bulk import, and scoping/multi-tenancy

  • Embeddings — wiring a real embedding provider (local / OpenAI / Ollama / HuggingFace / custom)

  • MCP server (engrava-mcp) — expose a store to MCP clients (Claude Desktop, Claude Code, Cursor, Windsurf, VS Code) via the standalone server package: install, run, client config, tools/resources/prompts

  • Configuration — YAML config format and options

  • Upgrade Guide — compatibility matrix, backups, and troubleshooting

  • Extensions — Writing custom extensions and hooks

  • Observability — Metrics snapshot API

  • Audit Trail — Tamper-evident hash-chain journal (enabling, querying, verifying, security model)

  • Evidence and Conflicts — model claims, provenance, contested facts, and caller-owned clarification

  • Security — trust boundaries, data egress, tenant isolation, encryption posture, and journal limits

  • Error Handling and Recovery — decide when to retry, repair input, or replace a store

  • Benchmarks — release gates, versioned observed values, and LongMemEval

  • API Reference — Full protocol and class reference

  • CLI Reference — every engrava command and option

  • Glossary — quick definitions of every Engrava term

  • MindQL — Query language syntax and examples

  • Troubleshooting — symptom → cause → fix for common errors

  • FAQ — quick answers (LLM/keys, embeddings-optional, scale, concurrency, backups, …)

  • Performance & Scaling — the vector-backend switch, bulk-ingest, and dreaming cost at scale

  • Data Lifecycle & Retention — lifecycle states, TTL, archive-vs-delete, GDPR erasure, disk reclamation

  • Deployment — process model, database files on disk, containers, graceful shutdown

  • Concurrency — the WAL single-writer model, busy timeout, and per-service isolation

  • Backup & Recovery — WAL-safe backups, snapshot vs file copy, restore verification

  • Known Limitations — Platform notes and constraints

Development

make install   # deps + dev extras + local git hooks -- from the primary checkout
make gate      # fast: hooks-active + lint + format check + type check + goldens drift, ~70s
make check     # lint + format check + type check + the full test suite with coverage (unchanged; no hooks-active, no goldens drift)

make install also wires a commit-msg hook that lints the message of the commit a squash merge creates — including on a local branch that never opens a pull request, where nothing else does. It refuses to install from a linked worktree (core.hooksPath is shared across all of them). See CONTRIBUTING.md for what it covers and what it does not.

License

MIT — see LICENSE for details.

docs/cli.md

CLI reference

Engrava ships an engrava command-line tool for inspecting, querying, and maintaining a database without writing code. This page documents every command and option.

engrava [GLOBAL OPTIONS] COMMAND [ARGS]...

Global options

These apply to every command and go before the command name:

OptionValues / typeDefaultDescription
--dbpath./engrava.dbPath to the SQLite database. Falls back to the ENGRAVA_DB env var, then the default.
--configpath—Path to engrava.yaml. Falls back to the ENGRAVA_CONFIG env var.
--formattable | json | csvtableOutput format for commands that print records.
--verboseflagoffEmit DEBUG logs from Engrava modules to stderr for this invocation. Command data on stdout keeps its selected format.
--no-extensionsflagoffPrevent loading both engrava.cli and engrava.extensions entry points. ENGRAVA_DISABLE_EXTENSIONS=1 provides the same control.
--helpflag—Show help and exit (works on the root and on every command).

Environment variables. ENGRAVA_DB and ENGRAVA_CONFIG are CLI fallbacks for --db and --config respectively; the explicit flag always wins (--db > ENGRAVA_DB > ./engrava.db). ENGRAVA_DISABLE_EXTENSIONS=1 activates --no-extensions before command resolution, including for root help and built-in commands.

export ENGRAVA_DB=/data/engrava.db
engrava info                       # uses /data/engrava.db
engrava --db other.db info         # flag overrides the env var

Commands

CommandPurpose
infoShow a metrics snapshot for the database.
verifyVerify the audit journal's hash chain.
queryRun a MindQL query.
rememberStore a thought in one call and print its id.
recallSearch for thoughts relevant to a query and print ranked results.
linkCreate a typed edge between two thoughts.
snapshotExport thoughts, edges, embeddings, and actions to a JSONL snapshot (not the audit journal).
restoreRestore a database from a JSONL snapshot.
gcGarbage-collect archived thoughts (and optionally expired ones).
migrateRun pending schema migrations.
exportExport thoughts to a portable JSON file.

Extension discovery

The executable discovers installed CLI extensions lazily. Built-in commands are resolved without scanning engrava.cli. Root help scans that entry-point group so installed commands appear in the command list, and an otherwise unknown command triggers the scan so an installed extension command can resolve.

The built-in query command performs a second scan of engrava.extensions. It loads each discovered manifest and registers its mindql_extensions for that query. This query-time scan does not apply manifest schema migrations.

Both scans import and may execute code from installed packages. Failed entry points are skipped with a warning log. Put --no-extensions before the command, or set ENGRAVA_DISABLE_EXTENSIONS=1, to prevent both entry-point groups from being scanned or loaded:

engrava --no-extensions --help
ENGRAVA_DISABLE_EXTENSIONS=1 engrava --db memory.db info
engrava --no-extensions --db memory.db query "SELECT thought_id FROM thought LIMIT 5"

This control is specific to automatic CLI entry-point loading. It does not disable explicit manifest paths or library-side manifests.discover settings used by an application-created store. Continue to install only trusted packages; see Security and Trust Boundaries.

--verbose enables DEBUG logging for the engrava logger hierarchy for the duration of the command. Logs go to stderr, leaving JSON/CSV/table command data on stdout.

Service resolution

The --service option on snapshot and restore resolves the same way in both commands:

--serviceServices config loaded?Result
--service NAME (explicit)eitherTargets service NAME. Its database is looked up in the services data_dir if a config is loaded, otherwise in the parent directory of --db (i.e. <parent-of-db>/NAME.db). snapshot exits 1 if it does not exist; restore creates it.
omittedyesFalls back to services.default_service.
omittednoOperates on the single --db database (not service mode).

In short: an explicit --service works even without a services config (using --db's directory as the data directory), while omitting it only enters multi-service mode when a services config is present.

Schema-version checks

The commands in this table check the database's schema version before they act, and the two kinds of command are held to different rules:

KindCommandsOn a schema below headOn a schema above head
Destructivegc, restoreRefuses, exit 1, names engrava migrateRefuses, exit 1
Readinfo, verify, export, snapshot, recall (with --db or the default path), a query that parses as FIND/COUNT/SELECTWarns on stderr and runs anywayRefuses, exit 1

Under --config, recall refuses on a schema below head as well as above it, because a configured store applies pending core migrations as it opens and a read must not. It also applies the pending migrations of the extensions listed under manifests whatever the core schema's version, so a configured recall can run those on a database at head.

remember and link do not migrate the core schema of a database that already exists. Below head they refuse, exit 1, naming engrava migrate; above head they refuse the way the commands above do. A path that does not exist yet is created at head, and so is an empty file (no bytes, no schema).

A destructive command never deletes rows through an engine that does not understand the schema it is deleting from — that gap is how a deleted thought could come back on an unmigrated database (see Deletion on a database that has not been migrated). Apart from recall under --config, a read command is allowed to attempt anyway, because refusing an ordinary read over a pending migration would be a worse failure than the one this replaces — but it is never silent about the gap. query classifies by the parsed command, not by the fact that you typed query: a registered extension command can write, so it is refused like a destructive one whenever the schema is behind.

migrate is not in either row — running the pending migrations (or refusing to, when the database is a populated pre-history schema ensure_schema() cannot safely bootstrap, or is stamped above this build's head version) is its entire job.

A refusal is a plain message on stderr, exit 1, and nothing on stdout — also under --json and --format json. It is not an engrava.cli.error.v1 object.

$ engrava --db old.db gc
Database schema is at version 11; this engrava build's head version is 21. Run 'engrava migrate' before running 'gc' on it.
$ echo $?
1

Unreadable or corrupt databases

info, verify, query, gc, migrate, and export open the configured database before doing anything else, and so do snapshot and restore when no --config is also given. A file that exists but is not a valid SQLite database (truncated, corrupted, or a plain text file), a path that is a directory, or any other failure while opening it exits 1 with a message naming the configured path — never a stack trace and never a hang:

$ engrava --db corrupt.sqlite info
info: corrupt.sqlite: unexpected DatabaseError: file is not a database
$ echo $?
1

The named path is exactly the one the invocation was configured with — via --db, ENGRAVA_DB, or the CLI's own default — never resolved to an absolute path the caller did not supply. Rerun with --verbose to log the caught exception's stack (frame filename, line, and function only, the same deliberately-not-a-full-traceback shape the remember / recall / link unexpected_error exit code uses, below) at DEBUG for a bug report.

snapshot and restore are the two exceptions: when --config is also given to either, the CLI loads it in its own group callback, before either command's body — and this boundary — ever runs. A --config value the loader itself rejects — missing, unparseable YAML, or the wrong shape — still exits 1 with a clean Error: ... message from that same callback; a --config path that cannot even be opened (a directory, a permission error, content that is not valid UTF-8) is not caught there and can exit 1 with a raw traceback instead of a message naming the path.

The --service branch of snapshot / restore resolves a different, per-service path through service resolution (see above) instead — an unclassified failure there is not yet named this way either.

info

Shows a metrics snapshot (counts, etc.) for the current database. Takes no command-specific options.

engrava --db engrava.db info
# Database: engrava.db
# Metrics schema version: 2 (database schema version: 21)
# Thoughts: 128 ({'OBSERVATION': 100, 'REFLECTION': 28})
# ...

The two version numbers are unrelated and are named separately on purpose: metrics schema version is the shape of the EngravaMetrics object returned by await store.metrics() (bumped when a field is added to that dataclass), and database schema version is the database's own PRAGMA user_version — the one Schema-version checks above gates on. --format json info carries the same two numbers as metrics_schema_version and database_schema_version; see the Upgrade Guide if you parse that JSON and used to read schema_version.

Use this after an upgrade or a restore to confirm the database is readable and the counts look right.

verify

Verifies the audit journal's hash chain. It walks every recorded journal_entry in sequence order, recomputes each SHA-256 hash, and checks the parent-hash linkage. Takes no command-specific options. The chain is verified regardless of whether journaling is currently enabled, so a journal recorded in an earlier session is still auditable.

engrava --db engrava.db verify
# Journal integrity OK — 128 entries verified.

engrava --db engrava.db --format json verify
# {"valid": true, "entries_checked": 128, ...}

The exit code is 0 when the chain verifies, 1 when it does not (the text output names the first broken sequence, and the JSON output carries first_invalid_sequence / error_message) or when the database is missing — so it drops straight into a CI job, a pre-backup hook, or a monitoring check. An empty or absent journal verifies as valid with entries_checked: 0.

Read the Security model first: this is a keyless in-file chain, so it detects accidental corruption and naive edits, not a chain-aware actor who rewrites the whole .db.

query

Executes a MindQL query and prints the results in the chosen --format.

engrava query "MQL"

The MQL string is a positional argument. It accepts FIND, COUNT, SELECT, or registered extension commands:

engrava query "FIND thoughts WHERE lifecycle_status = 'ACTIVE'"
engrava query "COUNT thoughts WHERE priority = 'P1'"
engrava --format json query "SELECT thought_id, essence FROM thought LIMIT 5"
engrava query "FIND thoughts WHERE valid_now"          # only currently-valid facts

The bi-temporal valid_now, valid_at, valid_within, and valid_between predicates work here too — see MindQL for their full semantics.

remember, recall, and link are the one-shot memory verbs: store or search a thought, or create an edge, in a single invocation — no python -c needed. They resolve the database they act on through the same two-tier precedence, highest first, plus the CLI's own default:

  1. A non-empty --db (or ENGRAVA_DB). This wins outright: with a non-empty --db, a --config file — even a missing or malformed one — is never read by these three commands at all. --db "" does not count as given — it is resolved the same way an omitted --db is (falling through to ENGRAVA_DB, then the CLI's own default below), because this tier tests the value by truthiness, not by whether the flag appeared on the command line at all. --db . or --db ./engrava.db are values; --db "" is not.

  2. Otherwise, a --config file's own database.path — loaded the same way SqliteEngravaCore.from_config() loads it, so a configured embeddings, search, and journal section all apply. This is why these three commands exist separately from a bare connection: recall over a configured embedding provider runs the same hybrid search a direct library recall() call would, vector arm included. A non-empty --config you named is validated here unconditionally: a file that does not exist, or that fails to parse, is always an error (exit 2) rather than being silently treated as though --config had never been given. --config "" is, by the same truthiness rule as --db above, indistinguishable from omitting --config entirely — it is never validated, and this tier never fires for it.

  3. Otherwise, the CLI's own default (./engrava.db).

Exactly one tier fires per invocation; --verbose reports which one and the resolved path. None of the three commands takes a --service option today (unlike snapshot / restore), so a service selected via services.default_service is never reachable from any of the three — that config section is consulted only by snapshot / restore themselves.

Creation. remember and link create the resolved database (and its parent directory) if it does not already exist, printing the path to stderr. The core schema of a database that already exists is never migrated by them; see Schema-version checks.

$ engrava --db new.db remember "first thought"
Created database: new.db
091aa106-fcc0-45a3-a19b-d335ad05ea45

recall never creates a database — a read against a database nobody wrote to yet exits 3 naming the resolved path, rather than silently reporting zero hits:

$ engrava --db missing.db recall "anything"
Database not found: missing.db
$ echo $?
3

Exit codes, consistent across all three:

CodeMeaning
0Success.
1A database refused for its schema version (see Schema-version checks), or an unanticipated failure — anything the command's own validation does not specifically check for (a corrupt database file, a directory given as --db, an unreadable or non-UTF-8 --config, ...). Every one of these three commands runs its whole body under a single error boundary: a check the command performs itself (below) keeps its own specific code and error kind, but any other exception is converted here instead of tracebacking. For an unanticipated failure the message names the resolved database's path, right after the command name, followed by the exception's own type and text (e.g. recall: /data/store.db: unexpected DatabaseError: file is not a database) — actionable, and specific to this invocation's database rather than leaving an operator running against several stores to guess which one failed. It is never a stack trace; either half of the exception's own description falls back to a fixed placeholder if it cannot be read safely, and a genuine Ctrl-C or sys.exit() raised while that message is being built escapes immediately instead of becoming this exit code at all. The path is only named once the database has actually been resolved — a failure earlier than that, such as a non-UTF-8 --config, falls back to the path-free recall: unexpected ... form. Rerun with --verbose to log the caught exception's stack (to stderr, at DEBUG) for a real bug report — deliberately not a full traceback: it lists each frame's filename, line number, and function name, read from the exception's own traceback without calling the exception's formatter (or a cause's, a context's, or an exception group's) a second time. That trade gives up some diagnostic detail — no chained-exception text, no source lines, no local variables — for a Ctrl-C or sys.exit() landing while --verbose builds that output now escaping immediately too, rather than the earlier behaviour where it could be absorbed and the command would still exit 1 with an ordinary error object.
2A usage or validation error: an unknown edge type, an empty TEXT, a malformed --meta / --filter token, an out-of-range --top-k / --weight, or a non-empty --config that does not exist or fails to parse. The message names the offending value when available, and for an enum, every valid member.
3The resolved database does not exist (recall only — remember / link create it instead).
4link named a FROM or TO thought that does not exist. The message names it, when available.

Exit 2 is also what a small, non-exhaustive set of failures Click itself catches use — see What is never JSON below for why those are plain text regardless of --json, and for the honest (not exhaustive) rule a consumer should apply instead of expecting a closed list.

--json errors. Once a --json failure reaches the command's own code — --type / --priority / --top-k / --weight already parsed and --json already known — an ordinary exception becomes a JSON object, never a bare traceback, whatever raised it: every specific error kind below keeps its documented exit code, and anything neither this command nor its libraries were specifically checked for still becomes one, under "error": "unexpected_error", exit 1 (see the exit-code table above). This does not cover a failure Click's own argument parser rejects before that code ever runs — a bad flag, an invalid choice, a missing value — which is plain usage text at exit 2 whether or not --json was given, because --json itself has not necessarily been parsed yet; see What is never JSON below for the full, non-exhaustive list. It also does not cover a genuine Ctrl-C or sys.exit() — even one that only surfaces while this CLI is building that very failure's message, or, under --verbose, while it is building that message's stack log — which propagates immediately, exactly as it would from a command's own body, rather than becoming a JSON object at all. A schema-version refusal is not covered either: it is a plain message on stderr (see Schema-version checks).

{"schema": "engrava.cli.error.v1", "error": "invalid_edge_type", "message": "Invalid edge type 'MADE_UP'; valid values: ASSOCIATED, DEPENDS_ON, DERIVED_FROM, MESSAGE_OF, BRIDGE, CONSOLIDATED_FROM, CONTESTED_BY"}

schema is a plain version string (not a URL) — there is no schema registry to publish a URL against. error is a short machine-readable identifier (invalid_edge_type, missing_thought, database_not_found, malformed_meta, malformed_filter, empty_text, invalid_top_k, invalid_weight, invalid_config, unexpected_error); message usually contains the offending value, or — for unexpected_error — the underlying exception's type and text. It does not always: an invalid_config or missing_thought whose underlying ConfigError / ReferentialIntegrityError is a third-party subclass, or whose relevant field (the config error's message, or the referential error's column / referenced_id) is present but not the plain type this CLI requires before using it, instead gets a fixed message. That detail is omitted, not attempted and failed to read: this CLI validates a field's type — and, for missing_thought, that it names one of the two real columns and matches the id this invocation was actually given — before ever reading it for display, and shows nothing at all rather than a value it did not validate.

Reading --json errors from stderr. The JSON object above is always the last line written to stderr, and nothing this CLI controls writes anything else to stderr afterwards — the write happens once, after every cleanup a failing invocation still had open (closing a database, for instance) has already run to completion, never before it. It is not always the only line, though: remember / link echo a Created database: ... notice to stderr the first time they create the resolved database, before anything can fail, and --verbose writes its own resolution notice (and, on an unanticipated failure, the caught exception's stack — filename, line number and function name per frame, not a full Python traceback, and typically multiple lines) to stderr as the invocation proceeds — all of that precedes the JSON object on a failing run, never follows it. Neither notice is guaranteed to be a single line: both echo caller-supplied text (a path, an exception's own message) raw, and a value containing a literal line feed reproduces it, spreading that one notice over more than one line of output.

A consumer should therefore json.loads() only the last non-blank element of stderr, not the whole stream. stderr.split("\n") and str.splitlines() both recover the same final element reliably: the error object's own ensure_ascii=True encoding (see _emit_and_exit in engrava.cli.memory_commands) guarantees it never contains a literal U+0085/U+2028/U+2029 — the three Unicode line separators str.splitlines() treats as breaks but a strict "\n" split does not — so nothing inside the object itself can make the two recipes disagree about where it ends; the test suite proves this directly by embedding a U+2028 in a --db path and checking that both recipes decode the identical object. What the two recipes do not agree on is the earlier lines: one of those separators inside an echoed path or a stack line makes str.splitlines() split that line into extra fragments, where a strict "\n" split keeps it intact as one. That only matters to a consumer who also parses the earlier lines — recovering an echoed Created database: ... path exactly, say — and for that, split strictly on "\n". For recovering only the final error object, either recipe is safe.

What is never JSON, --json or not. A failure Click itself rejects before this command's own code — and therefore before --json has even been parsed — always prints Click's own plain usage text to stderr and exits 2. This is not a closed list: it is everything Click's own argument parser rejects on its way to calling this command's body, and that surface belongs to Click, not to this CLI. Known examples include an invalid --type / --priority choice on remember, a --top-k or --weight that does not parse as a number, a missing required argument (TEXT, FROM/TO, link's --type), an unknown option at the root or subcommand level, an unexpected extra argument, an invalid root --format, and a missing value for any option that takes one (--db, --config, --meta, --filter, --top-k, --weight, --type, --priority). The rule for a consumer: if --json's own JSON object was never confirmed to have been reached — i.e. you cannot rule out a parse-phase rejection or a schema-version refusal — do not assume stderr decodes; attempt json.loads() and fall back to treating the raw text as plain text on failure, rather than relying on an enumerated exception list (this one or any other) to be complete.

One parse-phase footgun worth naming explicitly: an option that takes a value (--meta, --filter, --top-k, --weight, --type, --priority) consumes the very next token as that value, even if it looks like another flag, so a missing value does not reliably produce an error at all — Click's ordinary behaviour, not a bug, for these six. Use --option=value (= syntax) when a value might otherwise be ambiguous with a following flag, rather than relying on Click to catch the omission.

--db and --config are not on that list. A --db or --config value starting with - is a rejected usage error (exit 2, Click's own plain usage text, --json or not — see What is never JSON above), so engrava --db --json remember "x" fails rather than taking --json as a database path. A caller who genuinely needs such a path can disambiguate it the usual shell way, by prefixing it (--db ./--json).

remember

Stores TEXT as a thought and prints its id. Built over create_thought() with an explicitly constructed ThoughtRecord — not the library's remember() shorthand, which takes only text, metadata, and a dedup flag and always produces a NOTE / P3 thought, so it cannot honour --type / --priority.

OptionTypeDefaultDescription
TEXTpositionalrequiredContent to store. - reads it from stdin instead.
--typethought typeNOTEOne of TASK, OBSERVATION, BELIEF, REFLECTION, OUTPUT_DRAFT, NOTE.
--prioritypriorityP3One of P1, P2, P3, P4 (P1 highest).
--metaKEY=VALUE—Metadata entry (repeatable). Values are stored as strings.
--dedupflagoffOn identical existing content, bump confirmation_count and print the existing id instead of inserting a duplicate.
--jsonflagoffEmit a JSON object (schema engrava.cli.remember.v1) instead of a bare id.
engrava --db my.db remember "User prefers concise answers"
engrava --db my.db remember "Escalate the outage" --type REFLECTION --priority P1
echo "piped content" | engrava --db my.db remember -
engrava --db my.db remember "tagged" --meta topic=weather --meta lang=en
engrava --db my.db remember "same content twice" --dedup   # run again: same id, no new row

--json output:

{"schema": "engrava.cli.remember.v1", "thought_id": "091aa106-fcc0-45a3-a19b-d335ad05ea45", "deduplicated": false}

deduplicated is true only when --dedup was given and it matched an existing thought — the printed thought_id is that existing thought's, not a new row's.

recall

Searches for thoughts relevant to QUERY and prints ranked results. Calls the library recall() directly, so it behaves exactly like a direct library call against the same database or config — unlike a bare connection, which has no embedding provider and so no vector arm at all. This is not a substitute for query: query runs structural MindQL (FIND / COUNT / SELECT) with no ranking and no embedding provider of its own.

OptionTypeDefaultDescription
QUERYpositionalrequiredNatural-language text to search for.
--top-kint10Maximum results to return.
--filterKEY=VALUE—KEY is a metadata filter path (a dotted key such as a.b addresses a nested field); equality only; repeatable — different keys AND-combine, a repeated key keeps its last value.
--jsonflagoffEmit a JSON object (schema engrava.cli.recall.v1) instead of a formatted table.
engrava --db my.db recall "concise answers"
engrava --db my.db recall "tagged" --filter topic=weather --top-k 5
engrava --config engrava.yaml recall "concise answers" --json

Three checks run in this order. A KEY missing an = is malformed_filter, exit 2, checked before the database is even resolved. Next, a database that does not exist exits 3 (see above), whatever the filter's path validity. Only once the database is confirmed to exist does a KEY that is not a valid metadata filter path — FieldPredicate's own path grammar, not a rule this command adds — become malformed_filter, exit 2 too: the offending KEY=VALUE token is named in the message.

--json output:

{"schema": "engrava.cli.recall.v1", "query": "concise answers", "top_k": 10, "backends_used": ["fts5", "priority", "vector"], "results": [{"thought_id": "091aa106-fcc0-45a3-a19b-d335ad05ea45", "score": 0.4714285714285715, "essence": "User prefers concise answers"}]}

backends_used names every search backend that was available for this query — "vector" appears only when a configured embedding provider actually reached the query (see Store resolution above); its absence with a --config you expect to configure one is the degradation this command's whole design exists to avoid, not something to silently tolerate.

Creates a typed edge from FROM to TO and prints its id. Builds an EdgeRecord and calls the public create_edge() — there is no public link() to call instead.

OptionTypeDefaultDescription
FROMpositionalrequiredSource thought id.
TOpositionalrequiredTarget thought id.
--typeedge typerequiredOne of ASSOCIATED, DEPENDS_ON, DERIVED_FROM, MESSAGE_OF, BRIDGE, CONSOLIDATED_FROM, CONTESTED_BY.
--weightfloat1.0Relation strength, 0.0-1.0.
--jsonflagoffEmit a JSON object (schema engrava.cli.link.v1) instead of a bare id.
engrava --db my.db link 091aa106-fcc0-45a3-a19b-d335ad05ea45 f4620859-3dfa-4f13-8d2e-d35df62dbba4 --type ASSOCIATED --weight 0.8

An unknown --type exits 2 naming the value and every valid member; a FROM or TO that does not resolve to an existing thought exits 4, naming which one when available:

$ engrava --db my.db link ghost-id some-real-id --type ASSOCIATED
link: from_thought_id 'ghost-id' does not reference an existing thought.
$ echo $?
4

--json output:

{"schema": "engrava.cli.link.v1", "edge_id": "b190dc41-9c87-4a66-9291-d70974fd2342", "from_thought_id": "091aa106-fcc0-45a3-a19b-d335ad05ea45", "to_thought_id": "f4620859-3dfa-4f13-8d2e-d35df62dbba4", "edge_type": "ASSOCIATED", "weight": 0.8}
snapshot

Exports thoughts, edges, embeddings, and actions to a JSONL snapshot (one record per line) — not the audit journal; see below.

OptionTypeDefaultDescription
-o, --outputpathderived (see below)Output JSONL file path.
--servicenamesee belowThe service to snapshot (multi-service mode only).

Default output path depends on the mode:

  • Single database: <db-stem>.snapshot.jsonl next to the database — e.g. --db engrava.db → engrava.snapshot.jsonl (the .db suffix is replaced).

  • Multi-service: <data_dir>/<service>.snapshot.jsonl.

--service resolves in three ways (see Service resolution):

  • Explicit --service NAME targets that service even with no services config — the service database is looked up in the data directory, which is the services config's data_dir if one is loaded, otherwise the parent directory of --db. snapshot only reads: if that service database does not already exist it prints Service 'NAME' not found and exits 1 without creating anything (restore is the command that creates a missing service database).

  • Omitted, with a services config loaded → falls back to services.default_service.

  • Omitted, with no services config → snapshots the single --db database.

engrava --db engrava.db snapshot -o backup.jsonl
engrava --db engrava.db snapshot               # -> engrava.snapshot.jsonl
engrava --db /data/engrava.db snapshot --service tenant_a   # -> /data/tenant_a.snapshot.jsonl
engrava --config engrava.yaml snapshot --service tenant_a   # data_dir from config

A snapshot exports every column of the thought, edge, embedding, and action records — including the bi-temporal valid_from / valid_until fields — but not the audit journal (journal_entry). See Backup & Recovery for what this means and when to use a physical file backup instead.

Failure safety. snapshot writes to a temporary file next to -o and publishes it there only once every row has been read and the export's own read transaction has closed. On an exception or a cancelled run, an existing file at -o stays byte-identical rather than being replaced by a truncated one, and the temporary file is removed. A hard kill (SIGKILL) cannot run that cleanup, so -o then holds either the previous file or the complete new one, never a partial one, and a temporary file can be left behind and is safe to delete by hand. -o may not name the database currently open for --db, or that database's -wal / -shm companion files; pointing it there is refused before anything is written. An -o that is a symlink is followed to its real target, which is written and replaced, so the symlink itself is left pointing at the (now-updated) file; an -o that is a hard link instead becomes a new, separate file, so any other hard link to the old one keeps its old content.

restore

Restores a database from a JSONL snapshot produced by snapshot.

OptionTypeDefaultDescription
-i, --inputpathrequiredJSONL snapshot file to restore.
--clearflagoffEmpty the target's four core tables and its journal before restoring (not _metadata, extension_schema_versions, or extension-owned tables).
--clear-identityflagoffAlso clear the target's stored embedding identity (model name, dimension, prefix metadata). Requires --clear. Recovers a target whose stored embedding_dimension is corrupt -- --clear alone preserves an existing identity, corrupt or not.
--skip-embeddingsflagoffImport without embedding records.
--re-embedflagoffRe-embed all thoughts via the target provider, ignoring source embeddings. Requires --config with top-level or per-service embeddings.
--orphan-journal-entriesflagoffAllow a merge restore (no --clear) into a target whose journal_entry table is non-empty to replace a colliding row anyway. Without it, such a collision is refused.
--servicenamesee belowThe service to restore into.

For any restore --clear, an existing sqlite-vec table is dropped in the same transaction as the canonical rows. The next sqlite-vec-enabled open recreates and backfills it. Because SQLite must load the virtual-table module before it can remove that table safely, install engrava[vec] before clearing a database that already contains a persisted sqlite-vec index.

--service resolves exactly as for snapshot: an explicit --service NAME targets that service even without a services config (its database resolves in the services data_dir, or the parent directory of --db when no config is loaded); omitted with a services config falls back to services.default_service; omitted with no services config restores into the single --db database.

Thought and edge timestamps are written in the canonical UTC form engrava stores (2026-07-01T00:00:00+00:00; a value without an offset is read as UTC), so a snapshot taken by an earlier version, which may hold other ISO-8601 forms, restores with its timestamps compared by instant. A value that cannot be read as an ISO-8601 instant is restored as it was.

--skip-embeddings and --re-embed are mutually exclusive — passing both fails with:

Error: --re-embed and --skip-embeddings are mutually exclusive.

Use --re-embed when the target should use a different embedding model than the snapshot. In single-database mode, the provider comes from the top-level embeddings section of the file passed with --config. In service mode, services.configs.<name>.embeddings takes precedence; when no override exists, the same top-level embeddings section is the fallback. Restore discards source embedding rows and generates a replacement vector with the resolved provider for each restored thought. A target that already contains embeddings requires --clear; without it, restore refuses to relabel vectors that are not part of the snapshot. The sqlite-vec reset described above prevents stale index rows from surviving the re-embed.

If neither level declares a provider, restore fails before importing records and names the missing configuration. An explicit --service without a config-backed provider fails for the same reason. Use --skip-embeddings to import text without vectors, or restore normally to retain the snapshot vectors.

engrava --db fresh.db restore -i backup.jsonl
engrava --db fresh.db restore -i backup.jsonl --clear --skip-embeddings
engrava --db fresh.db --config engrava.yaml restore -i backup.jsonl --clear --re-embed
engrava --config engrava.yaml restore -i backup.jsonl --service main --clear --re-embed

When services.default_service names the configured target, --service main may be omitted. A restore without --clear and without --re-embed first checks that the target's own pre-existing embedding rows agree with its stored embedding model, or with each other when it has none (a dreaming-centroid row is compared on dimension only, never on model_name): --skip-embeddings does not import the snapshot's vectors as-is, so it cannot excuse a target that is already inconsistent going in. --re-embed refuses a target that already holds embedding rows unless --clear is given. A normal restore (neither --re-embed nor --skip-embeddings) additionally checks the model_name and dimension every embedding row in the snapshot declares against that same reference before inserting it, with the same centroid exemption — never against a configured provider, which a plain restore never resolves, and never against the snapshot's metadata header, which is not proof of anything the rows do not already say for themselves. On a mismatch, restore fails before committing anything, naming both identities; choose --re-embed (with --clear, or into a target that holds no embeddings) to regenerate vectors with the configured provider, or --skip-embeddings to import without vectors — an already-inconsistent target itself is not fixed by either flag and needs its own repair (or --clear) first. A normal restore of a snapshot that has a non-centroid embedding row, into a target that holds neither a stored model nor any non-centroid embeddings when the inserts begin (including one just emptied with --clear --clear-identity), writes the identity that first row declares as the target's lock — the same _metadata write embedding directly into it would make, though restore trusts the snapshot's declaration rather than computing and measuring a vector itself.

This check cannot see the document or query prefix the corpus was built with (see Embeddings guide → Asymmetric prefixes for instruction-tuned models) — a snapshot carries neither, only the vectors and a declared model name and dimension for each. A target that already had a prefix fingerprint locked before this restore keeps it unless the restore is run with --clear --clear-identity or --re-embed, but a target that locks fresh from the snapshot's own vectors (see above) records no prefix at all, regardless of what the source corpus actually used. If the source corpus was built with a non-empty document prefix, confirm that out of band before pointing a prefix-aware provider at a restored target — restore itself cannot tell you.

Restore recreates thoughts, edges, embeddings, and actions, not the audit journal. A fresh target therefore starts with an empty journal, and --clear empties the journal along with the data it wipes. A restore without --clear merges into the target — and if that target's journal is non-empty, it refuses any record that collides with an existing row (primary key or UNIQUE constraint) and rolls the whole restore back, rather than letting the merge silently replace it. Pass --orphan-journal-entries to allow the merge anyway; that restores the pre-gate behaviour, where a merge can orphan journal entries even when no incoming ID collides with one the journal describes — a duplicate (from_thought_id, to_thought_id, edge_type) triple replaces an existing edge, and replacing a thought cascades to that thought's own edges, embeddings, and actions — and verify still reports the chain as valid even though it no longer matches the data. The gate never applies to an empty journal, which is the ordinary case since journaling is opt-in. See Backup & Recovery for the full breakdown.

gc

Garbage-collects ARCHIVED thoughts together with every edge touching one on either end — including edges whose other end is still live — their embeddings and the actions sourced from them, then reconciles the vector index by removing every vec0 row no embedding row owns. With --expired it also runs the TTL expiry cleanup first.

gc keeps pinned and protected-priority rows by default. An ARCHIVED thought that is pinned, or at a priority in the hygiene policy's protected-priority set, survives gc and is reported as kept rather than deleted — see Forgetting → Protection for the default and how --config's hygiene_policy: section overrides it. Pass --include-pinned to delete them too — the unconditional behaviour gc used to always have. gc --expired's TTL pass has its own, narrower rule: it skips a pinned row regardless of --include-pinned (a TTL is the row's own explicit lifetime, not hygiene protection), but a protected-priority row with an expired TTL still expires — see Data lifecycle → Time-to-live. When --config names an engrava.yaml with journal.enabled: true, both passes append their journal entries (DELETE_THOUGHT for what gc itself deletes; the TTL pass's own entries the way cleanup_expired() already records them) in the same transaction as the write; without --config, or with journaling disabled, nothing is journaled, as before.

gc now refuses on a database that is not on the current schema. If --db (or the resolved service database) is below or above this build's head schema version, gc deletes nothing and exits 1 naming engrava migrate — see Schema-version checks above. If a gc that used to run cleanly now exits non-zero, that is this refusal, not a regression in what it collects: run engrava migrate first.

OptionTypeDefaultDescription
--dry-runflagoffShow what would be deleted without changing anything.
--expiredflagoffAlso run expiry cleanup (archive or delete per ttl.strategy) before collecting.
--include-pinnedflagoffAlso delete pinned and protected-priority archived thoughts (otherwise they are kept).
engrava --db engrava.db gc                 # delete ARCHIVED thoughts + their edges/embeddings/actions
engrava --db engrava.db gc --include-pinned  # also delete pinned / protected-priority rows
engrava --db engrava.db gc --expired       # run expiry cleanup first (per strategy)
engrava --db engrava.db gc --expired --dry-run

The behaviour of gc --expired depends on ttl.strategy: with delete it removes expired rows and then collects pre-existing archived rows; with the default archive it archives the expired rows and stops only if it archived at least one — collecting the rows it just archived would defeat the soft-retire. With no expired rows to archive it falls through to collecting pre-existing ARCHIVED rows. See Data lifecycle → running cleanup.

gc refuses to delete on a vec0-indexed store without the vector extra. If the database carries an embedding_vec table and sqlite-vec cannot be loaded — most commonly because engrava[vec] is not installed, though an unsupported build, an OS error or a SQLite error fail the same way — a pass that is about to physically delete stops before deleting anything and exits 1 with:

Error: This database has a sqlite-vec index, and collecting thoughts without removing their vectors would strand them in it. Install 'engrava[vec]' and retry.

Removing the rows without removing their vectors would strand those vectors in an index nothing can then reach them through. Only deleting passes are refused: --dry-run is never refused, and neither is a run with nothing to delete. Read the archive strategy carefully, though — gc --expired under the default ttl.strategy: archive stops after archiving only when it actually archived something. With no expired rows to archive it falls through to the archived-collection pass, which is refused when there are archived rows to collect. Install the extra (pip install 'engrava[vec]') and retry.

migrate

Runs pending schema migrations (ensures the core tables exist and are up to date). Takes no command-specific options. Safe to run after an upgrade.

engrava --db engrava.db migrate
export

Exports thoughts to a portable JSON file (with edges and metadata). Unlike snapshot (JSONL, whole-database, for backup/restore), export writes a single indented JSON document and can be filtered by lifecycle status.

OptionTypeDefaultDescription
-o, --outputpath<db-stem>.export.json (derived)Output JSON file path. Written next to the database with the .db suffix replaced, e.g. --db engrava.db → engrava.export.json.
--statuslifecycle statusallOnly export thoughts with this lifecycle_status (e.g. ACTIVE).
engrava --db engrava.db export -o thoughts.json
engrava --db engrava.db export --status ACTIVE

Failure safety. export reads the thought and edge tables from one consistent point in time -- without --status, that means a commit landing between the two scans can never leave an edge in the export whose thought is missing; with --status, an exported edge can still reference a thought the filter itself excluded, which is the filter's doing, not a gap in the transaction. Like snapshot, it writes to a temporary file next to -o and publishes it there only once every read has completed. On an exception or a cancelled run, an existing file at -o stays byte-identical and the temporary file is removed. A hard kill (SIGKILL) cannot run that cleanup, so -o then holds either the previous file or the complete new one, never a partial one, and a temporary file can be left behind and is safe to delete by hand. -o may not name the database currently open for --db, or that database's -wal / -shm companion files. A symlinked -o is followed and its real target replaced, leaving the link itself pointing at the new content; a hard-linked -o becomes a new, separate file instead.

Journal verification

Use engrava verify to verify the audit journal's hash chain from the shell (exit 0 = intact, 1 = broken or missing database). The equivalent Python API is store.verify_journal() (the store-level convenience) or store.journal.verify_integrity() (via the writer directly):

result = await store.verify_journal()
print(result.valid)

See also

402 files · 357 not served here

Advantages

Limitations

Security

Risk level: Low risk

This brain runs

Git hooks (1)

  • .githooks/commit-msg (commit-msg): runs on git commit-msg

Agent instructions that run or fetch something (4)

  • AGENTS.md: Run `git commit` and it fires on the patterns it checks for.
  • CLAUDE.md: pip install -e ".[dev]"
  • CLAUDE.md: Run a clean test suite locally before pushing;
  • CLAUDE.md: Branch from `dev`, follow Conventional Commits, run the local quality gate before pushing.

Install scripts (1)

  • Makefile (make install): echo "$(BLUE)>> Installing engrava + dev dependencies$(RESET)"; $(PIP) install --upgrade pip setuptools wheel; $(PIP) install -e ".[dev]"; bash scripts/install_hooks.sh; echo "$(GREEN)>> Done. Run 'make check' to verify quality gate.$(RESET)"
  • Low risk: scripts-present (.githooks/commit-msg)
  • Low risk: network-instructions (CLAUDE.md:40)
  • Low risk: network-instructions (pyproject.toml:98)
  • Low risk: scripts-present (scripts/install_hooks.sh)

Attribution and licence

Created by
Sovantica
Declared authors
  • Sovantica (declared in pyproject.toml)
Publisher
Sovantica (Official Sovantica project)
Source
sovantica/engrava · commit f6a8b73
Licence
MIT License (files served here)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Sovantica.

Install from source

357 of its files are not served here, so there is no ZIP. sovantica/engrava at f6a8b73