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/api-reference.md

API Reference

Complete reference for engrava's public API.

Core Store

SqliteEngravaCore

The main persistence engine. All operations are async.

SqliteEngravaCore wraps an already-open aiosqlite.Connection. For a config-driven, one-call setup that opens and owns the connection, use the from_config factory instead.

import aiosqlite
from engrava import SqliteEngravaCore

# Abbreviated for brevity: a bare async-with here can let a close failure
# mask a real error. See "Graceful shutdown" in deployment.md for the safe
# explicit-close shape.
async with aiosqlite.connect(":memory:") as conn:
    conn.row_factory = aiosqlite.Row
    store = SqliteEngravaCore(conn)
    await store.ensure_schema()

# Or, config-driven (opens and owns the connection):
async with await SqliteEngravaCore.from_config("engrava.yaml") as store:
    ...  # schema already applied by from_config
Constructor
ParameterTypeDefaultDescription
dbaiosqlite.Connection—An already-open aiosqlite connection (set row_factory = aiosqlite.Row)
hooksEngravaHooksProtocol | NoneNoneExtension hooks (defaults to DefaultEngravaHooks)
embedding_providerEmbeddingProviderProtocol | NoneNoneProvider used when auto_embed=True
auto_embedboolFalseAuto-embed thoughts on create/update
require_embeddingboolFalseWhen True, a failure of the provider's embed call raises EmbeddingGenerationError instead of only logging a WARNING and re-raising the provider error (see Embeddings for what that covers). Two independent questions decide the outcome for the thought row. If the call does not own the 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, so a caller that catches the failure and exits that window cleanly still commits every row inserted so far, single item or batch alike, while an uncaught failure rolls it back (see bulk_store below). If the call does own the outermost transaction, the path decides: create_thought/update_thought have already committed by the time embedding runs, so the failure cannot undo them; a standalone bulk_store instead wraps its whole batch's inserts and trailing embed call in one transaction, so a failure there rolls the whole batch back regardless, and none of it is persisted (batch auto-embed is one provider call made after every row is inserted, not a per-row step, so nothing is aborted partway). What is left behind is path-specific and only meaningful for whatever actually committed: create_thought leaves no embedding row at all; update_thought leaves any embedding the row already had in place — if the update committed, that embedding is now stale against the new content, and the row is still findable by vector search against that outdated vector; if the row had no embedding before, it still has none, and remains unfindable by vector search; if the update instead rolled back — only possible when this call is nested inside a caller's own window and the caller lets the failure escape it — the durable state reverts to whatever existed when that outermost window opened, not merely to what this call itself started from: an earlier write to the same thought inside the same window is undone right along with it; if the thought was created inside that same window, it no longer exists at all afterward; if it already existed before the window opened, it reverts to that pre-window state, and the retained embedding matches it only if it already did — an earlier update on the same thought, before this window ever opened, whose own re-embed failed can already have left that pre-window state stale, and this rollback neither detects nor repairs that. No effect unless auto_embed=True.
search_configSearchConfig | NoneNoneDefault hybrid-search weights, recency half-lives, graph expansion, reflection handling, and bounded candidate-pool settings
journal_enabledboolFalseRecord mutations in the hash-chain journal
ttl_strategystr"archive"Expiry action: "archive" or "delete"
ttl_check_every_nint0Automatic expiry-cleanup cadence, counted in thought create/update calls, not every store operation; 0 disables automatic cleanup
ttl_default_secondsint | NoneNoneDefault relative TTL for new thoughts
metrics_configMetricsConfig | NoneNoneMetrics enablement and latency-window settings
manifestsSequence[ExtensionManifest]()Extension manifests whose schema migrations are applied by ensure_schema()
access_tracking_enabledboolFalseBuffer retrieval access events for the dreaming frequency signal
hygiene_policyHygienePolicyConfig | NoneNoneMemory Hygiene policy; None means run_hygiene() is unavailable
derive_gatesDeriveGates | NoneNoneAutomatic derived-record production gates; defaults to disabled gates
cycle_providerCycleProvider | NoneNoneRuntime-only cognitive-cycle source used when a supported call omits current_cycle and no explicit recency_now selects transaction-time recency; explicit cycle values, including 0, win
write_lock_acquire_timeout_secondsfloat600.0Bound on how long a different task waits to acquire the in-process guarded-write lock before WriteLockTimeoutError is raised instead of hanging; the same task re-enters its own lock for free regardless of this value. Raise it for a store whose embedding provider or batch sizes can legitimately hold the lock (e.g. bulk_store's batch embedding call) longer than the default covers

The SqliteEngravaCore(db_path=...) form does not exist — pass a connection, or use await SqliteEngravaCore.from_config(path).

Factory
MethodReturnsDescription
await SqliteEngravaCore.from_config(config_path, *, cycle_provider=None, write_lock_acquire_timeout_seconds=600.0)SqliteEngravaCoreBuild from a YAML config; opens + owns the connection and applies the schema. cycle_provider is a runtime object and is never read from YAML. write_lock_acquire_timeout_seconds is forwarded verbatim to the constructor (below) and is likewise never read from YAML — a runtime tuning knob, not corpus-affecting configuration. Use the result as an async context manager.
Schema
MethodDescription
await ensure_schema()Create tables if missing, run migrations
await verify_journal()Verify the complete persisted hash chain and return JournalIntegrityResult
await close()Close the database connection
Thought CRUD

create_thought takes a single frozen ThoughtRecord object (build it, then pass it) and returns the persisted record — it does not take field keyword arguments and does not return a UUID string.

MethodReturnsDescription
await remember(text, *, metadata=None, deduplicate=False)ThoughtRecordStore a bare string as an ACTIVE P3 NOTE at cycle 0; its first 200 characters become essence. Use an explicit ThoughtRecord when the caller owns cognitive cycles or needs other fields.
await create_thought(thought, *, expires_after_seconds=None, deduplicate=False)ThoughtRecordPersist a ThoughtRecord; returns the stored record. Raises ValueError if the ID already exists.
await get_or_create(thought, *, expires_after_seconds=None)tuple[ThoughtRecord, bool]Content-hash convenience over dedup: returns (existing, False) on a hash hit (confirmation bumped, like deduplicate=True) or (new, True) on a miss. The bool tells you whether it created, removing the check-then-create round trip. Does not alter a matched row's fields.
await upsert_by_hash(thought, *, expires_after_seconds=None)ThoughtRecordUpdate-on-match upsert: on a hash hit, overwrites the stored row's mutable fields (essence, priority, metadata, visibility, lifecycle_status, source, confidence, source_type, thought_type) from thought and returns it (no confirmation bump — distinct from deduplicate=True, which returns the stored record unchanged); on a miss, inserts. content is never rewritten (it is the hash key).
await bulk_store(thoughts, *, deduplicate=False)list[ThoughtRecord]Transactional batch insert: the whole list commits once. Called on its own, any row error rolls the whole batch back and nothing is persisted. Nested inside the caller's own suspend_auto_commit() window, a row error still aborts the batch's inserts, but only the outermost window's exit decides commit/rollback — if the caller catches the error and that outer window then exits cleanly, the batch's successful prefix commits along with the rest of the window's work. Order preserved. Under auto_embed, all thoughts are embedded in one batch provider call. deduplicate applies per row.
await get_thought(thought_id)ThoughtRecord | NoneRetrieve by ID; None if not found
await update_thought(thought_id, **changes)ThoughtRecordPartial update: writes only the fields named in changes (plus the updated_at stamp), so columns another writer changed meanwhile are preserved; returns the row read back after the write. Raises ThoughtNotFoundError if the thought is missing when the call starts. Raises StaleDataError when the guarded write matches no row — any other guarded write bumped the row's revision in between (which rejects this update whatever field it touched), or deleted the row — see Concurrency
await restore_thought(thought_id, *, current_cycle=None)ThoughtRecordUn-archive: transition an ARCHIVED thought back to ACTIVE, clearing both hygiene markers (archived_at_cycle and archived_at). It does not restore expires_at: an expiry cleared during hygiene archival stays cleared after restore. The reversible counterpart to the memory-hygiene / TTL / manual archive paths, journaled as an UPDATE_THOUGHT. Raises ThoughtNotFoundError if missing, InvalidTransitionError if the thought is not currently ARCHIVED, StaleDataError if the guarded write matches no row — another guarded write bumped revision in between, or deleted the row (see update_thought for what that guard does and does not catch). This is the canonical un-archive path; a raw update_thought(lifecycle_status=...) back to ACTIVE does not manage those markers.
await list_thoughts(...)list[ThoughtRecord]List with filters (keyword-only)
await count_thoughts(...)intCount with filters (keyword-only)
await delete_thought(thought_id)boolHard delete; True if a row was removed. Deleting a thought also deletes the edges for which it is either endpoint, its embedding, and its linked actions — delete_thought issues those deletes itself, in the same savepoint as the parent delete, rather than relying on the ON DELETE CASCADE the core-12 migration adds to edge, embedding and action. See Deletion on a database that has not been migrated for a database that already holds dangling embedding rows from an older engrava build.
await invalidate_thought(thought_id, valid_until)ThoughtRecordClose the thought's valid-time interval at the given ISO-8601 instant — deterministic, idempotent, non-cascading, and not a delete (the row stays on file and remains retrievable for instants before valid_until). Raises ThoughtNotFoundError if missing. See Bi-temporal Model
await record_access(thought_id)NoneMark a thought as accessed — bumps access_count and sets last_accessed_at; raises ThoughtNotFoundError if missing. Drives the access-frequency dreaming signal.
import uuid
from engrava import ThoughtRecord, ThoughtType, Priority, LifecycleStatus

record = ThoughtRecord(
    thought_id=str(uuid.uuid4()),
    thought_type=ThoughtType.OBSERVATION,
    essence="Short summary (1-200 chars)",
    content="Full text content.",
    priority=Priority.P2,
    lifecycle_status=LifecycleStatus.ACTIVE,
    created_cycle=0,
    updated_cycle=0,
    source="human",
)
stored = await store.create_thought(record)
from engrava import LifecycleStatus

# Archive, then restore — a lossless round-trip.
await store.update_thought(stored.thought_id, lifecycle_status=LifecycleStatus.ARCHIVED)
restored = await store.restore_thought(stored.thought_id, current_cycle=42)
assert restored.lifecycle_status is LifecycleStatus.ACTIVE
create_thought keyword-only options
ParameterTypeDefaultDescription
expires_after_secondsint | NoneNoneRelative TTL; overrides the store default
deduplicateboolFalseCollapse identical content (SHA-256) into the existing thought, bumping confirmation_count
list_thoughts / count_thoughts filters (keyword-only)
ParameterTypeDescription
thought_typestr | NoneFilter by type
lifecycle_statusstr | NoneFilter by status
prioritystr | NoneFilter by priority
include_expiredboolInclude expired thoughts (default False)
min_cycleint | NoneMinimum updated_cycle, inclusive (list_thoughts only)
max_cycleint | NoneMaximum updated_cycle, inclusive (list_thoughts only)
limitintMax results (list_thoughts only; default 50)
offsetintResults to skip (list_thoughts only; default 0)

list_thoughts also supports visibility, exclude_visibility, and provenance_filter (see Provenance capture).

Provenance capture

Attach optional, typed write-time provenance to a thought — the signals that become irrecoverable once a synthesised thought exists (which session/actor produced it, what query and instruction shaped it, which thoughts it was built from). Set ThoughtRecord.provenance to a ProvenanceContext before create_thought; every field is optional and bounded, and the default None is byte-identical to not using provenance at all.

from engrava.domain.models.provenance import ProvenanceContext

record = ThoughtRecord(
    # ... the usual fields ...
    provenance=ProvenanceContext(
        session_id="sess-42",                              # indexed identity hint
        actor_id="agent-a",                                # indexed identity hint
        retrieval_query="remote work trade-offs",          # ≤4096 chars
        instruction_context="summarise for a busy exec",   # ≤4096 chars
        retrieval_context_ids=["t-1", "t-2"],              # ≤128 ids, ≤256 chars each
    ),
)
await store.create_thought(record)
ProvenanceContext fieldTypeNotes
session_idstr | NoneSession handle. Indexed for lookup. ≤256 chars
actor_idstr | NoneActor/agent handle. Indexed. ≤256 chars
retrieval_querystr | NoneQuery text that retrieved the feeding context. ≤4096 chars
instruction_contextstr | NoneInstruction / system-prompt fragment. ≤4096 chars
retrieval_context_idslist[str] | NoneSource thought ids. Queryable, not indexed. ≤128 × ≤256 chars

Query by provenance. list_thoughts(provenance_filter=...) takes a MetadataFilter — an AND of FieldPredicates over the provenance JSON column. Predicates on $.session_id / $.actor_id use the provenance identity index; rows whose provenance is NULL or malformed JSON never match a non-empty filter.

from engrava.domain.models.filters import FieldOp, FieldPredicate, MetadataFilter

mine = await store.list_thoughts(
    provenance_filter=MetadataFilter(
        [FieldPredicate("$.session_id", FieldOp.EQ, "sess-42")]
    ),
)

Untrusted hint — never identity, authentication, or authorization. Provenance is descriptive-only: the engine grants it zero authority and consults it for no access, ranking, or consolidation decision. actor_id is not a tenant boundary — tenant isolation is the store's file boundary (one store per tenant). The engine never infers provenance; the caller passes it explicitly.

Edge CRUD

create_edge takes a single EdgeRecord object and returns the persisted record. It raises ReferentialIntegrityError when an endpoint thought does not exist and DuplicateEdgeError when the same directed, typed relationship already exists.

MethodReturnsDescription
await create_edge(edge)EdgeRecordPersist an EdgeRecord; raises ReferentialIntegrityError on a missing endpoint or DuplicateEdgeError for an existing (from, to, type) relationship
await get_edges(thought_id, *, direction='BOTH', limit=None)list[EdgeRecord]Edges for a thought (direction is 'IN'/'OUT'/'BOTH', keyword-only); limit must be None or an integer >= 0 and keeps the highest-weight edges, 0 returns an empty list, None returns every edge unordered. Raises ValueError if limit is negative or a bool
await list_edges(*, edge_type=None, source=None, filters=None, limit=5000)list[EdgeRecord]List edges with optional filters (filters is a typed MetadataFilter over the edge metadata; see the metadata field note)
await update_edge(edge_id, **changes)EdgeRecordUpdate edge fields — only those that actually change (a no-op writes nothing). Raises ValueError if the edge does not exist at the initial read. Raises StaleDataError on a real change whose guarded write matches no row — another guarded write bumped revision in between, or deleted the row — see Concurrency
await delete_edge(edge_id)boolHard delete; True if a row was removed
await invalidate_edge(edge_id, valid_until)EdgeRecordClose the edge's valid-time interval at the given ISO-8601 instant — deterministic, idempotent, and not a delete (the row stays on file). Invalidating a thought does not cascade to its edges; invalidate them separately. See Bi-temporal Model

The database permits at most one edge for each (from_thought_id, to_thought_id, edge_type) tuple, independently of edge_id; attempting to insert the same directed, typed relationship twice raises DuplicateEdgeError. Reversing the endpoints or using a different edge type produces a distinct edge. A duplicate caller-supplied edge_id for a different relationship remains a separate integrity failure. A hard thought delete cascades to edges where the thought is either endpoint; invalidate_thought() does not remove or invalidate any edge.

Below core schema 12 there is no such cascade. The ON DELETE CASCADE on edge, embedding and action arrives with the core-12 migration. delete_thought does not rely on it: it issues its own deletes for the thought's rows in those three tables, in the same savepoint as the parent delete, which runs first. See Deletion on a database that has not been migrated for what engrava migrate cleans up on a database that already holds dangling embedding rows.

import uuid
from engrava import EdgeRecord, EdgeType

await store.create_edge(
    EdgeRecord(
        edge_id=str(uuid.uuid4()),
        from_thought_id=src_id,
        to_thought_id=dst_id,
        edge_type=EdgeType.ASSOCIATED,
        weight=0.8,
        created_cycle=0,
    )
)
REFLECTION lineage

Helpers for navigating the CONSOLIDATED_FROM graph that dreaming builds between a REFLECTION and the source thoughts it summarises.

MethodReturnsDescription
await consolidated_member_ids(reflection_id)list[str]The thought IDs a REFLECTION was consolidated from
await consolidated_source_statuses(reflection_id)list[str]The lifecycle statuses of those source thoughts (e.g. to detect a fully-archived, orphaned cluster)
await reflections_consolidated_from(source_id)list[str]The REFLECTION IDs that consolidated a given source thought (the reverse direction)
await thought_exists_by_source(*, source, thought_type_value)boolWhether any thought exists with the given source and type — keyword-only
# Walk a REFLECTION down to its sources, and back from a source to its REFLECTIONs.
member_ids = await store.consolidated_member_ids(reflection_id)
for thought_id in member_ids:
    source = await store.get_thought(thought_id)
    if source is not None:
        print(source.essence)

# Detect an orphaned cluster — every source archived/gone:
statuses = await store.consolidated_source_statuses(reflection_id)
is_orphaned = bool(statuses) and all(s != "ACTIVE" for s in statuses)

# Reverse direction: which REFLECTIONs summarise this source?
parents = await store.reflections_consolidated_from(member_ids[0])

# Exact-source existence check (e.g. dreaming's idempotency guard — a REFLECTION's
# source is "dreaming:<cluster_hash>", so match the full value, not a prefix):
exists = await store.thought_exists_by_source(
    source="dreaming:abc123def4567890", thought_type_value="REFLECTION"
)
Embedding Operations
MethodReturnsDescription
await store_embedding(thought_id, vector, *, model_name="all-MiniLM-L12-v2", embedding_id=None)EmbeddingRecordStore an embedding vector (dimension derived from len(vector))
await get_embedding(thought_id)EmbeddingRecord | NoneRetrieve embedding
await search_similar(query_vector, top_k=10, threshold=0.0, *, include_archived=False)list[tuple[str, float]]Cosine similarity search → (thought_id, score). Raises VectorDimensionMismatchError on a wrong-length query vector; a degenerate vector degrades to [] (see Known Limitations).

search_fts and search_similar return (thought_id, score) tuples — fetch the record with get_thought when you need its fields. search_hybrid returns a single HybridSearchResult container.

MethodReturnsDescription
await recall(query, *, top_k=10, current_cycle=None, recency_now=None, recency_now_half_life=None, filters=None, visibility=None, collapse_key=None, collapse_max_per_unit=None, include_archived=False)HybridSearchResultErgonomic shorthand over search_hybrid for the common retrieval case; supports either recency axis, scoped retrieval, collapse, and archived-row opt-in
await search_fts(query, top_k=10, *, include_archived=False)list[tuple[str, float]]FTS5/BM25 text search → (thought_id, bm25_score); malformed expert expressions retry once through safe bare normalization
await search_hybrid(query_text, query_vector=None, *, top_k=10, ...)HybridSearchResultCombined FTS + vector + recency + priority + graph
await search_reflections_only(query_text, query_vector=None, *, top_k=10, current_cycle=None)HybridSearchResultSearch restricted to active, unexpired REFLECTION thoughts, ranked by vector similarity and optional cycle recency. Expiry is evaluated against one UTC instant captured for the call; expires_at <= now is excluded.

The complete search_hybrid signature is:

async def search_hybrid(
    query_text: str,
    query_vector: list[float] | None = None,
    *,
    top_k: int = 10,
    fts_weight: float | None = None,
    vector_weight: float | None = None,
    recency_weight: float | None = None,
    recency_half_life: int | None = None,
    current_cycle: int | None = None,
    recency_now: str | None = None,
    recency_now_half_life: int | None = None,
    fts_top_k: int = 50,
    vector_top_k: int = 50,
    priority_weight: float | None = None,
    graph_weight: float | None = None,
    graph_edge_decay: float | None = None,
    include_reflections: bool = True,
    reflection_boost: float | None = None,
    filters: MetadataFilter | None = None,
    visibility: VisibilityQueryFilter | None = None,
    collapse_key: str | Sequence[str] | None = None,
    collapse_max_per_unit: int | None = None,
    include_archived: bool = False,
) -> HybridSearchResult: ...

current_cycle and recency_now are mutually exclusive when both are supplied explicitly. recency_now_half_life is measured in seconds and is only valid with recency_now; recency_half_life is measured in cognitive cycles.

Archived thoughts are excluded by default. recall, search_fts, search_hybrid, and search_similar drop ARCHIVED thoughts from the default candidate set (the mechanism behind Forgetting). Pass include_archived=True to re-admit them for one call, or restore_thought to re-activate a row. search_reflections_only remains restricted to active reflections and has no archive opt-in. See Search.

Scoped & de-fragmented retrieval (keyword-only)

Both recall and search_hybrid accept the same four opt-in keywords for narrowing and de-duplicating ranked results. Every one defaults to None and the None path is byte-identical to an unscoped query, so existing callers see no change.

ParameterTypeDescription
filtersMetadataFilter | NoneMetadata scope — an AND of typed FieldPredicates over the thought's metadata, applied in-arm before each arm's candidate limit so it never starves top_k. None (or an empty filter) leaves the candidate set unchanged. A query refinement, not a security boundary.
visibilityVisibilityQueryFilter | NoneBounded (visibility IN … [OR owner = …]) refinement over $.visibility / $.owner — the "public-or-mine" pattern. A query filter, not access control: it performs no authentication, authorization, or ownership enforcement; the caller can forge owner; it is bypassable and must not be used to protect tenant data (use one store per tenant for isolation).
collapse_keystr | Sequence[str] | NoneOpt-in de-fragmentation unit key — a single metadata path ("$.session_id") or an ordered sequence forming a composite key. When set, only the single best-ranked row per caller-defined unit reaches the result and the freed slots backfill deeper distinct units. A presentation / de-dup convenience, not a filter and not isolation. The collapse step mutates no score, but setting collapse_key widens the internal candidate pool, which can rescale the min-max-normalized keyword scores and shift order among units; only collapse_key=None leaves the path byte-identical. A missing/malformed key ⇒ the row is its own unit, never collapsed.
collapse_max_per_unitint | NoneOpt-in intra-unit retention depth for collapse_key. None (default) keeps one best row per unit; an integer >= 1 keeps up to that many highest-ranked members of a unit and lets the freed slots backfill deeper distinct units. Only effective together with collapse_key; a value < 1 is rejected.
from engrava import MetadataFilter, VisibilityQueryFilter
from engrava import FieldOp, FieldPredicate

# Scope recall to one session's memories, keep only "public" or my own rows,
# and collapse per-turn fragments to one best row each.
result = await store.recall(
    "remote work trade-offs",
    top_k=10,
    filters=MetadataFilter([FieldPredicate("$.session_id", FieldOp.EQ, "sess-42")]),
    visibility=VisibilityQueryFilter(frozenset({"public"}), owner="alice"),
    collapse_key="$.session_turn",
    collapse_max_per_unit=2,   # keep up to 2 rows per turn instead of 1
)
Metrics & maintenance
MethodReturnsDescription
await metrics()EngravaMetricsSnapshot of thought/edge counts, storage, and search-latency percentiles (see Observability)
await max_cycle()intCognitive-cycle high-water mark across thought.updated_cycle and edge.created_cycle; returns 0 for an empty store
await cleanup_expired(now=None, *, exclude_id=None)CleanupResultArchive or delete thoughts past their expires_at
await run_hygiene(*, current_cycle=None, now=None)HygieneResultRun one Forgetting / Memory Hygiene pass: archive cold thoughts (reversibly) and, when auto_gc_enabled, GC ones past both restore windows. Raises RuntimeError if no policy was supplied; returns an empty result when the supplied policy is disabled.
await derive_existing(thought_id)DeriveResultBackfill: run the registered derived-records producer over an already-stored source thought (idempotent). Raises SourceThoughtNotFoundError if the id is absent. See Backfilling existing thoughts.
await flush_access_buffer()intPersist buffered retrieval-access deltas in one batch; returns the number of buffered entries drained (distinct thought ids submitted to the update), not necessarily the number of rows actually updated — an id whose thought was deleted since it was buffered is drained but matches no row
await retire_orphan_reflections()intArchive active REFLECTIONs whose entire CONSOLIDATED_FROM source set is no longer active
attach_dreaming_extension(extension)NoneWire a DreamingConsolidatorProtocol implementation onto the store so consolidate() can run it — the supported alternative to setting the private _dreaming_extension attribute directly. A second call replaces whatever was attached before; there is no way to detach through this method. Checks extension against the runtime_checkable DreamingConsolidatorProtocol (which looks for a run_consolidation member) and raises TypeError if that check fails; it does not check the method's signature or that it is a coroutine function, so a same-named but wrong-shaped one (sync, or a different signature) fails only once consolidate() calls it. See Dreaming.
await consolidate(*, current_cycle=None)ConsolidationResultRun the dreaming extension wired by from_config; raises RuntimeError when dreaming is not enabled and uses cycle_provider when no explicit cycle is supplied
await verify_embedding_model()NoneRaise EmbeddingModelMismatchError if the stored model lock disagrees with the configured provider
async with store.suppress_access_tracking():context managerSuppress implicit retrieval-access buffering inside the current async task; nestable and concurrency-safe, with no effect when access tracking is disabled. Used by internal maintenance and read-only views so their reads do not inflate the frequency signal.
async with store.suspend_auto_commit():context managerDefer per-call commits so a block of writes commits once; an escaping error attempts rollback. A failed rollback quarantines this store before another writer can enter, preserving the body exception with the rollback error as cause unless the rollback is cancelled. See Transaction context behavior. Holds a task-reentrant lock for its duration: a different task's write now waits for the window to close rather than joining its transaction; the same task's own nested writes (and nested windows) proceed normally. See Concurrency
await close()NoneClose the owned connection (only when the store opened it via from_config)

Read-only health counters (plain properties, not await, not in the metrics snapshot): store.fts_match_failure_count and store.vector_arm_degradation_count are monotonic int counters that surface silent, self-healing search-arm degradations. See Observability signals.

# Bulk ingest: one transaction instead of one commit per write.
async with store.suspend_auto_commit():
    for record in many_records:
        await store.create_thought(record)
# commit happens once on clean exit; any exception rolls the whole block back
ReadOnlyEngrava

A composition wrapper that delegates reads to the wrapped store and raises ReadOnlyViolationError on any write. Use it to hand a retrieval-only view of shared memory to a component that should never mutate it — e.g. a sub-agent or worker whose job is only to look things up. Delegated reads suppress access tracking, so they do not stage deferred access-count mutations.

The guarantee is behavioural, not structural. ReadOnlyEngrava implements the narrower EngravaReadProtocol, but also exposes signature-compatible write methods whose only behaviour is to raise. Because EngravaCoreProtocol is a runtime-checkable structural protocol, those blockers mean isinstance(ro, EngravaCoreProtocol) is also True. Use EngravaReadProtocol to type a read capability; do not use the structural core check as evidence that a value is writable.

from engrava import ReadOnlyEngrava

ro = ReadOnlyEngrava(store)
thought = await ro.get_thought("abc")  # OK
await ro.create_thought(...)           # Raises ReadOnlyViolationError
EngravaManager

Multi-service database isolation. Each service owns a separate <data_dir>/<service_name>.db file, with its own schema, connection, embedding configuration, and journal. Service names accepted by get_store(), service_exists(), and delete_service() must match ^[a-z][a-z0-9_-]{0,62}$: start with a lowercase letter, then use lowercase letters, digits, _, or -, up to 63 characters.

MethodDescription
await get_store(service_name, *, migrate=True)Lazily create/open and initialize a service store on first access. The manager caches the store, so later calls for the same name return the same live instance until close_all() or deletion (migrate is ignored on a cache hit — the schema state was already decided by whichever call created the cached instance). With the default migrate=True, construction calls ensure_schema(): a fresh service is bootstrapped and an existing behind one is brought current — this can raise SchemaVersionError. Pass migrate=False to skip ensure_schema() and open the core schema exactly as stored; for a service that does not exist yet this leaves it with no core schema (pair it with a version check first, e.g. via peek_schema_version()). This is not "no schema write at all": the manager still configures the configured vector backend after construction regardless of migrate, and with vector_backend="sqlite-vec" that creates the embedding_vec virtual table and its supporting shadow tables on first use if they are not already present — independent of ensure_schema() and of migrate.
await peek_schema_version(service_name)Read a service database's stamped user_version without opening it through ensure_schema() — a plain PRAGMA on a throwaway connection, so this call itself can never migrate or refuse anything. Returns None when the service has no database file yet. Raises ConfigError for an invalid name. Meant to be checked before get_store(..., migrate=False), so a caller can warn, refuse, or proceed on its own terms first.
service_exists(service_name)Validate the service name, then check whether its .db file exists without opening or creating it. Raises ConfigError for an invalid name.
await list_services()Scan data_dir for .db files and return their stems in sorted order; returns [] when the directory does not exist.
await delete_service(service_name)Close and evict a cached store, then delete its .db, .db-wal, and .db-shm files. Raises FileNotFoundError when the main database file does not exist. This permanently deletes that service's data.
await close_all()Close all cached connections and clear the cache; idempotent. Database files remain on disk, and a later get_store() opens a fresh store instance. Async context-manager exit calls this method.
from pathlib import Path

from engrava import EngravaManager

async with EngravaManager(data_dir=Path("./data")) as mgr:
    store = await mgr.get_store("my-service")
    assert mgr.service_exists("my-service")
    print(await mgr.list_services())  # ["my-service"]

# The context manager has closed its cached connections, but the database
# remains. Delete is a separate, destructive operation:
async with EngravaManager(data_dir=Path("./data")) as mgr:
    await mgr.delete_service("my-service")

Domain Models

All models are frozen Pydantic objects. For ThoughtRecord, use thought.evolve(...) when changing lifecycle or temporal fields: it validates lifecycle transitions, preserves created_at immutability, refreshes updated_at unless supplied explicitly, and revalidates the complete record. Likewise, ActionRecord.evolve(...) validates action-status transitions.

Pydantic's model_copy(update={...}) does not validate its update payload. Reserve it for trusted, already-validated changes to fields on which no domain invariant depends (or use model_copy() without update for an unchanged copy). Do not use it for ThoughtRecord lifecycle or timestamp changes.

ThoughtRecord
FieldTypeDescription
thought_idstrUUID primary key
thought_typeThoughtTypeClassification
essencestrShort summary (1-200 chars)
contentstrFull text
priorityPriorityP1-P4
lifecycle_statusLifecycleStatusState machine status
created_cycleintCreation cycle number
updated_cycleintLast update cycle
sourcestrOrigin identifier
confidencefloat | NoneConfidence score
embedding_refstr | NoneEmbedding ID reference
source_typeKnowledgeSourceKnowledge provenance
confirmation_countintExperience confirmations
consolidated_fromlist[str] | NoneSource thought IDs if consolidated
visibilityThoughtVisibilityAccess scope
access_countintTimes explicitly accessed
action_outcome_scorefloat | NoneMean outcome value ([0.0, 1.0]) over the thought's terminal linked actions; None when it has none. Maintained by the store when a linked action reaches a terminal state
last_accessed_atstr | NoneISO-8601 datetime of last access
created_atstr | NoneISO-8601 datetime when persisted
updated_atstr | NoneISO-8601 datetime of last mutation
expires_atstr | NoneISO-8601 datetime when the thought expires (TTL)
valid_fromstr | NoneISO-8601 start of the fact's real-world valid time (open lower bound when None); see Bi-temporal Model
valid_untilstr | NoneISO-8601 end of valid time, exclusive (open upper bound when None); see Bi-temporal Model
metadatadict[str, MetadataValue]Caller-supplied structured attributes (default {})
provenanceProvenanceContext | NoneOptional bounded write-time provenance; an untrusted query hint, never identity or authorization
pinnedboolHard keep-intent for Memory Hygiene; pinned thoughts are never auto-archived or auto-GC'd (default False)
archived_at_cycleint | NoneCognitive cycle stamped by hygiene archival and cleared by restore_thought; gates cycle-based GC eligibility. A generic update_thought(lifecycle_status=ARCHIVED) call leaves a previously-set value in place instead of refreshing or clearing it, so a non-null value is not by itself proof this archival episode was hygiene's
archived_atstr | NoneUTC ISO-8601 instant stamped by hygiene archival and cleared by restore_thought; gates wall-clock GC eligibility, with the same caveat as archived_at_cycle above for a generic lifecycle write
metadata field

MetadataValue = str | int | float | bool | None | dict[str, MetadataValue] — leaf values must be JSON-serializable scalars; nested dict values are accepted for structured namespaces (e.g. metadata["source"] = {"is_self": True, "confidence": "high"}). Lists, tuples, and other rich containers are rejected at the create_thought / update_thought boundaries with a ValueError.

Conventional keys (recommended, not enforced — callers decide what to populate):

KeyTypePurposeExample
rolestrConversation role"user", "assistant", "system"
langstrContent language (ISO 639-1)"en", "pl", "ja"
content_typestrContent category"natural_language", "code", "speech"
session_idstrExternal session referenceUUID, business ID
turn_indexintTurn position within a session0, 1, 2
speakerstrNamed speaker (multi-party)"Alice", "Customer"
await store.create_thought(
    ThoughtRecord(
        thought_id="...",
        thought_type=ThoughtType.OBSERVATION,
        essence="...",
        content="...",
        priority=Priority.P3,
        lifecycle_status=LifecycleStatus.CREATED,
        created_cycle=0,
        updated_cycle=0,
        source="ingest",
        metadata={"role": "user", "lang": "en", "turn_index": 5},
    ),
)

Size limits. Serialized payloads above ~4 KiB emit a WARNING log; payloads above ~64 KiB are rejected with ValueError. If the data is genuinely large (e.g. transcripts, structured documents), store it in content or via an external reference rather than in metadata.

Persistence. Stored as a metadata_json TEXT NOT NULL DEFAULT '{}' column on the thought table since user_version = 11. JSON1 extension is recommended for filtering queries (json_extract(metadata_json, '$.role')).

EdgeRecord
FieldTypeDescription
edge_idstrUUID primary key
from_thought_idstrSource thought
to_thought_idstrTarget thought
edge_typeEdgeTypeRelationship type
weightfloatStrength (0.0-1.0)
created_cycleintCreation cycle
sourceKnowledgeSourceProvenance (default EXPERIENCE)
decay_multiplierfloatDecay rate multiplier (default 1.0)
valid_fromstr | NoneISO-8601 start of the edge's real-world valid time (open lower bound when None); see Bi-temporal Model
valid_untilstr | NoneISO-8601 end of valid time, exclusive (open upper bound when None); see Bi-temporal Model
metadatadict[str, MetadataValue]Caller-supplied structured attributes (default {}); see the metadata field note below
metadata field (edges)

Edges carry the same generic metadata field as thoughts — dict[str, MetadataValue], default {} — with the identical value domain (str | int | float | bool | None | dict[str, MetadataValue]; lists and non-finite floats such as NaN / Infinity are rejected) and the same ~4 KiB warn / ~64 KiB hard-reject size limits. It is validated at the create_edge / update_edge boundaries with a ValueError.

Unlike thought metadata, edge metadata keys carry no reserved meaning and no conventional-key table — the field is a purely generic carrier. Engrava assigns no domain semantics to any key, applies no metadata-driven ranking, enforces no vocabulary, and makes no compatibility guarantee about key names, so do not treat any key as "well-known". Engrava's own edge creators (dreaming, derived records) write an empty {}.

Filtering. list_edges(filters=...) accepts a typed MetadataFilter — an AND of FieldPredicates over the edge metadata — reusing the exact machinery that backs thought-metadata filtering: operators EQ and IN only, the restricted $ / $.key / $[0] JSONPath grammar, and a 250-predicate cap. An edge whose stored metadata is malformed JSON never matches a non-empty filter. This is a query refinement, not a security boundary.

from engrava import EdgeType
from engrava.domain.models.filters import FieldOp, FieldPredicate, MetadataFilter

supports = await store.list_edges(
    edge_type=EdgeType.ASSOCIATED,
    filters=MetadataFilter([FieldPredicate("$.subtype", FieldOp.EQ, "supports")]),
)

Persistence. Stored as a metadata_json TEXT NOT NULL DEFAULT '{}' column on the edge table since user_version = 19.

EmbeddingRecord
FieldTypeDescription
embedding_idstrUUID primary key
owner_typestrOwning entity type (currently "THOUGHT")
owner_idstrOwning record ID (the thought ID)
model_namestrEmbedding model identifier
dimensionintVector dimensionality
vector_blobbytesSerialized vector (packed floats)
created_atstrISO-8601 creation timestamp
ActionRecord

Records an action the agent took (a tool call, a message, …), linked to the thought that prompted it, with execution and verification state.

FieldTypeDescription
action_idstrUUID primary key
source_thought_idstrThe thought this action originated from
action_typeActionTypeAction classification
intentstrDescription of intent (min length 1)
statusActionStatusCurrent execution status
verification_statusVerificationStatusVerification state
raw_metrics_jsonstr | NoneOptional ground-truth facts for verification

Store methods (on SqliteEngravaCore):

MethodReturnsDescription
await create_action(action)ActionRecordPersist an ActionRecord
await update_action(action_id, *, status=None, verification_status=None)ActionRecordAdvance a stored action's status and/or verification_status. Validates the transition when status changes (illegal jump raises InvalidTransitionError); a verification-only update is allowed even on a terminal action. Raises ActionNotFoundError when the id is unknown at the initial read. A supplied value equal to the stored one is a no-op (no write, so it cannot go stale). On a real change, raises StaleDataError if the guarded write matches no row — another guarded write bumped revision in between, or deleted the row — see Concurrency
await get_actions(thought_id)list[ActionRecord]Actions linked to a thought

ActionStatus is a state machine: PLANNED → EXECUTING → CONFIRMED / FAILED, and PLANNED → BLOCKED → PLANNED. can_transition_to(...) / evolve(...) enforce valid transitions on the in-memory record, and update_action(...) enforces the same transitions when advancing the stored action (an illegal change raises InvalidTransitionError).

When a linked action reaches a terminal state (CONFIRMED / FAILED), the source thought's action_outcome_score — the mean outcome value over its terminal actions, None when it has none — is recomputed. That score is also read by the optional action_outcome dreaming signal and by Memory Hygiene's usage-history gate and scoring path.

The per-action value is exact and depends on both terminal execution status and verification:

Action statusVerification statusOutcome value
FAILEDAny0.0
CONFIRMEDCONFIRMED1.0
CONFIRMEDFAILED0.0
CONFIRMEDPENDING, PARTIAL, or UNVERIFIABLE0.5
PLANNED, EXECUTING, or BLOCKEDAnyExcluded (outcome undecided)

action_outcome_score is the arithmetic mean of those values across terminal actions only. It remains None when a thought has no terminal action.

import uuid
from engrava import ActionRecord, ActionType, ActionStatus, VerificationStatus

action = ActionRecord(
    action_id=str(uuid.uuid4()),
    source_thought_id=prompting_thought_id,
    action_type=ActionType.TOOL_CALL,
    intent="search the web for flight prices",
    status=ActionStatus.PLANNED,
    verification_status=VerificationStatus.PENDING,
)
await store.create_action(action)

# advance the STORED action through its lifecycle (journaled; validates each
# transition). A terminal status recomputes the source thought's outcome score:
await store.update_action(action.action_id, status=ActionStatus.EXECUTING)
await store.update_action(action.action_id, status=ActionStatus.CONFIRMED)
# verification can still advance while the status stays terminal:
await store.update_action(
    action.action_id, verification_status=VerificationStatus.CONFIRMED
)

actions = await store.get_actions(prompting_thought_id)
HybridSearchResult

A frozen container of ranked results plus backend diagnostics. It is not a per-result object with score breakdowns.

FieldTypeDescription
resultslist[tuple[str, float]]Ranked (thought_id, combined_score), highest first
backends_usedfrozenset[str]Backends/signals available or applicable to the query (e.g. "fts5", "vector", "graph_expansion"); a backend may appear even when it returned no rows
reflections_evictedintNumber of REFLECTION rows removed from the final top-K window by reflection_topk_cap and backfilled with non-reflections (default 0)
result = await store.search_hybrid("query text", top_k=5)
for thought_id, score in result.results:
    record = await store.get_thought(thought_id)
    ...
Forgetting (Memory Hygiene) types

Configured under hygiene_policy and returned by run_hygiene — see Memory Hygiene and Configuration → hygiene_policy.

TypeKindKey fields
HygienePolicyConfigconfigenabled, eviction_threshold, protected_priorities, signal_weights, auto_gc_enabled, gc_min_archive_age_cycles, gc_restore_window_seconds, min_inactivity_age_seconds, max_evictions_per_run, dry_run
HygieneResultresultarchived_count, gc_count, candidates_evaluated, dry_run, would_evict, flat_signals
EvictionReasonaudit recordthought_id, keep_score, eviction_score, decay_multiplier, threshold, signals, mechanism (always "hygiene")

Metadata Helpers

Three exported helpers build the structured metadata dict that pins a thought's origin (self vs external, source id, language). They are pure functions — same arguments always return an equal dict — and you are free to pass a literal dict instead; the helpers exist to remove typo-driven shape mismatches at the call site.

HelperSignatureUse for
perceptpercept(*, is_self=False, source_id=None, label=None, confidence="high", lang="en")Input arriving from outside (user message, document)
utteranceutterance(*, lang="en")The agent's own output sent to the world
thoughtthought(*, lang="en")The agent's internal cognition (reflection, plan)
from engrava import percept

metadata = percept(source_id="user-1", label="user")
# -> {'perspective': 'percept',
#     'source': {'is_self': False, 'confidence': 'high', 'id': 'user-1', 'label': 'user'},
#     'lang': 'en', 'content_type': 'natural_language'}

Pass the returned dict as ThoughtRecord(..., metadata=...).

Enums

All enums are StrEnum — JSON-serializable and stored as strings.

EnumValues
ThoughtTypeTASK, OBSERVATION, BELIEF, REFLECTION, OUTPUT_DRAFT, NOTE
PriorityP1, P2, P3, P4 (P1 highest)
LifecycleStatusCREATED, ACTIVE, DONE, ARCHIVED — allowed transitions are CREATED → ACTIVE, ACTIVE → DONE, ACTIVE → ARCHIVED (direct, without passing through DONE), DONE → ARCHIVED, and the reverse ARCHIVED → ACTIVE
EdgeTypeASSOCIATED, DEPENDS_ON, DERIVED_FROM, MESSAGE_OF, BRIDGE, CONSOLIDATED_FROM, CONTESTED_BY
ActionTypeCLI_OUTPUT, TOOL_CALL, MESSAGE, STATE_UPDATE
ActionStatusPLANNED, EXECUTING, CONFIRMED, FAILED, BLOCKED
ThoughtVisibilitymember names PRIVATE, SELECTIVE, PUBLIC — stored values are lowercase ("private", "selective", "public")
KnowledgeSourceEXPERIENCE, SEEDED_LLM, DISTILLED_LLM, DREAMING
VerificationStatusPENDING, CONFIRMED, PARTIAL, FAILED, UNVERIFIABLE
SplitModePARAGRAPH, FIXED_WINDOW — the StructuralSplitProducer split strategy (see Structural split modes)

Exceptions

The table below defines the public types. For operational retry, repair, and store-replacement guidance, see Error handling and recovery.

ExceptionBaseDescription
EngravaErrorExceptionBase for all engrava errors
ThoughtNotFoundErrorEngravaErrorThought ID not found
StaleDataErrorEngravaErrorThe guarded update matched no row: another guarded write bumped the row's revision, or deleted the row, before this write. Raised by update_thought / restore_thought / update_edge / update_action (on a real change) / upsert_by_hash. Nothing of the update was applied. Not a general staleness check — see Concurrency
InvalidTransitionErrorEngravaErrorInvalid lifecycle state transition
ReferentialIntegrityErrorEngravaErrorcreate_edge named an endpoint (from_thought_id or to_thought_id) that is not an existing thought
DuplicateEdgeErrorEngravaErrorThe directed (from_thought_id, to_thought_id, edge_type) relationship already exists
ReadOnlyViolationErrorEngravaErrorWrite attempt on read-only store
EmbeddingModelMismatchErrorEngravaErrorThe embedding identity (model name, dimension, document-prefix fingerprint) supplied to a write or to verify_embedding_model() disagrees with the identity already stored for the corpus. The identity comes from the configured provider; for store_embedding() the model name and dimension come from the model_name and vector passed in, while the document-prefix fingerprint still comes from the configured provider, so with a prefixed provider the identity is assembled from both, and the error can fire with no provider configured. Raised when an embedding is stored (store_embedding, including the auto-embed path) and by verify_embedding_model(). The identity is compared on every embedding write and on every verify_embedding_model() call, not only the first — a later write on the same store instance with a different model name, dimension, or document-prefix raises EmbeddingModelMismatchError instead of being silently accepted (see Known Limitations), except a write tagged with the internal dreaming-centroid sentinel model name (a REFLECTION centroid, not a configured provider's output), which skips the comparison entirely. A first store_embedding() call whose own write then fails locks nothing: the identity commits only together with that write actually landing, so a corrected retry is never refused by an identity a failed first call left behind. The engrava restore CLI command does not raise this class: it reports a snapshot/target model mismatch as a CLI error (exit 1) that suggests --re-embed or --skip-embeddings
EmbeddingProviderContractErrorEngravaErrorA configured embedding provider does not expose dimension, a required EmbeddingProviderProtocol member. Carries provider_class / member. Raised where the core reads the member (vector search, verify_embedding_model), never at construction. A provider that has the property and fails inside it raises its own exception instead
EmbeddingGenerationErrorEngravaErrorAuto-embed failed under require_embedding=True; carries the failing thought_id. If the call does not own the 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, so a caller that catches this and exits that window cleanly commits everything inserted so far, single item or the whole batch alike (not a prefix, since this error only fires after the whole batch is already inserted, see bulk_store above), while an uncaught failure rolls it back. If the call does own the outermost transaction, the path decides: create_thought/update_thought have already committed by the time this fires, so the failure cannot undo them; a standalone bulk_store instead wraps the whole batch's inserts and trailing embed call in one transaction, so this failure rolls the whole batch back regardless — including this thought — so nothing is persisted. What is left behind is path-specific and only meaningful for whatever actually committed: create_thought leaves no embedding row at all; update_thought leaves any embedding the row already had in place — if the update committed, that embedding is now stale against the new content, and the row is still findable by vector search against that outdated vector; if the row had no embedding before, it still has none, and remains unfindable by vector search; if the update instead rolled back — only possible when this call is nested inside a caller's own window and the caller lets the failure escape it — the durable state reverts to whatever existed when that outermost window opened, not merely to what this call itself started from: an earlier write to the same thought inside the same window is undone right along with it; if the thought was created inside that same window, it no longer exists at all afterward; if it already existed before the window opened, it reverts to that pre-window state, and the retained embedding matches it only if it already did — an earlier update on the same thought, before this window ever opened, whose own re-embed failed can already have left that pre-window state stale, and this rollback neither detects nor repairs that
ExtensionMigrationErrorEngravaErrorExtension schema migration failed (e.g. attempted downgrade)
ActionNotFoundErrorEngravaErrorAction record ID not found; carries the failing action_id
InvalidFilterErrorEngravaErrorMetadata/visibility filter invalid at construction (bad value, empty filter, unsupported operator, or too many predicates)
InvalidFilterPathErrorEngravaErrorFilter path does not match the allowed JSONPath grammar ($, $.key, $[0] only)
EmbeddingQueryPrefixMismatchErrorEngravaErrorActive query prefix diverges from the corpus pairing, silently degrading ranking
JournalIntegrityErrorEngravaErrorOn-open journal check (journal.verify_on_open) found a broken hash chain; carries first-broken diagnostics
DerivedRecordErrorEngravaErrorThe derived-records seam rejected a producer result (over-cap return, or a derived identity colliding with its source); carries the failing source_thought_id
SourceThoughtNotFoundErrorEngravaErrorderive_existing targeted a source thought_id that does not exist; carries the failing thought_id
VectorDimensionMismatchErrorEngravaErrorA vector-search query vector's length differs from the store's embedding dimension; carries expected / actual. Not a ValueError — catch this typed error (or EngravaError).
CycleProviderErrorEngravaErrorA configured cycle provider returned a boolean, non-integer, or negative value
RecencyModeConflictErrorEngravaErrorA query explicitly supplied both current_cycle and recency_now
InvalidRecencyArgumentErrorEngravaErrorrecency_now is malformed or recency_now_half_life is not positive
ConnectionQuarantinedErrorEngravaErrorThe connection was quarantined because a rollback or unwind this store depends on could not be proven to have completed, leaving the transaction potentially indeterminate; the store instance is terminal and must be replaced. Reachable when an outer transaction rollback fails (including recovery after a failed commit), and from independent guarded-unwind paths: the savepoint unwind of a guarded write failing (a derived child's own row or edge insert included), and any failure of delete_thought's (or the TTL delete strategy's, or hygiene GC's) child-row savepoint unwind — not only a cancellation racing the savepoint release, but also an ordinary error (e.g. a trigger veto) whose own ROLLBACK TO is then itself unable to complete, with no cancellation and no derivation involved. Reachable with the derived-records seam disabled, since neither of the delete-path triggers touches that seam. The triggering call preserves its original failure unless cleanup cancellation takes precedence; ConnectionQuarantinedError surfaces on subsequent guarded operations, including queued writers. A close timeout can itself raise it
WriteContentionErrorEngravaErrorTwo distinct raise sites. (1) The dedup probe-and-insert window (create_thought(deduplicate=True), get_or_create, upsert_by_hash, and bulk_store(deduplicate=True) per row — sharing the same internal dedup-insert path create_thought(deduplicate=True) uses, not a call to the public create_thought() itself — but bulk_store's whole batch runs inside one transaction, so only its first row opens this window; later rows in the same batch share the window the first row already holds) could not get the cross-connection write lock: PRAGMA busy_timeout already waits out normal contention on each BEGIN IMMEDIATE attempt, and the store's own bounded retries with backoff are exhausted on top of that (attempts counts these). (2) A revision-guarded write (update_thought, restore_thought, update_edge, update_action) reports lock contention on its single UPDATE; there is no application-level retry here, so attempts is always 1. Carries operation and attempts; from bulk_store, operation reads "create_thought" (that shared path's fixed label, not evidence the public method ran), not "bulk_store" — a caller branching on operation should account for that. Retrying the call outright is safe in both cases — nothing was read or written under the failed attempt
WriteLockTimeoutErrorEngravaErrorA task could not acquire the store's in-process write lock within write_lock_acquire_timeout_seconds. Usually a task spawned and awaited from inside another task's own suspend_auto_commit() window (an out-of-contract deadlock this store ends by raising, rather than hanging); can also mean that bound is configured too small for a legitimately slow embedding provider — see Concurrency
DedupLockReentryErrorEngravaErrorThe current task already holds the store's dedup lock and tried to acquire it again — same-task reentry into create_thought(deduplicate=True), get_or_create, upsert_by_hash, or bulk_store(deduplicate=True) while one of those is still mid-window on this task, most concretely an update_thought override (reached from upsert_by_hash's hit branch, which calls it when a mutable field differs, while _write_lock and _dedup_lock are held) that calls back into one of those same four entry points. A plain, non-reentrant lock would block that second acquisition on itself forever; this is raised instead, the same "raise, don't hang" policy WriteLockTimeoutError applies to a different task's wait — see Extension hooks §1B.3
SchemaVersionErrorEngravaErrorensure_schema() refused the database: a populated sub-floor schema (below the bootstrap floor, but already carrying a row — not safe to silently stamp current), a sub-floor schema whose core tables hold zero rows but already exist under an older shape the bootstrap script's CREATE ... IF NOT EXISTS statements would leave untouched, or one stamped above this build's head version (nothing to migrate; opening it would understand less than it claims to). Carries current_version and reason ("populated_sub_floor", "stale_shape_sub_floor", or "newer_than_head"). Reachable anywhere ensure_schema() runs, which includes from_config(), EngravaManager.get_store(), and the CLI's migrate command. See Deletion on a database that has not been migrated
CoreMigrationErrorEngravaErrorensure_schema() upgraded the on-disk schema but a migration step's own postcondition (a required column, table, index, foreign key, or trigger) did not hold afterward. Carries the target schema version and what was missing. The version is not stamped past the last fully-applied step, so the next call to ensure_schema() retries the remaining steps rather than silently treating a half-migrated database as current. In practice this means the on-disk file or the running build is not one this version of engrava can safely bring current — replace the database from a backup, or use a build that matches its schema history. Reachable anywhere ensure_schema() runs: from_config(), EngravaManager.get_store(), and the CLI's migrate command
ConfigErrorValueErrorYAML or direct config construction violates a documented configuration invariant
MindQLParseErrorExceptionparse() rejects invalid MindQL syntax — see Discovery and parsing helpers
MindStoreErrorExceptionDeprecated compatibility alias. It does not derive from EngravaError, it is EngravaError — the same class object, resolved lazily through engrava.__getattr__, which emits a DeprecationWarning on access. Its base is therefore Exception, like the class it aliases. New code should catch EngravaError directly — see Deprecated compatibility aliases

create_edge raises ReferentialIntegrityError when an endpoint thought does not exist and DuplicateEdgeError when the relationship already exists. All three of ReferentialIntegrityError, DuplicateEdgeError, and CoreMigrationError are importable directly from engrava, the same as every other exception in the table above.

Protocols

EngravaReadProtocol

The non-mutating capability shared by the writable store and ReadOnlyEngrava: thought/edge/action/embedding reads, FTS/vector/hybrid retrieval, recall, metrics, and max_cycle. Read-only retrieval suppresses the concrete SQLite store's optional access-frequency instrumentation. ReadOnlyEngrava declares and type-checks against this read-side protocol. Its compatibility write methods are blocked and raise ReadOnlyViolationError.

EngravaCoreProtocol

The writable abstract interface for an engrava implementation. It extends EngravaReadProtocol, the separately exported read capability implemented by both SqliteEngravaCore and ReadOnlyEngrava. Because runtime structural checks inspect member names and ReadOnlyEngrava exposes rejecting write methods, the wrapper also satisfies isinstance(ro, EngravaCoreProtocol). That result describes structural compatibility, not permission to write; use the narrower read protocol for capability boundaries.

CycleProvider

A runtime-checkable, synchronous pull protocol with one method: current_cycle() -> int. It supplies a cognitive cycle only when a supported call omits an explicit one and no explicit recency_now selects transaction-time recency. It never advances a cycle, never stamps writes, and is never loaded from YAML. The store validates each pulled value as a real, non-negative int.

ImplementationConstructionBehavior
StaticCycleProviderStaticCycleProvider(value)Always returns one fixed value
CallableCycleProviderCallableCycleProvider(fn)Calls a zero-argument consumer function on every pull; purity is the consumer's responsibility
MaxCycleProviderawait MaxCycleProvider.create(store)Caches await store.max_cycle(); current_cycle() may be stale until await refresh()
EngravaHooksProtocol

Extension hook interface — see Extensions.

DerivedRecordProducerProtocol

Optional capability: return N derived records from one stored thought. Core persists each as an ordinary thought after the source is durable, through a core-owned, guarded, per-child lifecycle. See Extension hooks §1A.

class DerivedRecordProducerProtocol(Protocol):
    async def derive_records(
        self, thought: ThoughtRecord, ctx: DeriveContext
    ) -> Sequence[DerivedRecord]: ...

Companion generic types (all public, X.Y.x-stable): DerivedRecord (producer-owned non-empty content / thought_type / priority / metadata / attach_provenance_edge; core derives the essence and owns identity), DeriveContext (source id, content hash, cycle, informational origin; no store handle), DeriveGates (enabled, on_error, max_derived_per_source), and DeriveResult (the created / reused / skipped tally returned by derive_existing). Configure via the derive: YAML section or the derive_gates= constructor argument. The shipped reference producer is StructuralSplitProducer (see Structural split modes).

EmbeddingProviderProtocol
class EmbeddingProviderProtocol(Protocol):
    @property
    def dimension(self) -> int: ...
    @property
    def model_name(self) -> str: ...
    async def embed(self, text: str) -> list[float]: ...

MindQL

See MindQL for the query language reference.

execute_mindql (on the store)

SqliteEngravaCore exposes a convenience that runs a MindQL query directly against the store's own connection, so you don't have to construct a MindQLExecutor yourself. Like the executor, it takes a parsed MindQLQuery — parse() the string first.

MethodReturnsDescription
await execute_mindql(query, *, extensions=None)MindQLResultRun a parsed MindQLQuery on the store's connection. extensions is an optional dict[str, MindQLExtension] registering extension commands.
from engrava import parse

result = await store.execute_mindql(
    parse("FIND thoughts WHERE lifecycle_status = 'ACTIVE' LIMIT 10")
)
print(result.rows)
MindQLExtension, ReadOnlyAccessor, and ReadOnlyCursor

MindQLExtension registers a custom MindQL command: command_name, handler, description, and category (defaults to "extension"). The executor invokes handler with two positional arguments — a ReadOnlyAccessor and the parsed argument list — and awaits a list[dict[str, object]] back. See Custom MindQL Commands for a worked example.

A handler receives a ReadOnlyAccessor, never the store's live connection — its only capability is execute(), restricted to a single SELECT:

TypeMemberDescription
ReadOnlyAccessorawait execute(sql, parameters=()) -> ReadOnlyCursorRuns one SELECT statement through the same guard as the SELECT passthrough; raises MindQLParseError for anything else.
ReadOnlyCursorfetchone(), fetchall(), fetchmany(size=None), async for row in cursor, .descriptionThe read surface of an aiosqlite.Cursor and nothing else — no execute()-family method, no connection.

See Extension Commands for the read-only guarantee this closes off.

MindQLExecutor

MindQLExecutor runs against an open aiosqlite.Connection (the same connection the store wraps), and execute() takes a parsed MindQLQuery — parse the string first.

from engrava import MindQLExecutor, MindQLResult, parse

executor = MindQLExecutor(conn)  # an aiosqlite.Connection, not a store
result: MindQLResult = await executor.execute(
    parse("FIND thoughts WHERE thought_type = 'OBSERVATION' LIMIT 10")
)
print(result.rows)    # list[dict]
print(result.count)   # int | None (set for COUNT queries)

MindQLResult fields: columns: list[str], rows: list[dict], count: int | None, command: str.

parse

parse() returns a MindQLQuery plan (not a MindQLCommand). The grammar requires a table name and an optional WHERE clause: FIND <table> [WHERE <field> <op> '<value>' [AND ...]] [LIMIT n].

from engrava import parse, MindQLQuery

query: MindQLQuery = parse("FIND thoughts WHERE thought_type = 'OBSERVATION' LIMIT 5")
print(query.command)     # MindQLCommand.FIND
print(query.table)       # "thought"
print(query.conditions)  # list of parsed conditions (field / operator / value)
print(query.limit)       # 5

See MindQL for the full grammar, operators, and per-table filterable columns.

Additional top-level exports

The sections above cover the primary store, records, protocols, and query surface in depth. The remaining names exported directly from engrava are listed here so the top-level package has a complete index; follow the linked specialist guide for operational detail.

Configuration objects and resolvers
ExportPurpose
EngravaConfigRoot typed configuration returned by load_config
SearchConfigHybrid-search weights, half-lives, graph/reflection controls, and candidate-pool bounds
DreamingConfig, DreamingGatesConsolidation settings and eligibility gates
EmbeddingConfigEmbedding provider and model settings
HygienePolicyConfigForgetting / Memory Hygiene policy
JournalConfigJournal enablement and verify-on-open settings
MetricsConfigMetrics enablement and rolling latency-window size
TTLConfigExpiry strategy, cadence, and default TTL
ServiceConfig, ServicesConfigPer-service and multi-service manager configuration
load_config(path)Parse and validate YAML into EngravaConfig
resolve_embedding_provider(config)Construct the provider selected by an EmbeddingConfig
resolve_hooks(hooks_class)Import and construct a dotted-path hooks class, or return default hooks for None
resolve_manifests(manifest_paths, *, discover=False)Load explicit manifest paths and optionally append entry-point discovery results

See Configuration for the fields, defaults, validation, and YAML examples.

Embedding and vector implementations
ExportPurpose
CallbackProviderAdapt caller-supplied embedding callbacks to EmbeddingProviderProtocol
SentenceTransformerProviderLocal sentence-transformers provider
HuggingFaceProviderHugging Face inference/provider adapter
OllamaProviderOllama embedding provider
OpenAICompatibleProviderProvider for OpenAI-compatible embedding endpoints
RoleAwareEmbeddingProviderOptional protocol for distinct query/document embedding paths
SqliteVecSearchBackendsqlite-vec vec0 backend used by configured stores

See Embeddings and Performance before selecting a provider or vector backend.

Dreaming and scoring types
ExportPurpose
DreamingExtensionConsolidation implementation. run_consolidation() runs immediately; is_due() / run_if_due() apply the configured cycle cadence without starting a background scheduler.
DreamingContextPer-run context supplied to dreaming signals
DreamingSignalProtocolContract implemented by promotion-scoring signals
ActionOutcomeSignal, ConfidenceSignal, ConfirmationSignalOutcome, confidence, and confirmation scoring signals
FrequencySignal, RecencySignal, StalenessSignalAccess-frequency, cycle-recency, and staleness scoring signals
ScoringContextContext type accepted by the reserved hook-level score_function

See Dreaming for activation, gates, signal semantics, and the no-retrieval-accuracy-claim boundary.

Journal, cleanup, and metrics value objects
ExportPurpose
JournalEntryOne persisted hash-chain journal entry
JournalWriterLow-level journal writer bound to an open connection; ordinary callers use store.journal / verify_journal()
MutationTypeJournal mutation-kind enum
CleanupStrategyTTL cleanup enum: ARCHIVE or DELETE
ThoughtCounts, EdgeCountsCount components nested in EngravaMetrics
StorageFootprintDatabase, WAL, vector-index, and total byte counts
LatencyHistogramRolling search-latency count and percentile summary
Discovery and parsing helpers
ExportPurpose
discover_manifests()Discover opt-in extension manifests from package entry points
MindQLParseErrorRaised when parse() rejects invalid MindQL syntax
Deprecated compatibility aliases

CoreThoughtRecord is a compatibility alias of ThoughtRecord. The legacy MindStore-era names below resolve lazily to their Engrava equivalents and emit a DeprecationWarning; new code should not use them.

Deprecated exportUse instead
SqliteMindStoreCoreSqliteEngravaCore
MindStoreManagerEngravaManager
MindStoreConfigEngravaConfig
MindStoreErrorEngravaError
MindStoreCoreProtocolEngravaCoreProtocol
MindStoreHooksProtocolEngravaHooksProtocol
DefaultMindStoreHooksDefaultEngravaHooks
ReadOnlyMindStoreReadOnlyEngrava
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