Skip to content

Memory

Memory is angelo's persistent knowledge across sessions: the place an agent records why it did something, what it tried, and what it learned — so the next session (yours or another agent's) starts informed instead of blank. It is one file-backed store with several faculties, all backed by markdown in .memory/ and committed to git.

The same memory entries morphing between a structural tree and semantic clusters

The same node set, re-projected: a structural tree of parent → child lineage becomes semantic clusters that pull cross-branch entries together by meaning.

Four faculties over one store

Everything lives as markdown + YAML under .memory/; the KGLite cache in .angelo/ is disposable and rebuilt on demand. On top of that one store, memory offers four faculties:

  • The research tree — a typed hierarchy of decisions, plans, experiments, and checkpoints that records why the work went the way it did.
  • The skills library — reusable procedures and patterns an agent can save once and recall later.
  • The code graph — a live structural map of the repo's functions, classes, and modules, queried instead of grepping.
  • Session continuity — handoff/pickup so a new session inherits the last one's focused context.

Because all four are files, they travel with the repo, diff in review, and survive process death — the memory is durable, not a cache that a crash loses.

The research tree

The tree uses a fixed four-level structure under a single root project:

Project > Subproject (plan) > Phase (plan) > Entries

Entries are typed — plan, checkpoint, experiment, decision, todo, note, annotation — and placed under the most specific entry they relate to, so provenance is encoded in the hierarchy itself. There is exactly one project per repo; new workstreams are subprojects, not new projects.

The decisions recorded here are mined into the browsable architecture decision records.

The memory dashboard Tree tab in Plan view: only the plan nodes — subprojects and phases — laid out as the skeleton of the research tree

The Tree tab's Plan view collapses to just the plan-node scaffold (subprojects → phases) — the backbone the typed entries hang from.

Two views of the same tree

The dashboard renders that one node set two ways — a structural tree of parent → child lineage, and semantic clusters that group entries by meaning regardless of where they were filed. They answer different questions and neither replaces the other. See Tree and clusters.

How search ranks results

search blends three recall channels — file-path match, keyword (title beats tags beats body), and semantic (model2vec vector similarity) — and fuses the keyword and semantic rankings with Reciprocal Rank Fusion. That fused order is the default result order.

On top of that recall, an optional blended reranker (shared with the zettelkasten) can reorder the hybrid-mode results by folding in two more signals and a diversity pass:

  • Importance — an entry's degree centrality in the tree (has_child / related / supersession edges). Hubs — heavily-linked decisions and phase roots — get a boost.
  • Recency — a half-life decay over each entry's updated_at/created_at, plus a lift for entries touched in the current session, so work-in-progress surfaces first.
  • MMR — Maximal Marginal Relevance diversification, which reduces near-duplicate clustering in the top results.

The reranker is non-punitive and off by default: with its weights at zero it reproduces the plain hybrid-RRF order exactly, and a signal a candidate lacks (an isolated leaf, a never-touched entry) simply doesn't contribute — it is never pushed down. Turn it on and tune it with environment variables: MEMORY_RERANK_W_IMPORTANCE, MEMORY_RERANK_W_RECENCY, MEMORY_RERANK_MMR_LAMBDA (1.0 = no MMR), MEMORY_RERANK_OVERFETCH, and MEMORY_RECENCY_HALF_LIFE_DAYS. When on, each result carries a rerank breakdown (fused/importance/recency) so you can see why it ranked where it did.

With well over a thousand entries, the tree needs orientation aids beyond the raw hierarchy. recall provides two, both additive — they never change the default tree output:

  • Completeness score. Every plan node (subproject / phase) carries a completeness score in [0, 1] — a deterministic, transparent read on "how done is this?".

A leaf phase (a plan node whose children include no plan nodes — the same definition open_phases uses) is scored directly, blending three signals already on the node: a concluding checkpoint (the strongest done-signal), the resolved-thread ratio, and type balance (substantive decisions/experiments/checkpoints vs. only open todos). Crucially the done-signals (checkpoint present, substantive balance) are read from the active children only, and a phase with no active children left (everything discarded) scores the floor 0.0 — an abandoned/retracted phase must never score higher than a genuinely concluded one. The resolved-thread ratio counts only threads resolved by a successor (superseded via invalidated_by), not bare discards: a plain discard is abandonment/churn, not completion, so discard-churn can never inflate the score above an in-progress or cleanly concluded phase (a messy phase with one active checkpoint and nine abandoned children must not outrank a clean [decision, checkpoint] phase).

