Skip to content

Memory

arcana.memory holds the concrete storage backends behind the MemoryAdapter protocol. The protocol is the seam: agents read and write through it and never learn which backend is underneath, so swapping stores is a matter of supplying a different adapter.

SQLiteAdapter is the first backend — a single-file, async store built on aiosqlite. One adapter instance owns one .db file; an agent's private memory lives at ~/.arcana/agents/{agent_id}/memory.db.

from uuid import uuid4

from arcana.memory import SQLiteAdapter
from arcana.types import MemoryEntry, MemoryQuery, MemoryType

agent_id = uuid4()
memory = SQLiteAdapter.for_agent(agent_id)
await memory.connect()                       # opens the file, runs migrations

await memory.write(
    MemoryEntry(
        agent_id=agent_id,
        type=MemoryType.SEMANTIC,
        content="The user prefers metric units.",
        importance=0.8,
    )
)

results = await memory.search(MemoryQuery(agent_id=agent_id, limit=5))
await memory.aclose()

When a query carries text, search() ranks results by full-text relevance (BM25) using a SQLite FTS5 index over each entry's content and tags. The usual metadata filters — scope, type, confidence, time range, conflict exclusion — still apply on top of the text match. A query with no text falls back to filter-and-order: pinned first, then importance, then recency.

from arcana.types import MemoryQuery, RetrievalMode

results = await memory.search(
    MemoryQuery(
        agent_id=agent_id,
        text="metric units preference",
        retrieval_mode=RetrievalMode.keyword,
        limit=5,
    )
)

Arbitrary user text is safe to pass directly: it is sanitized into a valid FTS5 MATCH expression (operator characters stripped, tokens quoted and OR-joined), so it can never raise a syntax error. The index is kept in lockstep with the entries table by database triggers, so writes, upserts, and deletes need no extra bookkeeping.

FTS5 must be compiled into SQLite — connect() raises MemoryStorageError if the build lacks it.

Importance-based promotion

When an adapter is constructed with a global_store, writing a PRIVATE entry whose importance >= 0.9 also copies it into the global store as a GLOBAL entry — the mechanism behind "all agents read; The World writes". Without a global_store, promotion is a no-op.

global_store = SQLiteAdapter(global_db_path)
agent_memory = SQLiteAdapter.for_agent(agent_id, global_store=global_store)

Schema migrations

Schema is versioned with SQLite's built-in PRAGMA user_version — deliberately no Alembic or SQLAlchemy, to keep arcana-core dependency-light. connect() brings the database to the latest version automatically.

The arcana.memory.migrations package separates the runner (runner.py, the forward-only engine that applies migrations) from the definitions (versions/, one module per schema version). Definitions are append-only: never edit a shipped migration — add a new versions/vNNN_*.py module and register it. Each migration's DDL and its user_version bump share a transaction, so a partial failure rolls back atomically.

Embedding gateway and model pinning

Vector search needs an embedder, and which one a database may use is pinned. The first model to write an embedding is recorded in the database's embedding_meta row (added by migration v3), and the database stays locked to it. EmbeddingGateway.resolve() turns that pin into the adapter to embed with:

  • New database (no pin yet) → the first healthy adapter, in priority order; the database is then pinned to it.
  • Pinned database → the adapter for its exact model when healthy; otherwise a healthy adapter in the same model_family (interchangeable vectors); otherwise None.

A None result is the signal to fall back to keyword (FTS5) search. The gateway never substitutes a model from a different family — that would compare vectors across incompatible spaces and corrupt similarity scores with no error raised.

from arcana.memory import EmbeddingGateway
from arcana.models.adapters.ollama_embedding import OllamaEmbeddingAdapter
from arcana.models.adapters.fastembed_embedding import FastEmbedEmbeddingAdapter

# Priority order: Ollama first, fastembed second.
gateway = EmbeddingGateway([OllamaEmbeddingAdapter(), FastEmbedEmbeddingAdapter()])

adapter = await gateway.resolve(db_meta)   # db_meta: EmbeddingMeta | None
if adapter is not None:
    vector = await adapter.embed("some text")
else:
    ...  # fall back to FTS5 keyword search

The gateway is pure resolution logic: it takes the database's EmbeddingMeta (or None) and returns an adapter. Reading and writing the embedding_meta row belongs to the vector backend that consumes the gateway — that backend is VectorAdapter, below.

Vector search (semantic)

VectorAdapter adds semantic search on top of a SQLiteAdapter. It composes (does not subclass) the SQLite store and an EmbeddingGateway, storing embeddings in a sqlite-vec vec0 index that lives in the same memory.db — so row and vector writes share one connection and stay consistent. Vector storage is an optional extra:

pip install "arcana-os[vector]"
from arcana.memory import EmbeddingGateway, SQLiteAdapter, VectorAdapter
from arcana.models.adapters.ollama_embedding import OllamaEmbeddingAdapter
from arcana.types import MemoryQuery, RetrievalMode

memory = VectorAdapter(
    SQLiteAdapter.for_agent(agent_id),
    EmbeddingGateway([OllamaEmbeddingAdapter()]),
)
await memory.connect()

await memory.write(entry)                    # embeds content, indexes the vector

results = await memory.search(
    MemoryQuery(
        agent_id=agent_id,
        text="units the user likes",
        retrieval_mode=RetrievalMode.semantic,
        limit=5,
    )
)

On write(), the adapter resolves an embedder through the gateway, embeds the entry's content (when no vector is supplied), and stores it in the index. The first embedded write both creates the dimension-sized index and pins the database to the model (writing its embedding_meta row); vectors are L2-normalized and the index ranks by cosine distance. A vector whose width disagrees with the pin is refused with MemoryStorageError rather than silently corrupting the index.

search() embeds the query, ranks by nearest-neighbour cosine distance, then applies the same metadata filters as keyword search. It falls back to FTS5 keyword search (with a one-time warning) whenever no compatible embedder is healthy — or when sqlite-vec is not installed — so memory stays usable without the extra, just without semantic ranking. A query with no text uses the filter-and-order path.

Hybrid retrieval

RetrievalMode.hybrid fuses the vector and keyword legs into one ranking. Because cosine distance and BM25 sit on different, incompatible scales, each leg is converted to a higher-is-better relevance and min-max normalized to [0, 1] within the query's candidate pool, then combined:

finalScore = vector_weight × vNorm + bm25_weight × bm25Norm

Per-query normalization keeps either leg from dominating purely because of scale. The weights are set on the adapter and normalized to sum 1, defaulting to 0.7 vector / 0.3 keyword:

memory = VectorAdapter(sqlite, gateway, vector_weight=0.7, bm25_weight=0.3)

results = await memory.search(
    MemoryQuery(
        agent_id=agent_id,
        text="metric units",
        retrieval_mode=RetrievalMode.hybrid,
        limit=5,
    )
)

An entry surfaced by only one leg contributes 0 for the other. With no healthy embedder, hybrid degrades to keyword-only (the BM25 leg alone).

Folder connector (Markdown)

MarkdownFolderAdapter exposes a directory of Markdown notes as retrievable memory — the zero-setup way to make an Obsidian vault (or any notes folder) searchable by an agent. It implements the same MemoryAdapter protocol, so it registers as a federation tier or attaches to an agent's memory slot with nothing but a folder path: no plugin, no server, no sync job.

One .md/.markdown file becomes one MemoryEntry. The id is a stable uuid5 of the file's root-relative path, so re-reads — and any future ingest into SQLite — upsert instead of duplicating, and the federation dedups a folder note against an ingested copy by that shared id. YAML frontmatter maps to fields (type, importance, pinned, tags), Obsidian #hashtags fold into tags, and malformed frontmatter is tolerated — one bad note never fails the scan.

The folder has no FTS5 or vector index, so every retrieval mode collapses to keyword: a semantic or hybrid query is served by the same in-process substring/token scan with a one-line degraded notice, never an error. Results rank pinned → lexical relevance → importance → recency. A process-local index cache keyed by path → (mtime, size) keeps a live read fresh — each search() restats the tree and re-parses only changed or new files — so the connector works standalone with zero sync infrastructure.

It is read-only: reads never mutate files, and write() raises MemoryWriteError (the folder is an external source of truth, not a sink). Dotfiles and dot-dirs (.obsidian/, .trash/), symlinks, oversized files (max_file_bytes, default 1 MiB), and any configurable ignore-glob are skipped; health_check() is a cheap stat + readability probe on the root and never raises.

from pathlib import Path
from uuid import uuid4

from arcana.memory import MarkdownFolderAdapter
from arcana.types import MemoryQuery, MemoryScope

vault = MarkdownFolderAdapter(
    Path("~/Documents/MyVault").expanduser(),
    agent_id=uuid4(),
    scope=MemoryScope.SHARED,        # e.g. registered as a shared read tier
    pool_name="vault",
)
results = await vault.search(MemoryQuery(text="metric units", limit=5))

Pruning

prune() removes low-value entries per a PrunePolicy. The min_importance floor and the max_entries cap compose into one victim set (an entry is removed if it falls below the floor or sits outside the top-N by importance), and pinned entries are never touched. PruneMode.ARCHIVE soft-deletes (sets archived, hiding the entry from search but keeping it recoverable); PruneMode.PURGE hard-deletes the row — and its vector, if a vec0 index exists. A PruneReport records what was scanned, archived, and purged.

from arcana.types import PrunePolicy, PruneMode

report = await memory.prune(PrunePolicy(min_importance=0.2, max_entries=10_000))

Knowledge graph (edges)

Beyond similarity, memory carries an explicit graph: typed, directed edges between nodes, stored in a memory_edges table (migration v4) that lives in the same SQLite database as the rows — a property graph, no separate engine. EdgeStore is its read/write layer. An edge is a MemoryEdge: src_id → dst_id under a relation, tagged by the source that produced it and carrying a confidence. Endpoints are stable node ids, so an edge can relate nodes that live in any tier — a folder connector's uuid5 note id is a valid endpoint even though it is not a stored row.

The (src_id, dst_id, relation) primary key makes writes idempotent. A producer owns its source and rewrites exactly that set with replace_source(), so a re-index stays convergent as links are added, removed, or re-pointed. neighbors() returns the ids one hop away in either direction — the seed→expand primitive for graph-aware retrieval.

WikilinkEdgeExtractor populates the graph from a Markdown folder: it parses Obsidian [[wikilinks]] (and ![[embeds]]) out of the notes a MarkdownFolderAdapter already reads and writes them as references edges. It is fully deterministic — regex over text, resolution by filename or path, no model in the loop — so it carries none of the hallucinated-edge risk that inferred edges would. Targets resolve within the folder (a bare [[Note]] by basename, [[folder/Note]] by path); ambiguous and dangling links are skipped and counted, never guessed. Each reindex() rewrites the whole wikilink edge set and returns an EdgeIndexReport tally.

from arcana.memory import (
    EdgeStore,
    MarkdownFolderAdapter,
    SQLiteAdapter,
    WikilinkEdgeExtractor,
)

reader = MarkdownFolderAdapter(vault_path, agent_id)
edges = EdgeStore(SQLiteAdapter.for_agent(agent_id))
await edges.connect()

report = await WikilinkEdgeExtractor(reader, edges).reindex()
neighbours = await edges.neighbors(note_id)   # ids one hop away

Populates, doesn't traverse

The extractor writes edges; consuming them at read time — seed by vector/keyword, expand along edges, re-rank — is a federation concern and is not wired into the read path yet.

Federation across tiers

A MemoryFederation presents many tier backends as a single MemoryAdapter, so an Agent holds it exactly where it would hold one store and the topology stays invisible. Three scopes make up the topology:

  • private — the agent's own store (the durability anchor);
  • shared — named pools an agent group reads and writes;
  • global — the shared-by-all tier, the mechanism behind "all agents read; The World writes".

A MemoryRouter owns the pure routing policy; the federation performs the I/O.

  • Fan-out writesroute_write() decides the target tiers, and the federation writes each concurrently. A high-importance (>= 0.9) PRIVATE entry is also written to GLOBAL as a scope-rewritten copy (promotion, same id). Writes are not transactional across tiers.
  • Merged readsroute_read() fans a query across the routed tiers concurrently; results are deduplicated by id (the most-local tier wins) and re-ranked by the agent's MemoryWeights before truncation to query.limit. stream_search() yields the same ranked sequence one entry at a time.

Reads and writes treat failure differently, on purpose: a failed read tier is dropped so the agent still sees the other tiers' results (partial memory beats none), while a failed private write raises MemoryWriteError — a lost write to the durability anchor is data loss the caller must know about. SHARED/GLOBAL write failures degrade instead (a degraded GLOBAL simply pauses promotion).

