Generated banner for Engrava
Generated logo for Engrava

Engrava

Memory frameworkOfficial Sovantica project

Created by Sovantica

Published in sovantica/engrava. Publisher page

12 GitHub stars

What it is

The memory database for AI agents - graph memory, hybrid search, tamper-evident thought/edge journal.
— the description by Sovantica

AutoGineer overview, from the brain’s own files

Engrava is an in-process memory database designed for AI agents, built on SQLite. It provides a structured way to store and retrieve agent thoughts using a graph-based model, where thoughts are linked by typed edges. The system supports hybrid search, combining vector similarity, full-text search (FTS5), recency, and priority. It includes built-in mechanisms for memory consolidation (dreaming), memory hygiene (archiving and garbage collection), and a tamper-evident journal for tracking mutations. Engrava operates as a library with no external service dependencies in its core path, allowing agents to maintain persistent, queryable state locally.

How it works

Memory model: Hybrid

AutoGineer overview, from the brain’s own files

Engrava uses a SQLite database to store thoughts and their relationships. It organizes data into thoughts and typed edges, which can be queried using the MindQL language. The system supports hybrid search by combining vector embeddings, keyword search, and metadata signals. It manages memory through a lifecycle that includes archiving and garbage collection, and it can consolidate memories into reflections. The system is extensible via hooks and provides an MCP server for integration with various AI agent frameworks.

Folders
FolderRoleFiles
.githubagent-config16
docsdocs37
examplesexamples13
scriptsscripts24
srcsource-code89
teststests201
.githooksfolder1

The agent reads

Requirements

Runtimes: Python >=3.11

Databases: SQLite

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Google Antigravity, Kiro, OpenCode, Amp, Roo Code, Cline, JetBrains Junie, Devin (ex-Windsurf)

Set it up

  1. This brain runs code automatically: 2 scripts. Review .githooks/commit-msg, Makefile before opening the folder in Claude Code. No step below turns a hook on for you.

    • .githooks/commit-msg: runs on git commit-msg
    • Makefile: echo "$(BLUE)>> Installing engrava + dev dependencies$(RESET)"; $(PIP) install --upgrade pip setuptools wheel; $(PIP) install -e ".[dev]"; bash scripts/install_hooks.sh; echo "$(GREEN)>> Done. Run 'make check' to verify quality gate.$(RESET)"
  2. Not scanned by AutoGineerIts dependencies are installed by pip or uv when you run it and were not scanned by AutoGineer (5 declared). https://pypi.org/project/engrava/
  3. Created by Sovantica. Published in sovantica/engrava. Licence: MIT License.

Usage

AutoGineer overview, from the brain’s own files

  • Persistent memory for autonomous AI agents
  • Personal knowledge base management
  • Conversation storage with semantic retrieval
  • Research notes with associative linking
  • Memory-backed agent development
  • Audit-trailed thought and edge tracking
“Store a new observation about a project requirement.”
The agent uses the remember() method to persist the thought, which is then indexed for future hybrid search and graph traversal.
“Retrieve relevant context for a coding task.”
The agent uses the recall() method, which performs a hybrid search across FTS5, vector, and graph signals to return ranked, contextually relevant thoughts.
“Query the memory graph for all observations with high priority.”
The agent executes a MindQL query: FIND thoughts WHERE thought_type = 'OBSERVATION' AND priority = 'P1'.
“Consolidate old memories to free up space.”
The agent triggers the DreamingExtension to cluster related thoughts and compute reflections, or uses the memory hygiene loop to archive low-signal thoughts.

Documentation by Sovantica

Engrava

The memory database for AI agents.

A queryable memory graph with bi-temporal valid-time predicates — in an in-process Python library over one SQLite file, with no generative-model call in the core write path.

Image: CI Image: PyPI Image: Python Image: License: MIT

Engrava is a standalone, in-process database for AI agent memory. Built on SQLite, it provides thought CRUD, edge-based knowledge graphs, embedding-based similarity search, full-text search (FTS5/BM25), and a declarative extension system — all in a single package with zero external service dependencies.

Benchmark results are published, and the runs are reproducible from a separate repository. Every published run is Group A — memory_pipeline_llms: [], no language model anywhere in the memory layer's built-in path. That is a property of the architecture's default signals and hooks rather than a measurement, unless a custom Dreaming signal or Memory Hygiene hook is configured to call one.

  • Benchmark results — the live table: every published row with the comparability segment it belongs to.

  • engrava-benchmark — the runner. Clone it and reproduce a result against the package from PyPI. MIT.

  • Discussions — questions; reproduction questions belong in the Q&A category.

