MINARA

Minara Memory

What Minara Memory is, and how it compares to mem0, OpenClaw, and Hermes Agent.

Minara Memory is the agent's memory system. It keeps three things in one embedded SQLite file: a general fact layer (facts the agent extracts from chat or records explicitly, kept fresh through consolidation and dedup), a financial learning loop (trading methodologies scored against real outcomes), and a file-compatible workspace layer it reads from OpenClaw and Hermes style context files.

The short version: Minara Memory = file-based workspace memory (kept as human-curated ground truth) + a structured SQLite layer (full-text and optional vector search) + a finance-domain learning loop (methodologies, attribution) + contradiction resolution. It builds on those files rather than replacing them.

Minara Memory architecture: three layers in one SQLite file. A general fact layer kept fresh by consolidation and dedup, a financial learning loop where methodologies are scored against real outcomes, and a workspace layer that reads OpenClaw and Hermes context files, all feeding the frozen snapshot the agent reads.

Minara Memory vs OpenClaw and Hermes Agent

These three are related. Minara Memory was ported from Hermes Agent's memory provider and stays compatible with OpenClaw workspace files. It layers a structured SQLite store and a learning loop on top of that file-based base.

DimensionMinara MemoryOpenClawHermes Agent
Memory carrierSQLite structured tables, and it also reads workspace markdown filesWorkspace context files (SOUL, AGENTS, USER, MEMORY, HEARTBEAT, daily notes)Markdown files (MEMORY.md, USER.md, and similar)
Persistencebetter-sqlite3 (WAL)Markdown files on diskMarkdown files on disk
InjectionFrozen snapshot built from SQLite and workspace files, injected into the system promptWorkspace files injected as identity and memory blocksFrozen snapshot built from markdown files
RetrievalFTS5 / BM25, optional vector, entity boost, plus a memory_search toolFiles are the context; no structured retrievalFiles are the context, basic reads
WritingTools write SQLite, plus background extraction and consolidationMostly hand-edited workspace filesA memory tool writes markdown, plus hand edits
StructureHigh: 20+ tables (methodologies, preferences, roles, cases, decisions)Low: free-text filesLow: free-text markdown
Domain self-learningYes, a full learning loopNoNo
Human ground truthWorkspace markdown stays highest priority and outranks derived layersWorkspace files are the truthMarkdown files are the truth
RelationshipPorted from Hermes, compatible with OpenClaw workspaces, with a SQLite and learning layer on topOne source of Minara's workspace compatibilityThe direct ancestor of Minara's memory store

Minara Memory vs mem0 and mainstream vector memory

mem0 and most framework memory components are general, vector-store-backed layers you plug into any agent. Minara Memory is embedded in a finance agent and adds a domain learning loop those libraries do not have.

DimensionMinara Memorymem0Mainstream vector memory (Zep / Letta / LangChain style)
PositioningMemory + domain-learning subsystem inside a finance agentGeneral memory-as-a-service / SDKGeneral memory library or framework component
Storage backendSingle SQLite file (FTS5 + optional embedded sqlite-vec)Vector store (Qdrant by default) plus optional graph storeExternal vector store or dedicated memory service
External dependenciesNone required (runs on pure BM25)Vector store plus an embedding APIUsually a vector store plus an embedding service
Writing factsExplicit tool, background LLM extraction, preference mining, methodology seeds and casesLLM extraction ADD pipeline (recent versions are single-pass ADD-only)LLM extraction or a conversation buffer
Contradiction resolutionMethodology and preference layers dedup and merge; the general fact layer resolves conflicts with an LLM-assisted decision plus an audit trailClassic versions did LLM-driven ADD / UPDATE / DELETE; recent versions are ADD-onlyVaries; some support it
RetrievalFTS5 / BM25 with a deterministic entity boost by default; hybrid vector search is optionalSemantic vector plus BM25 plus entity matching, fused, with temporal reasoningSemantic vector first
InjectionFrozen snapshot (prefix-cache friendly) plus an on-demand search toolRetrieve and concatenate via search()Retrieval or buffer concatenation
Domain self-learningYes: Wilson-score confidence, real P&L attribution, quarantine and graduation, a synthesis cronNoNo
Multi-tenantSingle-tenant focused (partial user_id coverage)First-class (user_id / agent_id / run_id)Varies
Governance and auditAudit log, capability toggles, circuit breaker, shadow mode, a consolidation audit tablePlatform-sideVaries
Language and ecosystemTypeScript / NodePython-first (with a TypeScript SDK)Mostly Python
ReusabilityCoupled to the agent, not a standalone libraryDrop-inDrop-in