from arcana.memory import MemoryFederation, MemoryRouter, SQLiteAdapter

router = MemoryRouter(
    private=SQLiteAdapter.for_agent(agent_id),
    global_=SQLiteAdapter(global_db_path),
    pools={"team-research": SQLiteAdapter(pool_db_path)},
    weights=agent_config.memory_weights,
)
memory = MemoryFederation(router)

await memory.write(entry)                    # fans out to every routed tier
results = await memory.search(MemoryQuery(agent_id=agent_id))  # merged + ranked

Assembling a federation for an agent

Wiring the router and tiers by hand (above) is the low-level API. In practice one call does the whole assembly: build_federation() turns an agent id plus the ~/.arcana home into a ready MemoryFederation.

from pathlib import Path
from uuid import uuid4

from arcana.memory import EmbeddingGateway, PoolConfig, build_federation
from arcana.models.adapters.fastembed_embedding import FastEmbedEmbeddingAdapter

federation = await build_federation(
    uuid4(),
    home=Path.home() / ".arcana",
    embedding=EmbeddingGateway([FastEmbedEmbeddingAdapter()]),  # optional
    pools=[PoolConfig("team-research", pool_adapter)],          # optional
)
# ... agent.run(...) ...
await federation.aclose()   # release the private handle and any vector store

It builds the tiers the way the runtime expects:

  • private — per-agent SQLite at ~/.arcana/agents/{id}/memory.db, opened eagerly so migrations run and a corrupt store is quarantined before first use. This tier is always present, the durability anchor.
  • global — a shared vector store at ~/.arcana/vector/global.db, wired only when an EmbeddingGateway is supplied. It is semantic when the embedder is healthy and keyword (FTS5) when not; with no embedder the tier is dropped and the agent runs private-only, so a zero-config install still works.
  • shared — each PoolConfig is registered on the router by name.

Degradations route to the observability audit log by default (pass on_degraded to override), so a thinned SHARED/GLOBAL tier is visible without failing the run.

Injection and configuration

Callers rarely invoke build_federation directly. AgentRegistry.build_runtime_with_memory() assembles and injects a federation for a stored agent, returning (agent, federation) so the caller owns teardown. The CLI arcana run path uses it, so agents remember across sessions by default. Opt out of a single run with --no-memory, or globally via the memory block in ~/.arcana/config.json:

{
  "memory": {
    "enabled": true,
    "private": "sqlite",
    "global": "vector",
    "pools": [],
    "extraction": {
      "strategy": "heuristic",
      "agent_confidence_cap": 0.7,
      "summarise_on_close": true,
      "min_confidence_to_store": 0.3
    }
  }
}

The extraction block selects the memory extractor and its thresholds: strategy is "heuristic" (default) or "llm", agent_confidence_cap bounds agent-asserted confidence below 1.0, summarise_on_close toggles the consolidated session-summary memory, and min_confidence_to_store drops weak entries before they are written. An absent block yields these defaults, and "llm" with no model provider falls back to the heuristic.

The global vector tier activates when an embedding provider is available: the CLI uses in-process fastembed when the arcana-os[embed] extra is installed, and stays private-SQLite-only otherwise.

Extraction and summarisation

What an agent writes to memory is decided by a MemoryExtractor: it turns one completed turn — (prompt, response, session) — into a small list of typed, confidence-scored MemoryEntrys, rather than one blanket slice of the response. Correct typing matters because MemoryType selects the decay profile — episodic decays fast, semantic slow, procedural very slow — so a durable fact and a throwaway exchange are retained on different clocks.

Two strategies sit behind one interface:

  • HeuristicExtractor — the default: deterministic and model-free. It records one EPISODIC entry per turn, promotes a stated user preference to SEMANTIC, and turns a how-to answer into a PROCEDURAL entry. A promoted SEMANTIC entry stores the distilled clause — the fact itself with its conversational framing ("by the way, remember that …") stripped — rather than the whole prompt, so the stored memory (and every later prompt injection of it) is the fact, not the wrapper. Free, testable, and it adds no round-trip.
  • LLMExtractor — opt-in: a single low-temperature gateway call returning a small JSON list of candidate memories. Any model error, malformed JSON, or empty result falls back to the heuristic for that turn, so extraction can never crash a run.

Honest confidence is the anti-poisoning guarantee: agent-generated text is capped below 1.0 and sourced as AGENT (so better evidence can override it later), while an explicit user statement ("remember that I …") is trusted higher and sourced as USER_CONFIRMED. Entries below min_confidence_to_store are dropped before the write. Importance is derived from signal — imperative or "remember" language, a pin — not a constant.

Extraction is best-effort throughout: a failure is logged to the audit log and swallowed, never surfaced into the user-facing run.

from arcana.memory import HeuristicExtractor, build_extractor, ExtractionConfig

# The default, wired automatically by the runtime.
extractor = HeuristicExtractor()
entries = await extractor.extract(prompt, response, session)   # list[MemoryEntry]

# Or select by config; "llm" with no model configured falls back to heuristic.
extractor = build_extractor(ExtractionConfig(strategy="llm"), gateway=gw, model="ollama/hermes-3")

Session summaries. SessionManager distils a whole session into Session.summary and writes one consolidated memory on close. summarise() sets the summary (using the extractor, or a deterministic heuristic fallback) and persists it; close_and_summarise() then writes a single consolidated entry — SEMANTIC when the session stated a durable fact, else EPISODIC — carrying that summary at a higher baseline importance. close() stays synchronous and does neither, so a sync caller never triggers a model call by accident.

summary = await session_manager.summarise(session)            # sets session.summary
await session_manager.close_and_summarise(session, memory=federation)

Selection and thresholds live in the extraction sub-block of the memory config (see below); with no model provider, extraction is forced to the deterministic heuristic. strategy is the ExtractionStrategy enum (heuristic / llm).

Env-overridable tunables. The scoring knobs — confidence caps, importance baselines, and the per-entry content cap (MAX_ENTRY_CONTENT, the ceiling trim_content enforces) — ship as defaults but read ARCANA_EXTRACTION_* environment variables at import time, so code built on Arcana can tune extraction without a release. Precedence, high to low: config.jsonARCANA_EXTRACTION_* env → built-in default. For example, ARCANA_EXTRACTION_MAX_CONTENT=1000 widens stored entries and ARCANA_EXTRACTION_AGENT_CONFIDENCE_CAP=0.6 lowers the agent-confidence ceiling. Bounds are validated: agent_confidence_cap must stay strictly below 1.0 — a value of 1.0 would let agent-asserted text become un-overridable, defeating anti-poisoning, so it is rejected at load rather than silently accepted.

Language signals. The heuristic's surface cues (imperative/"remember" language, stated preferences, how-to questions, step lists, and the leading framing stripped when distilling a durable clause) are language-specific and live in arcana.memory.extraction.signals, decoupled from the extractor. English ships as the default; a new language is a SignalPatterns registered via register_language() (or passed straight to HeuristicExtractor(signals=...)) — the extractor itself never changes.

Resilience

The router wraps every tier in a ResilientTier before handing it to the federation, so a slow, locked, or corrupt store can never stall or sink a whole session. Each wrapper bounds calls with a timeout, contains failures behind a per-tier circuit breaker, and surfaces the thinning as a MemoryDegradedEvent rather than swallowing it silently.

  • Reads are total — a timeout, open breaker, corruption, or backend error yields [] after emitting a degraded event.
  • Writes are partial — the same conditions raise TierWriteFailed carrying the tier's scope, so the federation decides the blast radius (PRIVATE fatal, SHARED/GLOBAL degrade).

A CircuitBreaker trips after fail_threshold consecutive failures during real traffic, fails fast while OPEN, then allows one HALF_OPEN probe once reset_after_seconds elapses. Corruption is special: a MemoryCorruptError is a session-long condition, so it forces the breaker open (quarantine) rather than counting as one transient failure.

Timeout budgets and breaker thresholds are per tier — a keyword read hits local SQLite, while a semantic read may call a remote embedder — and configurable via ~/.arcana/connections/memory-adapters.json (a missing file yields safe defaults, so existing programmatic wiring keeps working):

{
  "private":       { "read_timeout_ms": 250, "semantic_timeout_ms": 1500, "write_timeout_ms": 500 },
  "global":        { "read_timeout_ms": 400, "write_timeout_ms": 600 },
  "shared":        { "team-research": { "read_timeout_ms": 400 } },
  "default_shared": { "read_timeout_ms": 400, "write_timeout_ms": 600 }
}

Background jobs

Post-session extraction and periodic consolidation are meant to run off the request path. BackgroundJobQueue models that as a bounded queue with load-shedding: submit() never blocks, always accepting critical jobs (e.g. a user-confirmed preference) while shedding non-critical jobs once the backlog's drain estimate (depth × EWMA(service time)) exceeds its headroom.

Design-ahead, not yet wired

Extraction runs inline on every agent turn (Agent.run and Agent.stream) via the memory extractor; nothing drains this queue in the live path yet. Only the queue interface and its depth/drain metrics ship today — the consumer loop lands when extraction and consolidation actually move off the request path.

Adapters

arcana.memory.adapters.sqlite.SQLiteAdapter

SQLiteAdapter(
    db_path,
    *,
    global_store=None,
    refresh_on_access=True,
    quick_check_on_open=True,
)

Async SQLite memory backend. Implements the MemoryAdapter protocol.

Source code in packages/arcana-core/arcana/memory/adapters/sqlite.py
def __init__(
    self,
    db_path: Path,
    *,
    global_store: SQLiteAdapter | None = None,
    refresh_on_access: bool = True,
    quick_check_on_open: bool = True,
) -> None:
    self._db_path = Path(db_path)
    self._global_store = global_store
    self._refresh_on_access = refresh_on_access
    # PRIVATE stores open once per session, so a cheap integrity check at open
    # quarantines a corrupt agent before it runs. SHARED/GLOBAL stores open
    # widely and rely on detect-on-read instead; wiring disables this for them.
    self._quick_check_on_open = quick_check_on_open
    self._conn: aiosqlite.Connection | None = None
    #: Set to the integrity-failure detail once corruption is seen. Latches:
    #: a quarantined store stays quarantined for this adapter's lifetime.
    self._corrupt: str | None = None

connection property

connection

The live connection, for sibling adapters sharing this database.

VectorAdapter layers a vec0 index onto the same memory.db and needs this exact connection so its vector writes sit in the same database as the rows. Raises if the adapter was never connected.

for_agent classmethod

for_agent(agent_id, base_dir=None, **kwargs)

Build an adapter at ~/.arcana/agents/{agent_id}/memory.db.

Mirrors SessionManager's path convention so an agent's memory lives beside its sessions.

Source code in packages/arcana-core/arcana/memory/adapters/sqlite.py
@classmethod
def for_agent(
    cls,
    agent_id: UUID,
    base_dir: Path | None = None,
    **kwargs: object,
) -> SQLiteAdapter:
    """Build an adapter at ``~/.arcana/agents/{agent_id}/memory.db``.

    Mirrors ``SessionManager``'s path convention so an agent's memory lives
    beside its sessions.
    """
    base = base_dir or _default_base()
    return cls(base / str(agent_id) / "memory.db", **kwargs)  # type: ignore[arg-type]

connect async

connect()

Open the connection, set pragmas, and migrate to the latest schema.

Idempotent — safe to call repeatedly. search/write lazily call this, so explicit connect() is optional.

Source code in packages/arcana-core/arcana/memory/adapters/sqlite.py
async def connect(self) -> None:
    """Open the connection, set pragmas, and migrate to the latest schema.

    Idempotent — safe to call repeatedly. ``search``/``write`` lazily call
    this, so explicit ``connect()`` is optional.
    """
    if self._corrupt is not None:
        raise MemoryCorruptError(f"store at {self._db_path} is quarantined: {self._corrupt}")
    if self._conn is not None:
        return
    self._db_path.parent.mkdir(parents=True, exist_ok=True)
    conn = await aiosqlite.connect(self._db_path)

    try:
        conn.row_factory = aiosqlite.Row
        # WAL: concurrent readers alongside a single writer. busy_timeout: wait
        # rather than fail on transient lock contention.
        await conn.execute("PRAGMA journal_mode=WAL")
        await conn.execute("PRAGMA busy_timeout=5000")
        await conn.execute("PRAGMA foreign_keys=ON")
        await self._assert_fts5(conn)
        if self._quick_check_on_open:
            await self._run_quick_check(conn)
        await migrate_to_latest(conn)
    except aiosqlite.Error as exc:
        # A driver error during open on a file that should already be a valid
        # database is corruption territory — translate before it escapes raw.
        await conn.close()
        raise self._translate_sqlite_error(exc, f"failed to open store at {self._db_path}") from exc
    except MemoryCorruptError:
        await conn.close()
        raise
    self._conn = conn
    if self._global_store is not None:
        await self._global_store.connect()