Use Cases

  • AI agent persistent memory

  • Personal knowledge base

  • Conversation storage with semantic search

  • Research notes with associative linking

  • Any application that needs a thought-graph with embeddings

Quick Start

Installation
pip install engrava

Optional extras:

pip install 'engrava[vec]'                # sqlite-vec vector search backend
pip install 'engrava[embeddings-local]'   # sentence-transformers embeddings (local model)
pip install 'engrava[embeddings-openai]'  # OpenAI-compatible embeddings API
pip install 'engrava[embeddings-ollama]'  # Ollama local embeddings server
pip install 'engrava[embeddings-hf]'      # HuggingFace Inference API embeddings

Dreaming/consolidation and the knowledge graph need no extra — they are part of the base install.

embeddings-local carries a large first-run download. It pulls sentence-transformers and torch, plus a further, separate model download (~88 MB for all-MiniLM-L6-v2, cached under ~/.cache/huggingface/hub) on first use. On Linux, the PyPI build of torch also pulls the NVIDIA CUDA libraries as dependencies: several gigabytes, most of it usable only with an NVIDIA GPU. Without one, install the CPU-only build first (see Configuration: No NVIDIA GPU for the exact command and numbers). engrava-mcp[local] is the exact same download, reached through the server package instead of this one. If you do not need semantic search in-process, pip install engrava alone (no extra, no download, no model) already gives you keyword search, the graph, and MindQL — see Configuration → Quick-start profiles for that and the Ollama-backed alternative that keeps the model out of this process entirely.

Basic Usage

Store a memory and search for it in two calls — no IDs to generate, no record to assemble:

import asyncio

import aiosqlite

from engrava import SqliteEngravaCore


async def main() -> None:
    # SqliteEngravaCore wraps an open aiosqlite connection.
    async with aiosqlite.connect(":memory:") as conn:
        conn.row_factory = aiosqlite.Row
        store = SqliteEngravaCore(conn)
        await store.ensure_schema()

        await store.remember("Python is great for AI agents")
        await store.remember("SQLite needs no server")

        result = await store.recall("what language is good for agents?")
        for thought_id, score in result.results:
            thought = await store.get_thought(thought_id)
            if thought is not None:
                print(f"{thought.essence}  (score: {score:.3f})")


asyncio.run(main())

remember() stores the text as a thought (generating its ID for you) and returns the stored ThoughtRecord; recall() runs the same hybrid search as search_hybrid() and returns the ranked results. This store has no SearchConfig of its own, and it still ranks with the same default weights — FTS 0.30, vector 0.55, recency 0.10, priority 0.05, graph 0.00 (opt-in) — as a store built with one; see Hybrid Search. For full control — setting priority, thought type, metadata, or the cognitive cycle on a write — build a ThoughtRecord yourself and call create_thought().

From here, link thoughts with typed edges, query them with MindQL, or run the full ingest → dream → search tour in the Quick Start guide.

Configuration-Driven Setup
from engrava import SqliteEngravaCore

# from_config opens and OWNS the connection — use it as an async context manager.
async with await SqliteEngravaCore.from_config("engrava.yaml") as store:
    # The schema is already applied by from_config.
    thought = await store.get_thought("some-id")

See docs/configuration.md for the full YAML schema.

Upgrading

Automatic schema migration runs on first connection. See the upgrade guide for compatibility notes, backup guidance, and troubleshooting steps.

Features

Thought CRUD

Create, read, update, and archive thoughts with full lifecycle management. All models are frozen Pydantic objects — mutations happen via evolve().

Edge-Based Knowledge Graph

Link thoughts with typed, weighted edges. Edge types include ASSOCIATED, DEPENDS_ON, DERIVED_FROM, CONSOLIDATED_FROM (created by dreaming), and CONTESTED_BY.

Store embeddings alongside thoughts and search them with the built-in NumPy cosine backend or the optional sqlite-vec backend (pip install 'engrava[vec]'). Pluggable embedding providers:

ProviderExtraBackend
SentenceTransformerProviderembeddings-localLocal model via sentence-transformers
OpenAICompatibleProviderembeddings-openaiAny OpenAI-compatible API
OllamaProviderembeddings-ollamaLocal Ollama server
HuggingFaceProviderembeddings-hfHuggingFace Inference API
CallbackProvider(built-in)Custom callable
Full-Text Search (FTS5)

SQLite FTS5 virtual table with BM25 ranking. Hybrid search combines vector similarity, text relevance, recency, priority, and graph connectivity. Signals that cannot run for a query are skipped and the remaining weights are redistributed.

MindQL Query Language

Declarative query language for the thought-graph:

