Extensions
engrava provides a hook-based extension system that lets you plug into the thought lifecycle without modifying core code.
EngravaHooksProtocol
All extensions implement the EngravaHooksProtocol:
from engrava import (
EngravaHooksProtocol,
ThoughtRecord,
ScoringContext,
MindQLExtension,
)
class MyHooks(EngravaHooksProtocol):
async def on_store(self, thought: ThoughtRecord) -> ThoughtRecord:
"""Called after a thought is persisted. Return the (enriched) thought."""
return thought
async def on_retrieve(self, thought: ThoughtRecord) -> ThoughtRecord:
"""Called after a thought is loaded from DB. Return the (enriched) thought."""
return thought
async def score_function(
self, thought: ThoughtRecord, context: ScoringContext
) -> float:
"""Custom relevance score (reserved — not currently called by core)."""
return thought.confidence or 0.5
async def decay_function(
self, thought: ThoughtRecord, elapsed_cycles: int
) -> float:
"""Decay multiplier used by an enabled Memory Hygiene pass."""
return 1.0
def mindql_extension_registry(self) -> dict[str, MindQLExtension]:
"""Reserved — core wires MindQL verbs via ExtensionManifest, not this hook."""
return {}
Core invokes
on_store,on_retrieveanddecay_function. The last is consulted only when an enabled Memory Hygiene pass reaches archive scoring, andhygiene_policy.enabledisfalseby default — so a stub written on the assumption that nothing calls it can end up deciding which thoughts get archived.score_functionandmindql_extension_registry()are reserved and not called by core. SubclassDefaultEngravaHooksif you only want to override one or two methods.
Using Hooks
Pass hooks when creating a store (the store wraps an open connection):
import aiosqlite
from engrava import SqliteEngravaCore
hooks = MyHooks()
async with aiosqlite.connect("my.db") as conn:
conn.row_factory = aiosqlite.Row
store = SqliteEngravaCore(conn, hooks=hooks)
await store.ensure_schema()
# on_store now runs on the inserts create_thought performs; on_retrieve runs
# on get_thought and list_thoughts (not on search results); decay_function
# runs only when this store executes an enabled Memory Hygiene pass
Default Hooks
If no hooks are provided, DefaultEngravaHooks is used — every method is a
pass-through or returns a neutral value. on_store and on_retrieve return the
thought unchanged and mindql_extension_registry() returns an empty dict;
decay_function returns 1.0 (no decay) and score_function returns a score
derived from the thought’s priority (P1 = 4.0 down to P4 = 1.0), which
core does not currently call.
Hook Interface Reference
| Method | When | Returns |
|---|---|---|
on_store |
After an insert create_thought performs — not on update_thought, not on a deduplication hit, and not for derived children (those are core-persisted) |
ThoughtRecord (enriched or unchanged) |
on_retrieve |
After get_thought or list_thoughts loads a row — not on search results |
ThoughtRecord (enriched or unchanged) |
score_function |
Reserved — not currently called by core | float |
decay_function |
Per candidate of an enabled Memory Hygiene pass that reaches archive scoring; never in search, ranking or promotion | float in [0.0, 1.0] |
mindql_extension_registry |
Reserved — MindQL verbs are wired via ExtensionManifest, not this hook |
dict[str, MindQLExtension] |
Hook contracts:
on_storeandon_retrieveare the data-flow methods: bothasync, both return a value.- Hooks must not raise — unexpected exceptions will propagate to the caller.
- Hooks must not have side effects that modify shared state; return an enriched copy instead.
- Engrava is
frozen=True-first — if you need to mutate aThoughtRecord, returnthought.model_copy(update={...}).
Write Your Own Hook in 20 Lines
on_store is the one to start with: core calls it for you, with nothing to
enable first.
from __future__ import annotations
from engrava.domain.models.thought import ThoughtRecord
from engrava.domain.protocols.hooks import DefaultEngravaHooks
class WordCountHooks(DefaultEngravaHooks):
"""Returns the thought with a word_count entry added to its metadata."""
async def on_store(self, thought: ThoughtRecord) -> ThoughtRecord:
counted = {**thought.metadata, "word_count": len(thought.content.split())}
return thought.model_copy(update={"metadata": counted})
# Registration:
import aiosqlite
from engrava import SqliteEngravaCore
async def build_store(db_path: str) -> SqliteEngravaCore:
conn = await aiosqlite.connect(db_path)
conn.row_factory = aiosqlite.Row
store = SqliteEngravaCore(conn, hooks=WordCountHooks())
await store.ensure_schema()
return store
Core calls on_store after the row is inserted, and hands its return value back
to the caller — so create_thought gives you the annotated record while the
stored row keeps the metadata you submitted. Enrichment that has to survive a
reload goes on the record before you store it.
DefaultEngravaHooks supplies the other hooks, so override only the ones you
care about.
Derived Records
on_store is one-in / one-out, so it cannot express “turn one stored
thought into several records” — splitting a document into sections, distilling
an observation into atomic facts, extracting structured items. The
derived-records seam fills that gap as a separate, optional capability
protocol, so EngravaHooksProtocol is unchanged and an existing hooks class
keeps working byte-identically.
The contract
Implement derive_records on your hooks object. Core detects the capability with
isinstance(hooks, DerivedRecordProducerProtocol) — it is @runtime_checkable;
if the method is absent the seam is simply absent.
async def derive_records(
self, thought: ThoughtRecord, ctx: DeriveContext
) -> Sequence[DerivedRecord]: ...
- It is called only after the source thought is durable, and only when the
seam is enabled. If
on_storeraises, derivation never runs. - The producer describes what to derive; core owns how it is persisted.
A
DerivedRecordcarries only producer-owned fields — a non-emptycontent,thought_type,priority, ametadatapayload, and anattach_provenance_edgeflag. Identity,essence, timestamps, cycle, and lifecycle status are assigned by core and are not representable on the type, so there is nothing for a producer to forge. DeriveContextexposes only stable facts about the source (source_thought_id,source_content_hash,cycle_at_derivation) plus an informationalorigin. It carries no store handle: a producer must not persist, query, or mutate anything itself, and must not spawn background tasks.- Make the output a deterministic function of the source — that is what buys exact idempotency.
Persistence is source-first, per-child, deferred, and non-atomic. For each
returned record, in producer order, core runs the lifecycle an ordinary thought
gets: insert → commit → auto-embed → (if requested) attach the single
DERIVED_FROM provenance edge from child back to source. A child’s row commits
as its own durable unit and its enrichment completes afterwards, so a crash
mid-family leaves a partial but regenerable result — re-running derivation
fills the gaps, because content-addressed identity makes a re-run idempotent.
That per-child durability describes a create outside a caller-held
suspend_auto_commit window. A create issued inside one does not auto-derive
at all: the caller owns the open transaction and the source is not yet durable,
so trigger derivation with an explicit backfill once your transaction has
committed.
Gates
Configure the seam with DeriveGates (or the derive: YAML section):
| Gate | Default | Meaning |
|---|---|---|
enabled |
False |
Master switch for the automatic on-store trigger. When off, persisted results (database and journal) are byte-identical to a store without the seam. |
on_error |
"log" |
"log" records a failure and continues with the remaining children; "raise" re-raises after the source is durable, aborting the rest. |
max_derived_per_source |
32 |
Core reads at most this many + 1 items and rejects an over-cap (or lazy / unbounded) return before any child is written. |
Durability is decoupled from derivation: the source is always durable even
when a producer or a child fails, so with on_error="raise" a caller can see an
error while the source persists.
The shipped producer — StructuralSplitProducer
StructuralSplitProducer is a complete reference consumer that runs purely on
the stored text: no model, no network, no external service.
from engrava import DeriveGates, SqliteEngravaCore, SplitMode, StructuralSplitProducer
store = SqliteEngravaCore(
conn,
hooks=StructuralSplitProducer(),
derive_gates=DeriveGates(enabled=True),
)
await store.ensure_schema()
# Storing a multi-paragraph thought now also persists one derived child per
# paragraph, each carrying a DERIVED_FROM edge back to the source.
Two deterministic split modes are selected with split_mode:
| Mode | What it does |
|---|---|
SplitMode.PARAGRAPH (default) |
Splits on a blank-line (paragraph) boundary. |
SplitMode.FIXED_WINDOW |
Tiles the content into fixed-size windows, bounding chunk size for embedding robustness on long content with no dependence on natural boundaries. |
In fixed-window mode, window_size (default 1000) and window_overlap
(default 0) are counted in window_unit — "char" (default) or "word" —
windows advance by window_size - window_overlap and fully cover the content.
Every child records its split_mode, segment_index, and source char_start /
char_end in its metadata. In either mode, fewer than two resulting segments
produces no children: a single segment is not a structural split.
Backfilling an existing store — derive_existing
derive_records fires automatically on a durable create. To run a producer over
thoughts that are already stored, call derive_existing:
result = await store.derive_existing(thought_id)
print(result.created, result.reused, result.skipped)
Two properties make this usable on a live base:
- It converges with the automatic path. Backfilled children go through the
same core-owned per-child lifecycle, content-addressed identity, and
DERIVED_FROMedge as an on-store write, so the output is byte-identical to what an on-store write would have produced for the same content — backfilled and auto-derived records dedup against one another. Re-running is idempotent: already-present children come back asreused, not duplicates. - Its gating is independent of the master switch. It runs whenever a producer
capability is present, honouring
on_errorandmax_derived_per_sourcebut notDeriveGates.enabled— so you can backfill an existing base once without committing to automatic derivation on every future write. With no producer registered it is a clean no-op.
Unlike the automatic trigger, derive_existing does not early-return inside a
caller-held suspend_auto_commit window — the source is already durable, so
there is nothing to defer. In that case the children join the caller’s open
transaction like any other write instead of committing per-child, and under
on_error="raise" a child failure rolls that whole transaction back. The
already-committed source thought is unaffected.
derive_existing raises SourceThoughtNotFoundError when the id does not exist
(a precondition failure, distinct from the clean empty result returned for an
already-derived source), and DerivedRecordError under on_error="raise" when
a producer’s return violates the deterministic contract. A source that is itself
a derived record is never re-derived.
Custom MindQL Commands
A custom command is an MindQLExtension. Its handler is an async callable
that the executor invokes with two positional arguments — the open
aiosqlite.Connection and the parsed argument list — and returns a
list[dict[str, object]]. The MindQLExtension fields are command_name,
handler, description, and category (there is no help_text field):
import aiosqlite
from engrava import MindQLExtension
async def _handle_stats(
db: aiosqlite.Connection,
args: list[str], # noqa: ARG001 — STATS takes no args
) -> list[dict[str, object]]:
cursor = await db.execute(
"SELECT thought_type, COUNT(*) AS n FROM thought GROUP BY thought_type"
)
rows = await cursor.fetchall()
return [{row["thought_type"]: row["n"]} for row in rows]
STATS_COMMAND = MindQLExtension(
command_name="STATS",
handler=_handle_stats,
description="Show thought statistics",
)
Then run it through the executor, passing the command in extensions= and
telling parse() which verbs are registered:
from engrava import MindQLExecutor, parse
executor = MindQLExecutor(conn, extensions={"STATS": STATS_COMMAND})
result = await executor.execute(parse("STATS", known_extensions={"STATS"}))
Dreaming Extension
The built-in DreamingExtension performs periodic memory consolidation:
from engrava import DreamingExtension, DreamingConfig, DreamingGates
config = DreamingConfig(
enabled=True,
candidates_limit=100,
promote_threshold=0.6,
gates=DreamingGates(
min_confirmations=2,
min_age_cycles=1,
max_promoted_per_run=20,
),
)
dreaming = DreamingExtension(config=config)
result = await dreaming.run_consolidation(store, current_cycle=42)
print(f"Promoted {result.promoted_count} thoughts")
The weighted-score cutoff is DreamingConfig.promote_threshold;
DreamingGates controls eligibility (confirmations, age, per-run cap, and the
clustering/quality thresholds). See Dreaming for the
full consolidation reference.
Custom Signals
DreamingSignalProtocol is a callable protocol — implement __call__(thought, ctx) returning a score in [0.0, 1.0]. There is no name/weight attribute
or score() method; a signal’s weight is set separately in
DreamingConfig.signals, and the instance is wired in via
DreamingExtension(config, custom_signals={...}).
from engrava import DreamingContext, DreamingExtension, DreamingConfig, ThoughtRecord
from engrava import Priority
class ImportanceSignal:
"""Custom scoring signal — must be callable as (thought, ctx) -> float."""
def __call__(self, thought: ThoughtRecord, ctx: DreamingContext) -> float:
if thought.priority == Priority.P1:
return 1.0
if thought.priority == Priority.P2:
return 0.7
return 0.3
# Register the signal AND give it a weight in the signals map, or it never runs.
dreaming = DreamingExtension(
config=DreamingConfig(enabled=True, signals={"importance": 0.3}),
custom_signals={"importance": ImportanceSignal()},
)
Extension Manifest
For distributing extensions as packages, use ExtensionManifest:
from pathlib import Path
from engrava import ExtensionManifest
manifest = ExtensionManifest(
name="my-engrava-plugin",
version="1.0.0",
hooks_class=MyHooks,
mindql_extensions=[],
schema_migrations=[
Path("migrations/001_initial.sql"),
Path("migrations/002_add_tags.sql"),
],
)
Migration Files
Place SQL migration scripts alongside your extension package using the
convention NNN_slug.sql (e.g. 001_initial.sql, 002_add_tags.sql).
The runner sorts files lexicographically and applies them in order.
Each .sql file should contain SQLite DDL or DML statements, each terminated by
a semicolon. Use CREATE TABLE IF NOT EXISTS to keep migrations idempotent.
Transaction control is rejected. The runner applies every migration inside a
SAVEPOINT it owns, so a statement beginning BEGIN, COMMIT, END, ROLLBACK,
SAVEPOINT or RELEASE raises ExtensionMigrationError during preparation —
before any statement of that file runs. A file whose final statement is missing
its ;, or that leaves a string literal or block comment unterminated, is
rejected the same way.
Loading Extensions with Migrations
Pass manifests explicitly to SqliteEngravaCore. Schema migrations are
applied automatically during ensure_schema():
import aiosqlite
from engrava import SqliteEngravaCore
async with aiosqlite.connect("my.db") as db:
store = SqliteEngravaCore(db, manifests=[manifest])
await store.ensure_schema()
# migrations are now applied
Or use the opt-in discovery helper to load all installed extensions:
from engrava import SqliteEngravaCore
from engrava.extensions.discovery import discover_manifests
store = SqliteEngravaCore(db, manifests=discover_manifests())
await store.ensure_schema()
Note: Discovery is never automatic — always opt in explicitly. Schema migrations have side-effects (ALTER TABLE, CREATE TABLE) and should only run when the caller is aware of them.
YAML Configuration
Manifests can also be declared in engrava.yaml:
# Explicit dotted paths
manifests:
- "my_plugin.manifest:MANIFEST"
# Auto-discover via entry points
manifests:
discover: true
# Both
manifests:
discover: true
paths:
- "my_plugin.manifest:MANIFEST"
Version Tracking
The runner keeps two tables. extension_schema_migrations is the append-only
history: one row per applied migration, recording its 1-based index, filename and
a content checksum. extension_schema_versions is a single-row-per-extension
summary — extension name, count of applied migrations, timestamp, last applied
filename and the extension version at apply time.
History is the authority. Pending work is derived from the history table, and
before anything is applied the runner re-verifies every recorded migration
against the manifest. A renamed, reordered or inserted earlier file, an edited
one (checksum mismatch), a duplicate basename, or a summary that disagrees with
the history all raise ExtensionMigrationError rather than being applied over.
Applied migrations are immutable: add a new file instead of editing an old one.
Runner behavior at startup:
| State | Action |
|---|---|
| No recorded history (fresh install) | Apply all migration files |
History shorter than len(files) |
Apply only the pending suffix |
History equal to len(files) |
No-op |
History longer than len(files) |
Raise ExtensionMigrationError (downgrade detected) |
On SQL failure the version counter is not advanced.
ExtensionMigrationError is raised with the extension name and failing
filename so the caller can surface a clear error message.
Migration Path Resolution
Relative paths in schema_migrations are resolved in this order:
- Absolute path — used as-is (CI / developer override).
manifest.package_rootis set — joined withpackage_root(useful for test fixtures or non-installable manifests).- Default — resolved via
importlib.resources.filesagainst the top-level package that containshooks_class. Works correctly for installed wheels, editable installs, and zipapps.
from pathlib import Path
from engrava import ExtensionManifest
# Default (importlib.resources -- recommended for distributed packages)
manifest = ExtensionManifest(
name="my-plugin",
version="1.0.0",
hooks_class=MyHooks,
schema_migrations=[Path("migrations/001_initial.sql")],
)
# Absolute path (CI / local dev)
manifest = ExtensionManifest(
name="my-plugin",
version="1.0.0",
hooks_class=MyHooks,
schema_migrations=[Path("/abs/path/to/001_initial.sql")],
)
# package_root override (test fixtures)
manifest = ExtensionManifest(
name="my-plugin",
version="1.0.0",
hooks_class=MyHooks,
schema_migrations=[Path("migrations/001_initial.sql")],
package_root=Path(__file__).parent,
)
Subclassing SqliteEngravaCore
For deeper customization, subclass SqliteEngravaCore and override
the template methods:
from engrava import SqliteEngravaCore, ThoughtRecord
class ExtendedStore(SqliteEngravaCore):
def _row_to_thought(self, row: dict) -> ThoughtRecord:
"""Override to produce a richer model type."""
return super()._row_to_thought(row)
This is the recommended pattern for adding domain-specific fields to the thought model without forking the core.
Contract Testing
Add a contract test to verify your implementation satisfies the protocol:
from engrava.domain.protocols.hooks import EngravaHooksProtocol
def test_my_hooks_satisfy_protocol() -> None:
assert isinstance(RecencyBoostHooks(), EngravaHooksProtocol)
EngravaHooksProtocol is @runtime_checkable, so isinstance works without
metaclass magic.