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/data-lifecycle.md

Data lifecycle, retention & deletion

How a thought moves through its lifecycle, how time-to-live expiry works, and — importantly for privacy and compliance — what it takes to truly erase data, including the residue a naive delete leaves behind.

Compliance note. This page describes the mechanics honestly so you can build a correct retention/erasure process. The default expiry strategy archives (does not erase), and a hard delete can still leave content in the audit journal and in backups. Read the GDPR / hard deletion section before relying on TTL for "deletion".

Lifecycle states

Every thought carries a LifecycleStatus. There are four states:

StateMeaning
CREATEDJust created, not yet promoted into active use.
ACTIVEIn normal use — the default working state, included in queries.
DONECompleted (e.g. a finished task) but retained.
ARCHIVEDSoft-retired and retained until garbage-collected. Excluded from default ranked retrieval (reversible via restore_thought / include_archived) — see the note below.

You set the status on the ThoughtRecord you create, and update it over the thought's life. Archiving is the soft-retire step: an ARCHIVED thought still exists (and its content is still stored) until you garbage-collect it.

The ordinary lifecycle state machine is:

CREATED -> ACTIVE -> DONE -> ARCHIVED
              \--------------> ARCHIVED
ARCHIVED --restore_thought()--> ACTIVE

The direct ACTIVE -> ARCHIVED transition is valid; a thought does not have to pass through DONE before being manually archived. restore_thought() is the canonical reverse path: it returns an archived thought to ACTIVE and clears the hygiene archive stamps. Two maintenance mechanisms have dedicated archival paths outside the ordinary evolve() sequence:

  • TTL cleanup with the archive strategy archives any expired row that is not pinned, clears its expires_at, and clears the hygiene-specific archive stamps. A pinned row is never expired (see below).

  • Memory Hygiene may archive an eligible ACTIVE or CREATED row, clears its expires_at, and stamps archived_at_cycle plus archived_at for its restore windows.

Both remain reversible through restore_thought() until a hard-delete or garbage-collection stage removes the row.

ARCHIVED is excluded from default retrieval, but still stored and counted. Marking a thought ARCHIVED is a retention state — the row and its content stay in the database — but an archived thought is dropped from default ranked retrieval: search_hybrid / recall / search_fts / search_similar no longer return it unless you pass include_archived=True (and restore_thought re-activates it). This retrieval exclusion is the retrieval side of Forgetting — an opt-in, off-by-default hygiene loop — but the exclusion itself applies to any archived row (including one archived by the TTL archive strategy or manually), whether or not that loop is enabled. It is still counted by count_thoughts() / list_thoughts() (those are not ranked retrieval) — filter on lifecycle_status yourself to exclude it there — provided it is not also expired: both methods default to excluding expired rows regardless of lifecycle status, so an archived row whose expires_at has passed still needs include_expired=True to be counted or listed. Expired thoughts are likewise excluded from the four ranked retrieval methods listed above; those methods do not offer an include_expired escape hatch. include_expired=True applies only to list_thoughts() and count_thoughts(). A retired REFLECTION (one whose lifecycle_status is no longer ACTIVE) stays out of search by a type-specific freshness floor even under include_archived=True.

Time-to-live (TTL) and expiry

A thought can carry an expiry time. Three ways to set it:

  • Per-thought, absolute: set ThoughtRecord.expires_at to a timestamp.

  • Per-thought, relative at create time: pass expires_after_seconds= to create_thought(...), which computes expires_at for you.

  • A default for the whole store: ttl.default_ttl_seconds in config applies a default TTL to new thoughts that don't set their own (see Configuration → ttl).

When more than one is available at creation, precedence is explicit and deterministic: the expires_after_seconds argument wins, then the record's own expires_at, then the configured store default. The default is used only when neither per-call nor per-record expiry was supplied.

expires_at is stored in one canonical UTC form (2026-07-01T00:00:00+00:00), whichever ISO-8601 form you pass: an offset-aware value is converted to UTC, and a value with no offset is read as UTC. Expiry compares that stored value with the current UTC time, so 2026-07-01 00:00:00, 20260701T000000 and 2026-07-01T02:00:00+02:00 all expire at the same instant. Upgrading a database written by an earlier version converts its stored values once; the upgrade notes say which values it leaves as they are.