FIND thoughts WHERE thought_type = 'OBSERVATION' AND priority = 'P1' LIMIT 10
COUNT thoughts WHERE lifecycle_status = 'ACTIVE'
SELECT thought_id, essence FROM thought WHERE thought_type = 'BELIEF'

Extensible with custom commands through MindQLExecutor or an ExtensionManifest; the lifecycle hook registry is reserved and is not consulted by the core executor.

Extension System

Plug into the thought lifecycle via EngravaHooksProtocol. Subclass DefaultEngravaHooks when you only need selected active methods:

from engrava import DefaultEngravaHooks, ThoughtRecord

class MyHooks(DefaultEngravaHooks):
    async def on_store(self, thought: ThoughtRecord) -> ThoughtRecord:
        # Observe or enrich the object returned after persistence.
        return thought

    async def decay_function(
        self, thought: ThoughtRecord, elapsed_cycles: int
    ) -> float:
        # Supply a decay multiplier to an enabled Memory Hygiene pass.
        return 1.0

Core currently invokes on_store, on_retrieve, and decay_function. on_store runs after the source thought's row is inserted; changing its return value does not rewrite the persisted row. score_function and mindql_extension_registry() remain reserved protocol methods and are not called by core.

Dreaming / Memory Consolidation

Built-in DreamingExtension for periodic memory consolidation — scores thoughts via its default signals (no LLM calls), promotes high-value entries, and creates REFLECTION thoughts by clustering semantically related thoughts and computing centroid embeddings through a deterministic structural function, not an LLM. A custom signal you register with DreamingExtension runs whatever code it contains. Available since 0.3.0.

→ See docs/benchmarks.md for reproducible evidence (synthetic benchmark suite runnable in ~5 minutes).

Forgetting / Memory Hygiene

The subtractive half of memory maintenance, paired with Dreaming: an opt-in loop whose built-in scoring makes no LLM calls, that archives cold, low-signal thoughts — the default action, reversible via restore_thought — and, as a separately opted-in step, garbage-collects them (not reversible) once both restore windows have elapsed under their non-zero defaults — a cycle count and a wall-clock duration, either of which can be configured to 0 to disable that window. OFF by default; once enabled, archived thoughts drop out of default retrieval and can be restored (restore_thought / include_archived).

→ See docs/memory-hygiene.md for the loop, protection, restore windows, and the honest deletion posture.

Tamper-Evident Thought/Edge Journal

Opt-in hash-chain journal that records thought and edge mutations (plus action status/verification_status transitions) as SHA-256-linked, before/after entries — a tamper-evident thought/edge journal, not a whole-database audit (embeddings and action creation are not covered). Off by default, one config flag to enable. Query history with store.journal.get_entries(...) and validate the chain with store.verify_journal(), which audits whatever chain is on disk independent of the current journal.enabled state — the store.journal writer handle does not exist while journaling is off, but the chain earlier sessions wrote is still in journal_entry and still needs verifying.

→ See docs/audit-trail.md for enabling, querying, verification, and the security model (what "tamper-evident" does and does not guarantee).

Multi-Service Isolation

Run multiple independent databases under one EngravaManager:

from pathlib import Path

from engrava import EngravaManager

async with EngravaManager(data_dir=Path("./data")) as mgr:
    agent_a = await mgr.get_store("agent-a")
    agent_b = await mgr.get_store("agent-b")
    # Completely isolated databases
MCP Server

Want Engrava as a memory server for your agent? The MCP server ships as its own package, engrava-mcp — a native stdio server (no HTTP shim) with read tools, optional write tools, attachable engrava:// resources, and guided prompts, for any MCP client (Claude Desktop, Claude Code, Cursor, Windsurf, VS Code):

uvx engrava-mcp        # or: pip install engrava-mcp

engrava-mcp pulls engrava in transitively, so installing it also gives you the import engrava library. See the engrava-mcp package for install, client configuration, the full tool/resource/prompt reference, and read-only mode.

uvx vs. a persistent install. uvx engrava-mcp (equivalently uv tool run engrava-mcp) installs into "an ephemeral virtual environment in the uv cache directory" per uv's own --help text — fine for the lexical/network profiles above, which add nothing heavier than httpx. Once you are on the local profile (engrava-mcp[local], the same sentence-transformers + torch download as engrava[embeddings-local]), uv tool install 'engrava-mcp[local]' is the better fit: it installs once into a persistent environment — the same uvx engrava-mcp invocation then reuses that installed environment instead of resolving a fresh ephemeral one — and a later uv tool upgrade engrava-mcp re-pays only the changed packages, not the whole dependency tree.

CLI

