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.
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.
| Folder | Role | Files |
|---|---|---|
| .github | agent-config | 16 |
| docs | docs | 37 |
| examples | examples | 13 |
| scripts | scripts | 24 |
| src | source-code | 89 |
| tests | tests | 201 |
| .githooks | folder | 1 |
The agent reads
- CONTRIBUTING.md evidence: CLAUDE.md
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
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-msgMakefile: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)"
- 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/
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 engravaOptional 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 embeddingsDreaming/consolidation and the knowledge graph need no extra — they are part of the base install.
embeddings-localcarries a large first-run download. It pullssentence-transformersandtorch, plus a further, separate model download (~88 MB forall-MiniLM-L6-v2, cached under~/.cache/huggingface/hub) on first use. On Linux, the PyPI build oftorchalso 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 engravaalone (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.
Embedding Search
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:
| Provider | Extra | Backend |
|---|---|---|
SentenceTransformerProvider | embeddings-local | Local model via sentence-transformers |
OpenAICompatibleProvider | embeddings-openai | Any OpenAI-compatible API |
OllamaProvider | embeddings-ollama | Local Ollama server |
HuggingFaceProvider | embeddings-hf | HuggingFace 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.0Core 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 databasesMCP 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-mcpengrava-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.jsongc 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
aiosqliteHook-based extension — zero monkey-patching
Template method pattern — subclass
SqliteEngravaCorefor extended schemasZero 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,
invalidatewithout deletingPositioning — 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/promptsConfiguration — 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
engravacommand and optionGlossary — 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/concurrency.md
Concurrency
Engrava is built on SQLite, so it inherits SQLite's concurrency model: many concurrent readers, one writer at a time. This page states what a single store guarantees concurrent callers, what it deliberately does not guarantee, and which writer topologies are supported.
WAL: many readers, one writer
File databases opened via from_config use WAL (write-ahead logging) mode
by default — it is the database.wal_mode setting. Setting it to false
skips this pragma: a brand-new database then keeps SQLite's own default
rollback journal, but an existing database that is already in WAL mode stays
in WAL mode, since engrava issues no pragma to change it back. Outside WAL,
readers and a writer can block each other — an open read keeps a writer from
committing — so the first guarantee below is WAL-specific; the second — only
one writer at a time — is an ordinary SQLite property and holds under either
journal mode.
Under WAL:
Readers don't block the writer and the writer doesn't block readers. A read sees a consistent snapshot while a write is in progress.
There is still only one writer at a time. SQLite serialises writes; an Engrava-owned connection waits up to the configured busy timeout (five seconds by default) for the first to finish and then raises rather than waiting indefinitely — see Busy timeout below.
This is ideal for read-heavy agent-memory workloads: retrieval (the hot path) avoids per-result writes, and writes are comparatively infrequent. Calls sharing one store still serialise their SQLite statements (see Many async tasks, one store below), and with access tracking enabled, retrieval can buffer telemetry for a later write (Deployment).
Many async tasks, one store
Share one store instance across the asyncio tasks in your event loop. You do
not need a connection pool or multiple stores for in-process concurrency.
Every guarded write path on a store instance shares one in-process,
task-reentrant write lock: create_thought / get_or_create /
upsert_by_hash / bulk_store / update_thought / restore_thought /
delete_thought / create_edge / update_edge / delete_edge /
create_action / update_action / store_embedding / record_access /
cleanup_expired / run_hygiene / suspend_auto_commit() all take it before
touching a row. Once locked, each one's locked work runs inside one
continuous hold of the lock — one critical section with respect to every
other task on this instance — so a different task's call to any of these
blocks until the one in progress has committed (or rolled back) rather than
landing in the middle of it. That "once locked" qualifier is load-bearing for
four of them: get_or_create and upsert_by_hash take the lock twice on a
miss, with a real gap between (below); create_thought and bulk_store both
run the
pre-insert seam — and,
for create_thought, its surrounding metadata/provenance validation too —
before the single locked window even opens, holding no lock this call
itself acquires while they do. create_thought validates the candidate,
runs the seam, and revalidates whatever it returns before it ever takes the
lock, then takes the lock once for the existence check and the insert or
confirmation-bump together. bulk_store runs the seam item by item, in
input order until one call raises, for the whole batch, before it takes the lock
itself; it then takes the
lock once, for the rest of the batch — per-item validation, the existence
check, and the insert or bump, item by item — so on both of these entry
points the seam (and, for create_thought, its surrounding validation) is
not covered by the same critical section that protects the write (see
Extension hooks §1B.1 for the
per-entry-point seam invocation count).
get_or_create and upsert_by_hash are the exception, on a miss. Each
runs an exploratory probe under the lock; when that probe finds nothing, the
call releases _write_lock, _dedup_lock, and any BEGIN IMMEDIATE it
opened, runs the pre-insert seam
(prepare_thought_for_insert) with no lock this call itself took still
held — a lock an enclosing caller took is a different matter, covered
below — and only then reacquires _write_lock and _dedup_lock and, when
the connection has no transaction open, opens a BEGIN IMMEDIATE for a
decisive probe before it inserts. For a different
task, that gap is real: any other guarded write on this instance — a
second task's get_or_create / upsert_by_hash /
create_thought(deduplicate=True) / bulk_store targeting the same content,
or an unrelated update_thought / delete_thought / anything else in the
list above — can start and finish inside it, because nothing is held there to
stop it. The decisive probe exists to catch exactly that: it is re-run, keyed
on the seam's own output, so a write that landed in the gap turns this call's
outcome into a hit instead of a double insert (see
Extension hooks §1B.1 for the full
per-entry-point invocation-count contract). On a stable hit neither method
ever opens this gap — the seam does not run, and the exploratory probe's own
critical section is the whole call.
Nested inside the caller's own suspend_auto_commit(), none of that
applies to that caller's own task. _write_lock is task-reentrant (below):
the outer window's hold of it never actually drops while this call's own
exploratory probe releases its nested acquisition, so _write_lock stays
held — blocking every other task — for the whole seam call. Whatever
transaction the outer window had already opened before this call began is
untouched by it too: the exploratory probe only opens its own BEGIN IMMEDIATE when none was open yet, and only rolls one back on that same
condition, so an already-open outer transaction is never the one it closes.
_dedup_lock is not task-reentrant and is unaffected by any of this — it is
acquired and released fresh by this call regardless of nesting, and is the
one thing that genuinely comes off, for the duration of the seam call, in
this case. See Extension hooks §1B.1 (the
nesting note) for the same fact stated from the seam-override side.
The lock is task-reentrant, not call-reentrant: the same task may freely
nest calls — notably a write issued from inside its own
suspend_auto_commit() window — but a different task genuinely waits for
whichever critical section it is trying to enter (a task is not waiting on
this lock at all while it sits in the gap described above — nothing holds the
lock there to wait for). This closes several races the rest of this page used
to document as permanent; the exact scope (what changed and what did not) is
stated precisely below rather than summarised here, because the two are easy
to conflate and the difference matters.
What this does not do: it does not make a read-modify-write your own code
performs across two separate calls atomic — thought = await store.get_thought(id) followed later by await store.update_thought(id, field=thought.field + 1) is still two independent critical sections with a gap
between them, exactly as before (see the safe idioms
below). It also does not reach across connections — a second store on the same
database file is unaffected (see
Multiple stores, one database file) — or
around a caller holding a raw, unmediated transaction directly against the
connection (see Busy timeout). Nor does it reach a direct
call to store.journal.append() — the public JournalWriter returned by
store.journal does not hold this lock itself (it only serialises its own
sequence-number allocation), so appending through it yourself, outside of a
write this store performs on your behalf, has the same exposure as the raw,
unmediated connection case: it can land inside another task's open window and
be rolled back with it. Let the store journal its own mutations; treat a
direct append() call as unmediated use of the connection.
What is guaranteed
Statements do not run concurrently. aiosqlite runs the actual SQLite calls on a dedicated background thread and marshals every call onto it, so two tasks' statements are serialised rather than executed at once — and SQLite applies each statement atomically, so no query observes a half-written row.
A guarded write's own existence check and write are now atomic across tasks, and so is its validation for most of these calls — the exception is broader than one miss path:
get_or_create/upsert_by_hashhave their own real gap between the two probes on a miss, andcreate_thought/bulk_storevalidate the candidate and run the pre-insert seam before they ever take the lock, holding no lock this call itself acquires while they do (see above for both). Neither reopens a race on stored data — that pre-lock work only validates and enriches the incoming candidate, it never reads a row this store already holds — but it means the atomicity below covers each call's locked work, not literally every line it executes. For every other guarded write, a different task's entire operation could, before this, land between one call's read and its write; now it cannot start until the first call's write lock is released. Concretely: a second task's read insideupdate_thought/restore_thought/update_edge/update_actionalways observes the first task's already-committed result, never a stale in-flight value; a competing cycle stamp that used to spuriously reject an unrelated edit — merely because the two calls' reads and writes happened to straddle each other — no longer does, because the second call's version guard is now captured against the post-first-call row; and a lifecycle transition is now validated against whatever the most recently completed call on this instance actually left behind, not a value another task's call might still be mid-write on.An edit writes only the columns it owns.
update_thoughtwrites the fields the call gave a new value to, plus theupdated_atstamp — not the whole record. A field another task changed since this call read the row is preserved rather than rolled back. That includes the fields engrava maintains for you:access_count/last_accessed_atfromrecord_access(), andconfirmation_count.This holds for the ordinary case — two genuinely concurrent tasks on this store — because the write lock above already serialises them end to end: by the time the second call does its own internal read, the first call's write has fully committed, so the second call's guard is captured against the post-first-call row and never finds it stale. Every update also carries a
revisionguard now (below), and the engine bumpsrevisionon every guarded write, automatically — not only when a caller stamps something. What that guard actually catches is exactly the interleavings the write lock does not serialise: a same-task nested write reached through a caller-owned hook (the lock is task-reentrant, so it does not block this), and a second store on the same database file (a second process, or a second connection in this one). Either of those now makes the guarded write match no row and raiseStaleDataErrorin full — even when the two edits share no column — where before, the first case only ever raised if the competing writer happened to stamp a cycle explicitly, and the second case never raised at all (see Multiple stores, one database file).A deduplication sighting counts.
create_thought(deduplicate=True)andget_or_create()bumpconfirmation_countrelative to what is stored (confirmation_count + 1, evaluated by SQLite), so a bump made by another writer since the row was read is added to, never overwritten.Deduplication is serialised against itself, and now against every other guarded write too.
create_thought(deduplicate=True),get_or_create()andupsert_by_hash()share an internalasyncio.Lockcovering the whole "probe the content hash, then insert or bump" window, so two tasks on this instance calling one of these three methods cannot both race past the probe — and that same window is also held under_write_lock, so an unrelated guarded write from a different task cannot land inside it either (see the "riding along" exposure this closes, described under Busy timeout). The probe-and-insert window is also serialised across connections — see Multiple stores, one database file.suspend_auto_commit()genuinely excludes every other task's guarded write for its duration, and survives nesting. See the dedicated section below.
What is not guaranteed
A caller-level read-modify-write across two separate calls is still not atomic. The lock above covers one call's own internal read and write — it does not, and cannot, cover a gap between two calls your own code makes (
get_thoughtthen, later,update_thought). See the safe idioms for the caller-owned-lock pattern that closes that gap.get_or_create/upsert_by_hashhave this same kind of gap internally, on a miss. Between their exploratory probe and their decisive probe, every lock and transaction the call itself opened is released while the pre-insert seam runs — see above for what can land there and why the decisive probe exists.Same-field edits still resolve last-write-wins. Two tasks legitimately editing the same field is not a bug the lock removes — it removes the silent corruption (a call's own read or write being torn by another call landing mid-operation), not the ordinary fact that two edits to the same field can only leave one final value. Whichever call's critical section runs second determines the field's value, deterministically and without error.
StaleDataErrordoes not detect "someone else wrote it," in general. See Optimistic concurrency below for what therevisionguard actually rejects — it is a per-row write counter, not a semantic-conflict detector, and it says nothing about two sessions writing contradictory content to two different rows.A same-task nested call is not blocked by its own lock. A caller-owned hook or callback that itself issues a write, invoked synchronously from inside another operation's read-modify-write span (not a second
asynciotask — a genuinely nested call on the same task), still runs freely: the lock is task-reentrant by design (see the re-entrancy requirement above), so it does not — and structurally cannot — protect against this shape. This is a narrow, same-task case; ordinary callers issuing a second independent operation from a second task are covered by the guarantee above.A second store on the same database file is unaffected. See Multiple stores, one database file.
One store belongs to one event loop. Not because of the connection — aiosqlite creates each operation's future on the calling loop, so a plain read from a second loop works. It is the store's own synchronisation: the deduplication lock (and the audit journal's per-connection lock) are
asyncio.Locks, which bind to the first loop that has to wait on one and then raiseRuntimeErrorfor a waiter from any other loop. An uncontended lock never binds, so a store shared across loops can look healthy right up to the first time two callers actually contend. Use one store per loop; within that loop, share it freely. (See Known Limitations.)
suspend_auto_commit() is now a real, exclusive window
Before this, the deferred-commit flag lived on the store instance with
nothing to stop a different task's write from landing inside an open
suspend_auto_commit() window: it joined the window's transaction, and if the
window rolled back, that unrelated write rolled back with it — silently, with
the other task never told. suspend_auto_commit() now holds _write_lock for
its entire duration, so a different task's call to any guarded write path
cannot even begin until the window has closed. Concretely:
A different task's write now waits, and survives independently. It cannot join the window's transaction — by the time it runs, the window has already committed or rolled back — so it cannot share the window's writes. If the window's rollback fails, the store instead revokes admission before releasing the lock: the queued write raises
ConnectionQuarantinedErrorrather than committing abandoned work. Open a new store and reconcile durable state; the quarantined instance cannot be reused.Nesting is supported and safe. A nested
suspend_auto_commit()call — reachable only from the same task, since the lock above blocks any other task before it could nest — shares the outer call's transaction. Only the outermost call commits on a clean exit or rolls back on an exception; an inner call's own exit does neither.A write issued from inside your own window still works. The lock is task-reentrant: the same task's own writes inside the window it opened do not block on themselves.
This governs this store instance only. A second store on the same database file is unaffected — see Multiple stores, one database file — and the residual raw-transaction case described under Busy timeout is still outside what any in-process lock can reach.
A deadlock this store cannot resolve raises, it does not hang
The in-process write lock is task-reentrant: the task that opened a
suspend_auto_commit() window may issue writes of its own inside it freely,
but a different task cannot reuse that grant. If the window's own task then
spawns a task — asyncio.create_task, gather, wait_for, or one spawned
inside an on_store hook or embedding provider callback — and awaits it
before the window closes, neither can make progress: the spawned task can
never get the lock the window's task holds, and the window's task can never
release it while still awaiting the spawned task. This is already out of the
documented contract above (drive every write inside an open window from the
one task that opened it), so it is a caller bug, not a case this store can
make safe by construction.
What it must not become is a silent, unattributable hang. Acquiring this lock
as a different task is bounded, and past the bound the acquiring call raises
WriteLockTimeoutError instead of waiting forever. Raising also ends the
deadlock itself: the spawned task's failure lets whatever was awaiting it
unwind, which frees the window's task to finish and release the lock.
The same policy applies to the dedup lock's own, narrower failure mode: a
same-task re-entry. The write lock above is task-reentrant by design (a
write nested inside your own suspend_auto_commit() window must proceed);
the in-process lock guarding the dedup probe-and-insert window
(create_thought(deduplicate=True), get_or_create, upsert_by_hash) has
no such legitimate reentrant use, so a second acquisition by the same task
is always a bug rather than an intentional nesting to accommodate. The
concrete shape is upsert_by_hash's hit branch, when it updates the row,
calling the overridable update_thought while it holds _write_lock and the dedup lock (see Extension hooks
§1B.3):
an update_thought override that calls back into
create_thought(deduplicate=True) / get_or_create / upsert_by_hash /
bulk_store(deduplicate=True) on that same task tries to acquire the dedup
lock a second time. Rather than blocking on itself
forever, that second acquisition raises DedupLockReentryError — detected
synchronously, with no bound to configure, since whether the current task
already owns the lock is known immediately.
Say plainly what the bound is, since it is easy to state this in a way that
contradicts itself: it is a backstop that converts an unattributable hang
into an attributable error, and that backstop can fire on a legitimate
hold longer than the configured value — that is not a contradiction of its
purpose, it is why the value is configurable rather than fixed. Sizing it
requires real care for exactly that reason. bulk_store's batch embedding
call runs inside suspend_auto_commit()'s window (the batch's atomicity
spans insert + embed + commit as one unit), so this lock is held for that
whole call — a genuine network round trip to an embedding provider, which
can legitimately take minutes for a large batch. A bound sized for "ordinary
contention" would fire on this correct, documented usage; the default is
instead derived from the shipped default provider's own worst-case
retry-exhaustion time (three attempts at a 60s timeout each, plus backoff —
about 183 seconds) with headroom on top for larger batches and for other
providers this store cannot see the timeout/retry budget of. That headroom
lowers the odds of a false positive; it does not remove them, since this
store cannot know every caller's actual worst case.
Configure it to match your own embedding provider and batch sizes:
store = SqliteEngravaCore(
conn,
embedding_provider=my_slow_provider,
auto_embed=True,
write_lock_acquire_timeout_seconds=900, # if your batches legitimately run longer
)or via from_config(..., write_lock_acquire_timeout_seconds=...). Raise it
if your own provider's or batch's worst case is close to or above the
default — a legitimate hold longer than the configured value fails loudly
instead of completing, and that failure looks identical to the deadlock case
from the exception alone. Lower it only for a deployment that never drives
bulk_store with network-bound embedding under load.
The safe idioms
Hold one store for the process lifetime and share it across tasks — see Deployment.
Let one task own a row, or partition edits so that no two tasks write the same field of the same row. This needs no locking at all.
Or serialise the read-modify-write yourself when tasks genuinely compete for one field:
# engrava does not make read-modify-write atomic; a caller-owned lock does. # edit_lock is one asyncio.Lock shared by every task that writes this way. async with edit_lock: thought = await store.get_thought(thought_id) if thought is not None: await store.update_thought( thought_id, confidence=min(1.0, thought.confidence + 0.1), )This closes the window within one process only — see Multiple stores, one database file.
suspend_auto_commit()no longer needs this discipline enforced by convention — see the dedicated section above. It remains true that only the task that opened the window (or a write nested inside it) should be the one issuing writes inside it; a different task's write now simply waits rather than racing in, which is whatbulk_store()relies on.
Optimistic concurrency and StaleDataError
update_thought, restore_thought, update_edge, update_action and
upsert_by_hash all carry a revision guard. Every core row (thought,
edge, action) has a revision column, and every one of these guarded
writes both checks and increments it in the same atomic UPDATE — revision = revision + 1 WHERE id = ? AND revision = ?. No caller action arms this:
unlike the updated_cycle guard this replaced (which nothing advanced on its
own, so an ordinary edit passed it silently), revision moves on every
guarded write to a row, automatically. update_edge and update_action carry
this guard for the first time — before this, neither could ever raise
StaleDataError.
StaleDataError means the guarded UPDATE matched no row. Two different
situations produce that, and the error does not tell them apart:
the row's
revisionis no longer the value this call read — any other guarded write landed on this row in between, whatever field it touched; orthe row no longer exists — a competing writer deleted it between this call's read and its write. (
ThoughtNotFoundError/ActionNotFoundError/ aValueErrorfor edges covers a row that was already missing when the call started, not one that vanished mid-call.)
The consequences are worth stating plainly:
An ordinary concurrent edit now raises
StaleDataError, whatever field it touched, the moment it lands on a row between this call's own read and write — becauserevisionmoved. The rejected update then writes nothing at all — no field of it reaches storage. Recover by re-reading the record and recomputing the change; do not replay the originalchanges.In the ordinary case of two genuinely concurrent tasks on this store instance, this almost never fires: the write lock (Many async tasks, one store) already serialises them end to end, so the second call's own read happens strictly after the first call's write has committed, and its guard is captured against the post-first-call
revision.What it does catch, for the first time, is exactly the two shapes the write lock cannot reach: a same-task nested write issued from inside a caller-owned hook (the lock is task-reentrant, so it does not block this — see the narrow shape noted under Many async tasks, one store), and a write from a second store on the same database file — a second connection in this process, or a second process entirely. Both used to be silently lost (or, for the same-task case, spuriously rejected only if the competing write happened to stamp a cycle explicitly); both now raise
StaleDataErrorbefore anything is written. See Multiple stores, one database file for the cross-connection case in full.
StaleDataError is still not a general semantic-conflict detector: it is "this
write found no row to apply to", nothing more. It says nothing about two
sessions writing contradictory content to two different rows, and an
update_edge / update_action call that changes nothing (a true no-op) issues
no UPDATE at all, so it cannot go stale — there is nothing for it to be stale
against. If your application needs to know it read the row a specific caller
last wrote — not merely that nothing else has touched it since — that
caller-visible check is not yet part of the public API (it is designed, and
scheduled for a later release, but not shipped).
Busy timeout
When a connection can't immediately get the lock it needs (another writer holds
it), SQLite waits up to the busy timeout before giving up with
database is locked.
How the timeout is set depends on how the store opened its connection:
from_configandEngravaManager(engrava owns the connection) open it withPRAGMA busy_timeout=5000explicitly — a second connection waits up to 5 s for a lock instead of failing immediately. These paths also setPRAGMA synchronous=NORMAL, the documented-safe companion to WAL (durable across an application crash; only the most recent transactions are at risk on an OS crash or power loss).The manual
SqliteEngravaCore(conn)constructor (you own the connection) changes none of these pragmas. The connection keeps whatever it was opened with; Python'ssqlite3/aiosqlitedefaultbusy_timeoutalready happens to be 5000 ms, and the defaultsynchronousisFULL.
For workloads with more write contention you can raise the timeout on your own
connection before handing it to the store, or after from_config via the store's
connection:
import aiosqlite
from engrava import SqliteEngravaCore
conn = await aiosqlite.connect("engrava.db")
conn.row_factory = aiosqlite.Row
await conn.execute("PRAGMA busy_timeout = 15000") # wait up to 15s for a lock
store = SqliteEngravaCore(conn)
await store.ensure_schema()A longer busy timeout trades latency-on-contention for fewer database is locked
errors; tune it to your write pattern.
The content-hash dedup path is the one exception to "raw database is locked." create_thought(deduplicate=True), get_or_create(),
upsert_by_hash() and bulk_store(deduplicate=True) open their probe-and-row
window with BEGIN IMMEDIATE when the connection has no transaction open,
instead of letting the probe's SELECT open an
implicit deferred transaction — so lock contention surfaces at the start of the
window rather than only once the eventual INSERT/UPDATE needs the lock,
which is where a deferred transaction would otherwise invite the classic
embedded-SQLite deadlock. The busy timeout above still applies to each
attempt; on top of it, the call retries a bounded number of times with a
short backoff and, only once that is exhausted, raises WriteContentionError
(a subclass of EngravaError) instead of leaking sqlite3.OperationalError.
Retrying the call outright is safe — nothing was read or written before the
lock was acquired.
At the top level, for the single-row calls — create_thought(deduplicate=True),
get_or_create() and upsert_by_hash() — the lock is released again as soon
as the row is written, before auto-embed or the on_store hook run, so a slow
or stalled embedding call cannot hold the file's write lock hostage. Nested
inside the caller's own suspend_auto_commit() window, _write_lock stays
held through auto-embed and the hook too — see Many async tasks, one
store for why.
upsert_by_hash()'s hit branch can run a subclass override inside this
window. Whether it is the exploratory probe's hit or the
decisive probe's hit (see Many async tasks, one store),
the hit branch updates the matched row by calling the public, overridable
update_thought when a mutable field differs (otherwise it returns the stored
row untouched) — before the window closes, so update_thought
is called there while _write_lock and the in-process _dedup_lock are
held. The
exploratory hit never reaches the pre-insert preparation seam; the decisive hit
comes after the seam has already run once for the call. Which of the two routes
predates the seam and which one the seam introduced is stated once, in
Extension hooks
§1B.3 —
not repeated here. An update_thought override reached this way must not
call back into
create_thought(deduplicate=True) / get_or_create /
upsert_by_hash / bulk_store(deduplicate=True) on the same task
(_dedup_lock is not reentrant, so a second acquisition on
the same task raises DedupLockReentryError rather than deadlocking — see
below).
The probe-and-row window used to have a second exposure, in-process: a
concurrent, unrelated write from a different task on this same instance
(record_access(), update_thought(), a delete, ...) could land while the
window was open — joining its transaction, made durable a little earlier than
that other task's own commit would have if this call's write succeeded, and
rolled back along with this call's own work if it failed. That was the
"riding along" exposure: a connection has exactly one transaction, so once
another task's write had joined this one there was no way to tell the two
apart when deciding what to commit or roll back. An in-process
task-reentrant write lock closes it
in-process — the probe-and-row window now runs under the same _write_lock
every other guarded write does, so a different task's write can no longer even
start until the window has closed. It is not closed across connections: a
second store's write still can, which is exactly Multiple stores, one
database file's subject.
bulk_store(deduplicate=True) does not share this property, by design.
Its insert loop, and — when auto-embed is on
— the one batch embedding call it makes afterwards, run inside a single
suspend_auto_commit transaction (see
Many async tasks, one store), so the write lock
taken for the first row's probe is held until the whole batch commits at the
end: across every row's insert and across the batch embedding call. This is
not new behaviour. Before BEGIN IMMEDIATE existed on this path, the first
INSERT in the loop already took SQLite's RESERVED lock implicitly and held
it for exactly as long, because bulk_store is documented as one
all-or-nothing transaction per batch when it owns that transaction outright
(see the API reference for the nested-caller case, where
a caught row error still commits the batch's successful prefix, and a caught
embedding failure — which only fires after every row is already inserted —
commits the whole batch instead; the lock is held for the same span either
way). BEGIN IMMEDIATE only moves when the
lock is acquired — to before the first row's probe instead of at the first
row's insert — it does not change how long the lock is held.
One case falls back to the pre-BEGIN IMMEDIATE behaviour: a caller already
holding an open transaction on the connection through means this store does
not manage (a raw BEGIN issued directly against it, with nothing written
yet). This store cannot open a second BEGIN inside an already-open
transaction, so it cannot acquire the cross-connection write lock up front in
that case, and falls back to whatever atomicity the caller's own transaction
provides. It still gets the in-process _write_lock — a
different task on this instance still cannot land inside the window — only
the cross-connection ordering is unavailable here. Nothing in bulk_store or
suspend_auto_commit triggers this — both are covered by the paragraph
above — it can only arise from direct, unmediated use of the underlying
connection.
Multiple stores, one database file
Engrava does not currently support more than one store writing the same database file — whether those stores are in two processes or are two connections inside one process.
That is a statement about engrava, not about SQLite. WAL (by default) and
PRAGMA busy_timeout=5000 are configured, and they do exactly what they say: the
file tolerates several connections, readers do not block the writer, and SQLite
serialises the writers so the database itself is never corrupted. What they
cannot do is make engrava's own multi-statement operations correct across
connections, because every mechanism that orders those operations lives on a
single store object and stops at its boundary:
An update landing in the window is now rejected, not silently discarded. The read-modify-write window described above does not stop at the store — a second store's edit can still land inside it, since no in-process lock could ever reach across a connection boundary — but the
revisionguard lives in the database itself, not in a lock, so it closes this window anyway: the first store's own guarded write readsrevisionfresh at write time, finds the second store's committed bump, matches no row, and raisesStaleDataErrorinstead of overwriting. This is a correctness improvement, not a support statement — multiple writers on one file remain outside what this store is built and tested for (below), and a caller still needs a retry idiom for the newly-typed rejection.deduplicate=True,get_or_create(),upsert_by_hash()andbulk_store(deduplicate=True)no longer duplicate across stores. The in-processasyncio.Lockis still per store instance, but the probe-and-row window itself now opens withBEGIN IMMEDIATE(see Busy timeout) as soon as no transaction is already open on the connection — including the first row of abulk_storebatch, which covers the whole batch under that same lock even thoughbulk_storedefers every row's own commit to the end. A second store reaching the same window while it is open cannot even start its own transaction, so the two can no longer both probe unlocked and both insert. Ordinary contention is waited out; only past a bounded number of retries does the second store raiseWriteContentionErrorinstead of getting a turn. The one case this does not cover — a caller holding a raw, unmediated transaction on the connection — is the same residual gap named under Busy timeout.The audit journal's sequence numbers collide. When journaling is enabled, appends are serialised by an
asyncio.Lockkeyed on the connection. A second store has a second connection and therefore a different lock — in another process, where no lock could be shared at all, but equally in this one. Two stores journaling the same database can race the journal's monotonicsequence_number. The writer makes at most five total attempts — the first plus up to four retries on aUNIQUEcollision; if contention persists past that it raises:RuntimeError: Failed to append journal entry after 5 retries due to sequence contentionThat error is the loud symptom of this topology. The first of the three now raises
StaleDataError(above) rather than staying silent; the second is no longer a failure in the case this store manages — only the raw-transaction fallback described above still fails the same way, silently.
Several things do survive a second connection, and they are worth knowing so
the rule is not read as broader than it is: reading (any number of
processes may read one file under WAL); the relative confirmation_count
bump, evaluated by SQLite against the stored row rather than derived from a
prior read; and, as of the BEGIN IMMEDIATE window described above, the
dedup probe-and-insert itself — no longer racy, whether it lands as a fresh
insert or a confirmation bump.
Making the read-modify-write paths correct across connections needs a transaction-level mechanism engrava does not have yet. It is deferred, not ruled out; until it ships, treat one writer per database file as the contract. If you need multiple independent writers, give each its own database (next section).
Per-service isolation
EngravaManager runs one database file per named service, each with its own
connection and its own lock. This is the supported way to isolate writers (per
tenant, per worker, per logical partition):
from engrava import EngravaManager, load_config
config = load_config("engrava.yaml")
async with await EngravaManager.from_config(config.services) as mgr:
store_a = await mgr.get_store("tenant_a") # tenant_a.db
store_b = await mgr.get_store("tenant_b") # tenant_b.dbBecause each service is a separate file, writes to tenant_a never contend with
writes to tenant_b, and each can be backed up or deleted independently. See the
scoping section
for when to choose per-service isolation over in-store filtering.
Summary
| Scenario | Supported? | Notes |
|---|---|---|
| Many async tasks, one store, one loop | ✅ | The normal case — share the store. The rows below qualify it. |
| Many readers (WAL) | ✅ | Readers never block the writer. |
| One writer at a time | ✅ | SQLite serialises writes. |
| Two tasks editing different fields of one row | ✅ | An update writes only the columns it owns, and the write lock serialises the two calls end to end, so the second one's revision guard always matches. |
| A competing guarded write landing between one call's own read and write | ✅ (rejected) | revision bumps on every guarded write automatically; a write that lands in that window — a same-task nested call, or a second store on the file — makes the guard match no row, and the call raises StaleDataError rather than landing partially or silently. |
| Two tasks editing the same field of one row | ✅ | Still last write wins — that is the correct outcome for two genuine edits — but each call's own read and write can no longer be corrupted by the other landing mid-operation. |
| Two tasks moving one row through its state machine | ✅ | Each transition is validated against the row the previous, now-completed call actually left — a forbidden composite move is rejected instead of silently landing. |
| Two auto-embed provider calls for the same thought complete out of order | ✅ (rejected) | Each completion re-checks the thought's current essence/content against what it embedded before installing the vector; a completion for now-superseded content is dropped instead of overwriting a newer vector. |
suspend_auto_commit() with another writer on that store | ✅ | The window now holds a task-reentrant lock for its duration; a different task's write waits instead of joining the window's transaction. |
| One store across multiple event loops | ❌ | The store's asyncio.Locks bind to one loop; one store per loop. |
| Many processes reading the same file | ✅ | WAL supports concurrent readers. |
| Many stores/processes writing the same file | ❌ | Not supported — one writer per file; use EngravaManager. |
See also
Deployment — process model, files on disk, graceful shutdown
Known Limitations — the aiosqlite proxy and write-safety notes
Error handling and recovery — what to do when a write fails
Audit Trail — the journal whose lock is discussed above
402 files · 357 not served here
- .commitlintrc.js (not served here)
- .cursorrules (not served here)
- .gitattributes (not served here)
- .githooks/commit-msg (not served here)
- .github/ISSUE_TEMPLATE/bug.md (not served here)
- .github/ISSUE_TEMPLATE/config.yml (not served here)
- .github/ISSUE_TEMPLATE/feature.md (not served here)
- .github/ISSUE_TEMPLATE/question.md (not served here)
- .github/copilot-instructions.md
- .github/dependabot.yml (not served here)
- .github/pull_request_template.md (not served here)
- .github/release-notes-template.md (not served here)
- .github/workflows/branch-name-guard.yml (not served here)
- .github/workflows/ci.yml (not served here)
- .github/workflows/commitlint.yml (not served here)
- .github/workflows/main-carries-the-released-tag.yml (not served here)
- .github/workflows/release-merge-discipline.yml (not served here)
- .github/workflows/release.yml (not served here)
- .github/workflows/secret-scan.yml (not served here)
- .github/workflows/smoke-gate.yml (not served here)
- .gitignore (not served here)
- .releaserc.json (not served here)
- AGENTS.md
- ATTRIBUTION.md
- AUTOGINEER-RUNS.md
- BRANCHING.md
- CHANGELOG.md
- CLAUDE.md
- CODE_OF_CONDUCT.md
- CONTRIBUTING.md
- LICENSE
- MANIFEST.in (not served here)
- Makefile (not served here)
- README.md
- SECURITY.md
- commit-scopes.json (not served here)
- docs/api-reference.md
- docs/architecture.md
- docs/audit-trail.md
- docs/backup-and-recovery.md
- docs/benchmarks.md
- docs/bitemporal.md
- docs/cli.md
- docs/concepts.md
- docs/concurrency.md
- docs/configuration.md
- docs/data-lifecycle.md
- docs/deployment.md
- docs/dreaming.md
- docs/error-handling.md
- docs/evidence-and-conflicts.md
- docs/extension-hooks.md
- docs/extensions.md
- docs/faq.md
- docs/glossary.md
- docs/guides/agent-memory.md (not served here)
- docs/guides/embeddings.md (not served here)
- docs/guides/migrating-from-other-memory.md (not served here)
- docs/index.md
- docs/known-limitations.md
- docs/memory-hygiene.md
- docs/mindql.md
- docs/observability.md
- docs/performance.md
- docs/positioning.md
- docs/quickstart.md
- docs/recipes/index.md (not served here)
- docs/scopes.md
- docs/search.md
- docs/security.md
- docs/troubleshooting.md
- docs/tutorial.md
- docs/upgrade.md
- examples/README.md (not served here)
- examples/agent_loop.py (not served here)
- examples/config.yaml (not served here)
- examples/mcp-client-config.db-path.json (not served here)
- examples/mcp-client-config.json (not served here)
- examples/mcp-client-config.readonly.json (not served here)
- examples/mcp-client-config.vscode.json (not served here)
- examples/notes_memory.py (not served here)
- examples/profile-lexical.yaml (not served here)
- examples/profile-local.yaml (not served here)
- examples/profile-network-ollama.yaml (not served here)
- examples/quickstart.py (not served here)
- examples/simple_agent.py (not served here)
- pyproject.toml (not served here)
- release-target.json (not served here)
- scripts/backfill_content_hashes.py (not served here)
- scripts/build_upgrade_fixture.py (not served here)
- scripts/bump_pyproject_version.py (not served here)
- scripts/check_computed_version_matches_target.py (not served here)
- scripts/check_cpu_only_torch.py (not served here)
- scripts/check_hooks_active.sh (not served here)
- scripts/check_main_carries_the_released_tag.py (not served here)
- scripts/check_release_branch_merge_discipline.py (not served here)
- scripts/check_release_target_was_published.py (not served here)
- scripts/check_schema_release_gate.py (not served here)
- scripts/check_smoke_gate.py (not served here)
- scripts/install_gitleaks.sh (not served here)
- scripts/install_hooks.sh (not served here)
- scripts/rebalance_p1.py (not served here)
- scripts/reenrich_reflections_to_v2.py (not served here)
- scripts/regenerate_search_goldens.py (not served here)
- scripts/run_schema_release_gate.sh (not served here)
- scripts/run_upgrade_test.sh (not served here)
- scripts/verify_artifact_version.py (not served here)
- scripts/verify_dist_cardinality.py (not served here)
- scripts/verify_release_artifacts.sh (not served here)
- scripts/verify_release_content_scan.sh (not served here)
- scripts/verify_wheel_data.py (not served here)
- scripts/verify_wheel_execution.py (not served here)
- src/engrava/__init__.py (not served here)
- src/engrava/_composition.py (not served here)
- src/engrava/benchmarks/__init__.py (not served here)
- src/engrava/benchmarks/longmemeval/README.md (not served here)
- src/engrava/benchmarks/longmemeval/__init__.py (not served here)
- src/engrava/benchmarks/longmemeval/__main__.py (not served here)
- src/engrava/benchmarks/longmemeval/cli.py (not served here)
- src/engrava/benchmarks/longmemeval/dataset_loader.py (not served here)
- src/engrava/benchmarks/longmemeval/evaluate.py (not served here)
- src/engrava/benchmarks/longmemeval/runner.py (not served here)
- src/engrava/benchmarks/synthetic/README.md (not served here)
- src/engrava/benchmarks/synthetic/__init__.py (not served here)
- src/engrava/benchmarks/synthetic/__main__.py (not served here)
- src/engrava/benchmarks/synthetic/datasets/__init__.py (not served here)
- src/engrava/benchmarks/synthetic/datasets/synthetic-v1.json (not served here)
- src/engrava/benchmarks/synthetic/evaluate.py (not served here)
- src/engrava/benchmarks/synthetic/generate.py (not served here)
- src/engrava/benchmarks/synthetic/runner.py (not served here)
- src/engrava/benchmarks/synthetic/scenarios.py (not served here)
- src/engrava/cli/__init__.py (not served here)
- src/engrava/cli/config.py (not served here)
- src/engrava/cli/exception_reporting.py (not served here)
- src/engrava/cli/main.py (not served here)
- src/engrava/cli/memory_commands.py (not served here)
- src/engrava/cli/snapshot_records.py (not served here)
- src/engrava/cli/store_resolution.py (not served here)
- src/engrava/config.py (not served here)
- src/engrava/config_validation.py (not served here)
- src/engrava/cycle_providers.py (not served here)
- src/engrava/domain/__init__.py (not served here)
- src/engrava/domain/dreaming.py (not served here)
- src/engrava/domain/enums.py (not served here)
- src/engrava/domain/exceptions.py (not served here)
- src/engrava/domain/manifest.py (not served here)
- src/engrava/domain/models/__init__.py (not served here)
- src/engrava/domain/models/_temporal.py (not served here)
- src/engrava/domain/models/action.py (not served here)
- src/engrava/domain/models/edge.py (not served here)
- src/engrava/domain/models/embedding.py (not served here)
- src/engrava/domain/models/filters.py (not served here)
- src/engrava/domain/models/journal.py (not served here)
- src/engrava/domain/models/metrics.py (not served here)
- src/engrava/domain/models/mutation_type.py (not served here)
- src/engrava/domain/models/provenance.py (not served here)
- src/engrava/domain/models/search.py (not served here)
- src/engrava/domain/models/thought.py (not served here)
- src/engrava/domain/models/ttl.py (not served here)
- src/engrava/domain/protocols/__init__.py (not served here)
- src/engrava/domain/protocols/cycle_provider.py (not served here)
- src/engrava/domain/protocols/derived_records.py (not served here)
- src/engrava/domain/protocols/dreaming.py (not served here)
- src/engrava/domain/protocols/embedding_provider.py (not served here)
- src/engrava/domain/protocols/engrava_core.py (not served here)
- src/engrava/domain/protocols/engrava_read.py (not served here)
- src/engrava/domain/protocols/hooks.py (not served here)
- src/engrava/embeddings/__init__.py (not served here)
- src/engrava/embeddings/callback.py (not served here)
- src/engrava/embeddings/huggingface.py (not served here)
- src/engrava/embeddings/ollama.py (not served here)
- src/engrava/embeddings/openai_compatible.py (not served here)
- src/engrava/embeddings/sentence_transformer.py (not served here)
- src/engrava/extensions/__init__.py (not served here)
- src/engrava/extensions/discovery.py (not served here)
- src/engrava/extensions/dreaming.py (not served here)
- src/engrava/extensions/dreaming_cluster_quality.py (not served here)
- src/engrava/extensions/dreaming_keyphrases.py (not served here)
- src/engrava/extensions/dreaming_reflection_content.py (not served here)
- src/engrava/extensions/dreaming_reflection_extent.py (not served here)
- src/engrava/extensions/dreaming_signals.py (not served here)
- src/engrava/extensions/structural_split.py (not served here)
- src/engrava/extensions/vector_sqlite_vec.py (not served here)
- src/engrava/infrastructure/__init__.py (not served here)
- src/engrava/infrastructure/read_only_store.py (not served here)
- src/engrava/infrastructure/service_manager.py (not served here)
- src/engrava/infrastructure/sqlite/__init__.py (not served here)
- src/engrava/infrastructure/sqlite/aiosqlite_connect.py (not served here)
- src/engrava/infrastructure/sqlite/centroid.py (not served here)
- src/engrava/infrastructure/sqlite/connection_revocation.py (not served here)
- src/engrava/infrastructure/sqlite/engrava_core.py (not served here)
- src/engrava/infrastructure/sqlite/extension_migrations.py (not served here)
- src/engrava/infrastructure/sqlite/hygiene.py (not served here)
- src/engrava/infrastructure/sqlite/journal_writer.py (not served here)
- src/engrava/infrastructure/sqlite/schema_core.sql (not served here)
- src/engrava/infrastructure/sqlite/vector_sqlite_vec.py (not served here)
- src/engrava/metadata.py (not served here)
- src/engrava/mindql/__init__.py (not served here)
- src/engrava/mindql/executor.py (not served here)
- src/engrava/mindql/parser.py (not served here)
- src/engrava/py.typed (not served here)
- tests/__init__.py (not served here)
- tests/_shell_command.py (not served here)
- tests/_workflow_yaml.py (not served here)
- tests/benchmarks/__init__.py (not served here)
- tests/benchmarks/fixtures/longmemeval_tiny.json (not served here)
- tests/benchmarks/test_longmemeval_cli.py (not served here)
- tests/benchmarks/test_longmemeval_evaluate.py (not served here)
- tests/benchmarks/test_longmemeval_hygiene.py (not served here)
- tests/benchmarks/test_longmemeval_llm_mode.py (not served here)
- tests/benchmarks/test_longmemeval_loader.py (not served here)
- tests/benchmarks/test_longmemeval_runner.py (not served here)
- tests/benchmarks/test_synthetic_e2e.py (not served here)
- tests/benchmarks/test_synthetic_evaluate.py (not served here)
- tests/benchmarks/test_synthetic_frozen.py (not served here)
- tests/benchmarks/test_synthetic_generator.py (not served here)
- tests/benchmarks/test_synthetic_scenarios.py (not served here)
- tests/conftest.py (not served here)
- tests/data/bare_command_goldens.json (not served here)
- tests/docs/__init__.py (not served here)
- tests/docs/_documented_defaults_scan.py (not served here)
- tests/docs/_import_resolution.py (not served here)
- tests/docs/_md_blocks.py (not served here)
- tests/docs/test_docs_config_defaults.py (not served here)
- tests/docs/test_docs_config_examples.py (not served here)
- tests/docs/test_docs_examples_behavior.py (not served here)
- tests/docs/test_docs_examples_compile.py (not served here)
- tests/docs/test_docs_examples_coverage.py (not served here)
- tests/docs/test_docs_examples_execute.py (not served here)
- tests/docs/test_docs_examples_full_census.py (not served here)
- tests/docs/test_docs_examples_imports.py (not served here)
- tests/docs/test_docs_exceptions_table.py (not served here)
- tests/docs/test_docs_no_commit_citations.py (not served here)
- tests/docs/test_docs_shell_examples.py (not served here)
- tests/docs/test_error_handling_names_every_exception.py (not served here)
- tests/docs/test_md_blocks_fences.py (not served here)
- tests/docs/test_schema_version_error_docs_coverage.py (not served here)
- tests/domain/__init__.py (not served here)
- tests/domain/models/__init__.py (not served here)
- tests/domain/models/test_filters.py (not served here)
- tests/domain/models/test_temporal_fields.py (not served here)
- tests/domain/test_derived_records_types.py (not served here)
- tests/examples/__init__.py (not served here)
- tests/examples/_examples_census.py (not served here)
- tests/examples/test_examples_cleanup_close.py (not served here)
- tests/examples/test_examples_readme_census.py (not served here)
- tests/examples/test_mcp_client_configs.py (not served here)
- tests/examples/test_quickstart_runs.py (not served here)
- tests/extensions/__init__.py (not served here)
- tests/extensions/test_structural_split.py (not served here)
- tests/search_contract/__init__.py (not served here)
- tests/search_contract/conftest.py (not served here)
- tests/search_contract/golden_fixtures.py (not served here)
- tests/search_contract/goldens/from_config_search.yaml (not served here)
- tests/search_contract/goldens/fts_expert_normalization.json (not served here)
- tests/search_contract/goldens/hybrid_ranked_results.json (not served here)
- tests/search_contract/goldens/hybrid_ranked_results_from_config.json (not served here)
- tests/search_contract/test_archived_retrieval_exclusion.py (not served here)
- tests/search_contract/test_cross_arm_liveness.py (not served here)
- tests/search_contract/test_fts_expert_syntax_fallback.py (not served here)
- tests/search_contract/test_fts_fallback_log_content.py (not served here)
- tests/search_contract/test_fts_query_normalization_robustness.py (not served here)
- tests/search_contract/test_hybrid_weight_conservation.py (not served here)
- tests/search_contract/test_search_contract.py (not served here)
- tests/search_contract/test_search_goldens.py (not served here)
- tests/search_contract/test_vector_arm_degradation.py (not served here)
- tests/test_action_outcome_feedback.py (not served here)
- tests/test_aiosqlite_connect.py (not served here)
- tests/test_auto_embed_failure_log_content.py (not served here)
- tests/test_backfill_content_hashes.py (not served here)
- tests/test_begin_immediate_contention_is_typed.py (not served here)
- tests/test_cancellation_during_write_unit_entry.py (not served here)
- tests/test_check_cpu_only_torch.py (not served here)
- tests/test_ci_workflow_permissions.py (not served here)
- tests/test_cli.py (not served here)
- tests/test_cli_memory_verbs.py (not served here)
- tests/test_cli_memory_verbs_config.py (not served here)
- tests/test_cli_memory_verbs_embedding.py (not served here)
- tests/test_cli_memory_verbs_error_boundary.py (not served here)
- tests/test_cli_store_resolution_goldens.py (not served here)
- tests/test_cognitive_journal.py (not served here)
- tests/test_commit_scope_vocabulary.py (not served here)
- tests/test_concurrency_contract.py (not served here)
- tests/test_config.py (not served here)
- tests/test_config_validation_parity.py (not served here)
- tests/test_create_delete_journal_append_atomicity.py (not served here)
- tests/test_create_thought_performance.py (not served here)
- tests/test_cycle_provider.py (not served here)
- tests/test_de_fragmentation_collapse.py (not served here)
- tests/test_dedup_migration.py (not served here)
- tests/test_dedup_write_contention.py (not served here)
- tests/test_deletion_blast_radius.py (not served here)
- tests/test_deprecated_aliases.py (not served here)
- tests/test_derived_child_failure_transaction_isolation.py (not served here)
- tests/test_derived_records_seam.py (not served here)
- tests/test_derived_reuse_transaction_cleanup.py (not served here)
- tests/test_docs_anchor_links.py (not served here)
- tests/test_docs_no_deleted_observability_symbols.py (not served here)
- tests/test_dreaming.py (not served here)
- tests/test_dreaming_activation.py (not served here)
- tests/test_dreaming_boilerplate_filter.py (not served here)
- tests/test_dreaming_cluster_quality.py (not served here)
- tests/test_dreaming_clusters.py (not served here)
- tests/test_dreaming_content_v1_v2_compat.py (not served here)
- tests/test_dreaming_edges.py (not served here)
- tests/test_dreaming_gating_integration.py (not served here)
- tests/test_dreaming_integration.py (not served here)
- tests/test_dreaming_keyphrases.py (not served here)
- tests/test_dreaming_keyphrases_blocklist.py (not served here)
- tests/test_dreaming_lifecycle.py (not served here)
- tests/test_dreaming_metadata_filter.py (not served here)
- tests/test_dreaming_priority_bias.py (not served here)
- tests/test_dreaming_reflection_content.py (not served here)
- tests/test_dreaming_reflection_content_quality_fixes.py (not served here)
- tests/test_dreaming_reflection_extent.py (not served here)
- tests/test_edge_metadata.py (not served here)
- tests/test_edge_metadata_migration.py (not served here)
- tests/test_embedding_providers.py (not served here)
- tests/test_end_to_end_integration.py (not served here)
- tests/test_engrava_completeness.py (not served here)
- tests/test_exception_exports.py (not served here)
- tests/test_extension_infrastructure_boundary.py (not served here)
- tests/test_extension_load_with_migrations.py (not served here)
- tests/test_extension_migrations.py (not served here)
- tests/test_failed_commit_recovery.py (not served here)
- tests/test_from_config_integration.py (not served here)
- tests/test_hooks_active_gate.py (not served here)
- tests/test_hooks_wiring.py (not served here)
- tests/test_hot_path_index_migration.py (not served here)
- tests/test_hybrid_search_enhanced.py (not served here)
- tests/test_hygiene_journal_append_atomicity.py (not served here)
- tests/test_ingest_deduplication.py (not served here)
- tests/test_ingest_ergonomics.py (not served here)
- tests/test_invalidate.py (not served here)
- tests/test_journal_append_atomicity.py (not served here)
- tests/test_journal_append_recurrence_guard.py (not served here)
- tests/test_journal_verification.py (not served here)
- tests/test_main_carries_the_released_tag.py (not served here)
- tests/test_memory_hygiene.py (not served here)
- tests/test_metadata_helpers.py (not served here)
- tests/test_metadata_migration.py (not served here)
- tests/test_metrics.py (not served here)
- tests/test_metrics_measured_census.py (not served here)
- tests/test_migration_upgrade_chains.py (not served here)
- tests/test_mind_store_branches.py (not served here)
- tests/test_mind_store_core.py (not served here)
- tests/test_mindql.py (not served here)
- tests/test_outer_rollback_recovery.py (not served here)
- tests/test_partial_field_updates.py (not served here)
- tests/test_prepare_thought_for_insert_seam.py (not served here)
- tests/test_provenance_context_capture.py (not served here)
- tests/test_pyproject_urls.py (not served here)
- tests/test_quickstart_download_size_figure.py (not served here)
- tests/test_read_only_store.py (not served here)
- tests/test_rebalance_p1.py (not served here)
- tests/test_reenrich_reflections_to_v2.py (not served here)
- tests/test_referential_integrity.py (not served here)
- tests/test_reflection_centroid_staleness.py (not served here)
- tests/test_reflection_orphan_lifecycle.py (not served here)
- tests/test_release_branch_merge_discipline.py (not served here)
- tests/test_release_merge_discipline_workflow.py (not served here)
- tests/test_release_target_gate.py (not served here)
- tests/test_release_target_was_published.py (not served here)
- tests/test_remember_recall.py (not served here)
- tests/test_retrieval_microperf.py (not served here)
- tests/test_revision_guard_matrix.py (not served here)
- tests/test_schema_release_gate.py (not served here)
- tests/test_schema_version_gate.py (not served here)
- tests/test_scoped_retrieval.py (not served here)
- tests/test_search_defaults_single_source.py (not served here)
- tests/test_search_graph_weight.py (not served here)
- tests/test_search_hybrid_graph.py (not served here)
- tests/test_search_hybrid_reflection_cap.py (not served here)
- tests/test_search_top_k_floor.py (not served here)
- tests/test_service_isolation.py (not served here)
- tests/test_service_manager_cancellation_isolation.py (not served here)
- tests/test_shell_command.py (not served here)
- tests/test_smoke_gate.py (not served here)
- tests/test_snapshot_dreaming_roundtrip.py (not served here)
- tests/test_snapshot_records.py (not served here)
- tests/test_sqlite_vec.py (not served here)
- tests/test_stale_embedding_completion.py (not served here)
- tests/test_store_embedding_atomicity.py (not served here)
- tests/test_temporal_query_performance.py (not served here)
- tests/test_thought_metadata.py (not served here)
- tests/test_tier_boundary_guard.py (not served here)
- tests/test_timestamp_canonical_form.py (not served here)
- tests/test_timestamp_normalisation_migration.py (not served here)
- tests/test_timestamp_parameters.py (not served here)
- tests/test_transaction_recency.py (not served here)
- tests/test_ttl_auto_expiry.py (not served here)
- tests/test_two_process_dedup.py (not served here)
- tests/test_two_process_read_after_the_wait.py (not served here)
- tests/test_two_process_write_busy_wait.py (not served here)
- tests/test_update_readback_atomicity.py (not served here)
- tests/test_valid_interval_invariant.py (not served here)
- tests/test_valid_time_migration.py (not served here)
- tests/test_vec0_numpy_ranking_parity.py (not served here)
- tests/test_verify_wheel_execution.py (not served here)
- tests/upgrade/__init__.py (not served here)
- tests/upgrade/fixtures.py (not served here)
- tests/upgrade/test_upgrade_matrix.py (not served here)
Advantages
- Zero external service dependencies in the core write path. evidence: README.md
- Supports bi-temporal valid-time predicates for querying facts as of any instant. evidence: docs/index.md
- Provides a tamper-evident journal for auditing thought and edge mutations. evidence: README.md
Limitations
- Requires an optional embedding provider for semantic vector search functionality. evidence: docs/index.md
- The local embedding model option involves a large initial download. evidence: README.md
- Garbage collection of archived thoughts is a destructive, non-reversible operation. evidence: README.md
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