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/error-handling.md

Error Handling and Recovery

This guide describes the error and persistence contracts of Engrava v0.7. It is for application code that must decide whether to fix an input, retry a remote call, reconcile a partial write, or replace a store.

The central rule is: classify the failure and establish what committed before retrying. An exception does not universally mean "nothing was written," and Engrava does not make every write idempotent.

Decision table

SignalState after the errorActionRetry unchanged?
ConfigError, validation ValueError, invalid filter/recency/cycle, dimension or prefix mismatchRejected at a validation boundary in the usual pathCorrect the configuration or argumentsNo
ThoughtNotFoundError, ActionNotFoundError, SourceThoughtNotFoundError, ReferentialIntegrityErrorRequired data is absentRefresh identifiers or create the missing parent deliberatelyNo
DuplicateEdgeErrorThe requested directed, typed relationship already existsTreat the write as idempotent success or choose a different direction/typeNo
InvalidTransitionError, ReadOnlyViolationErrorRequested operation is not allowed in the current state/viewChange the requested transition or use a writable capabilityNo
StaleDataErrorThe guarded update matched no row — another guarded write bumped the row's revision in between, or deleted the row; nothing of the rejected update was writtenRe-read, recompute the intended change, then issue a new update — and handle the row being goneNo, not without re-reading
Remote embedding timeout, network error, 408, 409, 425, 429, or selected 5xxDepends on the operation; a single thought/update may already be committedLet the provider exhaust its bounded retry policy, then reconcile by thought IDNot the whole write blindly
SQLite OperationalError containing locked or busyThe operation did not complete successfully; reconcile durable state when the write boundary is ambiguousReduce writer contention or increase busy_timeout; retry only an operation known to convergeOnly with operation-specific proof
WriteContentionErrorThe call could not get the cross-connection write lock, after busy_timeout and, for the dedup calls, the store's own bounded retries; the failed attempt wrote nothingRetry the call; if it keeps recurring, remove the contention (see SQLite lock contention below)Yes, the call itself
WriteLockTimeoutErrorA task could not get the store's in-process write lock within write_lock_acquire_timeout_seconds; the guarded write did not startFind the cause first: a task spawned and awaited from inside another task's open suspend_auto_commit() window (drive writes from the one task that opened it), or a bound too small for a slow embedding provider (raise it)Not until the cause is fixed
DedupLockReentryErrorA task re-entered the dedup window it already holds and was refused instead of blocking on itselfFix the caller: an update_thought() override reached from upsert_by_hash()'s hit branch must not call back into a dedup entry pointNo
Rising fts_match_failure_countSearch retried with sanitized FTS syntax; the vector and other hybrid arms can still contributeInspect warning logs and offending queriesEngrava already retries the FTS arm once
Rising vector_arm_degradation_countThe vector arm returned no results for an empty, zero, or non-finite query vectorFix query embedding generationNo; the same vector degrades again
EmbeddingGenerationError from single-item create/updateIf this call owns its outermost transaction, the thought/update is committed; embedding is missing (create), or, only if the row already had one, left in place and now stale (update) — otherwise still none. Nested inside the caller's own suspend_auto_commit(), nothing is durable yet: the outermost window's exit decides, and rollback reverts to whatever existed when that outermost window opened, not just to before this call — an earlier write to the same thought inside the same window is undone too, and a thought created inside that window no longer exists at all after rollbackIf this call owned its transaction: look up the thought and repair its embedding, do not recreate it. If it was nested and the outer window rolled back, do not assume based on which call you issued — look the thought up by id first: absent means nothing this window wrote for it survived, including an earlier create in the same window — create it; present means it reverted to whatever state existed before the window opened — reissue the write against that state (a blind create instead raises ValueError on the still-existing id)Only when durable: retry embedding, not creation/update. Otherwise, look the thought up first and branch on what you find: create it if absent, or reissue the update if it is present — do not retry blindly based on which call you originally issued
JournalIntegrityError, ExtensionMigrationError, SchemaVersionError, CoreMigrationErrorStore opening or migration was rejected; CoreMigrationError leaves the schema version at the last fully applied stepStop writes, preserve the files, diagnose or restore. For SchemaVersionError, read reason: newer_than_head needs a newer Engrava; for the two sub-floor reasons, do not delete or re-initialise the file (see below)No
EngravaError caught as the base classDepends on the subclass; the base type says nothing about what committedHandle the specific subclasses above first and put a base-class handler after them; treat anything that reaches it as unclassifiedNot on the base type alone
ConnectionQuarantinedErrorThe store instance is terminally unusableClose it, create a new store over a fresh connection, then reconcile durable stateNever on the same store