aclose async

aclose()

Close the connection. Safe to call more than once.

Source code in packages/arcana-core/arcana/memory/adapters/sqlite.py
async def aclose(self) -> None:
    """Close the connection. Safe to call more than once."""
    if self._conn is not None:
        await self._conn.close()
        self._conn = None

health_check async

health_check()

Probe the backend with a trivial query. Never raises.

Ensures the connection (which validates the store), then runs SELECT 1. Any failure — unconnectable, corrupt, or FTS5-less — reports unhealthy so the resilience layer can gate on it as its half-open recovery probe without exception handling.

Source code in packages/arcana-core/arcana/memory/adapters/sqlite.py
async def health_check(self) -> AdapterHealth:
    """Probe the backend with a trivial query. Never raises.

    Ensures the connection (which validates the store), then runs
    ``SELECT 1``. Any failure — unconnectable, corrupt, or FTS5-less —
    reports unhealthy so the resilience layer can gate on it as its
    half-open recovery probe without exception handling.
    """
    adapter_id = str(self._db_path)
    if self._corrupt is not None:
        return AdapterHealth(adapter_id=adapter_id, healthy=False, message=f"corrupt: {self._corrupt}")

    try:
        conn = await self._ensure()
        await conn.execute("SELECT 1")
        return AdapterHealth(adapter_id=adapter_id, healthy=True)
    except Exception as exc:  # noqa: BLE001 — a health probe must not raise
        return AdapterHealth(adapter_id=adapter_id, healthy=False, message=str(exc))

write async

write(entry)

Upsert one entry (keyed on id), then promote to GLOBAL if eligible.

Source code in packages/arcana-core/arcana/memory/adapters/sqlite.py
async def write(self, entry: MemoryEntry) -> None:
    """Upsert one entry (keyed on ``id``), then promote to GLOBAL if eligible."""
    conn = await self._ensure()

    try:
        await conn.execute(_sql.UPSERT, _sql.entry_to_row(entry))
        await conn.commit()
    except aiosqlite.Error as exc:
        raise await self._fail_translated(exc, f"write failed for entry {entry.id}") from exc

    # Importance-based promotion. The entry's own rule gates on scope == PRIVATE,
    # so the GLOBAL copy can never re-promote (no recursion). Same id keeps the
    # global write idempotent across re-writes. Promotion is a no-op without a
    # configured global store — a higher layer wires that in.
    if self._global_store is not None and entry.should_promote_to_global:
        promoted = entry.model_copy(update={"scope": MemoryScope.GLOBAL, "pool_name": None})
        await self._global_store.write(promoted)

    self._emit_write(entry)

search async

search(query)

Return entries matching query.

When the query carries usable text, results are ranked by FTS5 BM25 relevance (keyword search). Otherwise it's filter-and-order: pinned → importance → recency. Any text query — whatever its retrieval_mode — is currently served by the keyword path.

Source code in packages/arcana-core/arcana/memory/adapters/sqlite.py
async def search(self, query: MemoryQuery) -> list[MemoryEntry]:
    """Return entries matching ``query``.

    When the query carries usable text, results are ranked by FTS5 BM25
    relevance (keyword search). Otherwise it's filter-and-order:
    pinned → importance → recency. Any text query — whatever its
    ``retrieval_mode`` — is currently served by the keyword path.
    """
    conn = await self._ensure()
    started = time.perf_counter()

    match = _sql.to_match_query(query.text) if query.text else None
    sql, params = _sql.keyword_search(query, match) if match is not None else _sql.filter_search(query)

    try:
        cursor = await conn.execute(sql, params)
        rows = await cursor.fetchall()
    except aiosqlite.Error as exc:
        raise await self._fail_translated(exc, "search failed") from exc

    # Decode/validation failures (e.g. corrupt JSON in a list column) are
    # translated too, so callers only ever see MemoryStorageError.
    try:
        entries = [_sql.row_to_entry(row) for row in rows]
    except Exception as exc:
        raise MemoryStorageError(f"failed to decode stored memory row: {exc}") from exc

    await self.record_read(query, entries, started)
    return entries

record_read async

record_read(query, entries, started)

Post-read bookkeeping: refresh access tracking and emit the read event.

Public so a sibling adapter (VectorAdapter) whose semantic path bypasses search shares the same access-refresh + audit behaviour. started is a time.perf_counter() stamp taken before the query ran.

Source code in packages/arcana-core/arcana/memory/adapters/sqlite.py
async def record_read(self, query: MemoryQuery, entries: list[MemoryEntry], started: float) -> None:
    """Post-read bookkeeping: refresh access tracking and emit the read event.

    Public so a sibling adapter (``VectorAdapter``) whose semantic path
    bypasses ``search`` shares the same access-refresh + audit behaviour.
    ``started`` is a ``time.perf_counter()`` stamp taken before the query ran.
    """
    await self._refresh_access(entries)
    elapsed_ms = int((time.perf_counter() - started) * 1000)
    self._emit_read(query, len(entries), elapsed_ms)

prune async

prune(policy)

Remove low-value entries per policy; pinned entries are never touched.

ARCHIVE soft-deletes (sets archived, hiding entries from search but keeping them recoverable); PURGE hard-deletes the row — and its vector, if a vec index exists. The importance floor and max-entries cap compose into a single victim set, removed in one committed transaction.

Source code in packages/arcana-core/arcana/memory/adapters/sqlite.py
async def prune(self, policy: PrunePolicy) -> PruneReport:
    """Remove low-value entries per ``policy``; pinned entries are never touched.

    ARCHIVE soft-deletes (sets ``archived``, hiding entries from search but
    keeping them recoverable); PURGE hard-deletes the row — and its vector,
    if a vec index exists. The importance floor and max-entries cap compose
    into a single victim set, removed in one committed transaction.
    """
    conn = await self._ensure()
    purge = policy.mode is PruneMode.PURGE
    try:
        row = await (await conn.execute(_sql.PRUNABLE_COUNT)).fetchone()
        scanned = int(row[0]) if row is not None else 0

        victims: set[str] = set()
        if policy.min_importance is not None:
            cursor = await conn.execute(
                _sql.prune_below_importance_sql(include_archived=purge), [policy.min_importance]
            )
            victims.update(r[0] for r in await cursor.fetchall())
        if policy.max_entries is not None:
            cursor = await conn.execute(_sql.prune_over_cap_sql(), [policy.max_entries])
            victims.update(r[0] for r in await cursor.fetchall())

        ids = list(victims)
        archived = purged = 0
        if ids:
            if purge:
                await conn.execute(_sql.delete_ids_sql(len(ids)), ids)
                if await self._vec_table_exists(conn):
                    for vid in ids:
                        await conn.execute(_sql.VEC_DELETE, (vid,))
                purged = len(ids)
            else:
                await conn.execute(_sql.archive_ids_sql(len(ids)), ids)
                archived = len(ids)
        await conn.commit()
    except aiosqlite.Error as exc:
        raise await self._fail_translated(exc, "prune failed") from exc

    report = PruneReport(scanned=scanned, archived=archived, purged=purged)
    self._emit_prune(report)
    return report

arcana.memory.adapters.vector.VectorAdapter

VectorAdapter(
    sqlite,
    gateway,
    *,
    candidate_multiplier=4,
    vector_weight=0.7,
    bm25_weight=0.3,
)

Semantic memory backend: sqlite-vec KNN over a SQLiteAdapter's database.

Implements the MemoryAdapter protocol (search/write). Composes — does not subclass — a SQLiteAdapter, reusing its row storage, FTS5 index, importance-based promotion, and access tracking unchanged, and adds the vector layer on top.

Source code in packages/arcana-core/arcana/memory/adapters/vector.py
def __init__(
    self,
    sqlite: SQLiteAdapter,
    gateway: EmbeddingGateway,
    *,
    candidate_multiplier: int = 4,
    vector_weight: float = 0.7,
    bm25_weight: float = 0.3,
) -> None:
    self._sqlite = sqlite
    self._gateway = gateway
    #: KNN oversampling factor: fetch ``limit * multiplier`` neighbours so
    #: metadata filtering still leaves enough to fill ``limit``.
    self._candidate_multiplier = candidate_multiplier
    #: Hybrid fusion weights, normalised to sum 1 so only their ratio matters.
    total = vector_weight + bm25_weight
    if total <= 0:
        raise ValueError("vector_weight + bm25_weight must be positive")
    self._vector_weight = vector_weight / total
    self._bm25_weight = bm25_weight / total
    self._vec_ok = False
    self._vec_attempted = False
    self._serialize: Callable[[list[float]], bytes] | None = None
    self._warned: set[str] = set()

connect async

connect()

Connect the underlying store and load the sqlite-vec extension once.

Idempotent. If sqlite-vec is missing or the SQLite build cannot load extensions, the adapter degrades to keyword-only (one visible warning) rather than failing — arcana stays usable without the [vector] extra.

Source code in packages/arcana-core/arcana/memory/adapters/vector.py
async def connect(self) -> None:
    """Connect the underlying store and load the sqlite-vec extension once.

    Idempotent. If sqlite-vec is missing or the SQLite build cannot load
    extensions, the adapter degrades to keyword-only (one visible warning)
    rather than failing — arcana stays usable without the ``[vector]`` extra.
    """
    await self._sqlite.connect()
    if self._vec_attempted:
        return
    self._vec_attempted = True
    try:
        import sqlite_vec  # lazy: module stays import-safe without the extra

        self._serialize = sqlite_vec.serialize_float32
        conn = self._sqlite.connection
        await conn.enable_load_extension(True)
        await conn.load_extension(sqlite_vec.loadable_path())
        await conn.enable_load_extension(False)
        self._vec_ok = True
    except Exception as exc:  # ImportError, sqlite3 build without load_extension, …
        self._vec_ok = False
        self._warn_once(
            "no-extension",
            f"sqlite-vec unavailable ({exc}); memory search is keyword-only. "
            "Install the `arcana-os[vector]` extra to enable semantic search.",
        )

aclose async

aclose()

Close the underlying store. Safe to call more than once.

Source code in packages/arcana-core/arcana/memory/adapters/vector.py
async def aclose(self) -> None:
    """Close the underlying store. Safe to call more than once."""
    await self._sqlite.aclose()

health_check async

health_check()

Report health of the underlying store. Never raises.

Storage health is the underlying SQLite store: if rows are readable, the tier is usable. A missing vector layer is degraded but healthy — keyword retrieval still answers — so it is noted in message rather than reported as unhealthy.

Source code in packages/arcana-core/arcana/memory/adapters/vector.py
async def health_check(self) -> AdapterHealth:
    """Report health of the underlying store. Never raises.

    Storage health is the underlying SQLite store: if rows are readable, the
    tier is usable. A missing vector layer is *degraded but healthy* —
    keyword retrieval still answers — so it is noted in ``message`` rather
    than reported as unhealthy.
    """
    base = await self._sqlite.health_check()
    if not base.healthy or self._vec_ok:
        return base
    return AdapterHealth(
        adapter_id=base.adapter_id,
        healthy=True,
        message="vector search unavailable; keyword-only",
    )

prune async

prune(policy)

Prune the underlying store. Shares the connection, so a PURGE here also clears the vec0 index rows for the removed entries.

Source code in packages/arcana-core/arcana/memory/adapters/vector.py
async def prune(self, policy: PrunePolicy) -> PruneReport:
    """Prune the underlying store. Shares the connection, so a PURGE here also
    clears the vec0 index rows for the removed entries.
    """
    return await self._sqlite.prune(policy)

write async

write(entry)

Store entry (row + FTS5 + promotion) and index its vector.

Resolves the embedder via the gateway. With no healthy embedder the entry is still stored and keyword-searchable, just without a vector. The first embedded write pins the database to the model (embedding_meta) and creates the dimension-sized vec0 index.