engrava --db mydata.db info          # Database stats
engrava --db mydata.db query "FIND thoughts WHERE thought_type = 'OBSERVATION' LIMIT 5"
engrava --db mydata.db snapshot -o backup.jsonl
engrava --db mydata.db restore -i backup.jsonl
engrava --db mydata.db gc            # Collect ARCHIVED thoughts + their edges/embeddings/actions
engrava --db mydata.db migrate       # Ensure schema is up-to-date
engrava --db mydata.db export -o portable.json

gc physically deletes ARCHIVED thoughts together with every edge touching one on either end — including edges whose other end is still live — their embeddings and the actions sourced from them, then reconciles the vector index by removing every vec0 row no embedding row owns; on a vec0-indexed store where sqlite-vec cannot be loaded — most commonly because engrava[vec] is not installed — a pass that is about to delete stops before deleting anything and exits 1 rather than stranding those vectors in an index nothing can then reach them through.

engrava info's --format json output carries every field of the metrics snapshot exposed by await store.metrics(), with the snapshot's own schema version renamed metrics_schema_version, plus two fields the snapshot itself does not carry, db_path and database_schema_version — see Upgrade Guide. The default text output is a shorter summary of that same data, not the full snapshot.

See the CLI reference for every command and option.

Architecture

  • SQLite with WAL mode for concurrent reads

  • Frozen Pydantic models — immutable domain objects

  • Async-first — all I/O via aiosqlite

  • Hook-based extension — zero monkey-patching

  • Template method pattern — subclass SqliteEngravaCore for extended schemas

  • Zero external services — everything runs locally in-process

