---
sources:
  - path: ari-core/ari/memory/letta_client.py
    role: implementation
  - path: ari-skill-memory
    role: implementation
last_verified: 2026-05-26
---

# Memory Architecture

Each node reads only from its ancestor chain:

```
root ──▶ memory["root"]
  ├─ node_A ──▶ memory["node_A"]
  │    ├─ node_A1  (reads: root + node_A)
  │    └─ node_A2  (reads: root + node_A, NOT node_A1)
  └─ node_B  (reads: root only, NOT node_A branch)
```

`search_memory` is invoked with `query = node.eval_summary` (a one-
sentence direction text). On Letta 0.16.7 the skill calls
`passages.search` (`GET /archival-memory/search`, `embed_query=True`)
with `top_k = max(letta_overfetch, limit*40)`, then post-filters the
ranked window by `ancestor_ids`, `ari_checkpoint`, and
`kind == "node_scope"` locally. The embedding-rank order returned by
the server is preserved — children see entries most relevant to
their query first. The deliberately-skipped sibling endpoint
`passages.list(search=q)` is **not** semantic — it routes to a SQL
substring filter (`LOWER(text) LIKE LOWER(%q%)`) which silently
returns 0 against long natural-language queries on structured
passages like `RESULT SUMMARY metrics=[...]`. See
`ari-skill-memory/src/ari_skill_memory/backends/letta_backend.py`
for the live verification.

### v0.6.0: backed by Letta

Both layers live in the same per-checkpoint Letta agent:

- `ari_node_<ckpt_hash>` — node-scope archival collection with the
  ancestor-scope metadata filter above.
- `ari_react_<ckpt_hash>` — flat per-checkpoint ReAct trace
  (`LettaMemoryClient`, not ancestor-filtered).

The read and write paths through these two collections (`HASH` = checkpoint
hash; the write guard and the post-filter are what enforce ancestor scope):

```mermaid
flowchart LR
    node["Executing node<br/>(= ARI_CURRENT_NODE_ID)"]
    subgraph letta["Per-checkpoint Letta agent"]
        nodecol["ari_node_HASH<br/>ancestor-scoped archival"]
        reactcol["ari_react_HASH<br/>flat ReAct trace"]
    end
    node -->|"add_memory (CoW: node_id must == current)"| nodecol
    node -->|"ReAct steps"| reactcol
    node -->|"search_memory(query, ancestor_ids)"| search["passages.search<br/>embed_query, top_k = max(overfetch, limit*40)"]
    search --> nodecol
    search -->|"post-filter: ancestor_ids + ari_checkpoint + kind == node_scope"| result["ranked, ancestor-only entries"]
```

The agent also seeds a core-memory block (`persona` + `human` +
`ari_context`) with experiment goal, primary metric, and hardware spec
once the first node's `generate_ideas` completes (the point at which
`primary_metric` is known). Skills can read it via
`get_experiment_context()` without paying for a search; the call
returns `{}` until that seed runs.

**Copy-on-Write**: write-side tools reject `node_id` ≠
`$ARI_CURRENT_NODE_ID` so ancestor entries are byte-stable across
siblings; Letta self-edit is disabled by default for the same reason.

**Portability**: each checkpoint carries a
`memory_backup.jsonl.gz` snapshot that is restored automatically on
`ari resume` when the target Letta is empty — keeping
`cp -r checkpoints/foo /elsewhere/` + `ari resume` working.

---

## See also

[Architecture](architecture.md) · [BFTS algorithm](bfts.md) · [Glossary](../reference/glossary.md)
