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/known-limitations.md

Known Limitations

This document covers platform-specific notes, constraints, and known issues. For the consolidated threat and trust-boundary model, see Security.

Search is unscoped by default (multi-tenant caveat)

search_hybrid() / recall() / search_similar() span the entire store by default — there is no implicit per-user or per-session boundary. If you keep multiple tenants' thoughts in one database file, an unfiltered search can return another tenant's memories.

Two supported ways to isolate:

  • A file per tenant (the strongest boundary) — one store per tenant, managed via EngravaManager. Isolation is then the file boundary itself.

  • Scoped retrieval within one file — pass filters= / visibility= to search_hybrid() or recall() to constrain results to a metadata scope (e.g. an owner or session key). The public search_similar() and search_fts() methods do not accept those metadata filters. See Search.

Choose file-per-tenant when tenants must never share a file; use scoped filters for soft, in-file partitioning.

Dreaming / consolidation: mechanism, not a proven retrieval lift

The built-in DreamingExtension's default signals make no LLM calls, and reflection content is built by a deterministic structural function; a custom signal registered via custom_signals runs whatever code it contains, including a call to an LLM. Consolidation is: scoring, and, conditional on gates, thresholds, and caps, promotion to priority boost, association edges, and reflections. With a fixed store, configuration, cycle, embedding inputs, and deterministic custom signals, its result is reproducible. Those are real mechanical ranking effects, but Engrava makes **no claim that enabling dreaming improves retrieval accuracy. The v0.5/v0.6-candidate frozen synthetic snapshot measured aggregate recall@5 at 0.80 with Dreaming off and 0.70 with it on, even though the separate curated release gates passed. Enable it for the cognitive-hygiene mechanics it provides, not for an expected accuracy gain. See Dreaming and the current benchmark evidence.

Embedding-provider conformance is not checked at construction

EmbeddingProviderProtocol requires public dimension and model_name members, but the protocol is structural: a store accepts any object passed as embedding_provider= and only discovers a missing member when it reads one. A provider that keeps the dimension privately (self._dimension) with no public property therefore constructs cleanly and fails later — on the first vector search that has to ask the provider — with EmbeddingProviderContractError naming the class and the member. A store whose vector backend is sqlite-vec takes the dimension from its dimension-typed vec0 table instead, so that search path does not ask the provider for it; verify_embedding_model() asks regardless.

This is deliberate. Construction does not read the member, and a store that never searches by vector — and never calls verify_embedding_model() — never needs it, so failing at construction would reject stores that work today. Call verify_embedding_model() after construction when you want the failure at startup instead.

Note that the requirement is not new: dimension has been a required member of the protocol since the first public release. See Upgrade.

Validity intervals cannot be inverted

The bi-temporal valid_from / valid_until bounds on a ThoughtRecord or EdgeRecord describe a forward interval. When both bounds are set, engrava rejects an inverted interval (valid_from strictly after valid_until):

  • On write — construction fails with a ValidationError; the store's update path re-validates the whole record, so a change that would invert a stored interval is refused; and invalidate_thought() / invalidate_edge() reject a valid_until earlier than the record's stored valid_from.

  • On read — the invariant lives in the domain model, and the store reconstructs that model whenever it loads a full record (get_thought(), list_thoughts(), get_edges() and list_edges()). A row that became inverted out of band — for example one written by an older engrava build before this validation existed, or edited directly in the database file — therefore raises a ValidationError when it is loaded through one of those calls, rather than silently returning a corrupt interval. This is a deliberate fail-loud choice for a data-integrity fault. Ranked search is the exception: search_fts(), search_similar(), search_hybrid() and recall() return ids and scores without rebuilding the model, so an inverted row can still appear in their results, and the ValidationError surfaces only when you load that record (for example with get_thought()). If you are upgrading a database that may contain such rows, repair them (set the offending bound to NULL, or correct the order) before reading.

Bounds are compared as UTC-normalised instants, so differing offsets are reconciled before the check. Two cases are not inversions and remain accepted:

  • Equal bounds (valid_from == valid_until) — a zero-length interval is a legitimate instantaneous fact.

  • An open bound (valid_from or valid_until is None) — the interval is open on that side (see The bi-temporal model).

There is no way to store a fact "valid from June to March"; express an open-ended fact with a None bound instead.

macOS SQLite Extension Loading

macOS ships with a system SQLite that has extension loading disabled by default. If you use the vec extra (pip install engrava[vec]), you may encounter:

sqlite3.OperationalError: not authorized

Workaround: Install Python via Homebrew or pyenv, which links against a full-featured SQLite build:

brew install [email protected]
# or
pyenv install 3.12

aiosqlite Proxy Architecture