Why the design splits this way

The general fact layer and the domain learning loop stay orthogonal on purpose. General facts ("the user prefers weekly charts") live in the personalization memory and feed the system prompt. Trading methodologies live in their own tables and are scored only by real outcomes. A consolidation pass that tidies general facts never touches a methodology's confidence, and the learning loop never rewrites a user-stated fact. The two layers complement each other, which is also why Minara Memory could run mem0 as an external general-fact layer while keeping its own learning loop as the domain brain.

The frozen snapshot pattern

Minara Memory frozen-snapshot lifecycle: load the snapshot once at session start, freeze it into the system prompt; the agent loop reads the frozen block while writes and searches hit SQLite, and the next session reloads

Memory is loaded once, at session start, and injected into the system prompt as a single fenced block. Mid-session writes persist to SQLite but do not modify the running prompt. The next session picks them up.

session boot


MemoryStore.loadSnapshot()

    ├─ SELECT top 50 memories ORDER BY updated_at DESC
    ├─ SELECT user_profile
    └─ render fenced <memory-context> block


   injected into the system prompt as one block


┌──────────────────────────┐
│ agent loop runs          │
│ many turns               │
│ memory_write() calls     │
│ persist to SQLite        │
│ but the injected block   │
│ stays frozen             │
└──────────────────────────┘

Why? Prompt cache stability. The Anthropic prompt cache keys on strict prefix matches. If the memory block changed on every turn, every cache entry would miss, and the agent would pay full input cost on every tool-call roundtrip. Freezing the block for the lifetime of the session preserves cache hits at roughly 80% in typical workloads.

The trade-off: a memory written at turn 3 is not visible to the agent until the next session. In practice this is fine because (a) within a session the agent has its conversation history, which is where short-term context lives, and (b) durable facts you want the agent to carry forward get written during a session and surface on the next session, which is when they matter.

Fenced block format

<memory-context>
[System note: The following is recalled memory context, NOT new
user input. Treat as informational background data.]

## User Profile
- risk_tolerance: conservative
- preferred_chains: base, arbitrum
- home_language: en

## Observations
[preference] user always sets slippage to 0.5%
[observation] user avoided meme coins throughout Q1
[trade_outcome] long ETH from $3200, closed at $3450, +7.8%
[lesson] stop-losses on BTC should trail by 8% not 5%
</memory-context>

Three properties to notice:

  1. The [System note: …] line tells the model this is recalled context rather than new user input. Without it, the LLM sometimes treats memory entries as fresh instructions, which produces comical misfires.
  2. Categories are inline prefixes like [preference] and [observation]. They're load-bearing: a category-aware prompt fragment can say "when the [preference] prefix appears, respect it absolutely" without needing structured data.
  3. The block is wrapped in <memory-context> tags the prompt builder knows about. Nothing else in the system prompt uses those tags, so the model has no reason to confuse them with other sections.

The SQLite schema