Caller, data, and configuration errors

These failures require a changed request, not backoff:

  • ConfigError means configuration loading, provider resolution, or a bounded configuration value failed validation.

  • ValueError is also used by some public write boundaries, including duplicate thought IDs and invalid metadata/provenance. Pydantic may raise its own validation error while constructing records.

  • InvalidFilterError and InvalidFilterPathError reject an invalid compiled metadata or visibility filter before query execution.

  • RecencyModeConflictError, InvalidRecencyArgumentError, and CycleProviderError identify an invalid recency request or provider result.

  • EmbeddingModelMismatchError, EmbeddingQueryPrefixMismatchError, and VectorDimensionMismatchError protect the corpus from incompatible vectors. Restore the matching model/prefix/dimension or deliberately re-embed the corpus; repeating the same call cannot repair the mismatch.

  • EmbeddingProviderContractError identifies a custom embedding provider that does not expose a required EmbeddingProviderProtocol member — today a public dimension. It names the provider class and the member; retrying cannot help, add the property. A provider that has the property and fails inside it does not raise this: its own exception propagates unchanged, and is classified by whatever raised it.

  • ThoughtNotFoundError, ActionNotFoundError, SourceThoughtNotFoundError, and ReferentialIntegrityError identify a missing target or parent.

  • DuplicateEdgeError identifies an existing (from, to, type) relationship; callers can safely use it as the idempotent-create signal without parsing SQLite error text.

  • InvalidTransitionError rejects an illegal lifecycle/action transition, and ReadOnlyViolationError rejects a write through ReadOnlyEngrava.

  • StaleDataError means a guarded update matched no row: another guarded write bumped the row's revision between your read and your write, or the row was deleted in that window. The error does not say which, and nothing of the rejected update was written. It is recoverable only through a new read-modify-write cycle: re-read the record, recompute the change, and do not replay stale changes without checking the newer record. Do not assume the record still exists, because a row deleted mid-call raises this too. The engine bumps revision on every guarded write, so a guarded update whose read-to-write window another writer's guarded write lands in raises this error, whatever field that other write touched, instead of silently overwriting that write. Between ordinary tasks sharing one store the write lock serialises guarded writes, so it almost never fires there; the shapes it does catch are a write from a second store on the same file and a same-task nested write from a caller-owned hook — see Concurrency.

  • WriteLockTimeoutError and DedupLockReentryError are raised instead of a hang. The first means a task waited longer than write_lock_acquire_timeout_seconds for the store's write lock; the exception alone does not say whether a task was spawned and awaited from inside another task's open suspend_auto_commit() window (a caller bug: issue the window's writes from the one task that opened it) or the bound is too small for a slow embedding provider or large batch (raise it). The second means the same task tried to enter the dedup window it already holds, typically an update_thought() override reached from upsert_by_hash()'s hit branch that calls back into create_thought(deduplicate=True), get_or_create(), upsert_by_hash() or bulk_store(deduplicate=True). Repeating that call unchanged cannot help; see Concurrency and Extension hooks.

  • DerivedRecordError means a producer violated a derivation gate or collided with an unrelated identity. The source thought is already durable on the automatic post-store path; some earlier derived children may also be durable.

JournalIntegrityError and ExtensionMigrationError are operator failures, not caller mistakes. Do not keep opening the same files in a write-capable process. Preserve the database and WAL files, inspect the reported sequence or migration, and follow Backup & Recovery.

SchemaVersionError and CoreMigrationError are the same kind of failure, raised by ensure_schema() and so by from_config(), EngravaManager.get_store(), and engrava migrate. SchemaVersionError carries current_version and a reason:

  • newer_than_head: the file was written by a newer Engrava. engrava migrate cannot help; upgrade Engrava before opening it.

  • populated_sub_floor and stale_shape_sub_floor: the file is stamped below the bootstrap floor and this build cannot tell which older version produced it, so it refuses instead of stamping it current. Do not delete or re-initialise it — even when no core table holds a row, it may carry data outside the core tables — preserve it and follow Backup & Recovery.

CoreMigrationError means a migration step returned without leaving the structure it targets (a column, table, index, foreign key, or trigger) in place. The version is not stamped past the last fully applied step, so the next ensure_schema() retries the remaining steps rather than treating a half-migrated file as current; repeating the open without changing anything is not a fix. Replace the database from a backup, or use a build that matches its schema history.

EngravaError is the base of every typed error above except ConfigError, which derives from ValueError. It is not raised directly and carries no guarantee about what committed, so a handler for it belongs after the specific ones: Python runs the first except clause that matches, and a base-class clause written first hides the specific recovery below it. A raw sqlite3.OperationalError is not an EngravaError either.

The complete exception surface is listed in the API Reference.

Remote embedding failures

Provider behavior is deliberately provider-specific:

  • OpenAICompatibleProvider retries transport timeouts/network errors and HTTP 408, 409, 425, 429, 500, 502, 503, and 504. It makes at most max_attempts attempts (default 3), waiting base_retry_delay_s * attempt_number before each next attempt (default waits: 1 second, then 2 seconds). Other non-success statuses, such as 400, 401, 403, and 404, fail immediately.

  • OllamaProvider and HuggingFaceProvider do not add an Engrava retry loop. Their dependency/provider exceptions propagate.

  • Local, callback, and third-party providers define their own failure behavior.

Once provider attempts are exhausted, auto-embedding on the thought a caller explicitly created or updated never swallows the error. With require_embedding=true, Engrava wraps it in EmbeddingGenerationError; otherwise the provider's original exception is re-raised. Strict mode changes the exception type, not the single-item commit boundary. A derived child's own embedding failure is a separate path: under the default on_error="log" derivation gate, it is logged and derivation continues, so the call that created or updated the source does not see it — see Derived records.

For a search that only failed while generating its query vector, retrying the search after a transient provider recovery does not replay a graph write. For a write with auto-embedding, inspect the persistence boundary below before doing anything else. See Embeddings for provider setup and retry controls.

Persistence boundaries

Single thought creation

Outside suspend_auto_commit(), create_thought() (and therefore remember()) commits the thought and its journal entry before auto-embedding, TTL cleanup, on_store, and automatic derivation run. A later failure leaves the source thought durable.

Consequences:

  • An embedding-provider failure leaves a newly-created thought without an embedding. It remains available to direct/FTS retrieval but is absent from vector search.

  • An on_store or derivation failure does not roll back the source. Depending on where derivation failed, earlier derived children may already be committed.

  • Retrying create_thought() with the same ID raises ValueError. Retrying remember() with the default deduplicate=False creates another random ID; with deduplicate=True, a byte-identical existing row is reused and its confirmation_count is incremented instead. Neither the default call nor the deduplicated one is a repair for a failed embedding.

Use strict embedding mode when callers need the typed thought ID for repair:

from engrava import EmbeddingGenerationError


async def create_with_partial_state(store, thought):
    try:
        return await store.create_thought(thought), True
    except EmbeddingGenerationError as exc:
        persisted = await store.get_thought(exc.thought_id)
        if persisted is None:
            raise RuntimeError("embedding failure did not leave the expected thought") from exc
        return persisted, False

The False result means "the thought exists but embedding repair is required," not "creation failed atomically."

If the application retains the provider, it can rebuild the same payload and role-aware dispatch used by auto-embedding, then upsert the missing vector:

from engrava import RoleAwareEmbeddingProvider, ThoughtNotFoundError


async def repair_embedding(store, provider, thought_id):
    thought = await store.get_thought(thought_id)
    if thought is None:
        raise ThoughtNotFoundError(thought_id)

    essence = thought.essence.strip()
    content = thought.content.strip()
    payload = thought.content if content.startswith(essence) else f"{thought.essence}\n{thought.content}"

    if isinstance(provider, RoleAwareEmbeddingProvider):
        vector = await provider.embed_document(payload)
    else:
        vector = await provider.embed(payload)

    return await store.store_embedding(
        thought_id,
        vector,
        model_name=provider.model_name,
    )

Use the same provider/model and role prefixes that built the corpus. The store's model lock rejects an incompatible model or dimension.

Thought updates

update_thought() commits the updated row and journal entry before re-embedding when essence or content changed, provided this call owns its outermost transaction. Nested inside the caller's own suspend_auto_commit() window, nothing is durable yet on any path — the outermost window's exit decides, rolling back an uncaught failure and everything it inserted. If the update does commit and query generation then fails, the row contains the new text but:

  • a thought that had no embedding remains unembedded, and stays unfindable by vector search;

  • a thought that had an embedding retains the old, now stale vector, and stays findable by vector search against that outdated content;

  • dependent REFLECTION centroids may not yet have been rebound.

Re-read the thought and repair/recompute enrichment. Do not replay the update unchanged and assume that doing so re-embeds: an update that no longer changes essence or content does not enter the re-embedding branch.

Bulk ingest

bulk_store() is different. Source rows, journal entries, and batch-generated embeddings run inside one suspend_auto_commit() transaction. Called on its own, any exception during that phase, including a provider exception or a task cancellation, rolls the batch back and nothing from it is persisted. The require_embedding option controls whether a failure of the provider's embed call is wrapped as EmbeddingGenerationError; it does not change the rollback (see Embeddings for what that covers). The one window the rollback does not cover is a cancellation that lands during the final commit, described under transaction contexts below.

Nested inside a caller's own suspend_auto_commit() window, the batch shares that outer transaction: a row error still aborts the batch's own inserts, but only the outermost window's exit decides commit or rollback. If the caller catches the propagated exception and the outer window then exits cleanly, the batch's successful prefix commits along with the rest of the outer block's work instead of being rolled back.

Automatic derivation runs only after the batch commits. A derivation failure can therefore leave the complete source batch durable and zero or more derived children durable. Reconcile sources by ID/content and use derive_existing() to complete deterministic derivation rather than blindly replaying bulk_store().

Derived records

The source is durable before automatic derivation. Each derived child's row is then committed as its own unit before its embedding and DERIVED_FROM edge are completed. Re-running derive_existing(source_id) reuses deterministic children and edges and fills missing enrichment, provided the producer obeys the deterministic content contract. A failed child undoes only its own failing step — the row and its journal entry, the edge and its journal entry, or an embedding attempt — never an earlier child's work, and never a caller's other pending writes when derive_existing runs inside the caller's own suspend_auto_commit() window or a raw BEGIN. When a step's own unwind cannot itself be trusted to have completed, it terminally quarantines the connection; that is one of several causes, listed under Terminal connection quarantine.

Transaction context behavior

suspend_auto_commit() groups writes on one store connection. A normal exit commits once; any exception escaping the block, including task cancellation, attempts a rollback of the outermost transaction. A successful rollback re-raises the identical body exception and leaves the connection usable. If rollback also fails, the store is quarantined before another guarded writer can enter: an ordinary rollback error is the explicit cause of the original body exception, while cancellation of the rollback takes precedence over the body failure.

async def store_atomically(store, first, second, link):
    async with store.suspend_auto_commit():
        await store.create_thought(first)
        await store.create_thought(second)
        await store.create_edge(link)

Operational rules:

  • Let an error escape the async with block. Catching it inside and then leaving normally tells the context manager to commit.

  • suspend_auto_commit() holds a task-reentrant lock for its whole duration: a different task's write on the same store instance now waits for the window to close instead of joining its transaction (a wait that is bounded: past write_lock_acquire_timeout_seconds it raises WriteLockTimeoutError), and nesting on the same task is supported (only the outermost call commits or rolls back). Drive the window from one task at a time regardless — a second task's write is safe from corruption, but it still simply waits, so interleaving unrelated writes through a long-running window serialises them for no benefit. See Concurrency for the full contract.

  • Automatic on-store derivation is skipped inside a caller-held transaction. After commit, invoke derive_existing() explicitly for sources that need it.

  • The rollback branch catches BaseException, so asyncio.CancelledError (and SystemExit or KeyboardInterrupt) escaping the block body rolls the outermost transaction back like any other error. The exception to that rule is a cancellation that lands during the outermost window's own commit(): the commit runs after the body has returned, outside the rollback branch, so it may or may not have completed by the time the cancellation propagates. Treat the outcome as indeterminate: reconcile the affected IDs from a new store before repeating anything, and do not let a later commit decide it. See the suspend_auto_commit() entry in the 0.6 to 0.7 upgrade notes for the change.

For the supported writer topology and task boundaries, see Concurrency.

SQLite lock contention

SQLite permits many readers but only one writer. Stores opened through SqliteEngravaCore.from_config() or EngravaManager set PRAGMA busy_timeout=5000, so a competing connection waits for up to five seconds before surfacing database is locked. A manually supplied connection keeps its own pragma settings.

create_thought(deduplicate=True), get_or_create(), upsert_by_hash(), bulk_store(deduplicate=True) and the revision-guarded updates (update_thought, restore_thought, update_edge, update_action) convert contention that outlasts busy_timeout into WriteContentionError, carrying operation and attempts; from bulk_store the operation reads "create_thought", the shared dedup path's label. The failed attempt wrote nothing, so retrying that call outright is safe. Other write paths can still surface the raw OperationalError from the table above.

Prefer removing contention over adding a generic retry loop:

  1. Keep write transactions short.

  2. Serialize writes per store/process.

  3. Partition independent writers into separate database files with EngravaManager.

  4. Increase busy_timeout only when the added tail latency is acceptable.

busy_timeout is a latency knob, not a correctness one. It decides how long a connection waits for the file lock; it does nothing about two stores interleaving inside one of engrava's read-modify-write operations, which is why only one store may write a given file (Concurrency).

If an operation is known to be read-only and repeatable, bounded retry is reasonable. This example deliberately retries journal verification, not an arbitrary write:

import asyncio
import sqlite3


async def verify_journal_with_lock_retry(store, attempts=3):
    for attempt in range(1, attempts + 1):
        try:
            return await store.verify_journal()
        except sqlite3.OperationalError as exc:
            locked = "locked" in str(exc).lower() or "busy" in str(exc).lower()
            if not locked or attempt == attempts:
                raise
            await asyncio.sleep(0.1 * attempt)
    raise AssertionError("unreachable")

Do not wrap every Engrava method in this helper. After an ambiguous write error, query the durable state from a healthy connection and choose a method whose repetition is known to converge.

Self-healing search and counters

Search has two intentional degradation paths:

  • If the primary FTS5 MATCH raises OperationalError, Engrava increments fts_match_failure_count, logs a warning, sanitizes the original query into bare mode, and retries once. If that fallback also fails, the FTS arm returns no results; hybrid search can still use its other active arms.

  • An empty, all-zero, NaN, or infinite query vector has no cosine direction. search_similar() increments vector_arm_degradation_count and returns []. A wrong-length vector is not degraded: it raises VectorDimensionMismatchError.

The counters are cumulative per store instance and reset when a new store is constructed. Alert on growth/deltas, not merely on a non-zero lifetime value:

async def recall_with_degradation_flags(store, query):
    fts_before = store.fts_match_failure_count
    vector_before = store.vector_arm_degradation_count

    result = await store.recall(query)

    return (
        result,
        store.fts_match_failure_count > fts_before,
        store.vector_arm_degradation_count > vector_before,
    )

An unavailable FTS5 index returns an empty FTS arm. Failure to load the optional sqlite-vec extension at store setup logs a warning and falls back to exhaustive NumPy search; it does not increment vector_arm_degradation_count. Runtime backend failures outside the two cases above are not covered by a blanket self-healing guarantee.

See Search and Observability for the result-level and metrics-level signals.

Terminal connection quarantine

ConnectionQuarantinedError means Engrava could not prove that a rollback or unwind it depends on completed, or that the connection's last operation ever reported. The old connection may have an indeterminate open transaction, so the store revokes all new operations and replaces its internal connection with a failing proxy. This state never clears. Any of these triggers it:

  • the savepoint unwind of a guarded write (update_thought, restore_thought, update_edge, update_action, a derived child's row insert or edge insert) failed;

  • the savepoint unwind of a delete_thought failed, including a delete made through the TTL delete strategy or hygiene garbage collection — an ordinary error such as a trigger veto can cause this, not only a cancellation, and the derived-records seam need not be involved;

  • the outer transaction's body failed and its rollback also failed, or the compensating rollback after a failed commit could not complete;

  • close() gave up waiting on the physical close after close_timeout_seconds — see Deployment.

The call that triggered the quarantine can surface its original error (or a cancellation that outranks it); a close() whose bound expired raises ConnectionQuarantinedError itself. Once quarantine is installed, the next newly-admitted public operation raises ConnectionQuarantinedError. This includes a writer already queued on the failed window's lock: it cannot commit that window's abandoned writes. Retirement does not wait for physical close, which may fail or remain pending; a concurrent owned close() shares that same physical-close task under its existing bound. A separately retained caller-owned raw connection is outside the core admission guarantee.

Close the old store, open a new one, and inspect durable state before deciding what to repeat:

from engrava import ConnectionQuarantinedError, SqliteEngravaCore


async def replace_and_reconcile(store, config_path, source_id):
    try:
        source = await store.get_thought(source_id)
    except ConnectionQuarantinedError:
        await store.close()
        store = await SqliteEngravaCore.from_config(config_path)
        source = await store.get_thought(source_id)

    if source is not None:
        await store.derive_existing(source.thought_id)
    return store

The caller owns the returned replacement and should close it during normal shutdown.

Do not retry against the quarantined instance, including through its journal. Quarantine detaches the real connection and schedules its best-effort close even when the store was built with the manual constructor. Any external reference to that same connection must still be treated as revoked and discarded.

Idempotency and convergence

Use these contracts narrowly:

OperationRepetition contract
invalidate_thought(id, same_valid_until)Idempotent; converges to the same valid-time boundary
invalidate_edge(id, same_valid_until)Idempotent; converges to the same valid-time boundary
derive_existing(source_id)Idempotent only under the deterministic producer contract; reuses children/edges and completes missing enrichment
upsert_by_hash(same_desired_record)State-convergent on the mutable fields, but a first-call post-commit enrichment failure still needs explicit reconciliation/derivation
store_embedding(thought_id, same_vector, same_model) with the default deterministic embedding IDUpserts the same vector, but refreshes embedding metadata such as created_at; convergent, not byte-identical
create_thought()Not idempotent; the same ID raises and a new ID creates another row
remember()Not idempotent; each call creates a fresh ID unless content deduplication is requested
create_thought(deduplicate=True) / get_or_create()Prevent duplicate content rows, but each hit increments confirmation_count; not observationally idempotent
bulk_store()Atomic through source+embedding commit when it owns that transaction outright — nested inside a caller's own suspend_auto_commit(), a caught row error can still commit the batch's successful prefix, while a caught embedding failure (see API reference) commits every row the batch inserted instead, since that failure only fires after every row is already in — and, either way, not safe to replay blindly after a post-commit derivation failure
Retrieval callsDo not mutate graph content, but can buffer access-frequency events when access tracking is enabled

When an API is not listed as idempotent, assume that retry requires an application-level operation key and a read-back/reconciliation step.

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