engrava uses aiosqlite which runs SQLite on a dedicated background thread and proxies calls via asyncio. This has implications:

  • Connection objects should not be shared across event loops.

  • Long-running SQL statements block the background thread: it runs one call at a time, so a slow statement delays every other call on that connection. An open transaction alone does not — while a suspend_auto_commit() window sits idle, other tasks' reads still run. What the window does hold is the store's write lock, so a different task's write waits for it, for at most write_lock_acquire_timeout_seconds, and raises WriteLockTimeoutError after that — a long hold can make the waiting write fail instead of complete (see Concurrency). Keep transactions short.

  • WAL mode is used by default for concurrent read access. Writes are serialized by SQLite's single-writer lock.

sqlite-vec Pre-v1 Status

The sqlite-vec extension is pre-v1. engrava pins >=0.1.0,<0.2.0 to avoid breaking changes. When sqlite-vec reaches 1.0, the pin will be relaxed.

Without the vec extra, engrava falls back to a brute-force cosine scan done with NumPy: cost grows with the eligible embedding population. Run pip install 'engrava[vec]' to use the compact compiled vec0 backend instead, but note that the pinned sqlite-vec 0.1.x line still performs an exhaustive linear KNN scan over a tightly packed columnar store — it is not an approximate or sub-linear index. Relative latency and memory use between the two backends depend on your data and hardware; measure your own p95 latency and see Performance.

FTS5 Availability

FTS5 is included in the standard SQLite build since version 3.9.0 (2015). Most Python distributions include it. If FTS5 is not available, creating the schema fails: ensure_schema() (and so opening a new store) raises a sqlite3.DatabaseError and the store does not open. Separately, on a store that is already open, search_fts() returns an empty list rather than raising when the thought_fts table does not exist.

To verify FTS5 support:

import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE VIRTUAL TABLE test USING fts5(content)")
conn.close()
print("FTS5 is available")

Concurrent Write Safety

SQLite supports one writer at a time. With WAL mode, readers do not block writers and vice versa. aiosqlite marshals every call onto one background thread, so concurrent tasks' statements do not run at the same time.

That is statement-level serialisation, and it is not by itself operation-level safety. Engrava's update methods (update_thought, restore_thought, upsert_by_hash, update_edge, update_action) read the row, apply the change in memory, then write. Two genuinely concurrent tasks sharing one store instance are serialised end to end by an in-process write lock, so one task's own read-modify-write cannot be corrupted by another's landing mid-way — see Concurrency. What survives as a real gap is a competing write from outside that lock: a same-task nested call reached through a caller-owned hook, or a write from a second store on the same database file (a second connection, or a second process). Every core row now carries a revision column that every guarded update checks and increments atomically, so a competing write landing in either of those gaps makes the guard match no row and raises StaleDataError — nothing of the rejected update is written — rather than silently overwriting. Two writers editing the same field, serialised through that guard, still resolve last-write-wins, which is the correct outcome for two genuine edits; what the guard removes is a write being torn or lost without any signal at all. See Optimistic concurrency for the full contract.

Only one store may write a given database file. The locks that order engrava's own operations live on the store instance, so a second store — in another process, or a second connection in this one — is outside all of them. WAL and busy_timeout keep the file intact under that topology, and the revision guard above keeps a lost-update race from landing silently; neither makes multiple concurrent writers on one file a supported topology.

The full contract, the guarantees that do hold, and the idioms that close the gap are in Concurrency. For multi-service setups via EngravaManager, each service has its own database file with independent locking — that is the supported way to run independent writers.

Separately: if the store ever quarantines its connection (an internal safety response to an indeterminate transaction), quarantine revokes admission synchronously: any new operation that touches the database or its journal then fails fast with ConnectionQuarantinedError, so no write can flush an orphaned transaction. A call that never reaches the database can still return, for example a search with an empty query text or a degenerate query vector, which returns []. Quarantine does not retract an operation already in flight — a reader admitted just before quarantine may complete a possibly-stale read on the pre-quarantine connection (never a commit). During quarantine the physical connection close is a detached, best-effort cleanup; a permanently-hung close is only a pending-task lifecycle nicety, not a safety concern.

Embedding Dimension Consistency

All embeddings for a given database must use the same dimensionality. Mixing dimensions (e.g., 384 and 768) is not supported. What happens depends on the vector backend:

  • With the sqlite-vec backend, storing a vector whose length differs from the backend's dimension raises sqlite3.OperationalError.

  • With the NumPy backend, once a store holds vectors of two lengths, a search raises: VectorDimensionMismatchError for a query whose length differs from the first stored vector's, and a ValueError from NumPy for a query of that length.

Engrava also checks the stored model identity (name, dimension and document prefix); a mismatch it finds is reported as EmbeddingModelMismatchError. The model check runs on every embedding write and on every verify_embedding_model() call on a store that has a provider — not only the first — so it does not stop comparing after the first write.