Source code in packages/arcana-core/arcana/memory/adapters/vector.py
async def write(self, entry: MemoryEntry) -> None:
    """Store ``entry`` (row + FTS5 + promotion) and index its vector.

    Resolves the embedder via the gateway. With no healthy embedder the entry
    is still stored and keyword-searchable, just without a vector. The first
    embedded write pins the database to the model (``embedding_meta``) and
    creates the dimension-sized vec0 index.
    """
    await self.connect()
    conn = self._sqlite.connection

    embedder: EmbeddingAdapter | None = None
    meta: EmbeddingMeta | None = None
    if self._vec_ok:
        meta = await self._read_meta(conn)
        embedder = await self._gateway.resolve(meta)

    if embedder is None:
        if self._vec_ok:
            self._warn_once(
                "write-fallback",
                "No healthy embedding adapter; storing memory without a vector (keyword-only).",
            )
        await self._sqlite.write(entry)
        return

    vec = _l2_normalize(entry.embedding if entry.embedding is not None else await embedder.embed(entry.content))

    # Dimension safety: never write a vector that disagrees with the embedder
    # or the database pin — a wrong-width vector would corrupt the index.
    if len(vec) != embedder.dimensions:
        raise MemoryStorageError(
            f"embedder {embedder.model_name} produced a {len(vec)}-d vector, expected {embedder.dimensions}"
        )
    if meta is not None and len(vec) != meta.dimensions:
        raise MemoryStorageError(
            f"vector dimension {len(vec)} does not match database pin {meta.dimensions} "
            f"(model {meta.model_name}); refusing to corrupt the index"
        )

    entry.embedding = vec

    # First embedding: create the dimension-sized index and pin the model.
    if meta is None:
        await conn.execute(_sql.vec_table_ddl(embedder.dimensions))
        await self._pin_meta(conn, embedder)

    # Row first (memory_entries + FTS5 + promotion), then the vector.
    await self._sqlite.write(entry)
    serialize = self._serialize
    assert serialize is not None  # noqa: S101 — set whenever _vec_ok (embedder resolved)
    try:
        await conn.execute(_sql.VEC_DELETE, (str(entry.id),))
        await conn.execute(_sql.VEC_INSERT, (str(entry.id), serialize(vec)))
        await conn.execute(_sql.BUMP_ENTRY_COUNT)
        await conn.commit()
    except aiosqlite.Error as exc:
        raise MemoryStorageError(f"vector write failed for entry {entry.id}: {exc}") from exc

search async

search(query)

Return entries matching query, semantically when possible.

Semantic KNN runs for semantic/hybrid queries that carry usable text against a pinned database with a healthy compatible embedder. Otherwise — keyword mode, no text, an unpinned database, or no healthy embedder — results come from the keyword/filter path on the underlying store.

Source code in packages/arcana-core/arcana/memory/adapters/vector.py
async def search(self, query: MemoryQuery) -> list[MemoryEntry]:
    """Return entries matching ``query``, semantically when possible.

    Semantic KNN runs for ``semantic``/``hybrid`` queries that carry usable
    text against a pinned database with a healthy compatible embedder.
    Otherwise — ``keyword`` mode, no text, an unpinned database, or no healthy
    embedder — results come from the keyword/filter path on the underlying
    store.
    """
    await self.connect()
    conn = self._sqlite.connection

    match_text = _sql.to_match_query(query.text) if query.text else None
    use_vector = self._vec_ok and query.retrieval_mode != RetrievalMode.keyword and match_text is not None

    if use_vector:
        meta = await self._read_meta(conn)
        # An unpinned database has no vectors to search; keyword path serves it
        # without a warning. A pin with no healthy embedder is the real fallback.
        if meta is not None:
            embedder = await self._gateway.resolve(meta)
            if embedder is not None:
                if query.retrieval_mode == RetrievalMode.hybrid:
                    return await self._hybrid_search(conn, query, embedder, meta)
                return await self._semantic_search(conn, query, embedder, meta)
            self._warn_once(
                "search-fallback",
                "Pinned embedding model is unavailable; falling back to FTS5 keyword search.",
            )

    return await self._sqlite.search(query)

arcana.memory.adapters.markdown.MarkdownFolderAdapter

MarkdownFolderAdapter(
    root,
    agent_id,
    *,
    scope=PRIVATE,
    pool_name=None,
    default_type=SEMANTIC,
    ignore_globs=_DEFAULT_IGNORE_GLOBS,
    max_file_bytes=1048576,
)

Read-only MemoryAdapter backed by a folder of Markdown notes.

One instance owns exactly one folder root and is scoped to one agent_id; composing multiple folders is the federation layer's job. Implements search, write (raises — the source is read-only), and health_check.

Source code in packages/arcana-core/arcana/memory/adapters/markdown.py
def __init__(
    self,
    root: Path,
    agent_id: UUID,
    *,
    scope: MemoryScope = MemoryScope.PRIVATE,
    pool_name: str | None = None,
    default_type: MemoryType = MemoryType.SEMANTIC,
    ignore_globs: list[str] = _DEFAULT_IGNORE_GLOBS,
    max_file_bytes: int = 1_048_576,
) -> None:
    self._root = Path(root)
    self._agent_id = agent_id
    self._scope = scope
    self._pool_name = pool_name
    self._default_type = default_type
    # Copy so a caller (or the shared module default) can't mutate our config.
    self._ignore_globs = list(ignore_globs)
    self._max_file_bytes = max_file_bytes
    #: path -> cached fingerprint + doc. Rebuilt wholesale each scan (last write
    #: wins) so concurrent scans never observe a half-updated index.
    self._index: dict[str, _CacheItem] = {}
    #: Categories already warned about, so a degraded notice logs at most once.
    self._warned: set[str] = set()

search async

search(query)

Scan the folder and return entries matching query, keyword-ranked.

The folder has no semantic or full-text index, so semantic/hybrid requests are served by the same keyword path with a one-line degraded notice — the mode is never an error. Ranking is pinned → lexical relevance → importance → recency, ties broken by path for determinism, truncated to query.limit.

Source code in packages/arcana-core/arcana/memory/adapters/markdown.py
async def search(self, query: MemoryQuery) -> list[MemoryEntry]:
    """Scan the folder and return entries matching ``query``, keyword-ranked.

    The folder has no semantic or full-text index, so ``semantic``/``hybrid``
    requests are served by the same keyword path with a one-line degraded
    notice — the mode is never an error. Ranking is pinned → lexical relevance
    → importance → recency, ties broken by path for determinism, truncated to
    ``query.limit``.
    """
    if query.retrieval_mode is not RetrievalMode.keyword:
        self._warn_once(
            f"mode:{query.retrieval_mode.value}",
            f"MarkdownFolderAdapter has no {query.retrieval_mode.value} index; "
            "serving keyword results for this query.",
        )

    # A query pinned to a different scope/pool/agent than this adapter owns can
    # never match — short-circuit before touching the disk.
    if query.scope is not None and query.scope is not self._scope:
        return []
    if query.pool_name is not None and query.pool_name != self._pool_name:
        return []
    if query.agent_id is not None and query.agent_id != self._agent_id:
        return []

    docs = await asyncio.to_thread(self._rescan)
    return self._filter_and_rank(docs, query)

write async

write(entry)

Reject writes — the folder is an external source of truth, not a sink.

Silently dropping a write would hide data loss, and the federation's writer propagates tier failures deliberately, so a read-only tier must say so.

Source code in packages/arcana-core/arcana/memory/adapters/markdown.py
async def write(self, entry: MemoryEntry) -> None:
    """Reject writes — the folder is an external source of truth, not a sink.

    Silently dropping a write would hide data loss, and the federation's writer
    propagates tier failures deliberately, so a read-only tier must say so.
    """
    raise MemoryWriteError("MarkdownFolderAdapter is read-only; configure a writable tier for persistence")

health_check async

health_check()

Report whether the root is a readable directory. Never raises.

Kept cheap — a single stat + os.access on the root, no tree walk — so the resilience layer can use it as a half-open recovery probe.

Source code in packages/arcana-core/arcana/memory/adapters/markdown.py
async def health_check(self) -> AdapterHealth:
    """Report whether the root is a readable directory. Never raises.

    Kept cheap — a single ``stat`` + ``os.access`` on the root, no tree walk —
    so the resilience layer can use it as a half-open recovery probe.
    """
    return await asyncio.to_thread(self._health)

scan async

scan()

Return every note as a public (entry, rel_path) pair.

Exposes the folder's parsed view — the same cached scan search uses — for consumers that need the on-disk path alongside the entry, such as resolving wikilink targets to note ids.

Source code in packages/arcana-core/arcana/memory/adapters/markdown.py
async def scan(self) -> list[ScannedNote]:
    """Return every note as a public ``(entry, rel_path)`` pair.

    Exposes the folder's parsed view — the same cached scan ``search`` uses —
    for consumers that need the on-disk path alongside the entry, such as
    resolving wikilink targets to note ids.
    """
    docs = await asyncio.to_thread(self._rescan)
    return [ScannedNote(entry=doc.entry, rel_path=doc.rel_path) for doc in docs]

get async

get(entry_id)

Return the note with stable id entry_id, or None if absent.

Lets a graph-expansion read rehydrate a folder-resident neighbour by id. The id is a one-way uuid5 of the path, so this resolves by matching over the (cached) tree rather than reversing the hash.

Source code in packages/arcana-core/arcana/memory/adapters/markdown.py
async def get(self, entry_id: UUID) -> MemoryEntry | None:
    """Return the note with stable id ``entry_id``, or ``None`` if absent.

    Lets a graph-expansion read rehydrate a folder-resident neighbour by id.
    The id is a one-way ``uuid5`` of the path, so this resolves by matching over
    the (cached) tree rather than reversing the hash.
    """
    docs = await asyncio.to_thread(self._rescan)
    for doc in docs:
        if doc.entry.id == entry_id:
            return doc.entry
    return None

arcana.memory.adapters.markdown.ScannedNote dataclass

ScannedNote(entry, rel_path)

A note as seen by a consumer: its entry plus the on-disk rel_path.

The public projection of the folder's parsed view. Exposes the root-relative POSIX path alongside the entry so a consumer (e.g. wikilink resolution) can map link targets to notes without re-walking the tree.

Knowledge graph

arcana.memory.edges.EdgeStore

EdgeStore(sqlite)

Async CRUD over memory_edges. Composes a :class:SQLiteAdapter.

Source code in packages/arcana-core/arcana/memory/edges.py
def __init__(self, sqlite: SQLiteAdapter) -> None:
    self._sqlite = sqlite

connect async

connect()

Ensure the underlying store (and its schema, incl. memory_edges).

Source code in packages/arcana-core/arcana/memory/edges.py
async def connect(self) -> None:
    """Ensure the underlying store (and its schema, incl. ``memory_edges``)."""
    await self._sqlite.connect()

upsert async

upsert(edges)

Insert or replace edges (keyed on src_id, dst_id, relation).

Returns the number of edge rows written.

Source code in packages/arcana-core/arcana/memory/edges.py
async def upsert(self, edges: Iterable[MemoryEdge]) -> int:
    """Insert or replace ``edges`` (keyed on ``src_id, dst_id, relation``).

    Returns the number of edge rows written.
    """
    await self.connect()
    rows = [_edge_to_row(e) for e in edges]
    if not rows:
        return 0

    try:
        await self._conn.executemany(_INSERT, rows)
        await self._conn.commit()
    except aiosqlite.Error as exc:
        raise MemoryStorageError(f"edge upsert failed: {exc}") from exc

    return len(rows)

replace_source async

replace_source(source, edges)

Atomically replace every edge tagged source with edges.

Delete-then-insert in one transaction: a producer owns its source and rewrites that whole set each pass, so removed or re-pointed edges disappear without any per-edge diffing. Returns the number of edges written.

Source code in packages/arcana-core/arcana/memory/edges.py
async def replace_source(self, source: str, edges: Iterable[MemoryEdge]) -> int:
    """Atomically replace every edge tagged ``source`` with ``edges``.

    Delete-then-insert in one transaction: a producer owns its ``source`` and
    rewrites that whole set each pass, so removed or re-pointed edges disappear
    without any per-edge diffing. Returns the number of edges written.
    """
    await self.connect()
    rows = [_edge_to_row(e) for e in edges]

    try:
        await self._conn.execute("BEGIN")
        await self._conn.execute(_DELETE_BY_SOURCE, (source,))
        if rows:
            await self._conn.executemany(_INSERT, rows)
        await self._conn.commit()
    except aiosqlite.Error as exc:
        await self._conn.rollback()
        raise MemoryStorageError(f"edge replace_source({source!r}) failed: {exc}") from exc

    return len(rows)

outgoing async

outgoing(node_id)

Edges pointing out of node_id (its references).

Source code in packages/arcana-core/arcana/memory/edges.py
async def outgoing(self, node_id: UUID) -> list[MemoryEdge]:
    """Edges pointing out of ``node_id`` (its references)."""
    return await self._query(_SELECT_OUT, (str(node_id),))

incoming async

incoming(node_id)

Edges pointing into node_id (its backlinks).

Source code in packages/arcana-core/arcana/memory/edges.py
async def incoming(self, node_id: UUID) -> list[MemoryEdge]:
    """Edges pointing into ``node_id`` (its backlinks)."""
    return await self._query(_SELECT_IN, (str(node_id),))