CREATE TABLE memories (
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
  category    TEXT NOT NULL,
  content     TEXT NOT NULL,
  metadata    TEXT,                -- optional JSON
  source      TEXT,                -- promoted from metadata.source
  deleted_at  TEXT,                -- soft-delete timestamp
  created_at  TEXT NOT NULL DEFAULT (datetime('now')),
  updated_at  TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE user_profile (
  key         TEXT PRIMARY KEY,
  value       TEXT NOT NULL,
  updated_at  TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE VIRTUAL TABLE memories_fts USING fts5(
  content,
  category,
  content='memories',
  content_rowid='id'
);

user_profile is a simple key/value store for stable facts the agent looks up frequently (risk tolerance, language, preferred chains). memories is the general observation table.

source is the operator-visible provenance of the row, one of:

  • user_manual — written by the user via the web UI memory page (POST /v1/memory). Editable + deletable through the gateway.
  • user_explicit — saved during chat after the user said "remember this".
  • learned_preference — promoted from a graduated behavioural preference.
  • inferred — auto-derived by the personalization rebuilder from chat history.
  • chat_extracted — auto-extracted by the memory rebuilder during a chat-turn scan.
  • agent_recorded — a durable fact saved through memory_write({ category: "fact" }), written as a pending row for the general fact layer to consolidate.

source was previously buried in metadata.source; promoting it to a top-level column lets the gateway enforce a category=personalization

  • source=user_manual allow-list on PATCH/DELETE without re-parsing the metadata blob on every request. Existing rows are back-filled at boot via UPDATE memories SET source = json_extract(metadata, '$.source') WHERE source IS NULL AND metadata IS NOT NULL.

deleted_at is the soft-delete cursor. The web UI's memory delete button writes deleted_at = datetime('now') so the row stops appearing in read paths but stays recoverable for FIN_PROFILE_MEMORY_SOFT_DELETE_RETENTION_DAYS (default 30) through POST /v1/memory/:id/restore. A 30-minute PersonalizationRefreshTask tick calls MemoryStore.purgeExpiredSoftDeletedMemories(cutoff) to physically delete past-retention rows. Read paths (searchMemories, searchMemoriesHybrid, readMemories, loadSnapshot, PersonalizationService.listMemories) all filter WHERE deleted_at IS NULL; the FTS5 triggers stay unchanged so restore is zero-cost.

Off-agent trade history mirror tables

Three additional tables back the personalization rebuilder's "three-source" view (see Personalization & Workspace for the consumer side):

-- Mirror of Hyperliquid fills served by Minara per perp sub-wallet.
-- Same `oid` can appear on multiple partial fills, so the dedup key is
-- a fill-level uid (tid -> hash -> sha1(raw_json) priority computed in
-- `apps/agent/src/minara/normalize-fill.ts`). Watermark + per-sub failure counter
-- live in `minara_history_sync_state`.
CREATE TABLE perps_fills (
  id              INTEGER PRIMARY KEY AUTOINCREMENT,
  sub_account_id  TEXT NOT NULL,
  wallet_address  TEXT,
  oid             TEXT NOT NULL,
  ts_ms           INTEGER NOT NULL,
  symbol          TEXT NOT NULL,
  side            TEXT NOT NULL,
  dir             TEXT,
  size            REAL NOT NULL,
  price           REAL NOT NULL,
  fee             REAL NOT NULL DEFAULT 0,
  closed_pnl      REAL NOT NULL DEFAULT 0,
  raw_json        TEXT,
  fill_uid        TEXT NOT NULL,
  created_at      TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX uniq_perps_fills_dedup
  ON perps_fills(sub_account_id, fill_uid);

-- Off-agent spot swaps + transfers from Minara cross-chain history.
-- `tx_hash` is globally unique per chain transaction.
CREATE TABLE external_spot_activities (
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
  tx_hash     TEXT NOT NULL UNIQUE,
  ts_ms       INTEGER NOT NULL,
  type        TEXT NOT NULL,        -- 'swap' | 'transfer' | ...
  from_token  TEXT,
  to_token    TEXT,
  amount      TEXT,
  value_usd   REAL,
  status      TEXT NOT NULL,
  raw_json    TEXT,
  created_at  TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Per-source incremental sync watermark. Primary key is
-- (source, sub_account_id) so a single sub-account's transient failure
-- never pollutes the global spot cursor or its sibling subs.
--   ('perps_fills', '<sub_account_id>')   one row per sub-wallet
--   ('spot_activities', '')               single row, empty sub_account_id
CREATE TABLE minara_history_sync_state (
  source                TEXT NOT NULL,
  sub_account_id        TEXT NOT NULL DEFAULT '',
  last_synced_ts_ms     INTEGER,
  last_synced_at        TEXT NOT NULL DEFAULT (datetime('now')),
  last_error            TEXT,
  consecutive_failures  INTEGER NOT NULL DEFAULT 0,
  PRIMARY KEY (source, sub_account_id)
);

MinaraHistorySync writes through MemoryStore.bulkInsertPerpsFills and MemoryStore.bulkInsertExternalSpot; both use INSERT OR IGNORE, return the inserted-row count, and emit perps_fills:recorded / external_spot:recorded events that the personalization rebuilder listens for. After historySyncMaxFailures consecutive failures the (source, sub) is skipped during normal scheduling, then probed again once historySyncFailureCooldownMs elapses since last_synced_at so a transient outage cannot permanently disable the mirror.

FTS5 triggers

Three triggers keep the FTS5 virtual table in sync with memories:

CREATE TRIGGER memories_ai AFTER INSERT ON memories BEGIN
  INSERT INTO memories_fts(rowid, content, category)
    VALUES (new.id, new.content, new.category);
END;

CREATE TRIGGER memories_ad AFTER DELETE ON memories BEGIN
  INSERT INTO memories_fts(memories_fts, rowid, content, category)
    VALUES('delete', old.id, old.content, old.category);
END;

CREATE TRIGGER memories_au AFTER UPDATE ON memories BEGIN
  INSERT INTO memories_fts(memories_fts, ...) VALUES('delete', ...);
  INSERT INTO memories_fts(rowid, content, category) VALUES (...);
END;

The content='memories' configuration means the FTS5 table is a content-less external index: the actual text lives in memories, and memories_fts stores only the tokenization data. Rows are joined on rowid = memories.id at query time. This is the standard FTS5 pattern in SQLite and keeps storage overhead low.

Memory categories

The category column is free-form text, but the codebase settles on a small vocabulary:

CategoryMeaningExample
preferenceA user-declared preference the agent should respect"always use 0.5% slippage"
observationSomething the agent noticed and wants to carry forward"user avoided memes throughout Q1"
trade_outcomeCompleted trade with P&L, stored for learning"long ETH $3200 → $3450, +7.8%"
lessonLesson learned from a trade outcome"stop-losses on BTC should trail 8%"
personalizationRebuilt periodically from conversation history"user treats crypto as a 5% allocation"
alertAn event the agent should remember (price hit, news)"BTC ETF inflows spiked on 2026-04-14"
referenceA durable fact: URL, address, number"TrueUSD issuer: 0xabc..."

These categories show up as [preference] and [observation] prefixes in the frozen snapshot. The convention is load-bearing enough that new categories should be added to this page at the same time as the code that writes them.

The write path

Memory is written only through tool calls. There is no back door, which matters because it means every write shows up in the audit log with reasoning and context.

memory_write

memory_write({
  category: "preference",
  content: "always use 0.5% slippage on swaps",
  metadata: { source: "user_turn", confidence: 0.95 }
})

Most categories call MemoryStore.writeMemory(category, content, metadata) and return the new row id; the FTS5 trigger indexes the content automatically. The fact category is the exception: it routes into the general fact layer (below) through GeneralFactService.recordFact, so a durable user fact is deduplicated and consolidated in the background instead of stacking up as duplicate rows.

memory_search({ query: "slippage", limit: 5 })

Runs an FTS5 MATCH query against memories_fts, joins back to memories, and returns the top results ranked by BM25. Falls back to LIKE if the query contains FTS5 syntax errors (e.g. unbalanced quotes), so the call never hard-fails on malformed input.

memory_read

memory_read({ category: "preference", limit: 20 })

Direct table scan ordered by updated_at DESC. Used for "give me everything in this category" retrieval that doesn't need ranking.

memory_learn

Invoked by the review engine at the end of a turn when a learning is recorded. Writes to the learnings table rather than memories; see Learning System for the distinction.

General fact layer

Durable user facts get their own lifecycle, so the prompt sees one clean fact per topic instead of every revision the agent ever heard. A fact reaches the layer two ways, and the two paths stay separate on purpose:

  • Agent-recorded. memory_write({ category: "fact" }) calls GeneralFactService.recordFact, which writes a pending fact (source: agent_recorded) and emits a fact:recorded event. The event schedules a debounced background sweep, so consolidation never blocks the turn that recorded the fact.
  • User-added. A fact the user types on the web UI memory page is written straight to a personalization / user_manual row through POST /v1/memory. It is the user's stated ground truth, so it is protected. The consolidation passes never retire a user_manual row.

Two consolidation passes run over agent-derived facts:

  1. Deterministic dedup (consolidatePending) drains pending agent-recorded facts oldest-first, with exact-normalized matching and no LLM. Each fact settles as it is processed, so a later restatement matches the already-settled older copy and the newer one is retired. The older statement survives.
  2. LLM contradiction resolution (FactConsolidator) is optional, gated by the MEMORY_CONSOLIDATION_ENABLED preference. When two facts genuinely conflict (such as "prefers weekly charts" against "prefers daily charts"), an LLM judges whether the new fact supersedes the old one. Hard invariants in code always override both the model and the user's free-text steer (MEMORY_CONSOLIDATION_GUIDANCE), which is a bounded, non-authoritative preference. Every decision is written to the memory_consolidation_events audit table.

Facts are retired, never hard-deleted, so the trail stays inspectable. Consolidated facts feed the personalization frozen snapshot, the same snapshot the agent reads at the top of each turn.

Search semantics

The FTS5 query language supports:

  • Token matching: slippage matches rows containing "slippage" anywhere in the content column.
  • Phrase matching: "always use" (with quotes) matches the exact phrase.
  • Boolean: slippage AND base for intersection, slippage OR impact for union.
  • Column filter: category:preference slippage restricts to a category.
  • Prefix matching: slip* matches "slippage," "slip," "slippery."

BM25 is the default ranking. Results come back ordered by relevance rather than recency. Add ORDER BY updated_at DESC in a custom query if you want recency ordering.

Loading and truncation

loadSnapshot() pulls the top 50 memories by updated_at DESC. That limit is deliberate:

  • 50 memories at ~80 tokens each is ~4 KB of prompt. Fits in the cacheable identity block without dominating it.
  • Ordered by updated_at means recently touched memories surface first. Writing to the same memory (via update) bumps it to the top naturally.
  • No category filter at load time. The snapshot is a general window; filtering belongs in search.

If you need more than 50 memories loaded, the right move is usually to promote a subset to user_profile or the personalization category and let the rebuild task curate which ones survive. Cranking the limit up is a cache-hit disaster.

Personalization-class memories

Memories with category = "personalization" are treated specially by apps/agent/src/memory/personalization-service.ts. They're rebuilt periodically from recent conversation history via a small LLM pass (Haiku by default) that:

  1. Reads the last N sessions from the sessions table.
  2. Prompts the model to extract durable facts about the user.
  3. Writes the extracted facts back to memories under personalization, deduplicating against existing rows.
  4. Records a snapshot so the next rebuild can diff cheaply.

The rebuild task is scheduled by the heartbeat monitor (default daily). Turning it off via MINARA_PERSONALIZATION_REBUILD=disabled disables automatic curation; manual memory_write calls still work.

See Personalization for the full rebuild lifecycle.

Hybrid retrieval (FTS5 + sqlite-vec via RRF)

The default FTS5 + entity-overlap re-rank covers most workloads. For semantic recall — queries whose vocabulary differs from the stored memory ("山寨币崩了" finding altcoin drawdown overnight) — there's an optional vector path that runs alongside FTS5 and fuses ranks with Reciprocal Rank Fusion (RRF).

Enable by setting EMBEDDING_PROVIDER plus EMBEDDING_API_KEY (see env vars). With the provider set, every writeMemory / writeRoleMemory schedules an asynchronous embedding via queueMicrotask (the write path itself stays sync) and stores the float vector in a sister vec0 virtual table loaded from the sqlite-vec extension.

The retrieval pipeline (searchMemoriesHybrid in apps/agent/src/memory/memory-store.ts):

  1. FTS5 BM25 query — same as before, returns the top-N keyword matches.
  2. vec0 KNN query — embeds the query text and pulls the top-N nearest vectors.
  3. RRF fusion — merges the two ranked lists by score = Σ 1/(60 + rank_i). An item appearing in both lists scores higher than one only in either.
  4. Soft entity-overlap boostfinal = rrf × (1 + 0.3 × entity_overlap_count). Ticker / chain / address overlap keeps lifting financial-domain matches without hard-pinning them above pure semantic hits.

The embedding_state column on each row tracks the lifecycle: pendingembedded (success) / failed (transient API error) / skipped (text too short or injection-rejected). The doctor section below surfaces the distribution; doctor --fix backfills failed and pending rows on demand.

Graceful degradation invariants:

  • EMBEDDING_PROVIDER=disabled (default) — searchMemoriesHybrid short-circuits to BM25; behaviour is byte-identical to the pre-hybrid path. embedding_state stays at pending with NULL embedding.
  • loadExtension for sqlite-vec fails (platform without the binary) — the constructor logs a warn and the hybrid path silently degrades to BM25.
  • The query embedding API call fails — the fusion step skips the vec arm and returns BM25 results.

Cost considerations:

  • Embedding cost is paid on write, amortised over the day. ~100 writes/day on text-embedding-3-small is single-digit cents per month.
  • Storage overhead is 4 × dim bytes per row plus the vec0 index, typically ~6–12 KB per row at 1536 dims.
  • Query latency stays under 10 ms for a few thousand rows; the FTS5 + vec0 calls run sequentially in better-sqlite3's synchronous binding, but each is cheap.

The frozen prompt snapshot is unaffected — hybrid retrieval only intercepts mid-session searchMemories* calls, not loadSnapshot().

Typed memory edges

Every memory write also emits a small set of typed edges into memory_edges. The extractor is pure regex over the entities that extractFinanceEntities already identifies, so no LLM call is involved. Edge types:

  • holds, exited, traded, watched — verb-driven (long / bought / 做多 → holds; sell / exited / 止损 → exited; …).
  • mentions — fallback when an entity appears without a verb.
  • co_occurs_with — between every pair of co-mentioned tickers in a content (capped at 5 tickers → 10 pair edges max).
  • belongs_to_scenario — derived only from system-supplied metadata.scenario_id, never from content text. Defends against prompt-injection attempts to tag a memory onto a high-trust scenario.

Schema (additive; never deletes existing rows):

CREATE TABLE memory_edges (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  src_table TEXT NOT NULL,
  src_id INTEGER NOT NULL,
  dst_entity_kind TEXT NOT NULL,
  dst_entity_key TEXT NOT NULL,
  edge_type TEXT NOT NULL,
  weight REAL NOT NULL DEFAULT 1.0,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  UNIQUE(src_table, src_id, dst_entity_kind, dst_entity_key, edge_type)
);

The UNIQUE constraint makes re-extraction (after the regex set changes) idempotent — replays cost zero rows. Two AFTER DELETE triggers on memories and role_memory cascade-purge the edges so getEntityNeighborhood and topEntities never surface phantom rows. The per-content edge cap is MAX_EDGES_PER_CONTENT = 20, with strong verbs ranked above mentions and co_occurs_with so a 30-ticker memory degrades gracefully.

Compiled pages (memory_compiled_page tool)

A read-only LLM tool that aggregates everything the agent currently knows about an asset into a single byte-deterministic markdown page. Used for "what do you currently think about BTC?" turns where the LLM wants a one-shot summary instead of probing multiple stores.

Sections in the output:

  • Compiled truth — graduated methodologies (Wilson ≥ 0.55) + active hard_constraint preferences mentioning the ticker (the hard-constraint surface can be suppressed via the includeHardConstraints option).
  • Timeline — most-recent reflected role_memory rows for the asset (ordered by reflected_at DESC) plus the most-recent trade_history rows.

Cache layer (CompiledPages in apps/agent/src/memory/compiled-pages.ts):

  • 60-second LRU keyed by <entity> and <entity>|nohc (the hard-constraint suppressed variant) — repeated tool calls inside a turn return byte-identical strings, friendly to prefix cache.
  • Invalidated by memory:written, trade:recorded, and trade:outcome_updated events so cross-turn writes pick up.
  • Forex pair preservation: EUR/USD normalises to EURUSD, not EUR, so the page lands on the right asset class.

Quarantined methodologies, pending (unreflected) role_memory rows, and entries that fail asset-class classification never appear in the compiled output.

Doctor health & --fix

minara doctor (see reference/cli/subcommands) extends its read-only health report with a Memory health section: methodology graduation/quarantine counts, role_memory pending-72h backlog, embedding-state distribution, edge totals plus top entities, and the snapshot dirty counters. The --anonymous flag buckets every count for safe sharing.

minara doctor --fix [--apply] runs an idempotent maintenance pipeline. The default action set covers three contracts every action satisfies:

ContractWhat it means
IdempotentReplaying twice produces zero net new writes
Reversible / non-destructiveUNIQUE constraints catch replays; demotion only ever raises quarantine; archive flags can be un-set
No LLMEvery action is deterministic SQL or a provider-bounded HTTP call (backfillEmbeddings)

Default actions (run when --only is omitted):

  • embeddings — backfill rows with embedding_state IN ('pending', 'failed').
  • edges — replay the deterministic memory_edges extractor over every memories + role_memory row.
  • methodology_demotion — quarantine methodologies whose Wilson lower bound has fallen below LEARNING_CONFIG.demotionWilsonThreshold (0.40) AND have at least LEARNING_CONFIG.minUsesBeforeDemotion (20) uses. Always raises quarantine 0 → 1, never the reverse.

Opt-in only via --only reflect_pending:

  • reflect_pending — invoke the role-reflector for every role with pending rows past their policy's age threshold. This calls the LLM for Stage 1 classification + Stage 2 lessons. Excluded from the default set so a casual --fix --apply never spends tokens unintentionally.

Hard safety envelope (covered by negative tests):

  • never graduates a methodology (Wilson upward stays manual)
  • never activates a learned_preferences row (hard_constraint especially never auto-activates)
  • never DELETEs any row
  • never rewrites role_memory.reflection
  • never modifies audit_log

Every applied action writes an audit_log row tagged tool_call='doctor.fix.<name>'. Dry-run leaves audit_log untouched.

Markdown round-trip (export / import)

minara memory export snapshots the agent's learned memory to a human-readable markdown tree under <dataDir>/exports/<timestamp>/. The default location is outside the LLM sandbox (<dataDir>/sandbox/files/), so a prompt-injected memory cannot leak the export back through a read_file tool call.

Output layout:

<out>/
  index.md                                 # toc + schema_version + instance_id
  methodologies/<asset_class>/<id>.md      # frontmatter + body
  preferences/<dimension>/<id>.md          # one file per active preference
  preferences/README.md                    # constraint-exclusion banner
  trade-cases/<id>.md                      # one file per reflected role_memory
  assets/<TICKER>.md                       # compiled-page snapshots
  signature.txt                            # optional --sign HMAC manifest

hard_constraint preferences are excluded by default (user-specific risk caps; one-way leakage hazard). Pass --include-constraints to opt in. The exclusion applies to BOTH the per-preference files and the asset compiled-page surface.

--sign computes per-file HMAC-SHA256 against the local instance_meta.hmac_key and writes signature.txt. The HMAC key never leaves SQLite — only the SHA-256 fingerprint (instance_id, first 16 hex chars) appears in frontmatter.

minara memory import round-trips the tree back via two channels:

  • Channel A (signed). signature.txt present + per-file HMAC verifies + instance_id matches THIS instance. Methodology imports preserve confidence / quarantine / times_used. Preferences keep state='active' when present. hard_constraint preferences NEVER auto-activate — even on channel A the user must approve via /preferences.
  • Channel B (unsigned / fall-through). Methodologies route through MethodologyStore.create() and land at quarantine=1, confidence=initialConfidence; dedup-match inherits the existing row's stats. Preferences land in state='proposed' via PreferenceStore.create(). A bundle marked signed but with one tampered file degrades that file to channel B without invalidating the rest.

hard_constraint imports (in either channel) are rejected unless BOTH --approve-hard-constraints is set AND the process runs in an interactive TTY (MINARA_NON_INTERACTIVE=1 always rejects). --apply is required for any write; without it the command runs as a dry-run that leaves audit_log untouched.

Per-file safety (both channels): a prompt-injection scan checks every body before dispatch; schema_version, asset_class, dimension, and kind must match the live enums. Every applied action — applied or rejected — writes an audit_log row tagged memory_import.signed or memory_import.unsigned.

Inspection and debugging

# How many memories in each category?
sqlite3 ~/.minara/minara.db \
  "SELECT category, COUNT(*) FROM memories GROUP BY category ORDER BY 2 DESC;"

# What does the frozen snapshot look like right now?
sqlite3 ~/.minara/minara.db \
  "SELECT category, content FROM memories ORDER BY updated_at DESC LIMIT 50;"

# When was the last personalization rebuild?
sqlite3 ~/.minara/minara.db \
  "SELECT MAX(updated_at) FROM memories WHERE category='personalization';"

# Find a specific observation
sqlite3 ~/.minara/minara.db \
  "SELECT m.* FROM memories m JOIN memories_fts f ON m.id=f.rowid
    WHERE memories_fts MATCH 'slippage' ORDER BY rank LIMIT 5;"

From inside the REPL:

/profile            # dump personalization snapshot
/prompt             # see the memory block as it appears in the system prompt

Safety properties

  • Memories are never secret input. The redactor that protects the audit log does not run on memory content. Don't write API keys or wallet mnemonics into memory via a tool. Nothing downstream expects this, so no redactor runs.
  • Memories can influence behavior. A preference entry saying "always confirm trades" will shift the agent toward confirmation, but it will NOT override the L3 risk gate. The gate is enforced in code; memories are advisory context.
  • Deleting is a plain DELETE. There is no tombstone or append-only log. If you need an auditable "user removed this memory" record, write a memory_removed row into audit_log before the delete.
  • Mid-session writes don't affect the current turn's prompt. If your debugging theory depends on a memory being visible mid-session, restart the REPL or run /new so the snapshot reloads.

What NOT to use memory for

  • Large data. Spreadsheet content, long documents, binary payloads. Put these in Artifacts & Files and let the agent reference them by id.
  • Conversation history. That's the sessions table. Do not duplicate.
  • Workflow state. That's workflow_instances. A workflow writing its own progress to memories is a sign of bad coupling.
  • Transient calculations. The agent gets a new turn every user message. If a fact only matters for the current turn, it doesn't belong in memory at all.

On this page