Expiry is not automatic on a timer. Expired thoughts remain until a cleanup pass runs (see running cleanup below). By default, expired thoughts are excluded from count_thoughts(...) and list_thoughts(...) — pass include_expired=True to include them:

live = await store.count_thoughts()  # excludes expired
everything = await store.count_thoughts(include_expired=True)

Ranked retrieval (search_hybrid, recall, search_fts, and search_similar) always excludes rows whose expires_at has passed, even before cleanup has archived or deleted them. include_expired belongs only to the list/count APIs; it is not a ranked-search parameter.

A pinned thought is never expired. A cleanup pass skips every row with pinned = True, whether reached directly (cleanup_expired()), through the ttl.check_every_n_operations cadence, or through the CLI's gc --expired — neither archived nor deleted, and reported separately (pinned_kept_count, below) rather than silently dropped from the count. pinned is the row's own durable keep-intent marker, so a store-wide ttl.default_ttl_seconds must not override it. A protected priority (P1 by default) is not exempt here: that protection is a Memory Hygiene concept, and a TTL is the row's own explicit lifetime — only pinned is a TTL exemption.

Archive vs. delete

What a cleanup pass does to an expired thought is governed by the store's TTL strategy, set via ttl.strategy in config (see Configuration → ttl):

StrategyEffect on an expired thoughtReversible?Content erased?
"archive" (default)Flips lifecycle_status to ARCHIVED; the row and its content stay in the databaseYesNo
"delete"Removes the thought row from the thought tableNoFrom the live table, yes — but see residue

The default is archive — chosen so expiry is non-destructive and auditable. This means expiry alone does not erase anything under the default configuration. To make expiry actually remove rows, set ttl.strategy: delete.

Running cleanup

Expiry is applied by an explicit cleanup pass — nothing happens on a timer.

From Python: cleanup_expired() returns a CleanupResult:

result = await store.cleanup_expired()
print(result.expired_count)  # how many thoughts were expired
print(result.strategy_applied)  # "archive" or "delete" (per config)
print(result.timestamp)  # ISO-8601 time of the pass
print(result.pinned_kept_count)  # past-TTL rows kept because they are pinned

You can also have the store run cleanup automatically via ttl.check_every_n_operations (default 0 = manual only): the counter advances on thought create/update calls, not on every store operation, and triggers a cleanup pass once it reaches N.

From the CLI: engrava gc --expired runs the expiry cleanup per your TTL strategy. What it does next depends on that strategy:

engrava gc --expired            # run expiry cleanup (per ttl.strategy)
engrava gc --expired --dry-run  # preview only, changes nothing
engrava gc                      # delete ARCHIVED thoughts + their edges/embeddings/actions