A container plan node (a subproject, or the project root — any plan node whose children are themselves plan phases) does not use the direct formula (its children are phases, not leaf work, so it would spuriously read ~0.0). Instead its completeness is a rollup = the mean of its descendant leaf-phase completeness scores (descending only through active plan children). So a subproject of one 0.7 phase reports ~0.7; a subproject of a 0.7 phase and a 0.1 phase reports ~0.4; a container with no active leaf phases reports the 0.0 floor. This keeps the promise that every plan node carries a meaningful "how done is this?" all the way up the tree.

This score is an intentional additive field: it appears on every plan node in the open_phases list and in the tree / outline payloads (nothing existing is reordered or removed), so a consumer that snapshots plan-node shape will see the extra completeness key. The weights are deliberately simple and tunable (see _COMPLETENESS_W_* in memory/server.py) because the formula is a judgment call, not ground truth.

  • Briefs (recall(view="brief", entry_id=…)). A brief is a rollup summary of a node's subtree: its child titles/types/statuses, the concluding checkpoint (if any), the descendant type histogram, the open-thread count, the most-recent activity date, the completeness score, and the top-N most central entries in the subtree (reusing search's importance channel, with a recency fallback). It is deterministic-first — no LLM required — so it builds and tests offline.

The concluding checkpoint is active-only: even under include_discarded=true (where the brief's children list carries discarded rows), a retracted checkpoint is never named as concluding_checkpoint, so the brief agrees with completeness (which likewise credits only active checkpoints) on the same node.

A brief is cached off-file in the long-lived MCP process' memory (memory.server._brief_cache, an LRU-bounded OrderedDict), keyed by (project, entry_id) and validated by a descendant signature (descendant_count + the max updated_at over the subtree including the node itself). The project component of the key prevents a wrong-project brief on an entry-id collision (ids are only unique within a store); the LRU cap (_BRIEF_CACHE_MAX) bounds growth so discarded/moved nodes can't retain briefs forever; and _rebuild_runtime_cache clears the cache on every reload/project switch. Deliberately not persisted to the entry's committed frontmatter: recall(view="brief") is a read and must stay side-effect-free w.r.t. git — writing a cache into a tracked file on every read would dirty the working tree (perturbing record's dirty-tree pin classifier) and fail on a read-only replica. The cache is disposable like the .angelo/ runtime caches (e.g. memory.kgl); losing it just triggers a cheap recompute. Re-summarizing an unchanged subtree is recompute-free (the response is flagged cached: true); adding, removing, or touching a descendant (or the node) changes the signature and invalidates the cache automatically. This mirrors the reranker's graph-signature caching. (Known limitations: an edit that does not bump a descendant's updated_at can leave a stale brief; and top_central is derived from a global importance normalization, so a link() in a sibling subtree can shift those scores without touching any timestamp in this subtree — the ordering of already-listed central entries is therefore best-effort and may lag until the next in-subtree change. The subtree-local headline signals (children/checkpoint/completeness) are always accurate.)

An optional prose layer turns the deterministic rollup into a 2–3 sentence narrative. It is strictly opt-in (prose=true) and only fires when a narrative generator is injected (memory.server._brief_summarize_fn, default None) — the memory package never imports an LLM or zettelkasten to produce one. The prose is derived solely from the deterministic brief, so it cannot invent structure.

When to read vs. write

  • Read first. Before exploring or grepping the codebase for a question or task, call search / recall / session pickup — prior work, decisions, or the answer often already live in the tree.
  • Write deliberately. record for significant decisions, experiment outcomes, and checkpoints — and, mandatorily, before every substantive git commit, so each code change travels with a memory entry pinned to its SHA.

Git-pinned references make this reproducible: pass a files list when recording and the server commits those files and pins the entry to the resulting SHA, so git show can always reconstruct exactly the code the entry describes.

Code graph

Alongside the research tree, the server keeps a live code graph — a structural map of the repo's functions, classes, and modules that agents query with the codebase() tool instead of grepping or reading files. It's built by parsing the working tree into a KGLite graph, cached in-process, and auto-rebuilt whenever git HEAD changes, so it always reflects the current checkout without a manual refresh.

The research tree beside the codebase() code graph: memory entries pin into modules, and a card notes every entry pins a commit SHA

The code has its own graph (right), queried via codebase(). Dotted tethers show research-tree entries git-pinned into modules — every entry pins a commit SHA, so git show can reconstruct exactly the code it describes.

