Engrava
Memory frameworkOfficial Sovantica project
Created by Sovantica
Published in sovantica/engrava. Publisher page
12 GitHub stars
What it is
The memory database for AI agents - graph memory, hybrid search, tamper-evident thought/edge journal.
AutoGineer overview, from the brain’s own files
Engrava is an in-process memory database designed for AI agents, built on SQLite. It provides a structured way to store and retrieve agent thoughts using a graph-based model, where thoughts are linked by typed edges. The system supports hybrid search, combining vector similarity, full-text search (FTS5), recency, and priority. It includes built-in mechanisms for memory consolidation (dreaming), memory hygiene (archiving and garbage collection), and a tamper-evident journal for tracking mutations. Engrava operates as a library with no external service dependencies in its core path, allowing agents to maintain persistent, queryable state locally.
How it works
Memory model: Hybrid
AutoGineer overview, from the brain’s own files
Engrava uses a SQLite database to store thoughts and their relationships. It organizes data into thoughts and typed edges, which can be queried using the MindQL language. The system supports hybrid search by combining vector embeddings, keyword search, and metadata signals. It manages memory through a lifecycle that includes archiving and garbage collection, and it can consolidate memories into reflections. The system is extensible via hooks and provides an MCP server for integration with various AI agent frameworks.
| Folder | Role | Files |
|---|---|---|
| .github | agent-config | 16 |
| docs | docs | 37 |
| examples | examples | 13 |
| scripts | scripts | 24 |
| src | source-code | 89 |
| tests | tests | 201 |
| .githooks | folder | 1 |
The agent reads
- CONTRIBUTING.md evidence: CLAUDE.md
Requirements
Runtimes: Python >=3.11
Databases: SQLite
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Google Antigravity, Kiro, OpenCode, Amp, Roo Code, Cline, JetBrains Junie, Devin (ex-Windsurf)
Set it up
This brain runs code automatically: 2 scripts. Review .githooks/commit-msg, Makefile before opening the folder in Claude Code. No step below turns a hook on for you.
.githooks/commit-msg:runs on git commit-msgMakefile:echo "$(BLUE)>> Installing engrava + dev dependencies$(RESET)"; $(PIP) install --upgrade pip setuptools wheel; $(PIP) install -e ".[dev]"; bash scripts/install_hooks.sh; echo "$(GREEN)>> Done. Run 'make check' to verify quality gate.$(RESET)"
- Not scanned by AutoGineerIts dependencies are installed by pip or uv when you run it and were not scanned by AutoGineer (5 declared). https://pypi.org/project/engrava/
Created by Sovantica. Published in sovantica/engrava. Licence: MIT License.
Usage
AutoGineer overview, from the brain’s own files
- Persistent memory for autonomous AI agents
- Personal knowledge base management
- Conversation storage with semantic retrieval
- Research notes with associative linking
- Memory-backed agent development
- Audit-trailed thought and edge tracking
- “Store a new observation about a project requirement.”
- The agent uses the remember() method to persist the thought, which is then indexed for future hybrid search and graph traversal.
- “Retrieve relevant context for a coding task.”
- The agent uses the recall() method, which performs a hybrid search across FTS5, vector, and graph signals to return ranked, contextually relevant thoughts.
- “Query the memory graph for all observations with high priority.”
- The agent executes a MindQL query: FIND thoughts WHERE thought_type = 'OBSERVATION' AND priority = 'P1'.
- “Consolidate old memories to free up space.”
- The agent triggers the DreamingExtension to cluster related thoughts and compute reflections, or uses the memory hygiene loop to archive low-signal thoughts.
Documentation by Sovantica
Engrava
The memory database for AI agents.
A queryable memory graph with bi-temporal valid-time predicates — in an in-process Python library over one SQLite file, with no generative-model call in the core write path.
Image: CI Image: PyPI Image: Python Image: License: MIT
Engrava is a standalone, in-process database for AI agent memory. Built on SQLite, it provides thought CRUD, edge-based knowledge graphs, embedding-based similarity search, full-text search (FTS5/BM25), and a declarative extension system — all in a single package with zero external service dependencies.
Benchmark results are published, and the runs are reproducible from a separate repository. Every
published run is Group A — memory_pipeline_llms: [], no language model anywhere in the memory
layer's built-in path. That is a property of the architecture's default signals and hooks rather
than a measurement, unless a custom Dreaming signal or Memory Hygiene hook is configured to call
one.
Benchmark results — the live table: every published row with the comparability segment it belongs to.
engrava-benchmark — the runner. Clone it and reproduce a result against the package from PyPI. MIT.
Discussions — questions; reproduction questions belong in the Q&A category.
Use Cases
AI agent persistent memory
Personal knowledge base
Conversation storage with semantic search
Research notes with associative linking
Any application that needs a thought-graph with embeddings
Quick Start
Installation
pip install engravaOptional extras:
pip install 'engrava[vec]' # sqlite-vec vector search backend
pip install 'engrava[embeddings-local]' # sentence-transformers embeddings (local model)
pip install 'engrava[embeddings-openai]' # OpenAI-compatible embeddings API
pip install 'engrava[embeddings-ollama]' # Ollama local embeddings server
pip install 'engrava[embeddings-hf]' # HuggingFace Inference API embeddingsDreaming/consolidation and the knowledge graph need no extra — they are part of the base install.
embeddings-localcarries a large first-run download. It pullssentence-transformersandtorch, plus a further, separate model download (~88 MB forall-MiniLM-L6-v2, cached under~/.cache/huggingface/hub) on first use. On Linux, the PyPI build oftorchalso pulls the NVIDIA CUDA libraries as dependencies: several gigabytes, most of it usable only with an NVIDIA GPU. Without one, install the CPU-only build first (see Configuration: No NVIDIA GPU for the exact command and numbers).engrava-mcp[local]is the exact same download, reached through the server package instead of this one. If you do not need semantic search in-process,pip install engravaalone (no extra, no download, no model) already gives you keyword search, the graph, and MindQL — see Configuration → Quick-start profiles for that and the Ollama-backed alternative that keeps the model out of this process entirely.
Basic Usage
Store a memory and search for it in two calls — no IDs to generate, no record to assemble:
import asyncio
import aiosqlite
from engrava import SqliteEngravaCore
async def main() -> None:
# SqliteEngravaCore wraps an open aiosqlite connection.
async with aiosqlite.connect(":memory:") as conn:
conn.row_factory = aiosqlite.Row
store = SqliteEngravaCore(conn)
await store.ensure_schema()
await store.remember("Python is great for AI agents")
await store.remember("SQLite needs no server")
result = await store.recall("what language is good for agents?")
for thought_id, score in result.results:
thought = await store.get_thought(thought_id)
if thought is not None:
print(f"{thought.essence} (score: {score:.3f})")
asyncio.run(main())remember() stores the text as a thought (generating its ID for you) and
returns the stored ThoughtRecord; recall() runs the same hybrid search as
search_hybrid() and returns the ranked results. This store has no
SearchConfig of its own, and it still ranks with the same default weights —
FTS 0.30, vector 0.55, recency 0.10, priority 0.05, graph 0.00
(opt-in) — as a store built with one; see Hybrid
Search.
For full control — setting
priority, thought type, metadata, or the cognitive cycle on a write — build a
ThoughtRecord yourself and call create_thought().
From here, link thoughts with typed edges, query them with MindQL, or run the full ingest → dream → search tour in the Quick Start guide.
Configuration-Driven Setup
from engrava import SqliteEngravaCore
# from_config opens and OWNS the connection — use it as an async context manager.
async with await SqliteEngravaCore.from_config("engrava.yaml") as store:
# The schema is already applied by from_config.
thought = await store.get_thought("some-id")See docs/configuration.md for the full YAML schema.
Upgrading
Automatic schema migration runs on first connection. See the upgrade guide for compatibility notes, backup guidance, and troubleshooting steps.
Features
Thought CRUD
Create, read, update, and archive thoughts with full lifecycle management.
All models are frozen Pydantic objects — mutations happen via evolve().
Edge-Based Knowledge Graph
Link thoughts with typed, weighted edges. Edge types include ASSOCIATED,
DEPENDS_ON, DERIVED_FROM, CONSOLIDATED_FROM (created by dreaming), and
CONTESTED_BY.
Embedding Search
Store embeddings alongside thoughts and search them with the built-in NumPy
cosine backend or the optional sqlite-vec backend (pip install 'engrava[vec]'). Pluggable embedding providers:
| Provider | Extra | Backend |
|---|---|---|
SentenceTransformerProvider | embeddings-local | Local model via sentence-transformers |
OpenAICompatibleProvider | embeddings-openai | Any OpenAI-compatible API |
OllamaProvider | embeddings-ollama | Local Ollama server |
HuggingFaceProvider | embeddings-hf | HuggingFace Inference API |
CallbackProvider | (built-in) | Custom callable |
Full-Text Search (FTS5)
SQLite FTS5 virtual table with BM25 ranking. Hybrid search combines vector similarity, text relevance, recency, priority, and graph connectivity. Signals that cannot run for a query are skipped and the remaining weights are redistributed.
MindQL Query Language
Declarative query language for the thought-graph:
FIND thoughts WHERE thought_type = 'OBSERVATION' AND priority = 'P1' LIMIT 10
COUNT thoughts WHERE lifecycle_status = 'ACTIVE'
SELECT thought_id, essence FROM thought WHERE thought_type = 'BELIEF'Extensible with custom commands through MindQLExecutor or an
ExtensionManifest; the lifecycle hook registry is reserved and is not
consulted by the core executor.
Extension System
Plug into the thought lifecycle via EngravaHooksProtocol. Subclass
DefaultEngravaHooks when you only need selected active methods:
from engrava import DefaultEngravaHooks, ThoughtRecord
class MyHooks(DefaultEngravaHooks):
async def on_store(self, thought: ThoughtRecord) -> ThoughtRecord:
# Observe or enrich the object returned after persistence.
return thought
async def decay_function(
self, thought: ThoughtRecord, elapsed_cycles: int
) -> float:
# Supply a decay multiplier to an enabled Memory Hygiene pass.
return 1.0Core currently invokes on_store, on_retrieve, and decay_function.
on_store runs after the source thought's row is inserted; changing its
return value does not rewrite the persisted row. score_function and
mindql_extension_registry() remain reserved protocol methods and are not
called by core.
Dreaming / Memory Consolidation
Built-in DreamingExtension for periodic memory consolidation — scores
thoughts via its default signals (no LLM calls), promotes high-value
entries, and creates REFLECTION thoughts by clustering semantically
related thoughts and computing centroid embeddings through a deterministic
structural function, not an LLM. A custom signal you register with
DreamingExtension runs whatever code it contains. Available since 0.3.0.
→ See docs/benchmarks.md for reproducible
evidence (synthetic benchmark suite runnable in ~5 minutes).
Forgetting / Memory Hygiene
The subtractive half of memory maintenance, paired with Dreaming: an opt-in
loop whose built-in scoring makes no LLM calls, that archives cold,
low-signal thoughts — the default action, reversible via restore_thought —
and, as a separately opted-in step, garbage-collects them (not reversible)
once both restore windows have elapsed under their non-zero defaults — a
cycle count and a wall-clock duration, either of which can be configured to
0 to disable that window. OFF by default; once enabled, archived thoughts
drop out of default retrieval and can be restored (restore_thought /
include_archived).
→ See docs/memory-hygiene.md for the
loop, protection, restore windows, and the honest deletion posture.
Tamper-Evident Thought/Edge Journal
Opt-in hash-chain journal that records thought and edge mutations (plus
action status/verification_status transitions) as SHA-256-linked, before/after
entries — a tamper-evident thought/edge journal, not a whole-database audit
(embeddings and action creation are not covered). Off by default, one config flag to
enable. Query history with store.journal.get_entries(...) and validate the
chain with store.verify_journal(), which audits whatever chain is on disk
independent of the current journal.enabled state — the store.journal writer
handle does not exist while journaling is off, but the chain earlier sessions
wrote is still in journal_entry and still needs verifying.
→ See docs/audit-trail.md for enabling, querying,
verification, and the security model (what "tamper-evident" does and does not
guarantee).
Multi-Service Isolation
Run multiple independent databases under one EngravaManager:
from pathlib import Path
from engrava import EngravaManager
async with EngravaManager(data_dir=Path("./data")) as mgr:
agent_a = await mgr.get_store("agent-a")
agent_b = await mgr.get_store("agent-b")
# Completely isolated databasesMCP Server
Want Engrava as a memory server for your agent? The MCP server ships as its own
package, engrava-mcp — a native stdio server (no HTTP shim) with read
tools, optional write tools, attachable engrava:// resources, and guided
prompts, for any MCP client (Claude Desktop, Claude Code, Cursor, Windsurf,
VS Code):
uvx engrava-mcp # or: pip install engrava-mcpengrava-mcp pulls engrava in transitively, so installing it also gives you
the import engrava library. See the
engrava-mcp package for install,
client configuration, the full tool/resource/prompt reference, and read-only
mode.
uvx vs. a persistent install. uvx engrava-mcp (equivalently uv tool run engrava-mcp) installs into "an ephemeral virtual environment in the uv
cache directory" per uv's own --help text — fine for the lexical/network
profiles above, which add nothing heavier than httpx. Once you are on the
local profile (engrava-mcp[local], the same sentence-transformers +
torch download as engrava[embeddings-local]), uv tool install 'engrava-mcp[local]' is the better fit: it installs once into a persistent
environment — the same uvx engrava-mcp invocation then reuses that
installed environment instead of resolving a fresh ephemeral one — and a
later uv tool upgrade engrava-mcp re-pays only the changed packages, not
the whole dependency tree.
CLI
engrava --db mydata.db info # Database stats
engrava --db mydata.db query "FIND thoughts WHERE thought_type = 'OBSERVATION' LIMIT 5"
engrava --db mydata.db snapshot -o backup.jsonl
engrava --db mydata.db restore -i backup.jsonl
engrava --db mydata.db gc # Collect ARCHIVED thoughts + their edges/embeddings/actions
engrava --db mydata.db migrate # Ensure schema is up-to-date
engrava --db mydata.db export -o portable.jsongc physically deletes ARCHIVED thoughts together with every edge touching one
on either end — including edges whose other end is still live — their embeddings
and the actions sourced from them, then reconciles the vector index by removing
every vec0 row no embedding row owns; on a vec0-indexed store where
sqlite-vec cannot be loaded — most commonly because engrava[vec] is not
installed — a pass that is about to delete stops before deleting anything and
exits 1 rather than stranding those vectors in an index nothing can then reach
them through.
engrava info's --format json output carries every field of the metrics
snapshot exposed by await store.metrics(), with the snapshot's own schema
version renamed metrics_schema_version, plus two fields the snapshot itself
does not carry, db_path and database_schema_version — see Upgrade
Guide. The default text output is a shorter summary
of that same data, not the full snapshot.
See the CLI reference for every command and option.
Architecture
SQLite with WAL mode for concurrent reads
Frozen Pydantic models — immutable domain objects
Async-first — all I/O via
aiosqliteHook-based extension — zero monkey-patching
Template method pattern — subclass
SqliteEngravaCorefor extended schemasZero external services — everything runs locally in-process
Documentation
Documentation Index — choose a path by task: learn, build, operate, extend, or migrate
Core Concepts — the mental model (thought, edge, reflection, cycle, …) — start here
The Bi-temporal Model — the optional valid-time axis: query a fact as of any instant,
invalidatewithout deletingPositioning — when Engrava is (and isn't) the right tool, and how it compares
Quick Start — 5-minute setup guide
Tutorial — build a small notes memory end to end
Recipes — copy-paste snippets for common tasks (store a turn, retrieve context, TTL, dedup, …)
Building a memory-backed agent — the end-to-end agent turn loop (ingest → retrieve → generate → consolidate)
Migrating from another memory system — concept mapping, porting calls, bulk import, and scoping/multi-tenancy
Embeddings — wiring a real embedding provider (local / OpenAI / Ollama / HuggingFace / custom)
MCP server (
engrava-mcp) — expose a store to MCP clients (Claude Desktop, Claude Code, Cursor, Windsurf, VS Code) via the standalone server package: install, run, client config, tools/resources/promptsConfiguration — YAML config format and options
Upgrade Guide — compatibility matrix, backups, and troubleshooting
Extensions — Writing custom extensions and hooks
Observability — Metrics snapshot API
Audit Trail — Tamper-evident hash-chain journal (enabling, querying, verifying, security model)
Evidence and Conflicts — model claims, provenance, contested facts, and caller-owned clarification
Security — trust boundaries, data egress, tenant isolation, encryption posture, and journal limits
Error Handling and Recovery — decide when to retry, repair input, or replace a store
Benchmarks — release gates, versioned observed values, and LongMemEval
API Reference — Full protocol and class reference
CLI Reference — every
engravacommand and optionGlossary — quick definitions of every Engrava term
MindQL — Query language syntax and examples
Troubleshooting — symptom → cause → fix for common errors
FAQ — quick answers (LLM/keys, embeddings-optional, scale, concurrency, backups, …)
Performance & Scaling — the vector-backend switch, bulk-ingest, and dreaming cost at scale
Data Lifecycle & Retention — lifecycle states, TTL, archive-vs-delete, GDPR erasure, disk reclamation
Deployment — process model, database files on disk, containers, graceful shutdown
Concurrency — the WAL single-writer model, busy timeout, and per-service isolation
Backup & Recovery — WAL-safe backups, snapshot vs file copy, restore verification
Known Limitations — Platform notes and constraints
Development
make install # deps + dev extras + local git hooks -- from the primary checkout
make gate # fast: hooks-active + lint + format check + type check + goldens drift, ~70s
make check # lint + format check + type check + the full test suite with coverage (unchanged; no hooks-active, no goldens drift)make install also wires a commit-msg hook that lints the message of the
commit a squash merge creates — including on a local branch that never opens
a pull request, where nothing else does. It refuses to install from a linked
worktree (core.hooksPath is shared across all of them). See
CONTRIBUTING.md
for what it covers and what it does not.
License
MIT — see LICENSE for details.
docs/search.md
Hybrid Search
engrava's search_hybrid() combines up to five scoring signals
into a single ranked result list.
Signal Model
| # | Signal | Weight key | Default | Source |
|---|---|---|---|---|
| 1 | FTS5 keyword | default_fts_weight | 0.30 | BM25 full-text score (min-max normalized) |
| 2 | Vector similarity | default_vector_weight | 0.55 | Cosine similarity from embedding search |
| 3 | Recency | default_recency_weight | 0.10 | Exponential decay along one recency axis — cognitive-cycle (current_cycle) or transaction-time (recency_now); see Two recency axes |
| 4 | Priority | default_priority_weight | 0.05 | Boost multiplier per priority level (P1–P4) |
| 5 | Graph | default_graph_weight | 0.00 | 1-hop-weighted neighbour boost (opt-in) |
Default weights sum to 1.0. When a signal is unavailable (e.g. neither
recency reference nor a cycle provider can resolve recency, or no embeddings
can resolve the vector arm),
its weight is redistributed proportionally across active signals.
The table's defaults apply on every construction path.
SqliteEngravaCore(conn, ...) with no search_config argument resolves them
exactly like a store built with a default SearchConfig() or from_config
with no override — one source, SearchConfig's own field defaults, so an
omitted search: section and search: {} resolve identically too. An
explicit per-call weight, or a SearchConfig you built with a different
value, still wins over the table.
Graceful Degradation
FTS5 unavailable or empty query → FTS skipped.
query_vectorisNoneand no embedding provider → vector skipped.No explicit
current_cycle, no configured cycle provider, and norecency_now→ recency skipped.priority_weightis0.0→ priority skipped.graph_weightis0.0→ the graph ranking signal is skipped and costs nothing. (Reflection-source candidate expansion is a separate step; see below.)All signals disabled → a dedicated query-less window of
top_krows (not alist_thoughtscall), pre-orderedupdated_cycle DESCso the truncation keeps the freshest rows. With no cycle reference, every row scores a flat0.0. A reference supplied atrecency_weight=0.0—current_cycleorrecency_now— is accepted but inert here too: the weight gates recency on this query-less path the same way it does everywhere else, so the row still scores a flat0.0rather than a decayed one. If another signal is active on this same path (priority_weightdefaults on), zeroingrecency_weightcan change which row ranks first, not just shrink the scores, because the now-flat recency term stops competing with it. The compiledfilters=/visibility=predicate is applied in-query and archived thoughts stay excluded unlessinclude_archivedis set, so this path enforces the same eligibility as the FTS and vector arms.That same query-less window serves whenever both the FTS and vector arms are out, even if recency survives — recency stays active only with a reference and a recency weight above
0.0. The window is then ordered and scored along the active recency axis:COALESCE(updated_at, created_at) DESCwith transaction-time decay when the transaction axis is active,updated_cycle DESCwith cognitive-cycle decay otherwise (the two axes are mutually exclusive).A fallback result is still a result:
collapse_key/collapse_max_per_unitandreflection_topk_capapply to this query-less window exactly as they do to an ordinary FTS/vector-active search — a fragmented unit is still collapsed to its best-ranked row(s), and REFLECTIONs are still capped attop_k * reflection_topk_cap, with the same off-list-candidate backfill. Whencollapse_keyis set (or the reflection cap is below1.0, which the default0.3is), the fallback also widens its own row window bysearch.collapse_pool_factorbeyondtop_k, the same bounded headroom collapse and the cap already get from the FTS/vector arms'fts_top_k/vector_top_kpools — each is a minimum per-arm pool, raised totop_kwhen smaller, before that widening — so backfill has distinct candidates to draw from. See the "De-fragmentation / collapse" andreflection_topk_capsections below.
The vector arm distinguishes two bad-query-vector cases:
A degenerate
query_vector(empty, all-zero, or non-finite) → the vector arm returns nothing and the read-onlyvector_arm_degradation_countcounter is incremented (a bad query embedding, not an empty corpus). It does not raise.A wrong-dimension
query_vector(its length differs from the store's embedding dimension) is a caller-contract violation and raisesVectorDimensionMismatchError— it is not silently degraded. See Observability signals and Known Limitations.
Both health counters are read-only, monotonically increasing properties on one store instance and reset when a new store is constructed:
print(store.fts_match_failure_count)
print(store.vector_arm_degradation_count)fts_match_failure_count increments before the one safe fallback retry whenever
the primary normalized FTS5 MATCH expression fails. The fallback may still
return valid matches; a non-zero count therefore means recovery occurred, not
that the FTS arm was necessarily lost. vector_arm_degradation_count increments
only for degenerate vectors. A dimension mismatch is not counted because it is
raised as a typed contract error.
v0.6.0 compatibility note. A wrong-dimension query vector is rejected with
VectorDimensionMismatchErrorinstead of being confused with an empty neighbourhood or leaking a backend-specific shape error. Callers that accept externally supplied vectors should catch this error (orEngravaError) and correct the embedding/model configuration; retrying the same vector cannot produce a valid result.
Archived thoughts are excluded by default
Every ranked read — search_hybrid(), recall(), search_fts(), and
search_similar() — drops archived thoughts (lifecycle_status = ARCHIVED)
from its default candidate set, the same eligibility class as expired rows and
retired reflections. This is the retrieval side of
Forgetting — an opt-in, off-by-default hygiene loop — but
the exclusion applies to any archived row, whether or not that loop is enabled: an
archived (forgotten) thought stops surfacing without being deleted.
The exclusion is reversible:
store.restore_thought(thought_id)flips the row back toACTIVE, so it is eligible again; andpassing
include_archived=Trueto any of the four methods re-admits archived rows for that one call, without restoring them.
Behaviour change. Marking a thought
ARCHIVED— including via the TTLarchivestrategy — now removes it from default retrieval; previously an archived thought still surfaced in search. It is still counted bycount_thoughts()/list_thoughts()(those are not ranked retrieval), provided it is not also expired — both methods default to excluding expired rows regardless of lifecycle status, so an archived-and-expired row needsinclude_expired=Truetoo. To exclude an archived row deliberately, filter onlifecycle_statusyourself. The retired-reflection freshness floor is independent — a retiredREFLECTIONstays excluded even underinclude_archived=True. See Data lifecycle and Known Limitations.
Two recency axes
Recency ranks along two separately-typed axes, and a query picks exactly one. Passing neither explicit reference uses a configured cycle provider when present; without one, recency remains off:
| Axis | Reference | Ages a row by | Half-life unit | For |
|---|---|---|---|---|
| Cognitive-cycle | current_cycle | updated_cycle vs the cycle | cycles (recency_half_life) | agents that own and advance a logical cycle |
| Transaction-time | recency_now | updated_at (→ created_at) vs the instant | seconds (recency_now_half_life) | "recently stored" — wall-time recency on any store |
Both use the same exponential half-life decay; only the clock differs. The transaction-time axis makes "recently written" rankable even on a store where every row shares one cognitive cycle (the common case for externally-written memories) — the signal cycle-recency cannot express there.
recency_now is a caller-supplied ISO-8601 instant: engrava's core reads no
wall clock in ranking, so retrieval stays deterministic and replayable (same
store + same recency_now → same ranking). A naive value is interpreted as UTC
(the host timezone is never consulted); a malformed recency_now (or a
non-positive recency_now_half_life) raises InvalidRecencyArgumentError. A row
whose updated_at/created_at is missing or malformed is treated as maximally
old (the minimum recency score). An explicit recency_now takes precedence
over a passive cycle_provider (the provider is not consulted when
recency_now is supplied with no explicit current_cycle); supplying both
explicit references — current_cycle and recency_now — raises
RecencyModeConflictError.
# Transaction-time recency: rank by how recently each memory was written,
# relative to a caller-supplied "now" (a 24-hour freshness half-life).
from datetime import UTC, datetime
results = await store.search_hybrid(
"incident timeline",
recency_now=datetime.now(UTC).isoformat(), # the caller owns "now"
recency_weight=0.4,
recency_now_half_life=86400, # seconds (1 day); default 604800 (7 days)
)The units never mix: a wall-clock age is never subtracted from a cognitive
cycle. To use transaction-time recency on a store that has a cycle provider
configured, pass recency_now while omitting an explicit current_cycle —
the provider is passive and is not consulted, so the query ranks by transaction
time. (Omitting recency_now instead selects the provider's cognitive-cycle
recency.) You only hit RecencyModeConflictError if you explicitly pass both
current_cycle and recency_now.
Keyword query syntax (FTS)
The keyword signal — and the search_fts() method that exposes it directly —
runs your text against an SQLite FTS5 index. engrava
normalises the query before handing it to FTS5, with two modes that switch
automatically on what you type:
Bare queries are matched with OR. A plain natural-language query like
what was my sister doing is treated as a bag of words joined with OR, so a
document matches when it shares any word. BM25's IDF weighting then ranks the
documents that share the most distinctive words first, so common function words
(what, was, my) carry little weight and need no stopword list or stemmer —
this works in any language. (Before this, the words were joined with FTS5's
implicit AND, so a question only matched documents containing every word and
relevant answers were missed.)
# Bare query -> OR-matched: finds docs sharing any content word, best-ranked first
hits = await store.search_fts("what was my sister doing", top_k=10)Expert syntax is preserved unchanged. If your query uses FTS5 operators, it is passed through as written:
quoted phrases —
"machine learning"matches the exact phrase;uppercase booleans —
AND,OR,NOT(must be uppercase) compose terms, e.g.python AND NOT snake;prefix — a trailing
*does prefix matching, e.g.neur*;column filters —
essence:andcontent:restrict a term to that column, e.g.content:berlin.
Punctuation never raises. Unsafe characters split a token into separate terms
rather than breaking the query: a contraction like sister's becomes sister OR s, so it still matches a stored sister's dog. Pasting a URL or a timestamp is
safe too — only the real essence: / content: column filters are honoured, so
http://example.com and 12:30 are treated as ordinary search terms (they do
not become spurious http: / 12: column filters). When a normalized
full-text expression is a genuinely malformed FTS5 query, engrava logs a warning,
increments the read-only fts_match_failure_count counter, and retries once
through the bare normalization (unsafe characters dropped, wildcards collapsed to
legal prefixes, any exposed AND/OR/NOT phrase-quoted so FTS5 cannot read it
as an operator), which is always a valid MATCH; the FTS arm returns that query's
matches (an empty set when the sanitized query matches nothing). See
Observability signals.
Graph-Aware Ranking
The graph signal uses 1-hop-weighted neighbour boost. If a candidate thought's graph neighbours also match the query, the candidate receives a boost proportional to the neighbour's semantic score and the connecting edge weight.
Algorithm
candidate_scores = { C: max(fts_score[C], vector_score[C]) for C in fusion pool } # pool members only
For each candidate C in the fusion pool:
neighbours = get_edges(C, direction="BOTH", limit=max_neighbors)
ordered by edge.weight DESC (deterministic)
For each (edge, neighbour):
neighbour_base = candidate_scores.get(neighbour, 0.0) # 0 if neighbour is off-pool
boost[C] += edge.weight × neighbour_base × graph_edge_decay
final_score[C] += graph_weight × boost[C]A neighbour outside the fusion pool contributes 0, not its own fts/vector
score — only pool members' base scores propagate (see
Graph-aware search).
Key properties:
Only semantic scores propagate — priority, recency, and graph scores are excluded from
neighbour_baseto prevent hub-cascade effects.No new candidates — graph signal re-ranks existing results, it does not add thoughts to the result set.
Deterministic — neighbours are sorted by
edge.weight DESCbefore themax_neighborscap is applied.
Configuration
search:
default_graph_weight: 0.0 # opt-in (0.0 = disabled)
graph_edge_decay: 0.5 # decay factor for 1-hop distance
max_neighbors_per_candidate: 5 # safety capPer-query override:
result = await store.search_hybrid(
"python async",
graph_weight=0.1,
graph_edge_decay=0.3,
)Performance
When graph_weight=0.0 (default), the graph ranking signal is off: its
1-hop neighbour query is not executed and it adds no ranking cost. This does
not switch off reflection-source candidate
expansion, which is controlled
separately by graph_expansion_enabled (default true) and reads
CONSOLIDATED_FROM edges whenever a REFLECTION ranks among the top
candidates. When the ranking signal is active, the implementation batches the
candidate pool into fixed-size ID chunks (one query per chunk, needed to stay
under SQLite's bound-parameter ceiling once the pool is large) and each
chunk's query itself is bounded at the SQL layer to at most
max_neighbors_per_candidate rows per candidate — via a ranking window over
that candidate's edges, not a limit applied in Python after fetching
everything — so the total rows returned across all chunks is
O(top_k × max_neighbors_per_candidate), not the size of the full adjacency.
Observability
When the graph signal contributes to at least one candidate,
"graph" appears in HybridSearchResult.backends_used.
Reflection-source candidate expansion
Graph expansion is separate from the weighted graph ranking signal above. It is a bounded candidate-generation step over dreaming lineage, not a sixth fusion signal:
After the available fusion signals have scored the current pool, engrava selects up to
graph_expansion_top_ntop-rankedREFLECTIONcandidates.It follows each reflection's outgoing
CONSOLIDATED_FROMedges, ordered by edge weight, and admits only source thoughts of typeOBSERVATION.Each admitted source receives
parent_score × graph_expansion_propagation_factor × edge.weight; when the source is already present, the higher of its existing and propagated score wins.
Unlike graph-aware ranking, expansion can add new candidates and is not
controlled by graph_weight. It is enabled by default even though the weighted
graph signal defaults to 0.0. Expansion re-applies expiry, archive, metadata,
and visibility eligibility, so it cannot bring back a source excluded by the
active query (unless include_archived=True explicitly re-admits archived
rows). Non-observation targets are ignored. Expansion runs before the final
reflection filter, so eligible source observations may still be admitted when
include_reflections=False; only the reflection rows are removed.
The store-level controls are:
search:
graph_expansion_enabled: true
graph_expansion_top_n: 5
graph_expansion_propagation_factor: 0.7
graph_expansion_max_sources_per_reflection: 20
graph_expansion_reflection_source_ceiling: 50The per-reflection source cap keeps traversal bounded. A reflection with more
than graph_expansion_reflection_source_ceiling lineage sources is skipped
entirely, preventing a very large cluster from flooding the candidate pool.
These settings belong to SearchConfig; search_hybrid() has no per-query
expansion override. When expansion adds or improves at least one candidate,
"graph_expansion" appears in HybridSearchResult.backends_used.
Per-Query Overrides
All weights can be overridden per call via keyword arguments:
result = await store.search_hybrid(
"quantum computing",
query_vector=embedding,
fts_weight=0.4,
vector_weight=0.4,
recency_weight=0.1,
priority_weight=0.05,
graph_weight=0.05,
current_cycle=42,
)Scoped retrieval
search_hybrid() and recall() accept two optional, keyword-only filter
arguments that scope the ranked query to rows whose metadata satisfies a
typed predicate. Both default to None (no filtering, the candidate set is
unchanged).
from engrava import FieldOp, FieldPredicate, MetadataFilter, VisibilityQueryFilter
# Equality / membership over your metadata keys (an AND of predicates):
result = await store.search_hybrid(
"deployment runbook",
top_k=10,
filters=MetadataFilter(
[
FieldPredicate("$.project", FieldOp.EQ, "atlas"),
FieldPredicate("$.env", FieldOp.IN, ["staging", "prod"]),
]
),
)
# "Public, or mine" — admit public rows plus rows this user owns:
result = await store.recall(
"deployment runbook",
top_k=10,
visibility=VisibilityQueryFilter(allowed={"public"}, owner="u1"),
)Where the filter is applied. The predicate runs inside each search arm,
before that arm's limit — so a narrow filter still returns up to top_k
matching rows and is never starved by out-of-filter candidates consuming the
ranking budget. This is the key advantage over over-fetching and post-filtering
(which can starve top_k) or a raw json_extract pre-filter (which loses the
hybrid ranking).
Semantics:
filtersis aMetadataFilter— anANDofFieldPredicate(path, op, value)over JSON paths ($,$.key,$.key[0]). Operators areEQandIN.EQ Nonematches both a missing path and an explicit JSONnull. An emptyINmatches nothing. An emptyMetadataFilteris a match-all no-op.Values compare under SQLite value equality: booleans are stored as integers, so
EQ Truematches a stored1(andEQ Falsematches0).visibilityis aVisibilityQueryFilter(allowed, owner)reading$.visibilityand$.owner. It composes withfiltersbyAND; itsORis bounded and parenthesised, so anownermatch can never escape the metadataAND.Filter objects validate at construction (bad path, unsupported operator, out-of-range integer, non-finite float, an all-empty
VisibilityQueryFilter) and raiseInvalidFilterError/InvalidFilterPathError— never mid-query.
A filter is a query refinement, not an isolation boundary. It narrows what a ranked query considers; it performs no authentication, authorization, ownership validation, or write enforcement. The
visibilityfilter reads only the metadata your application wrote, and a caller can omit it or supply anyowner. Do not use it to keep one tenant's data away from another — for tenant isolation give each tenant its own store viaEngravaManager(see migrating-from-other-memory.md).
Determinism. Results are ordered by combined score descending, with ties
broken by canonical thought_id ascending — a stable total order independent of
the underlying scan order. (This tie-break is always applied, with or without a
filter.)
Note on BM25 scores. The keyword arm's BM25 ranking uses store-global corpus statistics. A filter changes which rows are eligible, but does not recompute term frequencies over the filtered subset — relative scores are still computed against the whole corpus. This is the intended behaviour for a query refinement within one memory.
De-fragmentation / collapse
search_hybrid() and recall() also accept an optional, keyword-only
collapse_key that de-duplicates fragments of the same logical unit in the
result. When many rows in your store describe the same thing — for example
several chunks of one conversation turn, or several versions of one document —
a plain ranked query can fill the top-k window with near-duplicates of one
unit and push other, distinct units out. collapse_key keeps only the single
best-ranked row per unit and lets deeper distinct units take the freed slots.
# One best row per (caller-defined) unit; deeper distinct units backfill in.
result = await store.recall(
"what did we decide about the rollout?",
top_k=10,
collapse_key="$.turn_id", # a single metadata path
)
# Composite unit key — rows share a unit only if ALL components are equal:
result = await store.search_hybrid(
"rollout decision",
top_k=10,
collapse_key=["$.session_id", "$.turn_index"],
)What it does. Among the already-ranked candidates, the highest-ranked row of
each unit is kept; lower-ranked rows of that same unit are removed, and the
slots they vacate are backfilled by the next distinct units. So the prompt sees
one best row per unit plus more distinct units, instead of a pile of fragments
of one unit. collapse_key=None (the default) leaves the result exactly
unchanged.
Semantics:
collapse_keyis a single JSON path ("$.turn_id") or an ordered sequence of paths forming a composite key (["$.session_id", "$.turn_index"]). Paths use the same grammar asfilters($,$.key,$.key[0]) and are validated at call time — a malformed path raisesInvalidFilterPathError, never mid-query.A row whose key is missing, holds malformed metadata, or (for a composite key) is missing any component is treated as its own unit — it is never collapsed with another row. Distinct key-less rows all survive.
The kept row per unit is the highest-ranked one under the same deterministic order used everywhere on this path (combined score descending, then canonical
thought_idascending), so the keeper and final order are stable across runs.To give backfill a deeper pool to draw from, each search arm's candidate budget is widened by a small, bounded factor only while
collapse_keyis set (configurable assearch.collapse_pool_factor, default4); thecollapse_key=Nonepath is never widened. Because the keyword arm's scores are min-max normalized over the candidate set, this wider pool can rescale the normalized fusion scores and shift the relative order among units — so settingcollapse_keyis not score-neutral even though the collapse step itself never mutates a score. Onlycollapse_key=Noneis byte-identical to a plain query.
Keeping more than one row per unit (collapse_max_per_unit). By default
collapse keeps a single best row per unit. When a unit is genuinely a long,
multi-part thing — say one long turn split into several chunks whose useful
detail is spread across more than the top chunk — dropping every chunk but the
best one can discard the part you actually need. The optional, keyword-only
collapse_max_per_unit sets how many of a unit's highest-ranked rows may reach
the result, so a long unit can keep its deeper rows while the remaining slots
still backfill deeper distinct units:
# Keep up to 2 rows of each unit; distinct deeper units still backfill.
result = await store.search_hybrid(
"rollout decision",
top_k=20,
collapse_key="$.turn_id",
collapse_max_per_unit=2,
)None(the default) keeps exactly one best row per unit — identical to passing onlycollapse_key. An integer>= 1keeps up to that many of a unit's highest-ranked rows;1is the single-best-row default.It only takes effect together with
collapse_key(there is no unit to retain by otherwise), and is validated at call time — a value below1raisesInvalidFilterError.It only relaxes the per-unit retention count. It never adds a row a search arm did not produce, never mutates a score, and never merges or drops a distinct unit as a side effect; the usual
top_ktruncation still applies, so distinct units beyondtop_kare dropped exactly as they are without it. Key-less rows are unaffected — each is already its own unit.
Collapse is a presentation convenience, not a filter and not an isolation boundary. It does not change which rows are eligible (use
filters/visibilityfor that). The collapse step itself mutates no score — it only decides which of the already-eligible rows is shown per unit, dropping lower-ranked members of the same unit. (Settingcollapse_keydoes widen the internal candidate pool, which can affect normalized fusion scores and order, as noted above.) It is only as meaningful as the unit metadata your application writes: if two genuinely different rows carry the same key they will be treated as one unit, and rows without the key are never merged. Eligibility (filters/visibility) always applies first, then collapse.
Whole-turn assembly (a caller-side recipe)
Collapse and collapse_max_per_unit decide which rows reach your prompt. If you
also want the reader to see a unit's contiguous text — a whole conversation
turn in order, not only the chunks that happened to rank — assemble it yourself
from the results, using only the public API. engrava stores no intrinsic
chunk-sequence, so this works exactly to the extent your ingest wrote (a) a
stable unit key and (b) an orderable ordinal (e.g. a chunk_index) on each
chunk.
from engrava.domain.models.filters import FieldOp, FieldPredicate, MetadataFilter
# 1. Retrieve as usual — collapse dedupes fragments; distinct units backfill.
result = await store.search_hybrid(
"what did the assistant explain about retries?",
top_k=20,
collapse_key=["$.session_id", "$.turn_index"],
collapse_max_per_unit=2,
)
# 2. For a result you want in full, read its unit-key value from its own metadata,
# gather the unit's chunks with a metadata-filtered search, then order them by
# your ordinal and concatenate.
async def assemble_unit(query: str, thought_id: str) -> str:
seed = await store.get_thought(thought_id)
assert seed is not None
unit = await store.recall(
query, # any query — the filter selects the unit
top_k=100, # generous, to cover the unit's chunk count
filters=MetadataFilter([
FieldPredicate("$.session_id", FieldOp.EQ, seed.metadata["session_id"]),
FieldPredicate("$.turn_index", FieldOp.EQ, seed.metadata["turn_index"]),
]),
)
chunks = [await store.get_thought(tid) for tid, _score in unit.results]
ordered = sorted((c for c in chunks if c), key=lambda t: t.metadata["chunk_index"])
return "\n".join(t.content for t in ordered)No new API, no result-shape change. This uses only
get_thoughtand the metadatafilters=you already have onsearch_hybrid()/recall()— the same surface you call to read a result's text.search_hybrid()'s result stays(thought_id, score)tuples.The metadata predicate lives on the ranked search surface (
search_hybrid/recallfilters=), not on the publicsearch_similarorsearch_ftsmethods or onlist_thoughts(whose filters cover the built-in columns + provenance, not arbitrary metadata), so gathering a unit's chunks is a filtered hybrid search with a generoustop_k; the ranking is irrelevant because you re-order by your own ordinal.Precondition (yours to guarantee). Contiguous order is only as good as the metadata you wrote: without a stable, orderable ordinal on each chunk, siblings can be fetched but not meaningfully ordered — engrava has no chunk-sequence concept of its own. Write the ordinal at ingest alongside the unit key.
Where it runs. Entirely on the results engrava already returned — prompt and context assembly is your application's concern, not the store's.
Configuration Reference
See configuration.md for the full YAML reference
of SearchConfig fields.
Querying reflections
After DreamingExtension.run_consolidation() runs its clustering phase,
ThoughtType.REFLECTION meta-thoughts exist in the store. Three knobs
control how hybrid search handles them.
include_reflections (default True)
When False, REFLECTION thoughts are excluded from search_hybrid()
results. Useful when you want raw observations / insights without
higher-order aggregates:
result = await store.search_hybrid(
"machine learning",
query_vector=embedding,
include_reflections=False,
)reflection_boost (default SearchConfig.reflection_boost = 1.0)
When REFLECTIONs are included, their final score is multiplied by this
factor. The default 1.0 leaves REFLECTIONs competing on equal footing;
raise it above 1.0 for a modest upranking so high-level abstractions
surface for broad queries without dominating narrow ones.
# Stronger boost — reflections rank near the top for broad queries
result = await store.search_hybrid(
"patterns in memory",
query_vector=embedding,
reflection_boost=1.5,
)
# Disable boost — reflections compete on equal footing
result = await store.search_hybrid(
"specific fact",
query_vector=embedding,
reflection_boost=1.0,
)Configure the default in YAML:
search:
reflection_boost: 1.0 # applies when reflection_boost not overridden per-callreflection_topk_cap (default 0.3)
Caps how much of the final top-K window REFLECTION thoughts may occupy. After
all signals are applied, if reflections hold more than top_k * reflection_topk_cap slots, the lowest-scoring excess reflections are evicted
and the freed slots are backfilled with the highest-scoring off-list
non-reflection candidates. Set it to 1.0 to disable the cap.
When the cap actually evicts, the result carries a programmatic signal and the
event is logged at INFO:
result = await store.search_hybrid("patterns in memory", query_vector=embedding, top_k=10)
if result.reflections_evicted:
# e.g. cap=0.3 * top_k=10 → 3 reflection slots; extra reflections were evicted
print(f"{result.reflections_evicted} reflection(s) evicted by reflection_topk_cap")HybridSearchResult.reflections_evicted is 0 on every query where the cap did
not evict (its default), so existing consumers are unaffected; a positive value
means the cap reshaped the top-K window.
search_reflections_only()
Convenience helper that returns only REFLECTION thoughts, scored by cosine similarity to the query vector (plus optional recency blend). Designed for queries like "what themes exist in my memory?":
result = await store.search_reflections_only(
"recurring ideas about learning",
query_vector=embedding,
top_k=5,
current_cycle=42, # optional recency blend
)
for thought_id, score in result.results:
ref = await store.get_thought(thought_id)
print(ref.content) # JSON with member_ids + keywordsKey difference from search_hybrid(include_reflections=True):
search_reflections_only() fetches all eligible REFLECTIONs directly from
the store (no pagination gap) and scores them purely by cosine similarity
to the query. It does not compete against regular thoughts for result slots.
Only active, unexpired REFLECTIONs are eligible. Expiry is compared against one
UTC instant captured for the call, and a row with expires_at <= now is
excluded. Equal scores are ordered by thought_id for deterministic results.
402 files · 357 not served here
- .commitlintrc.js (not served here)
- .cursorrules (not served here)
- .gitattributes (not served here)
- .githooks/commit-msg (not served here)
- .github/ISSUE_TEMPLATE/bug.md (not served here)
- .github/ISSUE_TEMPLATE/config.yml (not served here)
- .github/ISSUE_TEMPLATE/feature.md (not served here)
- .github/ISSUE_TEMPLATE/question.md (not served here)
- .github/copilot-instructions.md
- .github/dependabot.yml (not served here)
- .github/pull_request_template.md (not served here)
- .github/release-notes-template.md (not served here)
- .github/workflows/branch-name-guard.yml (not served here)
- .github/workflows/ci.yml (not served here)
- .github/workflows/commitlint.yml (not served here)
- .github/workflows/main-carries-the-released-tag.yml (not served here)
- .github/workflows/release-merge-discipline.yml (not served here)
- .github/workflows/release.yml (not served here)
- .github/workflows/secret-scan.yml (not served here)
- .github/workflows/smoke-gate.yml (not served here)
- .gitignore (not served here)
- .releaserc.json (not served here)
- AGENTS.md
- ATTRIBUTION.md
- AUTOGINEER-RUNS.md
- BRANCHING.md
- CHANGELOG.md
- CLAUDE.md
- CODE_OF_CONDUCT.md
- CONTRIBUTING.md
- LICENSE
- MANIFEST.in (not served here)
- Makefile (not served here)
- README.md
- SECURITY.md
- commit-scopes.json (not served here)
- docs/api-reference.md
- docs/architecture.md
- docs/audit-trail.md
- docs/backup-and-recovery.md
- docs/benchmarks.md
- docs/bitemporal.md
- docs/cli.md
- docs/concepts.md
- docs/concurrency.md
- docs/configuration.md
- docs/data-lifecycle.md
- docs/deployment.md
- docs/dreaming.md
- docs/error-handling.md
- docs/evidence-and-conflicts.md
- docs/extension-hooks.md
- docs/extensions.md
- docs/faq.md
- docs/glossary.md
- docs/guides/agent-memory.md (not served here)
- docs/guides/embeddings.md (not served here)
- docs/guides/migrating-from-other-memory.md (not served here)
- docs/index.md
- docs/known-limitations.md
- docs/memory-hygiene.md
- docs/mindql.md
- docs/observability.md
- docs/performance.md
- docs/positioning.md
- docs/quickstart.md
- docs/recipes/index.md (not served here)
- docs/scopes.md
- docs/search.md
- docs/security.md
- docs/troubleshooting.md
- docs/tutorial.md
- docs/upgrade.md
- examples/README.md (not served here)
- examples/agent_loop.py (not served here)
- examples/config.yaml (not served here)
- examples/mcp-client-config.db-path.json (not served here)
- examples/mcp-client-config.json (not served here)
- examples/mcp-client-config.readonly.json (not served here)
- examples/mcp-client-config.vscode.json (not served here)
- examples/notes_memory.py (not served here)
- examples/profile-lexical.yaml (not served here)
- examples/profile-local.yaml (not served here)
- examples/profile-network-ollama.yaml (not served here)
- examples/quickstart.py (not served here)
- examples/simple_agent.py (not served here)
- pyproject.toml (not served here)
- release-target.json (not served here)
- scripts/backfill_content_hashes.py (not served here)
- scripts/build_upgrade_fixture.py (not served here)
- scripts/bump_pyproject_version.py (not served here)
- scripts/check_computed_version_matches_target.py (not served here)
- scripts/check_cpu_only_torch.py (not served here)
- scripts/check_hooks_active.sh (not served here)
- scripts/check_main_carries_the_released_tag.py (not served here)
- scripts/check_release_branch_merge_discipline.py (not served here)
- scripts/check_release_target_was_published.py (not served here)
- scripts/check_schema_release_gate.py (not served here)
- scripts/check_smoke_gate.py (not served here)
- scripts/install_gitleaks.sh (not served here)
- scripts/install_hooks.sh (not served here)
- scripts/rebalance_p1.py (not served here)
- scripts/reenrich_reflections_to_v2.py (not served here)
- scripts/regenerate_search_goldens.py (not served here)
- scripts/run_schema_release_gate.sh (not served here)
- scripts/run_upgrade_test.sh (not served here)
- scripts/verify_artifact_version.py (not served here)
- scripts/verify_dist_cardinality.py (not served here)
- scripts/verify_release_artifacts.sh (not served here)
- scripts/verify_release_content_scan.sh (not served here)
- scripts/verify_wheel_data.py (not served here)
- scripts/verify_wheel_execution.py (not served here)
- src/engrava/__init__.py (not served here)
- src/engrava/_composition.py (not served here)
- src/engrava/benchmarks/__init__.py (not served here)
- src/engrava/benchmarks/longmemeval/README.md (not served here)
- src/engrava/benchmarks/longmemeval/__init__.py (not served here)
- src/engrava/benchmarks/longmemeval/__main__.py (not served here)
- src/engrava/benchmarks/longmemeval/cli.py (not served here)
- src/engrava/benchmarks/longmemeval/dataset_loader.py (not served here)
- src/engrava/benchmarks/longmemeval/evaluate.py (not served here)
- src/engrava/benchmarks/longmemeval/runner.py (not served here)
- src/engrava/benchmarks/synthetic/README.md (not served here)
- src/engrava/benchmarks/synthetic/__init__.py (not served here)
- src/engrava/benchmarks/synthetic/__main__.py (not served here)
- src/engrava/benchmarks/synthetic/datasets/__init__.py (not served here)
- src/engrava/benchmarks/synthetic/datasets/synthetic-v1.json (not served here)
- src/engrava/benchmarks/synthetic/evaluate.py (not served here)
- src/engrava/benchmarks/synthetic/generate.py (not served here)
- src/engrava/benchmarks/synthetic/runner.py (not served here)
- src/engrava/benchmarks/synthetic/scenarios.py (not served here)
- src/engrava/cli/__init__.py (not served here)
- src/engrava/cli/config.py (not served here)
- src/engrava/cli/exception_reporting.py (not served here)
- src/engrava/cli/main.py (not served here)
- src/engrava/cli/memory_commands.py (not served here)
- src/engrava/cli/snapshot_records.py (not served here)
- src/engrava/cli/store_resolution.py (not served here)
- src/engrava/config.py (not served here)
- src/engrava/config_validation.py (not served here)
- src/engrava/cycle_providers.py (not served here)
- src/engrava/domain/__init__.py (not served here)
- src/engrava/domain/dreaming.py (not served here)
- src/engrava/domain/enums.py (not served here)
- src/engrava/domain/exceptions.py (not served here)
- src/engrava/domain/manifest.py (not served here)
- src/engrava/domain/models/__init__.py (not served here)
- src/engrava/domain/models/_temporal.py (not served here)
- src/engrava/domain/models/action.py (not served here)
- src/engrava/domain/models/edge.py (not served here)
- src/engrava/domain/models/embedding.py (not served here)
- src/engrava/domain/models/filters.py (not served here)
- src/engrava/domain/models/journal.py (not served here)
- src/engrava/domain/models/metrics.py (not served here)
- src/engrava/domain/models/mutation_type.py (not served here)
- src/engrava/domain/models/provenance.py (not served here)
- src/engrava/domain/models/search.py (not served here)
- src/engrava/domain/models/thought.py (not served here)
- src/engrava/domain/models/ttl.py (not served here)
- src/engrava/domain/protocols/__init__.py (not served here)
- src/engrava/domain/protocols/cycle_provider.py (not served here)
- src/engrava/domain/protocols/derived_records.py (not served here)
- src/engrava/domain/protocols/dreaming.py (not served here)
- src/engrava/domain/protocols/embedding_provider.py (not served here)
- src/engrava/domain/protocols/engrava_core.py (not served here)
- src/engrava/domain/protocols/engrava_read.py (not served here)
- src/engrava/domain/protocols/hooks.py (not served here)
- src/engrava/embeddings/__init__.py (not served here)
- src/engrava/embeddings/callback.py (not served here)
- src/engrava/embeddings/huggingface.py (not served here)
- src/engrava/embeddings/ollama.py (not served here)
- src/engrava/embeddings/openai_compatible.py (not served here)
- src/engrava/embeddings/sentence_transformer.py (not served here)
- src/engrava/extensions/__init__.py (not served here)
- src/engrava/extensions/discovery.py (not served here)
- src/engrava/extensions/dreaming.py (not served here)
- src/engrava/extensions/dreaming_cluster_quality.py (not served here)
- src/engrava/extensions/dreaming_keyphrases.py (not served here)
- src/engrava/extensions/dreaming_reflection_content.py (not served here)
- src/engrava/extensions/dreaming_reflection_extent.py (not served here)
- src/engrava/extensions/dreaming_signals.py (not served here)
- src/engrava/extensions/structural_split.py (not served here)
- src/engrava/extensions/vector_sqlite_vec.py (not served here)
- src/engrava/infrastructure/__init__.py (not served here)
- src/engrava/infrastructure/read_only_store.py (not served here)
- src/engrava/infrastructure/service_manager.py (not served here)
- src/engrava/infrastructure/sqlite/__init__.py (not served here)
- src/engrava/infrastructure/sqlite/aiosqlite_connect.py (not served here)
- src/engrava/infrastructure/sqlite/centroid.py (not served here)
- src/engrava/infrastructure/sqlite/connection_revocation.py (not served here)
- src/engrava/infrastructure/sqlite/engrava_core.py (not served here)
- src/engrava/infrastructure/sqlite/extension_migrations.py (not served here)
- src/engrava/infrastructure/sqlite/hygiene.py (not served here)
- src/engrava/infrastructure/sqlite/journal_writer.py (not served here)
- src/engrava/infrastructure/sqlite/schema_core.sql (not served here)
- src/engrava/infrastructure/sqlite/vector_sqlite_vec.py (not served here)
- src/engrava/metadata.py (not served here)
- src/engrava/mindql/__init__.py (not served here)
- src/engrava/mindql/executor.py (not served here)
- src/engrava/mindql/parser.py (not served here)
- src/engrava/py.typed (not served here)
- tests/__init__.py (not served here)
- tests/_shell_command.py (not served here)
- tests/_workflow_yaml.py (not served here)
- tests/benchmarks/__init__.py (not served here)
- tests/benchmarks/fixtures/longmemeval_tiny.json (not served here)
- tests/benchmarks/test_longmemeval_cli.py (not served here)
- tests/benchmarks/test_longmemeval_evaluate.py (not served here)
- tests/benchmarks/test_longmemeval_hygiene.py (not served here)
- tests/benchmarks/test_longmemeval_llm_mode.py (not served here)
- tests/benchmarks/test_longmemeval_loader.py (not served here)
- tests/benchmarks/test_longmemeval_runner.py (not served here)
- tests/benchmarks/test_synthetic_e2e.py (not served here)
- tests/benchmarks/test_synthetic_evaluate.py (not served here)
- tests/benchmarks/test_synthetic_frozen.py (not served here)
- tests/benchmarks/test_synthetic_generator.py (not served here)
- tests/benchmarks/test_synthetic_scenarios.py (not served here)
- tests/conftest.py (not served here)
- tests/data/bare_command_goldens.json (not served here)
- tests/docs/__init__.py (not served here)
- tests/docs/_documented_defaults_scan.py (not served here)
- tests/docs/_import_resolution.py (not served here)
- tests/docs/_md_blocks.py (not served here)
- tests/docs/test_docs_config_defaults.py (not served here)
- tests/docs/test_docs_config_examples.py (not served here)
- tests/docs/test_docs_examples_behavior.py (not served here)
- tests/docs/test_docs_examples_compile.py (not served here)
- tests/docs/test_docs_examples_coverage.py (not served here)
- tests/docs/test_docs_examples_execute.py (not served here)
- tests/docs/test_docs_examples_full_census.py (not served here)
- tests/docs/test_docs_examples_imports.py (not served here)
- tests/docs/test_docs_exceptions_table.py (not served here)
- tests/docs/test_docs_no_commit_citations.py (not served here)
- tests/docs/test_docs_shell_examples.py (not served here)
- tests/docs/test_error_handling_names_every_exception.py (not served here)
- tests/docs/test_md_blocks_fences.py (not served here)
- tests/docs/test_schema_version_error_docs_coverage.py (not served here)
- tests/domain/__init__.py (not served here)
- tests/domain/models/__init__.py (not served here)
- tests/domain/models/test_filters.py (not served here)
- tests/domain/models/test_temporal_fields.py (not served here)
- tests/domain/test_derived_records_types.py (not served here)
- tests/examples/__init__.py (not served here)
- tests/examples/_examples_census.py (not served here)
- tests/examples/test_examples_cleanup_close.py (not served here)
- tests/examples/test_examples_readme_census.py (not served here)
- tests/examples/test_mcp_client_configs.py (not served here)
- tests/examples/test_quickstart_runs.py (not served here)
- tests/extensions/__init__.py (not served here)
- tests/extensions/test_structural_split.py (not served here)
- tests/search_contract/__init__.py (not served here)
- tests/search_contract/conftest.py (not served here)
- tests/search_contract/golden_fixtures.py (not served here)
- tests/search_contract/goldens/from_config_search.yaml (not served here)
- tests/search_contract/goldens/fts_expert_normalization.json (not served here)
- tests/search_contract/goldens/hybrid_ranked_results.json (not served here)
- tests/search_contract/goldens/hybrid_ranked_results_from_config.json (not served here)
- tests/search_contract/test_archived_retrieval_exclusion.py (not served here)
- tests/search_contract/test_cross_arm_liveness.py (not served here)
- tests/search_contract/test_fts_expert_syntax_fallback.py (not served here)
- tests/search_contract/test_fts_fallback_log_content.py (not served here)
- tests/search_contract/test_fts_query_normalization_robustness.py (not served here)
- tests/search_contract/test_hybrid_weight_conservation.py (not served here)
- tests/search_contract/test_search_contract.py (not served here)
- tests/search_contract/test_search_goldens.py (not served here)
- tests/search_contract/test_vector_arm_degradation.py (not served here)
- tests/test_action_outcome_feedback.py (not served here)
- tests/test_aiosqlite_connect.py (not served here)
- tests/test_auto_embed_failure_log_content.py (not served here)
- tests/test_backfill_content_hashes.py (not served here)
- tests/test_begin_immediate_contention_is_typed.py (not served here)
- tests/test_cancellation_during_write_unit_entry.py (not served here)
- tests/test_check_cpu_only_torch.py (not served here)
- tests/test_ci_workflow_permissions.py (not served here)
- tests/test_cli.py (not served here)
- tests/test_cli_memory_verbs.py (not served here)
- tests/test_cli_memory_verbs_config.py (not served here)
- tests/test_cli_memory_verbs_embedding.py (not served here)
- tests/test_cli_memory_verbs_error_boundary.py (not served here)
- tests/test_cli_store_resolution_goldens.py (not served here)
- tests/test_cognitive_journal.py (not served here)
- tests/test_commit_scope_vocabulary.py (not served here)
- tests/test_concurrency_contract.py (not served here)
- tests/test_config.py (not served here)
- tests/test_config_validation_parity.py (not served here)
- tests/test_create_delete_journal_append_atomicity.py (not served here)
- tests/test_create_thought_performance.py (not served here)
- tests/test_cycle_provider.py (not served here)
- tests/test_de_fragmentation_collapse.py (not served here)
- tests/test_dedup_migration.py (not served here)
- tests/test_dedup_write_contention.py (not served here)
- tests/test_deletion_blast_radius.py (not served here)
- tests/test_deprecated_aliases.py (not served here)
- tests/test_derived_child_failure_transaction_isolation.py (not served here)
- tests/test_derived_records_seam.py (not served here)
- tests/test_derived_reuse_transaction_cleanup.py (not served here)
- tests/test_docs_anchor_links.py (not served here)
- tests/test_docs_no_deleted_observability_symbols.py (not served here)
- tests/test_dreaming.py (not served here)
- tests/test_dreaming_activation.py (not served here)
- tests/test_dreaming_boilerplate_filter.py (not served here)
- tests/test_dreaming_cluster_quality.py (not served here)
- tests/test_dreaming_clusters.py (not served here)
- tests/test_dreaming_content_v1_v2_compat.py (not served here)
- tests/test_dreaming_edges.py (not served here)
- tests/test_dreaming_gating_integration.py (not served here)
- tests/test_dreaming_integration.py (not served here)
- tests/test_dreaming_keyphrases.py (not served here)
- tests/test_dreaming_keyphrases_blocklist.py (not served here)
- tests/test_dreaming_lifecycle.py (not served here)
- tests/test_dreaming_metadata_filter.py (not served here)
- tests/test_dreaming_priority_bias.py (not served here)
- tests/test_dreaming_reflection_content.py (not served here)
- tests/test_dreaming_reflection_content_quality_fixes.py (not served here)
- tests/test_dreaming_reflection_extent.py (not served here)
- tests/test_edge_metadata.py (not served here)
- tests/test_edge_metadata_migration.py (not served here)
- tests/test_embedding_providers.py (not served here)
- tests/test_end_to_end_integration.py (not served here)
- tests/test_engrava_completeness.py (not served here)
- tests/test_exception_exports.py (not served here)
- tests/test_extension_infrastructure_boundary.py (not served here)
- tests/test_extension_load_with_migrations.py (not served here)
- tests/test_extension_migrations.py (not served here)
- tests/test_failed_commit_recovery.py (not served here)
- tests/test_from_config_integration.py (not served here)
- tests/test_hooks_active_gate.py (not served here)
- tests/test_hooks_wiring.py (not served here)
- tests/test_hot_path_index_migration.py (not served here)
- tests/test_hybrid_search_enhanced.py (not served here)
- tests/test_hygiene_journal_append_atomicity.py (not served here)
- tests/test_ingest_deduplication.py (not served here)
- tests/test_ingest_ergonomics.py (not served here)
- tests/test_invalidate.py (not served here)
- tests/test_journal_append_atomicity.py (not served here)
- tests/test_journal_append_recurrence_guard.py (not served here)
- tests/test_journal_verification.py (not served here)
- tests/test_main_carries_the_released_tag.py (not served here)
- tests/test_memory_hygiene.py (not served here)
- tests/test_metadata_helpers.py (not served here)
- tests/test_metadata_migration.py (not served here)
- tests/test_metrics.py (not served here)
- tests/test_metrics_measured_census.py (not served here)
- tests/test_migration_upgrade_chains.py (not served here)
- tests/test_mind_store_branches.py (not served here)
- tests/test_mind_store_core.py (not served here)
- tests/test_mindql.py (not served here)
- tests/test_outer_rollback_recovery.py (not served here)
- tests/test_partial_field_updates.py (not served here)
- tests/test_prepare_thought_for_insert_seam.py (not served here)
- tests/test_provenance_context_capture.py (not served here)
- tests/test_pyproject_urls.py (not served here)
- tests/test_quickstart_download_size_figure.py (not served here)
- tests/test_read_only_store.py (not served here)
- tests/test_rebalance_p1.py (not served here)
- tests/test_reenrich_reflections_to_v2.py (not served here)
- tests/test_referential_integrity.py (not served here)
- tests/test_reflection_centroid_staleness.py (not served here)
- tests/test_reflection_orphan_lifecycle.py (not served here)
- tests/test_release_branch_merge_discipline.py (not served here)
- tests/test_release_merge_discipline_workflow.py (not served here)
- tests/test_release_target_gate.py (not served here)
- tests/test_release_target_was_published.py (not served here)
- tests/test_remember_recall.py (not served here)
- tests/test_retrieval_microperf.py (not served here)
- tests/test_revision_guard_matrix.py (not served here)
- tests/test_schema_release_gate.py (not served here)
- tests/test_schema_version_gate.py (not served here)
- tests/test_scoped_retrieval.py (not served here)
- tests/test_search_defaults_single_source.py (not served here)
- tests/test_search_graph_weight.py (not served here)
- tests/test_search_hybrid_graph.py (not served here)
- tests/test_search_hybrid_reflection_cap.py (not served here)
- tests/test_search_top_k_floor.py (not served here)
- tests/test_service_isolation.py (not served here)
- tests/test_service_manager_cancellation_isolation.py (not served here)
- tests/test_shell_command.py (not served here)
- tests/test_smoke_gate.py (not served here)
- tests/test_snapshot_dreaming_roundtrip.py (not served here)
- tests/test_snapshot_records.py (not served here)
- tests/test_sqlite_vec.py (not served here)
- tests/test_stale_embedding_completion.py (not served here)
- tests/test_store_embedding_atomicity.py (not served here)
- tests/test_temporal_query_performance.py (not served here)
- tests/test_thought_metadata.py (not served here)
- tests/test_tier_boundary_guard.py (not served here)
- tests/test_timestamp_canonical_form.py (not served here)
- tests/test_timestamp_normalisation_migration.py (not served here)
- tests/test_timestamp_parameters.py (not served here)
- tests/test_transaction_recency.py (not served here)
- tests/test_ttl_auto_expiry.py (not served here)
- tests/test_two_process_dedup.py (not served here)
- tests/test_two_process_read_after_the_wait.py (not served here)
- tests/test_two_process_write_busy_wait.py (not served here)
- tests/test_update_readback_atomicity.py (not served here)
- tests/test_valid_interval_invariant.py (not served here)
- tests/test_valid_time_migration.py (not served here)
- tests/test_vec0_numpy_ranking_parity.py (not served here)
- tests/test_verify_wheel_execution.py (not served here)
- tests/upgrade/__init__.py (not served here)
- tests/upgrade/fixtures.py (not served here)
- tests/upgrade/test_upgrade_matrix.py (not served here)
Advantages
- Zero external service dependencies in the core write path. evidence: README.md
- Supports bi-temporal valid-time predicates for querying facts as of any instant. evidence: docs/index.md
- Provides a tamper-evident journal for auditing thought and edge mutations. evidence: README.md
Limitations
- Requires an optional embedding provider for semantic vector search functionality. evidence: docs/index.md
- The local embedding model option involves a large initial download. evidence: README.md
- Garbage collection of archived thoughts is a destructive, non-reversible operation. evidence: README.md
Security
Risk level: Low risk
This brain runs
Git hooks (1)
.githooks/commit-msg(commit-msg):runs on git commit-msg
Agent instructions that run or fetch something (4)
AGENTS.md:Run `git commit` and it fires on the patterns it checks for.CLAUDE.md:pip install -e ".[dev]"CLAUDE.md:Run a clean test suite locally before pushing;CLAUDE.md:Branch from `dev`, follow Conventional Commits, run the local quality gate before pushing.
Install scripts (1)
Makefile(make install):echo "$(BLUE)>> Installing engrava + dev dependencies$(RESET)"; $(PIP) install --upgrade pip setuptools wheel; $(PIP) install -e ".[dev]"; bash scripts/install_hooks.sh; echo "$(GREEN)>> Done. Run 'make check' to verify quality gate.$(RESET)"
- Low risk: scripts-present (.githooks/commit-msg)
- Low risk: network-instructions (CLAUDE.md:40)
- Low risk: network-instructions (pyproject.toml:98)
- Low risk: scripts-present (scripts/install_hooks.sh)
Attribution and licence
- Created by
- Sovantica
- Declared authors
- Sovantica (declared in pyproject.toml)
- Publisher
- Sovantica (Official Sovantica project)
- Source
- sovantica/engrava · commit f6a8b73
- Licence
- MIT License (files served here)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Sovantica.
Install from source
357 of its files are not served here, so there is no ZIP. sovantica/engrava at f6a8b73