Documentation

  • Documentation Index — choose a path by task: learn, build, operate, extend, or migrate

  • Core Concepts — the mental model (thought, edge, reflection, cycle, …) — start here

  • The Bi-temporal Model — the optional valid-time axis: query a fact as of any instant, invalidate without deleting

  • Positioning — when Engrava is (and isn't) the right tool, and how it compares

  • Quick Start — 5-minute setup guide

  • Tutorial — build a small notes memory end to end

  • Recipes — copy-paste snippets for common tasks (store a turn, retrieve context, TTL, dedup, …)

  • Building a memory-backed agent — the end-to-end agent turn loop (ingest → retrieve → generate → consolidate)

  • Migrating from another memory system — concept mapping, porting calls, bulk import, and scoping/multi-tenancy

  • Embeddings — wiring a real embedding provider (local / OpenAI / Ollama / HuggingFace / custom)

  • MCP server (engrava-mcp) — expose a store to MCP clients (Claude Desktop, Claude Code, Cursor, Windsurf, VS Code) via the standalone server package: install, run, client config, tools/resources/prompts

  • Configuration — YAML config format and options

  • Upgrade Guide — compatibility matrix, backups, and troubleshooting

  • Extensions — Writing custom extensions and hooks

  • Observability — Metrics snapshot API

  • Audit Trail — Tamper-evident hash-chain journal (enabling, querying, verifying, security model)

  • Evidence and Conflicts — model claims, provenance, contested facts, and caller-owned clarification

  • Security — trust boundaries, data egress, tenant isolation, encryption posture, and journal limits

  • Error Handling and Recovery — decide when to retry, repair input, or replace a store

  • Benchmarks — release gates, versioned observed values, and LongMemEval

  • API Reference — Full protocol and class reference

  • CLI Reference — every engrava command and option

  • Glossary — quick definitions of every Engrava term

  • MindQL — Query language syntax and examples

  • Troubleshooting — symptom → cause → fix for common errors

  • FAQ — quick answers (LLM/keys, embeddings-optional, scale, concurrency, backups, …)

  • Performance & Scaling — the vector-backend switch, bulk-ingest, and dreaming cost at scale

  • Data Lifecycle & Retention — lifecycle states, TTL, archive-vs-delete, GDPR erasure, disk reclamation

  • Deployment — process model, database files on disk, containers, graceful shutdown

  • Concurrency — the WAL single-writer model, busy timeout, and per-service isolation

  • Backup & Recovery — WAL-safe backups, snapshot vs file copy, restore verification

  • Known Limitations — Platform notes and constraints

Development

make install   # deps + dev extras + local git hooks -- from the primary checkout
make gate      # fast: hooks-active + lint + format check + type check + goldens drift, ~70s
make check     # lint + format check + type check + the full test suite with coverage (unchanged; no hooks-active, no goldens drift)

make install also wires a commit-msg hook that lints the message of the commit a squash merge creates — including on a local branch that never opens a pull request, where nothing else does. It refuses to install from a linked worktree (core.hooksPath is shared across all of them). See CONTRIBUTING.md for what it covers and what it does not.

License

MIT — see LICENSE for details.

docs/memory-hygiene.md

Forgetting

Mechanism: Memory Hygiene — the built-in memory-hygiene loop makes no LLM calls. A configured on_retrieve or decay_function hook is async and can itself call an LLM or any other external service during a pass. See Bounded, deterministic, previewable for exactly what this loop does and does not reproduce across runs.

Forgetting is how an Engrava store lets cold, low-signal memories fade. It is opt-in (off by default) and reversible: the default action archives a thought — a soft-retire you can restore — and only a separate, independently opted-in step ever garbage-collects an archived thought. The hygiene loop mutates only when you call run_hygiene() explicitly, or when an enabled and due hygiene policy runs as a convenience step inside consolidate() (see Running it); physical garbage-collection additionally requires auto_gc_enabled.

Dreaming + Forgetting — the two halves of memory maintenance

Forgetting is the subtractive half of a memory-maintenance cycle whose additive half is Dreaming:

  • Dreaming (consolidation) promotes — it keeps and strengthens what matters, links related memories with edges, and clusters them into higher-level reflections.

  • Forgetting (memory hygiene) demotes — it lets memories that have gone cold and low-signal fade out of the active working set.

Together they mirror how biological memory maintains itself — reinforcing some traces while letting others fade — so Forgetting can reduce the active working set over time. The built-in scoring and default hooks make no LLM calls; a configured on_retrieve or decay_function hook runs during the pass and may itself call an LLM or any other external service. See Bounded, deterministic, previewable for what reproducibility this loop actually offers.

The whole capability is OFF by default: the hygiene loop does nothing until you enable it — a store that never configures hygiene_policy never scores, archives, or garbage-collects anything, so the loop itself changes no read or write path. One thing is not gated on the policy, though: retrieval eligibility. Any archived row — whether archived by hygiene, by the TTL archive strategy, or manually — is excluded from default retrieval (see Search), so a store is byte-identical to the pre-feature behaviour only when it holds no archived rows. Following the same pattern as Dreaming (the concept "Dreaming" over the method consolidate()), the public API keeps the mechanism's name: you invoke Forgetting with store.run_hygiene(...) and configure it under hygiene_policy.

What it does

Memory Hygiene runs outside the normal CRUD path. You invoke it explicitly with store.run_hygiene(current_cycle=N) (or let it run at the end of a dreaming cycle — see Running it).

  run_hygiene(current_cycle=N)
    │
  ┌─┴──────────────────────────────────────────────────────────┐
  │ score      each ACTIVE/CREATED thought gets a keep-score      │
  │            from the same signals dreaming uses, times a       │
  │            decay multiplier -> an eviction-score              │
  │ 1. archive thoughts below the eviction threshold (and not     │
  │    (Stage 1) protected) flip to ARCHIVED — lifecycle-reversible│
  │            via restore_thought(); archive stamps are set      │
  │ 2. gc      only when auto_gc_enabled: hygiene-archived         │
  │    (Stage 2) thoughts past the restore window are physically   │
  │            deleted (cascading edges/embeddings/actions, then  │
  │            purging the vector index)                           │
  └─┬──────────────────────────────────────────────────────────┘
    │
    ▼
  a smaller, higher-signal working set

The "score" step is a simplification: the candidate pool is unexpired ACTIVE/CREATED rows, a keep-score is computed only once both run-level gates below pass, and a candidate excluded by protection or the cold-start guards is never scored at all.

The keep-score

For each candidate thought that reaches scoring — excluding one filtered out by protection, the cold-start guards, or the run-level gates, as noted above — hygiene computes a keep-score as a weighted combination of the active scoring signals, normalised by the total active weight — the same library dreaming uses — under hygiene's own weight vector:

SignalDefault weightHigher when…
recency0.30the thought was updated recently
frequency0.25the thought has been accessed often
confirmation0.20the same fact has been re-encountered
confidence0.15the thought carries a high confidence value
staleness0.10the thought has been active over a long span

Which signals are active is decided once per run, over the whole candidate pool, by the rules dreaming uses for its default signals: recency and staleness are always active, and each other signal is active only when some candidate has its data (frequency also needs access tracking on). An inactive signal's weight is set to 0.0, and each active signal's weight is divided by the sum of the active weights. If no signal is active, or that sum is zero, the keep-score selects nothing for archiving that run. This keeps the keep-score meaningful on sparse stores instead of dragging every score toward a constant.

The keep-score is then multiplied by the decay_function hook to produce the eviction-score:

keep_score     = Σ active-signal weight · signal(thought)     (renormalised)
eviction_score = keep_score · decay_function(thought, elapsed_cycles)
archive(thought) ⇐ eviction_score < eviction_threshold  AND  not protected(thought)

The default decay_function returns 1.0 (no decay), so out of the box the eviction-score is the keep-score. A custom hook can shape a decay curve; its return is clamped into [0.0, 1.0], and a non-finite value is treated as 1.0, so decay multiplies the keep-score by a factor between 0.0 and 1.0. Because a custom hook can deliberately make an otherwise high-scoring thought archivable, treat it as part of the retention policy and preview its effect with dry_run before enabling mutations.

The Memory Hygiene loop is the only place decay_function is consulted; it is not part of search, ranking, or promotion.

Protection — what never gets forgotten

A thought is protected (never auto-archived and never auto-GC'd) when:

  • it is pinned (ThoughtRecord.pinned = True) — the durable, node-level never-forget marker; or

  • its priority is listed in protected_priorities (default: ("P1",)).

from engrava import ThoughtRecord, ThoughtType, Priority, LifecycleStatus

keep_me = ThoughtRecord(
    thought_id="user-birthday",
    thought_type=ThoughtType.OBSERVATION,
    essence="User's birthday is 3 March",
    content="The user mentioned their birthday is 3 March.",
    priority=Priority.P2,
    lifecycle_status=LifecycleStatus.ACTIVE,
    created_cycle=0,
    updated_cycle=0,
    source="user",
    pinned=True,   # never auto-archived or auto-GC'd, regardless of score
)

confidence is not protection. A high-confidence but cold thought can still be archived — a model-confidence estimate is not a user keep-decision. confidence only contributes to the keep-score via its signal.

protected_priorities is a default, not an invariant: an operator who wants more aggressive hygiene can set it to () so even top-priority thoughts are eligible. Pinning is the invariant.

This protection also covers the orphan-REFLECTION sweep and engrava gc. A pinned or protected-priority REFLECTION is never retired by the sweep described in Dreaming → Orphan sweep, and the CLI's engrava gc keeps a pinned or protected-priority ARCHIVED row by default (--include-pinned overrides it) — see Data lifecycle → Running cleanup. This protection is not a TTL exemption. A protected-priority thought with an expired TTL still expires — only pinned exempts a row from TTL cleanup; see Data lifecycle → Time-to-live.

Two stages: archive, then (optionally) GC

Stage 1 (archive) is the default action and is reversible for lifecycle and the hygiene archive markers:

  • A below-threshold, unprotected thought flips to ARCHIVED and its archived_at_cycle is set to the current cycle while archived_at receives the run's wall-clock instant. Its expires_at is cleared so it is no longer subject to TTL. This dedicated path accepts eligible ACTIVE and CREATED rows; it does not require an ordinary lifecycle journey through DONE.

  • Restore un-archives a thought: store.restore_thought(thought_id) transitions it back to ACTIVE (the ARCHIVED → ACTIVE lifecycle edge) and clears both archived_at_cycle and archived_at. It does not restore expires_at: an expiry the thought carried before archival stays cleared after restore. Restoring a thought that is not archived raises InvalidTransitionError.

Stage 2 (garbage collection) runs only when auto_gc_enabled is set (it is false by default) — enabling hygiene never implicitly enables deletion:

  • A thought is GC-eligible only when it was archived by hygiene (archived_at_cycle is set) and it has cleared both enabled restore windows:

    • a cycle window — current_cycle - archived_at_cycle >= gc_min_archive_age_cycles (default gc_min_archive_age_cycles = 10 cycles). Setting gc_min_archive_age_cycles: 0 makes this window always pass — the cycle gate is disabled, symmetric with the wall-clock case below; and

    • a wall-clock window — the thought has been archived for at least gc_restore_window_seconds of real time (archived_at <= now - gc_restore_window_seconds, default 2592000 = 30 days). This exists so a fast-cycling or bulk store cannot burn through the cycle window and delete a just-archived thought before there was any real-time chance to restore_thought it. Set gc_restore_window_seconds: 0 to disable the wall-clock window (cycle-only, the pre-window behaviour).

    With both windows disabled (0), a hygiene-archived thought has no restore window before it becomes GC-eligible — opt into that only deliberately.

  • GC excludes any row whose archived_at_cycle is None — ordinarily true of a thought archived through TTL or a manual lifecycle change. That marker is not proof of this archival's provenance, though: a raw update_thought(lifecycle_status=ARCHIVED) (bypassing run_hygiene and TTL cleanup, both of which refresh or clear it) leaves a prior hygiene archival's archived_at_cycle in place, so a thought re-archived that way can still be picked up by a later GC pass.

  • A hygiene-archived row that predates the wall-clock archived_at column (so archived_at is None) is never GC'd while the wall-clock window is active — the irreversible stage fails closed rather than guess an age.

  • Deletion runs an orphan-reflection sweep first (so no REFLECTION is left summarising a cluster the delete would empty), then cascades to edges/embeddings/actions, then purges the vector index.

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

GC is not erasure. Garbage collection reclaims the live, queryable working set — it does not purge history. When the hash-chain journal is enabled, a GC delete is recorded as a DELETE_THOUGHT entry that keeps a full before snapshot, so the content survives in the append-only journal after the live row is gone.

Garbage collection here is cognitive hygiene, not compliance deletion. It is best-effort, window-gated, and opt-in — it offers no deletion guarantee, legal hold, scheduled/enforced retention, or erasure receipt. For the honest deletion mechanics (and the residue a hard delete leaves in the audit journal and backups), see Data lifecycle, retention & deletion.

Bounded, deterministic, previewable

  • Bounded, for the archive and GC selections. max_evictions_per_run (default 100) caps each stage independently — at most that many archived, and at most that many GC'd, per run. The GC stage also runs an orphan-reflection sweep beforehand, so a synthesis never outlives its whole source cluster; that sweep is not capped by max_evictions_per_run and retires every qualifying orphan reflection it finds, counted separately from archived_count.

  • Deterministic for fixed inputs, for the windows now governs. The injected (or, if omitted, wall-clock) now fixes the inactivity-age and restore-window boundaries for the run, and the same store + config + cycle + now selects the same set on those boundaries alone, when any configured custom hooks are deterministic too. Candidate collection is a separate matter: it pages through list_thoughts(), whose own default expiry filter reads real wall-clock time on every call regardless of the now passed to run_hygiene — so a thought's expiry crossing during a long-running pass can still change which candidates are collected, independent of now. When more candidates qualify than the cap allows, the archive stage keeps the lowest-scoring, then oldest, then lowest-id thoughts; the GC stage keeps the oldest-archived, then lowest-id thoughts.

  • Fail-safe on a blank slate. A brand-new store archives nothing, but not because "no signal is active" — with the default weights, recency and staleness are active on presence alone (see above), so that condition is essentially unreachable. The actual guard is a separate usage-signal gate: without any usage-history signal (access counts, confirmations) anywhere in the candidate pool, cycle-recency alone cannot distinguish "cold" from "ingested early", so the pass archives nothing until at least one usage signal has data to work with.

  • Dry run. With dry_run: true, run_hygiene computes and returns the set it would archive (with a per-thought reason). Hygiene itself issues no archive or GC mutation and writes no journal entry for this run — that is the whole guarantee. It still runs candidate collection and scoring to compute that set, so a configured on_retrieve or decay_function hook still executes; a hook that holds its own reference to the store can still write to it, or produce any other side effect, regardless of dry_run — the preview guarantee is about hygiene's own actions, not a hook's.

result = await store.run_hygiene(current_cycle=1000)
print(result.archived_count, result.gc_count, result.candidates_evaluated)

# Preview mode (hygiene_policy.dry_run = true):
preview = await store.run_hygiene(current_cycle=1000)
for reason in preview.would_evict:
    print(reason.thought_id, reason.eviction_score, reason.signals)

Cold-start safety

Two guards stop a fresh or bulk-imported store — where cycle-recency has no history to work with and degenerates into ingest order — from archiving its earliest-loaded rows. Both only ever add protection; neither can cause an archival the keep-score alone would not.

  • Minimum inactivity age. A thought is eligible for archival only once now is at least min_inactivity_age_seconds past its last contact (last_accessed_at, else updated_at, else created_at) — the row's own persisted timestamp, not when the store or the row was created. Default 604800 (7 days). A freshly created thought is protected until that persisted timestamp falls inside the window. A row restored from a snapshot or otherwise imported with the timestamps it already carried is protected only if one of those persisted timestamps is recent enough — an imported row carrying an old created_at (for example, a restored backup) is archival-eligible immediately, not protected by virtue of having just been imported. min_inactivity_age_seconds: 0 disables the gate (the pre-gate behaviour); a row with no known last-contact time fails closed (protected).

  • Usage-signal access gate. A run archives nothing unless at least one usage-history signal — frequency (reads), confirmation (reinforcements), or action_outcome — is active across the candidate pool. Without any evidence a thought was ever used, "cold" cannot be told apart from "merely ingested early", so cycle-recency must not drive eviction on its own. In practice this means Forgetting only has an effect once the store carries genuine usage data: enable access tracking (on by default when dreaming is enabled) or record confirmations. Implicit accesses are buffered; consolidate() flushes them before scoring, while a direct run_hygiene() does not. Call flush_access_buffer() first when a standalone hygiene pass must include the newest pending reads.

Together with the all-flat fail-safe above, these make the first hygiene runs on a new store a safe no-op rather than a guess.

Audit trail

Every archival and every garbage-collection is recorded in the hash-chain journal when journaling is enabled, using the existing mutation kinds (an archive is an UPDATE_THOUGHT; a GC-delete is a DELETE_THOUGHT — no new mutation type is introduced). The forgetting rationale rides in the entry's delta under a nested eviction_reason:

{
  "mechanism": "hygiene",
  "keep_score": 0.03,
  "eviction_score": 0.03,
  "decay_multiplier": 1.0,
  "threshold": 0.20,
  "signals": { "recency": 0.02, "staleness": 0.0 }
}

Each recorded decision carries its own score, multiplier, threshold, and per-signal values, and the chain still verifies with verify_journal() after an archive and a GC — retained entries and the linkage between them can be checked for tampering. It does not, on its own, prove the set of decisions is complete: the recorded reason does not include the full candidate pool, the effective weights, or the eviction-cap ordering for the run, and, as Audit journal threat model explains, verification cannot detect the deletion of a self-consistent suffix of the chain — so a local check cannot by itself prove that no later decision was removed. A dry_run preview journals nothing, since nothing was mutated.

Running it

Directly — bypasses the cadence and runs a pass immediately, provided a hygiene policy is configured and enabled, and a cognitive cycle is available: calling run_hygiene() with no hygiene_policy configured raises RuntimeError; a configured but disabled policy (enabled: false) returns an empty result without scoring anything, even on this explicit call; and with neither an explicit current_cycle argument nor a configured cycle_provider, it raises ValueError instead of inventing a cycle. Pass a timezone-aware now when a replay or benchmark needs fixed wall-clock boundaries:

import asyncio
from datetime import UTC, datetime

from engrava import SqliteEngravaCore


async def run_a_hygiene_pass() -> None:
    async with await SqliteEngravaCore.from_config("engrava.yaml") as store:
        result = await store.run_hygiene(
            current_cycle=1000,
            now=datetime(2026, 7, 23, tzinfo=UTC),
        )
        print(result.archived_count, result.gc_count)


asyncio.run(run_a_hygiene_pass())

As a convenience at the end of a dreaming cycle — when both dreaming and hygiene_policy are enabled, store.consolidate(current_cycle=N) runs one hygiene pass after promotion and the orphan sweep, but only when the cycle satisfies the cadence (current_cycle % check_every_n_cycles == 0). An explicit run_hygiene call always bypasses the cadence.

Configuration

See Configuration → hygiene_policy for the full YAML surface. A minimal enable:

hygiene_policy:
  enabled: true                       # OFF by default
  eviction_threshold: 0.20            # archive below this eviction-score
  auto_gc_enabled: false              # keep GC off until you want physical deletion
  min_inactivity_age_seconds: 604800  # 7 days untouched before archivable; 0 disables
  gc_restore_window_seconds: 2592000  # 30-day real-time restore window before GC; 0 disables
  dry_run: true                       # preview first
402 files · 357 not served here

Advantages

Limitations

Security

Risk level: Low risk

This brain runs

Git hooks (1)

  • .githooks/commit-msg (commit-msg): runs on git commit-msg

Agent instructions that run or fetch something (4)

  • AGENTS.md: Run `git commit` and it fires on the patterns it checks for.
  • CLAUDE.md: pip install -e ".[dev]"
  • CLAUDE.md: Run a clean test suite locally before pushing;
  • CLAUDE.md: Branch from `dev`, follow Conventional Commits, run the local quality gate before pushing.

Install scripts (1)

  • Makefile (make install): echo "$(BLUE)>> Installing engrava + dev dependencies$(RESET)"; $(PIP) install --upgrade pip setuptools wheel; $(PIP) install -e ".[dev]"; bash scripts/install_hooks.sh; echo "$(GREEN)>> Done. Run 'make check' to verify quality gate.$(RESET)"
  • Low risk: scripts-present (.githooks/commit-msg)
  • Low risk: network-instructions (CLAUDE.md:40)
  • Low risk: network-instructions (pyproject.toml:98)
  • Low risk: scripts-present (scripts/install_hooks.sh)

Attribution and licence

Created by
Sovantica
Declared authors
  • Sovantica (declared in pyproject.toml)
Publisher
Sovantica (Official Sovantica project)
Source
sovantica/engrava · commit f6a8b73
Licence
MIT License (files served here)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Sovantica.

Install from source

357 of its files are not served here, so there is no ZIP. sovantica/engrava at f6a8b73