neighbors async

neighbors(node_id)

Distinct node ids adjacent to node_id in either direction.

The seed→expand step of graph-aware retrieval: given a node, the ids of everything one hop away, deduped and order-stable (outgoing then incoming).

Source code in packages/arcana-core/arcana/memory/edges.py
async def neighbors(self, node_id: UUID) -> list[UUID]:
    """Distinct node ids adjacent to ``node_id`` in either direction.

    The seed→expand step of graph-aware retrieval: given a node, the ids of
    everything one hop away, deduped and order-stable (outgoing then incoming).
    """
    seen: set[UUID] = set()
    order: list[UUID] = []

    for edge in await self.outgoing(node_id):
        if edge.dst_id not in seen:
            seen.add(edge.dst_id)
            order.append(edge.dst_id)

    for edge in await self.incoming(node_id):
        if edge.src_id not in seen:
            seen.add(edge.src_id)
            order.append(edge.src_id)

    return order

all async

all()

Every edge — for inspection and tests.

Source code in packages/arcana-core/arcana/memory/edges.py
async def all(self) -> list[MemoryEdge]:
    """Every edge — for inspection and tests."""
    return await self._query(_SELECT_ALL, ())

arcana.memory.wikilinks.WikilinkEdgeExtractor

WikilinkEdgeExtractor(reader, edges)

Extract [[wikilink]] edges from a folder into an EdgeStore.

Source code in packages/arcana-core/arcana/memory/wikilinks.py
def __init__(self, reader: MarkdownFolderAdapter, edges: EdgeStore) -> None:
    self._reader = reader
    self._edges = edges

reindex async

reindex()

Scan the folder, resolve every wikilink, and rewrite the edge set.

Rewrites the whole wikilink source in one shot, so the graph reflects exactly the links present on disk right now. Returns a tally of what was found and written.

Source code in packages/arcana-core/arcana/memory/wikilinks.py
async def reindex(self) -> EdgeIndexReport:
    """Scan the folder, resolve every wikilink, and rewrite the edge set.

    Rewrites the whole ``wikilink`` source in one shot, so the graph reflects
    exactly the links present on disk right now. Returns a tally of what was
    found and written.
    """
    notes = await self._reader.scan()
    resolver = _Resolver(notes)
    stamped = datetime.now(UTC)

    edges: list[MemoryEdge] = []
    seen: set[tuple[str, str]] = set()  # (src, dst) pair keys, for dedup
    links_found = resolved = dangling = ambiguous = 0

    for note in notes:
        for raw in _wikilink_targets(note.entry.content):
            target = _clean_target(raw)
            if not target:
                continue
            links_found += 1
            match, status = resolver.resolve(target)
            if status == _Status.AMBIGUOUS:
                ambiguous += 1
                logger.debug("ambiguous wikilink %r in %s; skipped", target, note.rel_path)
                continue
            if match is None:
                dangling += 1
                logger.debug("dangling wikilink %r in %s; skipped", target, note.rel_path)
                continue
            resolved += 1
            if match.entry.id == note.entry.id:
                continue  # self-link
            key = (str(note.entry.id), str(match.entry.id))
            if key in seen:
                continue
            seen.add(key)
            edges.append(
                MemoryEdge(
                    src_id=note.entry.id,
                    dst_id=match.entry.id,
                    relation=WIKILINK_RELATION,
                    confidence=1.0,
                    source=WIKILINK_SOURCE,
                    created_at=stamped,
                )
            )

    written = await self._edges.replace_source(WIKILINK_SOURCE, edges)
    return EdgeIndexReport(
        notes_scanned=len(notes),
        links_found=links_found,
        resolved=resolved,
        dangling=dangling,
        ambiguous=ambiguous,
        edges_written=written,
    )

arcana.memory.wikilinks.EdgeIndexReport dataclass

EdgeIndexReport(
    notes_scanned=0,
    links_found=0,
    resolved=0,
    dangling=0,
    ambiguous=0,
    edges_written=0,
)

Outcome of a re-index pass. All counts are per-occurrence except the last.

Federation and routing

arcana.memory.assembly.build_federation async

build_federation(
    agent_id,
    *,
    home,
    embedding=None,
    pools=None,
    decay_profiles=None,
    on_degraded=None,
)

Assemble the tier stack for one agent and return its federation.

  • PRIVATE — per-agent SQLite at <home>/agents/{id}/memory.db, the durability anchor. Opening it runs pending migrations and, on a corrupt file, quarantines the store before first use.
  • GLOBAL — a shared vector store at <home>/vector/global.db, present only when embedding is given. It is semantic when the embedder is healthy and keyword (FTS5) when not; with no embedder the tier is dropped entirely and the agent runs private-only. Shared across agents, so its open-time integrity check is off (it detects corruption on read instead).
  • SHARED — each pool in pools is registered on the router by name.

decay_profiles sets the per-type half-lives the router ranks retrieval with; when omitted the system defaults apply. Callers derive these from a card via :func:~arcana.memory.decay.resolve_decay_profiles.

on_degraded defaults, inside the federation, to the observability audit log, so a degraded SHARED/GLOBAL tier is recorded without failing the run.

Source code in packages/arcana-core/arcana/memory/assembly.py
async def build_federation(
    agent_id: UUID,
    *,
    home: Path,
    embedding: EmbeddingGateway | None = None,
    pools: list[PoolConfig] | None = None,
    decay_profiles: dict[MemoryType, DecayProfile] | None = None,
    on_degraded: Callable[[MemoryDegradedEvent], None] | None = None,
) -> MemoryFederation:
    """Assemble the tier stack for one agent and return its federation.

    * **PRIVATE** — per-agent SQLite at ``<home>/agents/{id}/memory.db``, the
      durability anchor. Opening it runs pending migrations and, on a corrupt
      file, quarantines the store before first use.
    * **GLOBAL** — a shared vector store at ``<home>/vector/global.db``, present
      only when ``embedding`` is given. It is semantic when the embedder is
      healthy and keyword (FTS5) when not; with no embedder the tier is dropped
      entirely and the agent runs private-only. Shared across agents, so its
      open-time integrity check is off (it detects corruption on read instead).
    * **SHARED** — each pool in ``pools`` is registered on the router by name.

    ``decay_profiles`` sets the per-type half-lives the router ranks retrieval
    with; when omitted the system defaults apply. Callers derive these from a
    card via :func:`~arcana.memory.decay.resolve_decay_profiles`.

    ``on_degraded`` defaults, inside the federation, to the observability audit
    log, so a degraded SHARED/GLOBAL tier is recorded without failing the run.
    """
    private_db = home / "agents" / str(agent_id) / "memory.db"
    private = SQLiteAdapter(private_db)
    await private.connect()  # opens, migrates, and integrity-checks the private store

    global_: MemoryAdapter | None = None
    if embedding is not None:
        global_store = SQLiteAdapter(home / "vector" / "global.db", quick_check_on_open=False)
        global_ = VectorAdapter(global_store, embedding)
        await global_.connect()
    else:
        logger.info("no embedding provider — global memory tier disabled (private SQLite only)")

    router = MemoryRouter(private=private, global_=global_, decay_profiles=decay_profiles)
    for pool in pools or []:
        router.register_pool(pool.name, pool.adapter)

    return MemoryFederation(router, on_degraded=on_degraded)

arcana.memory.assembly.PoolConfig dataclass

PoolConfig(name, adapter)

A shared pool to register on the federation: a name and its backend.

arcana.memory.assembly.MemoryConfig

Bases: BaseModel

The memory block of ~/.arcana/config.json.

An absent block yields these defaults, so an install that predates the block keeps working: memory on, private SQLite, global vector, no shared pools. global is a Python reserved word, so it maps to global_ via alias.

arcana.memory.federation.MemoryFederation

MemoryFederation(router, on_degraded=None)

Fan-out writes and merged reads across a router's memory tiers.

Implements the MemoryAdapter protocol. Composes a MemoryRouter for every routing and ranking decision; this layer only performs the I/O.

Source code in packages/arcana-core/arcana/memory/federation.py
def __init__(
    self,
    router: MemoryRouter,
    on_degraded: Callable[[MemoryDegradedEvent], None] | None = None,
) -> None:
    self._router = router
    # Write/promote degradation is emitted here (not in the tier wrapper),
    # since only this layer knows a leg is a promotion and whether a failure
    # is fatal (PRIVATE) or degraded (SHARED / GLOBAL).
    self._on_degraded = on_degraded or emit_degraded

write async

write(entry)

Write an entry to every tier the router selects, by failure policy.

When a target tier's scope differs from the entry's, a scope-rewritten copy is sent there instead — this is the promotion case, where a high-importance PRIVATE entry is also stored GLOBAL. The copy keeps the same id (idempotent) and drops pool_name.

Writes fan out concurrently and are not transactional across tiers, and a failing tier is handled by its scope: PRIVATE is the durability anchor, so its failure raises MemoryWriteError; a SHARED or GLOBAL failure degrades (the tier wrapper has already surfaced a MemoryDegradedEvent) so the session proceeds. A degraded GLOBAL therefore pauses promotion without ever failing the private write.

Source code in packages/arcana-core/arcana/memory/federation.py
async def write(self, entry: MemoryEntry) -> None:
    """Write an entry to every tier the router selects, by failure policy.

    When a target tier's scope differs from the entry's, a scope-rewritten
    copy is sent there instead — this is the promotion case, where a
    high-importance PRIVATE entry is also stored GLOBAL. The copy keeps the
    same ``id`` (idempotent) and drops ``pool_name``.

    Writes fan out concurrently and are not transactional across tiers, and a
    failing tier is handled by its scope: PRIVATE is the durability anchor,
    so its failure raises ``MemoryWriteError``; a SHARED or GLOBAL failure
    degrades (the tier wrapper has already surfaced a ``MemoryDegradedEvent``)
    so the session proceeds. A degraded GLOBAL therefore pauses promotion
    without ever failing the private write.
    """
    targets = self._router.route_write(entry)
    results = await asyncio.gather(
        *(tier.adapter.write(self._payload_for(tier, entry)) for tier in targets),
        return_exceptions=True,
    )

    for tier, result in zip(targets, results, strict=True):
        if result is None:
            continue
        if isinstance(result, TierWriteFailed):
            if result.scope is MemoryScope.PRIVATE:
                # Fatal, not a degradation — raise, do not emit a degraded event.
                raise MemoryWriteError(f"private memory write failed for entry {entry.id}") from result.cause
            # SHARED / GLOBAL degrade. A scope-rewritten leg is a promotion.
            operation = "promote" if tier.scope != entry.scope else "write"
            self._emit_degraded(tier, operation, result)
            continue
        raise result  # an unwrapped backend or unexpected error — surface it

search async

search(query)

Fan a query across all routed tiers, then merge, dedup, and rank.

Tiers are queried concurrently. A tier that fails is logged and dropped rather than sinking the whole read. Results are deduplicated by id (keeping the copy from the most-local tier in routing order) and ranked by the agent's memory weights, then truncated to query.limit.

Source code in packages/arcana-core/arcana/memory/federation.py
async def search(self, query: MemoryQuery) -> list[MemoryEntry]:
    """Fan a query across all routed tiers, then merge, dedup, and rank.

    Tiers are queried concurrently. A tier that fails is logged and dropped
    rather than sinking the whole read. Results are deduplicated by ``id``
    (keeping the copy from the most-local tier in routing order) and ranked
    by the agent's memory weights, then truncated to ``query.limit``.
    """
    return await self._merge_ranked(query)
stream_search(query)

Yield the merged, ranked entries best-first, one at a time.

Produces the same sequence as :meth:search but as an async generator, so a caller can stop iterating the moment it has what it needs (e.g. once a context budget is full) instead of materialising the whole list.

Best-first order needs the full candidate pool, so the fan-out and rank still happen up front; the streaming is on the consumption side. An empty routing set yields nothing.

Source code in packages/arcana-core/arcana/memory/federation.py
async def stream_search(self, query: MemoryQuery) -> AsyncIterator[MemoryEntry]:
    """Yield the merged, ranked entries best-first, one at a time.

    Produces the same sequence as :meth:`search` but as an async generator,
    so a caller can stop iterating the moment it has what it needs (e.g. once
    a context budget is full) instead of materialising the whole list.

    Best-first order needs the full candidate pool, so the fan-out and rank
    still happen up front; the streaming is on the consumption side. An empty
    routing set yields nothing.
    """
    for entry in await self._merge_ranked(query):
        yield entry

health_check async

health_check()

Aggregate health across all registered tiers. Never raises.