A first store_embedding() call whose own write fails — a vector whose length disagrees with the sqlite-vec backend's configured width, or a thought_id naming no thought row — locks nothing: the model identity commits only together with that first write actually landing, so a corrected retry on the same store is not refused by an identity a failed call left behind.

A deliberate CLI re-embed is available while restoring a snapshot into a configured direct database or service. Pass --config: direct mode uses the top-level embeddings provider, while service mode prefers its per-service override and otherwise uses the top-level provider. Without a configured provider, use --skip-embeddings or preserve the source vectors. See CLI restore.

Query-vector dimension mismatch

Distinct from the DB-wide Embedding Dimension Consistency above (which is about the stored corpus all sharing one dimension): this is about a single query vector whose length does not match the store's embedding dimension.

search_similar() (and the vector arm of search_hybrid()) computes cosine similarity between the query vector and stored embeddings, which is only defined when both share a dimension. A wrong-length query vector is a caller-contract violation, so it is raised loudly as VectorDimensionMismatchError (carrying expected and actual) rather than silently returning an empty result. The check is dimension-only and runs before the degeneracy check, so a wrong-length all-zero vector is a dimension error, not a degenerate-vector degradation.

Behaviour change — catch the typed error. The wrong-dimension case now raises VectorDimensionMismatchError (a subclass of EngravaError, not of ValueError). Any caller that previously caught a plain ValueError around a vector search must catch VectorDimensionMismatchError (or EngravaError) instead.

Archived thoughts and default retrieval

Archived thoughts (lifecycle_status = ARCHIVED) are excluded from default retrieval on every ranked read — search_hybrid(), recall(), search_fts(), and search_similar(). This is the retrieval side of Forgetting — an opt-in, off-by-default hygiene loop — but the exclusion applies to any archived row, whether or not that loop is enabled: a forgotten (archived) thought stops surfacing without being deleted.

  • Behaviour change: previously an ARCHIVED thought still appeared in search. It no longer does. This also affects the TTL archive strategy — a TTL-expired, archived thought now drops out of default search too.

  • Reversible: restore_thought() re-activates a row, and include_archived=True re-admits archived rows for a single call without restoring them.

  • Not applied to counts/listing: count_thoughts() and list_thoughts() still include archived rows (they are not ranked retrieval) — filter on lifecycle_status yourself if you need them excluded there.

Deletion on a database that has not been migrated

A database below the core-12 schema (the migration that adds ON DELETE CASCADE to edge, embedding, and action) has no cascade to remove a deleted thought's child rows. What the code does about that:

  • Child rows are deleted explicitly. delete_thought, the TTL delete strategy, and hygiene GC each issue their own deletes for a thought's edge, embedding, and action rows, in the same savepoint as the parent delete, which runs first, instead of relying on the cascade.

  • Reconciliation only backfills for an owner that matches a thought. sync_embeddings (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.

  • The purge is the same join. The sweep that removes orphaned embedding_vec rows (on reconcile, and in engrava gc) treats a vector as orphaned when its embedding row is gone or its owner id matches no row in thought.

  • Search resolves through a join that requires the thought row. Both the vec0 rowid-to-id resolution and the post-search eligibility check confirm that a thought row with that id exists (and is otherwise eligible) rather than only checking that it isn't excluded; an id that matches no thought row is dropped, not passed through by default.

Historical residue. On a pre-core-12 schema an older engrava build left a deleted thought's embedding row behind, since nothing removed it. engrava migrate runs the core-12 step, which recreates the three child tables with ON DELETE CASCADE and purges the orphan rows that had already accumulated.

engrava --db engrava.db migrate

engrava gc refuses instead of running on a database below head. Physically deleting rows through an engine that does not understand the schema it is deleting from is how this defect reached a user in the first place, so gc — and every other built-in command that deletes user data — now exits non-zero and names engrava migrate rather than proceeding on an unmigrated database. A read-only command (the list is under Schema-version checks, which also names the one exception, recall under --config) is still allowed to run against a behind schema — refusing an ordinary read because a migration is pending would trade this defect for a worse one — but it warns on stderr that the schema is behind rather than staying silent about it.

Maximum Database Size

SQLite supports databases up to 281 TB (theoretical). In practice, engrava has been tested with databases up to ~10 GB (millions of thoughts) without issues. Performance depends on index coverage and query patterns.

HybridSearchResult.backends_used Is an Open Set

backends_used is a frozenset[str] that may grow as new scoring signals are added (e.g. "priority" was added in v0.3.0). Do not compare it with exact equality (== {"fts5", "vector"}). Use subset checks instead:

assert {"fts5"} <= result.backends_used  # preferred
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