--dry-run does not mirror the real run's stop condition under ttl.strategy: archive. A real gc --expired run stops after archiving when it archived at least one row (below), but --dry-run always also previews the archived-collection step when pre-existing ARCHIVED rows exist — even on a preview where expired rows would have been archived and the corresponding real run would have stopped there.

  • With ttl.strategy: delete: the expired rows are deleted outright, and the same pass then garbage-collects any pre-existing ARCHIVED thoughts.

  • With ttl.strategy: archive (default): the expired rows are archived (marked ARCHIVED), and the pass stops there — but only if it archived at least one row. (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, exactly as a plain engrava gc would. So a repeated gc --expired under the archive strategy does eventually collect: it stops on the run that archives something and collects on the run that does not.

Plain engrava gc (no --expired) removes 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. This is how archived data is finally deleted from the live table.

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 a plain gc — it prints how many it kept. Pass --include-pinned to delete them too, the unconditional behaviour gc used to always have. See Forgetting → Protection for the default protected-priority set and how --config's hygiene_policy: section overrides it. When --config names an engrava.yaml with journal.enabled: true, gc also appends a DELETE_THOUGHT journal entry per row it deletes, in the same transaction as the delete.

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.

GDPR and hard deletion

If you must erase a user's data (e.g. a GDPR erasure request), be aware that neither archiving nor a single delete is sufficient on its own. The procedure below addresses the following:

  1. Archive does not erase. Under the default ttl.strategy: archive, an "expired" thought is only marked ARCHIVED — the row and its content remain in the database. Note that engrava gc --expired under the archive strategy archives the rows and stops only if it archived at least one — on that run it does not also delete archived rows. With no expired rows to archive the same command falls through and does collect pre-existing ARCHIVED rows, so do not read "it only archives" as a guarantee that nothing was deleted. To remove the row deliberately, run a separate engrava gc, or use ttl.strategy: delete so the row is deleted outright. If the row is pinned, or at a protected priority (P1 by default), engrava gc keeps it instead of deleting it — pass --include-pinned when an erasure target may be one of those.

  2. The audit journal retains a content delta. If the audit journal is enabled, the thought's content can live in that journal independently of how it is later deleted: the original INSERT_THOUGHT entry (and any UPDATE_THOUGHT entry) holds the content in its delta from whenever it was written through a journal-enabled store method, and a journal-enabled delete_thought() additionally records a DELETE_THOUGHT entry carrying the same content — so the data survives in journal_entry after the thought row is gone, in the insert/update entry if nothing else. The CLI's engrava gc (and gc --expired) construct their own connection, so they journal a deletion made that way only when --config names an engrava.yaml with journal.enabled: true; without --config, or with journaling disabled, no DELETE_THOUGHT entry is recorded for it even when a store opened elsewhere has journal.enabled set — see Audit Trail — but that does not remove the earlier insert or update entry either way. A true erasure must purge every journal entry that carries this content: the original insert (and any update), and the delete entry if one exists. Purging a non-tail entry breaks the retained chain's linkage and requires an explicit re-baseline if journal verification is retained; purging a self-consistent suffix, including every entry, does not by itself make local verification fail — see Security → Audit journal threat model.

  3. Backups. Any snapshot or file backup taken before the deletion still contains the data. Erasure must extend to your backup retention.

A hard-erasure procedure therefore covers: delete (or archive-then-gc) the thought rows → purge every journal_entry row that carries this thought's content, if journaling was ever enabled while it existed → roll the deletion through your backup retention. Don't treat "the thought no longer appears in search" as "the data is gone." This list is not exhaustive: it does not address SQLite WAL or freelist page residue (a page holding the old content can persist in -wal or the freelist after the row is gone, depending on your secure_delete setting), nor any export or snapshot file made after the delete but before you rotate it out, nor deleted-but-still-cached state on a remote embedding provider that received the original text — evaluate each against your own threat model.

A database below the core-12 schema can carry a deleted thought's embedding row. That schema has no ON DELETE CASCADE on embedding, so a delete made by an older engrava build could leave the row, vector included, behind. Reconciliation (the pass that runs when a sqlite-vec backend opens) backfills a vector only for an embedding row whose owner id matches a row in thought, and delete_thought issues its own delete for the thought's embedding row. The core-12 step of engrava migrate purges the dangling embedding rows that earlier deletes left behind. Full mechanism: Known Limitations → Deletion on a database that has not been migrated.

Memory-hygiene GC is a hard delete, not erasure. The opt-in Forgetting garbage-collection stage removes archived rows exactly like the deletes above: it reclaims the live/queryable working set, but when journaling is on the content survives in the DELETE_THOUGHT journal entry. Treat it as cognitive cleanup, not guaranteed erasure.

Reclaiming disk space

Deleting rows — whether via ttl.strategy: delete, engrava gc, or a hard erasure — does not shrink the database file under SQLite's default auto_vacuum=NONE, which engrava does not override. SQLite returns the freed pages to an internal free-list and reuses them for future writes; the file stays the same size on disk. A database externally configured with auto_vacuum=FULL can instead truncate freed tail pages automatically at commit — engrava neither sets nor requires a particular auto_vacuum mode.

To actually reclaim file size on the default configuration you must run VACUUM, which rebuilds the database into a compact file. Plan for its cost:

  • Exclusive lock, with a WAL exception for existing readers. VACUUM requires write access and blocks competing writers for its whole duration. Because engrava uses WAL mode by default, a reader that already has an established snapshot can continue running concurrently with it; a new connection still needs to wait its turn. Run it during a maintenance window.

  • Temporary space. It writes a fresh copy before swapping, so it needs additional temporary disk space that depends on the database's contents and SQLite's own temporary-storage behavior — measure it for your database rather than assuming a fixed multiple.

  • Off-peak. On a large database this can take a while; schedule it off-peak.

VACUUM;                 -- rebuild in place (exclusive lock against writers)
VACUUM INTO 'copy.db';  -- write a compacted copy without locking in place as long

Until you VACUUM, expect the file size to reflect the high-water mark, not the live row count — this is normal SQLite behaviour, not a leak.

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