The federation is usable as long as at least one tier is reachable — consistent with degraded reads returning partial context. Unhealthy tiers are named in message so a caller can see what dropped out.

Source code in packages/arcana-core/arcana/memory/federation.py
async def health_check(self) -> AdapterHealth:
    """Aggregate health across all registered tiers. Never raises.

    The federation is usable as long as at least one tier is reachable —
    consistent with degraded reads returning partial context. Unhealthy
    tiers are named in ``message`` so a caller can see what dropped out.
    """
    tiers = self._router.all_tiers()
    probes = await asyncio.gather(*(tier.adapter.health_check() for tier in tiers), return_exceptions=True)
    unhealthy: list[str] = []
    any_healthy = False
    for tier, probe in zip(tiers, probes, strict=True):
        if isinstance(probe, AdapterHealth) and probe.healthy:
            any_healthy = True
        else:
            unhealthy.append(_tier_label(tier))
    message = "" if not unhealthy else f"degraded tiers: {', '.join(unhealthy)}"
    return AdapterHealth(adapter_id="federation", healthy=any_healthy, message=message)

prune async

prune(policy)

Prune every tier whose backend supports it; aggregate the reports.

Read-only backends (no prune method) are skipped. The resilient tier wrapper does not forward pruning, so we prune the backend it wraps directly: pruning is a destructive maintenance op that deliberately bypasses the read/write resilience path — a tier failure propagates, because losing track of a destructive operation should surface rather than be silently swallowed.

Source code in packages/arcana-core/arcana/memory/federation.py
async def prune(self, policy: PrunePolicy) -> PruneReport:
    """Prune every tier whose backend supports it; aggregate the reports.

    Read-only backends (no ``prune`` method) are skipped. The resilient tier
    wrapper does not forward pruning, so we prune the backend it wraps
    directly: pruning is a destructive maintenance op that deliberately
    bypasses the read/write resilience path — a tier failure propagates,
    because losing track of a destructive operation should surface rather
    than be silently swallowed.
    """
    prunable = [inner for t in self._router.all_tiers() if (inner := _prunable_backend(t.adapter)) is not None]
    reports: list[PruneReport] = await asyncio.gather(*(adapter.prune(policy) for adapter in prunable))
    return PruneReport(
        scanned=sum(r.scanned for r in reports),
        archived=sum(r.archived for r in reports),
        purged=sum(r.purged for r in reports),
        tiers=len(prunable),
    )

aclose async

aclose()

Close every tier's backend connection. Safe to call more than once.

Reaches past the router's resilience wrappers to the real backend and closes it when it supports aclose (read-only tiers may not). This is the teardown seam a caller uses to release the private SQLite handle and any vector store when a run ends, leaving no open connections behind.

Source code in packages/arcana-core/arcana/memory/federation.py
async def aclose(self) -> None:
    """Close every tier's backend connection. Safe to call more than once.

    Reaches past the router's resilience wrappers to the real backend and
    closes it when it supports ``aclose`` (read-only tiers may not). This is
    the teardown seam a caller uses to release the private SQLite handle and
    any vector store when a run ends, leaving no open connections behind.
    """
    for tier in self._router.all_tiers():
        backend = tier.adapter.inner if isinstance(tier.adapter, ResilientTier) else tier.adapter
        closer = getattr(backend, "aclose", None)
        if closer is not None:
            await closer()

arcana.memory.router.MemoryRouter

MemoryRouter(
    *,
    private,
    global_=None,
    pools=None,
    weights=None,
    decay_profiles=None,
    clock=now_utc,
    resilience=None,
)

Routes memory writes and reads across private, shared, and global tiers.

A neutral set of weights (all 0.5) is used when none is supplied, so an agent with no card preferences still gets a stable importance-driven order.

Source code in packages/arcana-core/arcana/memory/router.py
def __init__(
    self,
    *,
    private: MemoryAdapter,
    global_: MemoryAdapter | None = None,
    pools: dict[str, MemoryAdapter] | None = None,
    weights: MemoryWeights | None = None,
    decay_profiles: dict[MemoryType, DecayProfile] | None = None,
    clock: Callable[[], datetime] = now_utc,
    resilience: MemoryResilienceConfig | None = None,
) -> None:
    # Each tier is wrapped once here so every routing decision hands the
    # federation a resilient adapter — timeouts, breaker, and degraded
    # reporting — without the federation knowing which backend is underneath.
    self._resilience = resilience or MemoryResilienceConfig.load()
    self._weights = weights or MemoryWeights()
    # A caller may override only some types; fall back to the system default
    # for the rest so ranking always has a full per-type profile map. The
    # clock is injected so decay is deterministic in tests (no sleeps).
    self._decay_profiles = {**DEFAULT_DECAY_PROFILES, **(decay_profiles or {})}
    self._clock = clock
    self._private = self._wrap(private, MemoryScope.PRIVATE)
    self._global = self._wrap(global_, MemoryScope.GLOBAL) if global_ is not None else None
    self._pools: dict[str, MemoryAdapter] = {
        name: self._wrap(adapter, MemoryScope.SHARED, name) for name, adapter in (pools or {}).items()
    }

register_pool

register_pool(name, adapter)

Register (or replace) the backend serving a named shared pool.

Source code in packages/arcana-core/arcana/memory/router.py
def register_pool(self, name: str, adapter: MemoryAdapter) -> None:
    """Register (or replace) the backend serving a named shared pool."""
    self._pools[name] = self._wrap(adapter, MemoryScope.SHARED, name)

route_write

route_write(entry)

Return the tier backend(s) an entry must be written to.

  • PRIVATE → the private tier, plus the global tier when the entry is eligible for promotion (importance >= 0.9) and a global backend is registered. The caller writes a scope=GLOBAL copy to that target.
  • SHARED → the backend for entry.pool_name.
  • GLOBAL → the global tier.

Raises MemoryRoutingError when a required tier is missing: a SHARED write with no or unknown pool_name, or a GLOBAL write with no global backend. A PRIVATE entry eligible for promotion but lacking a global backend is not an error — it simply stays private.

Source code in packages/arcana-core/arcana/memory/router.py
def route_write(self, entry: MemoryEntry) -> list[TierBackend]:
    """Return the tier backend(s) an entry must be written to.

    * ``PRIVATE`` → the private tier, plus the global tier when the entry is
      eligible for promotion (``importance >= 0.9``) and a global backend is
      registered. The caller writes a ``scope=GLOBAL`` copy to that target.
    * ``SHARED`` → the backend for ``entry.pool_name``.
    * ``GLOBAL`` → the global tier.

    Raises ``MemoryRoutingError`` when a required tier is missing: a SHARED
    write with no or unknown ``pool_name``, or a GLOBAL write with no global
    backend. A PRIVATE entry eligible for promotion but lacking a global
    backend is not an error — it simply stays private.
    """
    if entry.scope == MemoryScope.PRIVATE:
        targets = [TierBackend(MemoryScope.PRIVATE, self._private)]
        if entry.should_promote_to_global and self._global is not None:
            targets.append(TierBackend(MemoryScope.GLOBAL, self._global))
        return targets

    if entry.scope == MemoryScope.SHARED:
        return [self._require_pool(entry.pool_name)]

    if entry.scope == MemoryScope.GLOBAL:
        return [self._require_global()]

    raise MemoryRoutingError(f"unroutable scope: {entry.scope!r}")  # pragma: no cover

route_read

route_read(query)

Return the tier backend(s) a query should fan across.

  • scope is None (federated read) → every registered tier.
  • PRIVATE / GLOBAL → that single tier.
  • SHARED → the named pool if pool_name is set, else every pool.

Reads degrade rather than raise: a tier that isn't registered (e.g. no global backend) is silently skipped. An explicitly named pool that does not exist is a caller error and raises MemoryRoutingError.

Source code in packages/arcana-core/arcana/memory/router.py
def route_read(self, query: MemoryQuery) -> list[TierBackend]:
    """Return the tier backend(s) a query should fan across.

    * ``scope is None`` (federated read) → every registered tier.
    * ``PRIVATE`` / ``GLOBAL`` → that single tier.
    * ``SHARED`` → the named pool if ``pool_name`` is set, else every pool.

    Reads degrade rather than raise: a tier that isn't registered (e.g. no
    global backend) is silently skipped. An explicitly named pool that does
    not exist is a caller error and raises ``MemoryRoutingError``.
    """
    if query.scope is None:
        return self.all_tiers()

    if query.scope == MemoryScope.PRIVATE:
        return [TierBackend(MemoryScope.PRIVATE, self._private)]

    if query.scope == MemoryScope.GLOBAL:
        return [TierBackend(MemoryScope.GLOBAL, self._global)] if self._global is not None else []

    if query.scope == MemoryScope.SHARED:
        if query.pool_name is not None:
            return [self._require_pool(query.pool_name)]
        return [TierBackend(MemoryScope.SHARED, a, pool_name=n) for n, a in self._pools.items()]

    raise MemoryRoutingError(f"unroutable scope: {query.scope!r}")  # pragma: no cover

rank

rank(entries, query)

Re-rank merged candidates by decayed effective importance and weights.

Layers card preference on top of each adapter's own ordering, using the age-discounted importance (decay is a score, not a delete) rather than the raw stored value:

score = effective_importance(entry, profile, now) × weights.for_type(type)

Entries that have aged out — decayed below their consolidation threshold (:func:~arcana.memory.decay.should_consolidate) — are dropped from the result, so a fast-decaying type stops surfacing once stale. Pinned entries are exempt from decay and always sort first; ties break on score then recency. Assumes the input is already deduplicated by id (the federation merges per-tier results before ranking). Truncates to query.limit.

Source code in packages/arcana-core/arcana/memory/router.py
def rank(self, entries: list[MemoryEntry], query: MemoryQuery) -> list[MemoryEntry]:
    """Re-rank merged candidates by decayed effective importance and weights.

    Layers card preference on top of each adapter's own ordering, using the
    age-discounted importance (decay is a score, not a delete) rather than the
    raw stored value:

        score = effective_importance(entry, profile, now) × weights.for_type(type)

    Entries that have aged out — decayed below their consolidation threshold
    (:func:`~arcana.memory.decay.should_consolidate`) — are dropped from the
    result, so a fast-decaying type stops surfacing once stale. Pinned entries
    are exempt from decay and always sort first; ties break on score then
    recency. Assumes the input is already deduplicated by ``id`` (the
    federation merges per-tier results before ranking). Truncates to
    ``query.limit``.
    """
    now = self._clock()

    def sort_key(entry: MemoryEntry) -> tuple[bool, float, float]:
        profile = self._decay_profiles[entry.type]
        score = effective_importance(entry, profile, now) * self._weights.for_type(entry.type)
        return (entry.pinned, score, entry.last_accessed_at.timestamp())

    live = [e for e in entries if not should_consolidate(e, self._decay_profiles[e.type], now)]
    ranked = sorted(live, key=sort_key, reverse=True)
    return ranked[: query.limit]

all_tiers

all_tiers()

Every registered tier — private, each shared pool, then global.

The same private → shared → global order a federated read fans across, and the set a store-wide operation (e.g. pruning) iterates.

Source code in packages/arcana-core/arcana/memory/router.py
def all_tiers(self) -> list[TierBackend]:
    """Every registered tier — private, each shared pool, then global.

    The same private → shared → global order a federated read fans across,
    and the set a store-wide operation (e.g. pruning) iterates.
    """
    tiers = [TierBackend(MemoryScope.PRIVATE, self._private)]
    tiers += [TierBackend(MemoryScope.SHARED, a, pool_name=n) for n, a in self._pools.items()]
    if self._global is not None:
        tiers.append(TierBackend(MemoryScope.GLOBAL, self._global))
    return tiers

arcana.memory.router.TierBackend dataclass

TierBackend(scope, adapter, pool_name=None)

A registered memory backend and the scope it serves.

pool_name is set only for SHARED backends; it names the pool this adapter stands in for.

Extraction and summarisation

arcana.memory.extraction.MemoryExtractor

Bases: Protocol

Turns an exchange (and a whole session) into memories.

signals property

signals

The language cue set this extractor detects with — drives consolidation typing.

arcana.memory.extraction.HeuristicExtractor

HeuristicExtractor(
    *,
    agent_confidence_cap=DEFAULT_AGENT_CONFIDENCE_CAP,
    signals=ENGLISH,
)

Deterministic, model-free extraction.

Per turn it always records one EPISODIC entry (what happened), promotes a stated user preference/fact to SEMANTIC, and — when the user asked a how-to and the answer contains steps — records a PROCEDURAL entry. Turn entries are agent-asserted, so confidence is capped; an explicit user statement is USER_CONFIRMED at higher confidence.

