Quick Start
Get up and running with engrava in 5 minutes.
Which profile do you want?
Decide before your first pip install whether a machine-learning model
should ever load into your process — the base install itself never pulls
one in:
| If you want… | Install | Cost |
|---|---|---|
| Keyword search only, nothing else to think about | pip install engrava |
none — semantic search is inert, not degraded |
| Semantic search, model stays out of your process | pip install 'engrava[embeddings-ollama]' + a running Ollama |
one extra service to run; the model downloads onto that service (ollama pull ...), not into this process |
| Semantic search, offline after first use (needs 2 env vars set) | pip install 'engrava[embeddings-local]' |
a large one-time download (sentence-transformers + torch, about 3,000 MB on Linux, most of it NVIDIA CUDA libraries; no GPU?) plus a first-use model download |
Each option ships as a ready-to-copy engrava.yaml — see
Configuration → Quick-start profiles
for the exact files and where every number above came from.
Installation
engrava needs Python 3.11 or later; CI tests 3.11, 3.12, 3.13 and 3.14. Its SQLite build must include FTS5, which standard Python builds do (see Known Limitations: FTS5 Availability).
pip install engrava
For vector search and the bundled walkthrough you also need a local
embedding encoder — install the embeddings-local extra (the local
profile above):
pip install 'engrava[embeddings-local]'
The extra pulls sentence-transformers and torch, a large one-time
download (about 3,000 MB on Linux, most of it NVIDIA CUDA libraries), plus a
further, separate encoder-model download on first use. Without an NVIDIA GPU,
install the CPU-only build of torch
first. Configuration: Quick-start profiles
has the exact, measured figures. The encoder is not a language model:
it turns text into a fixed-size vector. There are no API keys for this
local setup, but “offline after first use” needs two environment variables
set (HF_HUB_OFFLINE=1, TRANSFORMERS_OFFLINE=1) — without them, even a
warm, already-cached model load still issues a network request and can fail
with none reachable. Engrava’s built-in path does not call any LLM; a
custom Dreaming signal or Memory Hygiene hook you register runs whatever
code it contains, including a call to one.
Installing the extra is necessary but not sufficient: a store only embeds on
write once an embedding provider is passed to it and auto_embed=True,
and only ranks on vectors at search time when a provider is configured or the
caller supplies an explicit vector — so a store built with neither skips the
vector signal however the extra is installed. See
Configuration for wiring one up.
Run the bundled walkthrough
The repository ships a single-file walkthrough that exercises the end-to-end ingest → dream → query flow on a small demo dataset:
python examples/quickstart.py # 5-minute end-to-end tour
quickstart.py boots an in-memory store, ingests a handful of
percepts (things the agent learned about the user) plus two
utterances (replies the agent already produced), runs one dreaming
consolidation cycle, and queries via hybrid search. The expected
top result for the shipped query is My favorite color is teal..
What is dreaming?
Dreaming is engrava’s offline consolidation step. Between interactions the engine reviews the thoughts that have proven durable — confirmed and revisited over time — and groups related memories into REFLECTION nodes: deterministic, structural summaries that record which observations were grouped and the keywords distilled from them. Built-in dreaming makes no language-model call of its own. Dreaming is deliberately conservative: a brand-new store of one-off facts has nothing to consolidate yet; REFLECTIONs emerge as memories accumulate and repeat over an agent’s lifetime.
Self-anchored identity
Every thought carries structured metadata that pins its origin. The
package exposes three small helpers in engrava.metadata:
| Helper | Use for |
|---|---|
percept() |
Input arriving from outside (user message, document) |
utterance() |
The agent’s own output sent to the world |
thought() |
The agent’s internal cognition (reflection, plan) |
from engrava import percept, utterance, thought
percept(source_id="user-42", label="user")
# -> {'perspective': 'percept', 'source': {'is_self': False, 'confidence': 'high', 'id': 'user-42', 'label': 'user'}, 'lang': 'en', 'content_type': 'natural_language'}
utterance()
# -> {'perspective': 'utterance', 'source': {'is_self': True, 'confidence': 'high'}, 'lang': 'en', 'content_type': 'natural_language'}
The helpers are pure functions: same arguments always return an equal dictionary, and the returned value carries no shared state. Callers who want a different shape are free to pass a literal dictionary instead — the helpers exist to remove a class of typo-driven shape mismatches at the call site.
Create a Store
import asyncio
import sys
import aiosqlite
from engrava import SqliteEngravaCore
async def _close_quietly(conn: aiosqlite.Connection) -> None:
"""Close *conn*, reporting rather than raising if the close itself fails."""
try:
await conn.close()
except Exception as exc: # noqa: BLE001 - logged, not raised; see the note below
print(f"warning: failed to close the database connection: {exc}", file=sys.stderr)
async def main() -> None:
# SqliteEngravaCore wraps an open aiosqlite connection.
# Use ":memory:" for experimentation, or a file path to persist.
conn = await aiosqlite.connect(":memory:")
try:
conn.row_factory = aiosqlite.Row
store = SqliteEngravaCore(conn)
await store.ensure_schema()
print("Store ready!")
except BaseException:
await _close_quietly(conn)
raise
else:
await conn.close()
asyncio.run(main())
The rest of this page assumes you are inside the
tryblock above, sostoreandconnare in scope. For a configuration-driven alternative, useawait SqliteEngravaCore.from_config("engrava.yaml")(it opens and owns the connection for you). A bareasync with aiosqlite.connect(...)looks like a shortcut for the same thing, but its__aexit__unconditionally callsclose()— if the block raised, a close failure there would replace the real error instead of the caller seeing it (it does not cover a cancellation landing in that cleanup the same way). See “Graceful shutdown” for the full explicit-close pattern production code should use instead.
Store and search a memory — the short way
remember() and recall() are the two-call path for getting started: store a
piece of text, then search for it. No IDs to generate, no record to assemble.
await store.remember("User prefers concise answers")
await store.remember("User works in Berlin")
result = await store.recall("what does the user prefer?")
for thought_id, score in result.results:
record = await store.get_thought(thought_id)
if record is not None:
print(f"{record.essence} (score: {score:.3f})")
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 — on the store built above that
is keyword and priority ranking: no embedding provider is configured, so the
vector signal is skipped and its weight redistributed across the active ones.
See Hybrid Search for the full signal model.
A note on time.
recall()leaves recency ranking off until it has a reference to decay against: an explicitcurrent_cycle, a configuredcycle_provider, or arecency_nowinstant. A cycle is a logical clock you own — increment it once per turn and pass it torecall(..., current_cycle=n)on read. That reference is enough on the store built above: its recency weight isSearchConfig’s default0.1, whichever way the store was built, sorecall()blends recency in.recall()takes no per-callrecency_weight; usesearch_hybrid(..., current_cycle=n, recency_weight=...)for one, or build the store with aSearchConfigto change the default.remember()stamps both cycle fields at0; when you need to set the cycle on a write, build aThoughtRecordwithcreated_cycle=nand callcreate_thought()(shown below).
The rest of this page shows the full-control path: building a ThoughtRecord
yourself, linking thoughts with edges, and querying with MindQL. Reach for it
when you need to set fields remember() defaults for you (priority, thought
type, metadata, and the cycle clock on writes).
Add Thoughts
import uuid
from engrava import ThoughtRecord, ThoughtType, Priority, LifecycleStatus
observation = ThoughtRecord(
thought_id=str(uuid.uuid4()),
thought_type=ThoughtType.OBSERVATION,
essence="Python is great for AI agents",
content="Python's async ecosystem and rich ML libraries make it ideal.",
priority=Priority.P2,
lifecycle_status=LifecycleStatus.ACTIVE,
created_cycle=0,
updated_cycle=0,
source="human",
)
stored = await store.create_thought(observation)
print(f"Created thought: {stored.thought_id}")
Link Thoughts with Edges
from engrava import EdgeRecord, EdgeType
belief = ThoughtRecord(
thought_id=str(uuid.uuid4()),
thought_type=ThoughtType.BELIEF,
essence="SQLite provides zero-config persistence",
content="WAL mode enables concurrent reads with single-writer safety.",
priority=Priority.P2,
lifecycle_status=LifecycleStatus.ACTIVE,
created_cycle=0,
updated_cycle=0,
source="human",
)
await store.create_thought(belief)
edge = await store.create_edge(
EdgeRecord(
edge_id=str(uuid.uuid4()),
from_thought_id=observation.thought_id,
to_thought_id=belief.thought_id,
edge_type=EdgeType.ASSOCIATED,
weight=0.8,
created_cycle=0,
)
)
print(f"Linked thoughts via edge: {edge.edge_id}")
Search
Full-Text Search
# search_fts returns (thought_id, bm25_score) tuples — fetch the record for fields.
for thought_id, score in await store.search_fts("Python AI", top_k=5):
record = await store.get_thought(thought_id)
if record is not None:
print(f" [{record.priority.value}] {record.essence} (score={score:.3f})")
Embedding Similarity Search
from engrava import CallbackProvider
# Use any embedding function
provider = CallbackProvider(
callback=lambda text: [0.1] * 384, # Replace with real embeddings
dimension=384,
model_name="my-model",
)
# Store an embedding for an existing thought
vector = await provider.embed(observation.content)
await store.store_embedding(observation.thought_id, vector, model_name="my-model")
# Search by similarity — returns (thought_id, score) tuples
for thought_id, score in await store.search_similar(vector, top_k=5):
record = await store.get_thought(thought_id)
if record is not None:
print(f" {record.essence} (score: {score:.3f})")
Query with MindQL
from engrava import MindQLExecutor, parse
# MindQLExecutor runs against an aiosqlite connection; parse the string first.
executor = MindQLExecutor(conn)
# Find observations
result = await executor.execute(
parse("FIND thoughts WHERE thought_type = 'OBSERVATION' LIMIT 10")
)
print(f"Found {len(result.rows)} thoughts")
# Count active thoughts
result = await executor.execute(
parse("COUNT thoughts WHERE lifecycle_status = 'ACTIVE'")
)
print(f"Active thoughts: {result.count}")
Use the CLI
# Database info
engrava --db my_thoughts.db info
# Run a MindQL query
engrava --db my_thoughts.db query "FIND thoughts WHERE thought_type = 'OBSERVATION' LIMIT 5"
# Back up your data
engrava --db my_thoughts.db snapshot -o backup.jsonl
# Restore from backup
engrava --db my_thoughts.db restore -i backup.jsonl
Store and search a memory, from a shell
remember and recall are the CLI’s own two-call path — the shell
equivalent of store.remember() / store.recall() above, for a script or
agent harness with no Python of its own in the loop. remember creates
my_thoughts.db if it does not exist yet; recall never does — it exits
naming the path instead of silently reporting zero hits against a database
nobody created.
$ engrava --db my_thoughts.db remember "User prefers concise answers"
Created database: my_thoughts.db
091aa106-fcc0-45a3-a19b-d335ad05ea45
$ engrava --db my_thoughts.db recall "concise answers"
thought_id score essence
------------------------------------ ------------------ ----------------------------
091aa106-fcc0-45a3-a19b-d335ad05ea45 0.4714285714285715 User prefers concise answers
link builds the edge the same way create_edge() does above, without the
Python:
$ engrava --db my_thoughts.db link <from-id> <to-id> --type ASSOCIATED --weight 0.8
All three honour --config — a database resolved from engrava.yaml’s
database.path is opened through from_config(), so a configured embedding
provider, hybrid-search weights, and journal settings apply exactly as they
would to a direct library call. See CLI reference
for the full option list, exit codes, and --json schemas.
Next Steps
- Configuration — YAML-based setup for production use
- Extensions — Hook into the thought lifecycle
- API Reference — Key classes and methods