codebase(action=..., ...) is an action dispatcher over that graph. Each action returns a decision-shaped payload — ranked, capped, with file:line and a next hint naming the follow-on call — rather than a raw graph dump:

  • lookup (the default, back-compatible) — name/scope symbol search. Bare codebase() returns a census of the graph (how many functions, classes, modules), codebase(query="build_agent_registry") matches nodes by name, and scope="coordinator/" restricts to a directory subtree.
  • blast — "what breaks if I change this?". A reverse CALLS / IMPORTS walk from a symbol or file, returning impacted entries (each with qualified_name, file, line, distance, and a severity of breaks or maybe) plus tests_affected.
  • callers / callees / imports — tighter 1-hop views of the call and import neighborhood.
  • context — "why does this exist?". For a symbol or file anchor it returns a rolling tip and a provenance chain (below); expand_synapse=true hops from the tip to related zettelkasten canon.
  • health — graph-metric health with located anti-pattern drivers (god objects, coupling, cycles, churn hotspots, dead code).
  • ownership — "who wrote this / who do I ask?". Resolves the anchor, git-blames the symbol's line range (or the whole file, for path=), and aggregates by git author into a ranked owners list — each with a line count, commit count, and the author's last commit (sha + date). The memory-mcp bot author is excluded so the owners reflect human contributors. It degrades gracefully (an informative note, never a raised error) when there is no git repo, blame fails, or the anchor is absent.

The tether: tip + chain. The memory behind a symbol is derived from the file pins you already make — you never hand-maintain it. When record(..., files=...) pins a file, the server diffs the pin commit against its parent, intersects the changed lines with the graph's symbol ranges, and attaches the entry to the touched qualified_names (tagged changed) or, when the diff is empty or the file is unmappable, to the file anchor (tagged referenced). Each symbol then exposes an append-only chain (every entry that touched it, oldest to newest) and a rolling tip (the newest high-signal entry — decision / experiment / checkpoint — preferring changed over referenced). The changed vs referenced split is computed against the symbol's span at the pin commit (a Python reparse of that revision), so a rename or line-shift no longer mislabels a genuine edit as merely referenced; non-Python languages fall back to the live span. The tip advances only when a new pin lands, never silently from git. This makes the "dotted tethers" above real at the API layer: context is how an agent reads why a symbol is the way it is before changing it.

Julia (.jl) and other languages the graph doesn't parse degrade to file-level anchors — the chain and tip still work at file grain, without Class.method symbols. Anchor resolution now also handles a bare name in an unparsed language: a name query with no parsed symbol resolves to a file anchor when exactly one source file defines that name (path queries already worked). When several files define the name it stays ambiguous rather than guessing.

Together this is complementary to search and recall, which navigate the semantic tree of decisions and plans rather than code structure.

Skills and sessions

  • Skills are reusable procedures saved with skill(action='save') and found later with skill(action='search') — the library an agent builds of "how we do X here". Browse and prune it from the dashboard's Skills tab.
  • Sessions persist what a chat did: session(action='handoff') finalizes the work, and session(action='pickup') loads the previous session's focused context (or searches past sessions by topic) so a new chat resumes with intent.

Seeing it: the dashboard

The memory tree is excellent for agents and git but a poor thing for a human to read as a directory of frontmatter files. The memory dashboard projects the same files into scannable views — the tree and clusters, the skills library, live and past sessions, experiment provenance, and in-flight coordinator runs — without ever becoming a second source of truth. See Dashboard and the how-to, Use the memory dashboard.

Experiments and artifacts

A toggleable companion server, memory-artifacts, captures experiment provenance (code SHA, data hashes, command, environment) and backs large or regenerable files with DVC/S3 while git keeps only lightweight pointers. It rides on the same tree — experiment entries link to their manifests — and can be enabled or disabled independently of core memory. See Experiments and cloud reruns.

Commands

Natural-language phrasings that trigger memory — say the thing, the agent runs it. See the full command cheatsheet.

Say this What runs
"commit", "commit this", "push it up", "commit and push" Record a memory entry then git commit — the code change and its entry travel together
"what were we doing", "catch me up", "pick up where we left off" session(action="pickup")
"wrap up", "hand off", "save the session" session(action="handoff")
"log this decision", "checkpoint this", "record that" record(...)
"remind me to…", "add a todo", "we should do X later" record(type="todo") under the most specific entry
"show the research tree", "what's the plan", "recall" recall
"have we tried this", "search memory for X" search
"where is X defined", "what lives in this module" codebase()
"save this as a skill", "remember how to do this" skill(action="save")
"open the memory dashboard" launch_dashboard

Semantically related entries from the memory graph.