signals selects the language cue set (English by default); pass another :class:SignalPatterns to extract in a different language.

Source code in packages/arcana-core/arcana/memory/extraction/extractors.py
def __init__(
    self,
    *,
    agent_confidence_cap: float = DEFAULT_AGENT_CONFIDENCE_CAP,
    signals: SignalPatterns = ENGLISH,
) -> None:
    self._cap = _validate_agent_cap(agent_confidence_cap)
    self._signals = signals

arcana.memory.extraction.LLMExtractor

LLMExtractor(
    gateway,
    model,
    *,
    agent_confidence_cap=DEFAULT_AGENT_CONFIDENCE_CAP,
    temperature=0.0,
    fallback=None,
)

Low-temperature gateway extraction with a heuristic safety net.

A single small completion returns a JSON list of candidate memories, each validated into a :class:MemoryEntry at agent-capped confidence (a model never asserts a fact at 1.0). Any gateway error, malformed JSON, or empty result falls back to :class:HeuristicExtractor for that turn, so extraction never fails a run.

Source code in packages/arcana-core/arcana/memory/extraction/extractors.py
def __init__(
    self,
    gateway: ModelGateway,
    model: str,
    *,
    agent_confidence_cap: float = DEFAULT_AGENT_CONFIDENCE_CAP,
    temperature: float = 0.0,
    fallback: MemoryExtractor | None = None,
) -> None:
    self._gateway = gateway
    self._model = model
    self._cap = _validate_agent_cap(agent_confidence_cap)
    self._temperature = temperature
    self._fallback: MemoryExtractor = fallback or HeuristicExtractor(agent_confidence_cap=agent_confidence_cap)

arcana.memory.extraction.build_extractor

build_extractor(config, *, gateway=None, model=None)

Select an extractor from config, falling back to heuristic without a model.

ExtractionStrategy.LLM needs both a gateway and a non-empty model; absent either (the no-provider case), extraction is forced to the deterministic heuristic.

Source code in packages/arcana-core/arcana/memory/extraction/extractors.py
def build_extractor(
    config: ExtractionConfig,
    *,
    gateway: ModelGateway | None = None,
    model: str | None = None,
) -> MemoryExtractor:
    """Select an extractor from config, falling back to heuristic without a model.

    ``ExtractionStrategy.LLM`` needs both a gateway and a non-empty model; absent
    either (the no-provider case), extraction is forced to the deterministic
    heuristic.
    """
    if config.strategy == ExtractionStrategy.LLM and gateway is not None and model:
        return LLMExtractor(gateway, model, agent_confidence_cap=config.agent_confidence_cap)
    return HeuristicExtractor(agent_confidence_cap=config.agent_confidence_cap)

arcana.memory.extraction.trim_content

trim_content(text, limit=MAX_ENTRY_CONTENT)

Collapse whitespace and cap length, appending an ellipsis when truncated.

The default limit is the env-tunable per-entry content cap; pass an explicit limit to frame model input. The result is never longer than limit characters.

Source code in packages/arcana-core/arcana/memory/extraction/scoring.py
def trim_content(text: str, limit: int = MAX_ENTRY_CONTENT) -> str:
    """Collapse whitespace and cap length, appending an ellipsis when truncated.

    The default ``limit`` is the env-tunable per-entry content cap; pass an
    explicit limit to frame model input. The result is never longer than
    ``limit`` characters.
    """
    text = " ".join(text.split())
    return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"

arcana.memory.extraction.distill_semantic_clause

distill_semantic_clause(text, signals=ENGLISH)

Reduce a durable user statement to the clause worth storing as SEMANTIC.

A stated fact usually arrives wrapped in conversational framing — "by the way, please remember that my project is called Arcana". Persisting the whole prompt carries that framing into memory and back into every future prompt injection; this keeps the fact itself: "my project is called Arcana".

It picks the sentence bearing the durable preference cue (the same signal that classified the turn as SEMANTIC) and strips a leading framing wrapper, then trims. Falls back to the full trimmed text whenever stripping would leave nothing — so a bare "remember that." is never reduced to an empty memory.

Pure and deterministic; signals selects the language, so a non-English cue set distils in its own language (or, absent framing cues, simply trims).

Source code in packages/arcana-core/arcana/memory/extraction/scoring.py
def distill_semantic_clause(text: str, signals: SignalPatterns = ENGLISH) -> str:
    """Reduce a durable user statement to the clause worth storing as SEMANTIC.

    A stated fact usually arrives wrapped in conversational framing — *"by the
    way, please remember that my project is called Arcana"*. Persisting the whole
    prompt carries that framing into memory and back into every future prompt
    injection; this keeps the fact itself: *"my project is called Arcana"*.

    It picks the sentence bearing the durable ``preference`` cue (the same signal
    that classified the turn as SEMANTIC) and strips a leading ``framing`` wrapper,
    then trims. Falls back to the full trimmed text whenever stripping would leave
    nothing — so a bare *"remember that."* is never reduced to an empty memory.

    Pure and deterministic; ``signals`` selects the language, so a non-English cue
    set distils in its own language (or, absent framing cues, simply trims).
    """
    sentences = [s.strip() for s in _SENTENCE_SPLIT.split(text) if s.strip()]
    clause = next((s for s in sentences if signals.preference.search(s)), text.strip())
    distilled = signals.framing.sub("", clause, count=1).strip(" ,.:;")
    return trim_content(distilled or clause)

arcana.memory.extraction.config.ExtractionConfig

Bases: BaseModel

The extraction sub-block of the memory config.

strategy selects the extractor; llm needs a model/gateway or it is forced back to heuristic by build_extractor. Numeric fields default to the env-overridable tunables above and carry the same bounds — notably agent_confidence_cap must stay strictly below 1.0 (the anti-poisoning invariant), so a config setting it to 1.0 is rejected at load.

arcana.memory.extraction.signals.SignalPatterns dataclass

SignalPatterns(
    imperative,
    preference,
    howto,
    steps,
    framing=_NO_FRAMING,
)

Compiled surface-cue patterns for a single language.

  • imperative — emphasis/"remember" language that raises importance.
  • preference — a durable, user-stated fact/preference (→ SEMANTIC).
  • howto — the user is asking for a procedure (paired with steps).
  • steps — a step/ordered list in a response (→ PROCEDURAL).
  • framing — a leading conversational/imperative wrapper ("remember that", "by the way") stripped when distilling a durable statement down to the clause worth storing. Anchored at the start; defaults to a never-match pattern for languages that define no framing cues.

arcana.memory.extraction.signals.register_language

register_language(code, patterns)

Register a language's signal patterns under an ISO code (e.g. "pt").

Source code in packages/arcana-core/arcana/memory/extraction/signals/registry.py
def register_language(code: str, patterns: SignalPatterns) -> None:
    """Register a language's signal patterns under an ISO code (e.g. ``"pt"``)."""
    _REGISTRY[code] = patterns

Resilience

arcana.memory.resilience.ResilientTier

ResilientTier(
    inner,
    *,
    scope,
    label,
    config,
    breaker,
    on_degraded=None,
)

Wraps one memory backend with a timeout, a circuit breaker, and degraded reporting. Drops into TierBackend.adapter in place of the raw adapter.

Reads are total — a timeout, open breaker, corruption, or backend error yields [] (partial context beats none) after emitting a MemoryDegradedEvent. Writes are partial — the same conditions raise TierWriteFailed carrying this tier's scope, so the federation can decide the blast radius (PRIVATE fatal; SHARED/GLOBAL degrade).

Corruption is special: it is a session-long condition, so a MemoryCorruptError forces the breaker open (quarantine) rather than counting as one transient failure.

Source code in packages/arcana-core/arcana/memory/resilience.py
def __init__(
    self,
    inner: MemoryAdapter,
    *,
    scope: MemoryScope,
    label: str,
    config: TierResilienceConfig,
    breaker: CircuitBreaker,
    on_degraded: Callable[[MemoryDegradedEvent], None] | None = None,
) -> None:
    self._inner = inner
    self._scope = scope
    self._label = label
    self._config = config
    self._breaker = breaker
    self._on_degraded = on_degraded or emit_degraded

inner property

inner

The wrapped backend. Lets callers reach capabilities the wrapper does not forward (e.g. pruning, a destructive op that deliberately bypasses the read/write resilience path).

search async

search(query)

Fan a query to the backend within budget; never raise.

Source code in packages/arcana-core/arcana/memory/resilience.py
async def search(self, query: MemoryQuery) -> list[MemoryEntry]:
    """Fan a query to the backend within budget; never raise."""
    if not self._breaker.allow():
        self._degrade("read", "breaker_open", "circuit open; tier skipped")
        self._publish_state()
        return []

    budget = self._read_budget(query)
    started = time.perf_counter()

    try:
        result = await asyncio.wait_for(self._inner.search(query), budget)
    except TimeoutError:
        self._breaker.record_failure()
        self._degrade("read", "timeout", f"read exceeded {budget:.3f}s")
        return []
    except MemoryCorruptError as exc:
        self._breaker.force_open()
        self._degrade("read", "corruption", str(exc))
        return []
    except Exception as exc:  # noqa: BLE001 — reads must stay total
        self._breaker.record_failure()
        self._degrade("read", "backend_error", str(exc))
        return []
    else:
        self._breaker.record_success()
        self._record_latency("read", started)
        return result
    finally:
        self._publish_state()

write async

write(entry)

Write to the backend within budget; raise TierWriteFailed on failure.

A failed write surfaces by raising (carrying scope + reason) rather than emitting a degraded event: only the federation knows whether this leg is a promotion and whether the failure is fatal (PRIVATE) or a degradation (SHARED / GLOBAL), so it owns the MemoryDegradedEvent.

Source code in packages/arcana-core/arcana/memory/resilience.py
async def write(self, entry: MemoryEntry) -> None:
    """Write to the backend within budget; raise ``TierWriteFailed`` on failure.

    A failed write surfaces by *raising* (carrying scope + reason) rather than
    emitting a degraded event: only the federation knows whether this leg is a
    promotion and whether the failure is fatal (PRIVATE) or a degradation
    (SHARED / GLOBAL), so it owns the ``MemoryDegradedEvent``.
    """
    if not self._breaker.allow():
        self._publish_state()
        raise TierWriteFailed(self._scope, _BreakerOpen("circuit open"), "breaker_open")

    budget = self._config.write_timeout_ms / 1000
    started = time.perf_counter()

    try:
        await asyncio.wait_for(self._inner.write(entry), budget)
    except TimeoutError as exc:
        self._breaker.record_failure()
        raise TierWriteFailed(self._scope, exc, "timeout") from exc
    except MemoryCorruptError as exc:
        self._breaker.force_open()
        raise TierWriteFailed(self._scope, exc, "corruption") from exc
    except Exception as exc:
        self._breaker.record_failure()
        raise TierWriteFailed(self._scope, exc, "backend_error") from exc
    else:
        self._breaker.record_success()
        self._record_latency("write", started)
    finally:
        self._publish_state()

health_check async

health_check()

Delegate to the wrapped backend — the breaker's half-open probe.

Source code in packages/arcana-core/arcana/memory/resilience.py
async def health_check(self) -> AdapterHealth:
    """Delegate to the wrapped backend — the breaker's half-open probe."""
    return await self._inner.health_check()

arcana.memory.resilience.CircuitBreaker

CircuitBreaker(
    *,
    fail_threshold=3,
    reset_after_seconds=30.0,
    clock=monotonic,
)

Trips after consecutive failures; recovers via a single probe call.

The breaker is tripped by observed failures during real traffic — a passing health check does not prove queries succeed, and we will not poll on every call. Once open it fails fast (allow returns False) until reset_after_seconds passes, after which one HALF_OPEN probe decides whether to close again or re-open.

The clock is injected so callers (and tests) control time without sleeping; it must be a monotonic source of seconds.

Source code in packages/arcana-core/arcana/memory/resilience.py
def __init__(
    self,
    *,
    fail_threshold: int = 3,
    reset_after_seconds: float = 30.0,
    clock: Callable[[], float] = time.monotonic,
) -> None:
    if fail_threshold < 1:
        raise ValueError("fail_threshold must be >= 1")
    self._fail_threshold = fail_threshold
    self._reset_after = reset_after_seconds
    self._clock = clock
    self._consecutive_failures = 0
    self._opened_at: float | None = None
    self._state = BreakerState.CLOSED

state property

state

Current state, accounting for an elapsed cooldown (OPEN → HALF_OPEN).

allow

allow()

Whether a call may proceed. False means skip (breaker open, cooling).

Source code in packages/arcana-core/arcana/memory/resilience.py
def allow(self) -> bool:
    """Whether a call may proceed. ``False`` means skip (breaker open, cooling)."""
    return self.state is not BreakerState.OPEN

record_success

record_success()

A call succeeded: reset failures and close the breaker.

Source code in packages/arcana-core/arcana/memory/resilience.py
def record_success(self) -> None:
    """A call succeeded: reset failures and close the breaker."""
    self._consecutive_failures = 0
    self._opened_at = None
    self._state = BreakerState.CLOSED

record_failure

record_failure()

A call failed: count it and open the breaker at the threshold.

A failure while HALF_OPEN re-opens immediately regardless of the count — the probe told us the backend is still unhealthy.

Source code in packages/arcana-core/arcana/memory/resilience.py
def record_failure(self) -> None:
    """A call failed: count it and open the breaker at the threshold.

    A failure while HALF_OPEN re-opens immediately regardless of the count —
    the probe told us the backend is still unhealthy.
    """
    if self.state is BreakerState.HALF_OPEN:
        self._trip()
        return
    self._consecutive_failures += 1
    if self._consecutive_failures >= self._fail_threshold:
        self._trip()

force_open

force_open()

Open the breaker immediately, ignoring the failure count.

Used for conditions known to be session-long rather than transient — chiefly store corruption, where retrying within the session is pointless.

Source code in packages/arcana-core/arcana/memory/resilience.py
def force_open(self) -> None:
    """Open the breaker immediately, ignoring the failure count.

    Used for conditions known to be session-long rather than transient —
    chiefly store corruption, where retrying within the session is pointless.
    """
    self._trip()

arcana.memory.resilience.BreakerState

Bases: StrEnum

Lifecycle of a per-adapter circuit breaker.

Configuration

arcana.memory.config.MemoryResilienceConfig

Bases: BaseModel

Resilience config for every tier the federation may register.

shared is keyed by pool name; a pool with no explicit entry falls back to default_shared. global_ uses the global JSON key (Python reserved word) via its field alias.

for_shared

for_shared(pool_name)

Config for a named shared pool, falling back to default_shared.

Source code in packages/arcana-core/arcana/memory/config.py
def for_shared(self, pool_name: str) -> TierResilienceConfig:
    """Config for a named shared pool, falling back to ``default_shared``."""
    return self.shared.get(pool_name, self.default_shared)

load classmethod

load(path=None)

Load config from disk. A missing file yields all defaults (never raises).

Source code in packages/arcana-core/arcana/memory/config.py
@classmethod
def load(cls, path: Path | None = None) -> "MemoryResilienceConfig":
    """Load config from disk. A missing file yields all defaults (never raises)."""
    target = path or DEFAULT_CONFIG_PATH
    if not target.exists():
        return cls()
    data = json.loads(target.read_text(encoding="utf-8"))
    return cls.model_validate(data)

arcana.memory.config.TierResilienceConfig

Bases: BaseModel

Timeout + breaker budget for a single memory tier.

The keyword/semantic split is per operation: the wrapper applies semantic_timeout_ms when a query would touch the embedding path (retrieval_mode != keyword) and read_timeout_ms otherwise, so a slow embedder degrades to no vector tier rather than a stalled session.

Background jobs

arcana.memory.jobs.BackgroundJobQueue

BackgroundJobQueue(
    *,
    max_drain_seconds=30.0,
    initial_service_seconds=0.1,
    ewma_alpha=0.3,
)

A bounded FIFO queue for background memory work with load-shedding.

submit never blocks: critical jobs are always enqueued, non-critical jobs are dropped (submit returns False) once the estimated drain time reaches max_drain_seconds (so a headroom of 0 sheds all non-critical work). The drain estimate is depth × EWMA(service time); the EWMA is seeded and refined by a consumer via record_service_time once one exists.

Source code in packages/arcana-core/arcana/memory/jobs.py
def __init__(
    self,
    *,
    max_drain_seconds: float = 30.0,
    initial_service_seconds: float = 0.1,
    ewma_alpha: float = 0.3,
) -> None:
    if not 0.0 < ewma_alpha <= 1.0:
        raise ValueError("ewma_alpha must be in (0, 1]")
    self._queue: deque[MemoryJob] = deque()
    self._max_drain = max_drain_seconds
    self._service_ewma = initial_service_seconds
    self._alpha = ewma_alpha
    self._publish()

submit

submit(job, *, critical)

Enqueue a job. Returns False if a non-critical job was load-shed.

Critical work (e.g. a user-confirmed preference) is always accepted; non-critical work is shed when the backlog would take longer to drain than the configured headroom allows.

Source code in packages/arcana-core/arcana/memory/jobs.py
def submit(self, job: MemoryJob, *, critical: bool) -> bool:
    """Enqueue a job. Returns ``False`` if a non-critical job was load-shed.

    Critical work (e.g. a user-confirmed preference) is always accepted;
    non-critical work is shed when the backlog would take longer to drain
    than the configured headroom allows.
    """
    if not critical and self.drain_estimate_seconds() >= self._max_drain:
        return False
    self._queue.append(job)
    self._publish()
    return True

pop_next

pop_next()

Remove and return the oldest job, or None if the queue is empty.

The single primitive a future consumer loop needs; updates the metrics.

Source code in packages/arcana-core/arcana/memory/jobs.py
def pop_next(self) -> MemoryJob | None:
    """Remove and return the oldest job, or ``None`` if the queue is empty.

    The single primitive a future consumer loop needs; updates the metrics.
    """
    if not self._queue:
        return None
    job = self._queue.popleft()
    self._publish()
    return job

record_service_time

record_service_time(seconds)

Fold an observed job service time into the EWMA used for drain estimates.

Source code in packages/arcana-core/arcana/memory/jobs.py
def record_service_time(self, seconds: float) -> None:
    """Fold an observed job service time into the EWMA used for drain estimates."""
    self._service_ewma = self._alpha * seconds + (1 - self._alpha) * self._service_ewma

arcana.memory.jobs.MemoryJob dataclass

MemoryJob(kind, run, label='')

A unit of deferred memory work.

run is the coroutine factory a future consumer awaits; keeping the work behind a thunk lets the queue stay agnostic about what the job does.

arcana.memory.jobs.MemoryJobKind

Bases: StrEnum

What a deferred memory job does — and, by nature, how sheddable it is.

Embedding gateway

arcana.memory.embedding_gateway.EmbeddingGateway

EmbeddingGateway(adapters)

Picks the embedding adapter for a database, honouring its model pin.

Source code in packages/arcana-core/arcana/memory/embedding_gateway.py
def __init__(self, adapters: list[EmbeddingAdapter]) -> None:
    # Priority order matters for unpinned databases: the first healthy
    # adapter wins and the database is pinned to it.
    self._adapters = adapters

resolve async

resolve(db_meta)

Return the adapter to embed with, or None to fall back to FTS5.

Source code in packages/arcana-core/arcana/memory/embedding_gateway.py
async def resolve(self, db_meta: EmbeddingMeta | None) -> EmbeddingAdapter | None:
    """Return the adapter to embed with, or ``None`` to fall back to FTS5."""
    health: dict[int, bool] = {}

    async def healthy(adapter: EmbeddingAdapter) -> bool:
        # Memoise per call: health_check may do real I/O, and the family
        # pass would otherwise re-probe adapters already checked.
        key = id(adapter)
        if key not in health:
            health[key] = (await adapter.health_check()).healthy
        return health[key]

    if db_meta is None:
        # New database: pin to the first healthy adapter, in priority order.
        for adapter in self._adapters:
            if await healthy(adapter):
                await adapter.ensure_model()
                return adapter
        return None

    # Pinned database — prefer the exact model that wrote it.
    for adapter in self._adapters:
        if adapter.model_name == db_meta.model_name and await healthy(adapter):
            return adapter

    # Exact model unavailable: fall back within the same family, whose
    # vectors are interchangeable, so a pinned database keeps doing semantic
    # search (e.g. across tiers) instead of dropping to keyword-only.
    family = self._family_of(db_meta.model_name)
    if family is not None:
        for adapter in self._adapters:
            if adapter.model_family == family and await healthy(adapter):
                await adapter.ensure_model()
                return adapter

    return None  # no compatible adapter healthy → caller uses FTS5

Migrations

arcana.memory.migrations.migrate_to_latest async

migrate_to_latest(conn, migrations=MIGRATIONS)

Apply every migration newer than the DB's current user_version, in order.

Each migration runs in its own transaction together with the user_version bump, so a partial failure leaves the database exactly at its prior version. Idempotent: a database already at head applies nothing. Returns the version the database is at after running.

migrations is injectable purely so tests can exercise the runner (e.g. rollback on a deliberately failing statement) without touching real schema.

Source code in packages/arcana-core/arcana/memory/migrations/runner.py
async def migrate_to_latest(
    conn: aiosqlite.Connection,
    migrations: list[tuple[int, list[str]]] = MIGRATIONS,
) -> int:
    """Apply every migration newer than the DB's current ``user_version``, in order.

    Each migration runs in its own transaction together with the ``user_version``
    bump, so a partial failure leaves the database exactly at its prior version.
    Idempotent: a database already at head applies nothing. Returns the version
    the database is at after running.

    ``migrations`` is injectable purely so tests can exercise the runner (e.g.
    rollback on a deliberately failing statement) without touching real schema.
    """
    row = await (await conn.execute("PRAGMA user_version")).fetchone()
    current: int = row[0] if row else 0

    for version, statements in migrations:
        if version <= current:
            continue
        await conn.execute("BEGIN")
        try:
            for stmt in statements:
                await conn.execute(stmt)
            # PRAGMA does not accept bound parameters; ``version`` is an int we
            # control (never user input), so interpolation is safe here.
            await conn.execute(f"PRAGMA user_version = {version}")
            await conn.commit()
        except Exception:
            await conn.rollback()
            raise
        current = version

    return current

arcana.memory.migrations.latest_version

latest_version(migrations=MIGRATIONS)

Highest version defined. Zero when no migrations exist.

Source code in packages/arcana-core/arcana/memory/migrations/runner.py
def latest_version(migrations: list[tuple[int, list[str]]] = MIGRATIONS) -> int:
    """Highest version defined. Zero when no migrations exist."""
    return migrations[-1][0] if migrations else 0

Errors

arcana.memory.errors.MemoryError

Bases: Exception

Base class for all memory-backend errors.

arcana.memory.errors.MemoryStorageError

Bases: MemoryError

A read/write against the backend failed (I/O, corruption, constraint).

arcana.memory.errors.MemoryCorruptError

Bases: MemoryStorageError

The backend reported a malformed or unreadable database image.

A subclass of MemoryStorageError so existing except MemoryStorageError sites still catch it, but distinct so the resilience layer can treat it specially: corruption is a session-long condition, not a transient failure, so a tier that raises this is quarantined (breaker forced open) rather than merely counted as one failure.

arcana.memory.errors.MemoryNotConnectedError

Bases: MemoryError

The adapter was used before a connection/schema was established.

arcana.memory.errors.MemoryRoutingError

Bases: MemoryError

A write targets a tier that is missing or under-specified.

Raised when an entry must reach a backend that was never registered — a GLOBAL write with no global backend, or a SHARED write whose pool_name is absent or names an unknown pool.

arcana.memory.errors.MemoryWriteError

Bases: MemoryError

A write to an agent's PRIVATE store failed and could not be recovered.

PRIVATE memory is the durability anchor: an agent that cannot persist its own memory must learn of it rather than silently lose data it believes was written. The federation raises this when the private write leg fails; SHARED and GLOBAL write failures degrade instead (surfaced via MemoryDegradedEvent).

arcana.memory.errors.TierWriteFailed

TierWriteFailed(scope, cause, reason='backend_error')

Bases: MemoryError

A single tier's write failed inside the resilience wrapper.

Carries the scope of the failing tier so the federation can decide the blast radius — PRIVATE is fatal (re-raised as MemoryWriteError), SHARED and GLOBAL degrade. cause is the original backend exception (or a breaker-open sentinel); reason is the degradation category (timeout / breaker_open / backend_error / corruption) the federation puts on the MemoryDegradedEvent it emits for a degraded tier. The wrapper raises rather than emits, so the federation can label the event write vs promote and suppress it entirely for the fatal PRIVATE case.

Source code in packages/arcana-core/arcana/memory/errors.py
def __init__(
    self, scope: "MemoryScope", cause: BaseException, reason: "MemoryDegradeReason" = "backend_error"
) -> None:
    self.scope = scope
    self.cause = cause
    self.reason: MemoryDegradeReason = reason
    super().__init__(f"write to {scope} tier failed ({reason}): {cause!r}")