Memory System
Prax has a layered, research-grounded memory system inspired by human cognition: a compact quick-reference user_notes.md file for high-signal preferences, a fast short-term memory (STM) for immediate context, and a scalable long-term…
35c345e5
View source ↗
Prax has a layered, research-grounded memory system inspired by human cognition: a compact quick-reference user_notes.md file for high-signal preferences, a fast short-term memory (STM) for immediate context, and a scalable long-term memory (LTM) for durable recall across conversations.
Table of Contents
- Architecture Overview
- Concepts: Dense vs Sparse Retrieval
- Quick-Reference User Notes
- Short-Term Memory (STM)
- Long-Term Memory (LTM)
- Knowledge Graph Namespaces
- Consolidation Pipeline
- Memory Decay (Time-Based Forgetting Curve)
- Embedding Providers
- Agent Tools
- AST Code Analysis
- Configuration Reference
- Deployment
- Graceful Degradation
- Research Foundations
Architecture Overview
User message
│
▼
Orchestrator ──→ Memory context injection (STM scratchpad + LTM recall)
│ │
│ ┌─────┴──────┐
│ │ Retrieval │
│ │ Pipeline │
│ └─────┬──────┘
│ │
│ ┌──────────┼──────────┐
│ ▼ ▼ ▼
│ Dense Search Sparse Graph
│ (Qdrant) Search Neighbourhood
│ (Qdrant) (Neo4j)
│ │ │ │
│ └──────────┼──────────┘
│ │
│ ┌─────┴──────┐
│ │ RRF Fusion │
│ │ + Decay │
│ │ + Boost │
│ └─────┬──────┘
│ │
│ Top-k results injected into system prompt
│
▼
Memory Spoke Agent
├── memory_stm_write / read / delete (STM)
├── memory_remember / recall / forget (LTM Vector)
├── memory_entity_lookup / graph_query (LTM Graph)
└── memory_consolidate / stats (Maintenance)
│
▼
Consolidation Pipeline (every 5 turns + manual)
├── LLM extraction: entities, relations, key facts (4 000-char batches)
├── Importance scoring (0-1, "poignancy" rating)
├── Graph upsert (current-edge match, weight accumulation)
├── Vector upsert (dense + sparse embeddings)
├── Time-based decay/prune (≤ once per 24 h, configurable half-life)
└── Hierarchical summaries (daily → global)
The system has four independent data stores, each serving a different recall pattern:
| Store | Technology | What it’s good at |
|---|---|---|
| User notes | Workspace markdown file | Small canonical facts and aliases that should affect future behavior |
| STM scratchpad | Workspace JSON file | Fast key-value notes, always available, no infra |
| Vector store | Qdrant | “Find memories similar to X” — semantic similarity |
| Knowledge graph | Neo4j | “How are X and Y related?” — structured traversal |
At retrieval time, memory context is kept bounded. user_notes.md is filtered deterministically against the current user message; STM and LTM retrieval are budgeted separately.
Concepts: Dense vs Sparse Retrieval
Understanding the two types of vector search is key to understanding why the memory system uses both.
Dense Retrieval (Semantic Embeddings)
A dense embedding is a fixed-length numerical vector (e.g., 1536 floating-point numbers for OpenAI’s text-embedding-3-small) that captures the meaning of a text. Every dimension carries information — hence “dense” (no zeros).
How it works:
- The text “I prefer dark mode” is passed to an embedding model
- The model outputs a vector like
[0.023, -0.041, 0.087, ...](1536 dimensions) - Semantically similar texts produce vectors that are close together in this high-dimensional space
- At search time, the query is embedded and the nearest vectors are returned (cosine similarity)
Strengths:
- Understands paraphrase: “dark theme” matches “dark mode”
- Understands semantic relationships: “Python” matches “programming language”
- Robust to word order and phrasing variations
Weaknesses:
- Opaque — you can’t inspect why two vectors are similar
- Struggles with exact matches: searching for “error code E-4012” may match any error code
- Rare words, names, and identifiers can be lost in the embedding
In Prax: Dense embeddings are generated by OpenAI’s text-embedding-3-small (1536-dim) by default, or locally via fastembed / Ollama when configured for offline use.
Sparse Retrieval (TF-IDF / BM25)
A sparse vector represents text as a bag of weighted terms. Most dimensions are zero (hence “sparse”) — only the terms present in the text have non-zero values.
TF-IDF stands for Term Frequency–Inverse Document Frequency:
-
Term Frequency (TF): How often a word appears in this document. More occurrences → higher weight. Typically normalised:
TF = 0.5 + 0.5 × (count / max_count)(augmented TF, prevents bias toward long documents). -
Inverse Document Frequency (IDF): How rare a word is across all documents. The word “the” appears everywhere (low IDF); the word “eigenvalue” appears rarely (high IDF).
IDF = log(N / df)whereNis total documents anddfis the number containing the term. -
TF-IDF = TF × IDF. Common words get low scores, rare-but-present words get high scores.
BM25 is a refined version of TF-IDF used in production search engines (Elasticsearch, Lucene). It adds document length normalisation and saturation (diminishing returns for repeated terms).
How sparse vectors work in Prax:
- Text is tokenised into words, stop words removed
- Each word is hashed to a stable index (integer)
- TF weight is computed (augmented TF, log-scaled)
- The result is a sparse vector:
{word_hash: weight, ...} - At search time, the query is encoded the same way and matched against stored sparse vectors
Strengths:
- Exact keyword matching: “E-4012” matches exactly
- Transparent — you can see which terms matched
- No ML model required — pure computation
Weaknesses:
- No semantic understanding: “dark theme” does NOT match “dark mode”
- Sensitive to word choice and phrasing
- No concept of meaning beyond word overlap
Why Both? (Hybrid Search)
Neither dense nor sparse retrieval is strictly better — they’re complementary:
| Query type | Dense wins | Sparse wins |
|---|---|---|
| “What did we discuss about machine learning?” | Matches “ML”, “AI models”, “neural networks” | Matches only if “machine learning” appears literally |
| “Error code E-4012” | May match random error codes | Exact match on “E-4012” |
| “The user’s timezone preference” | Matches “they’re in EST” | Matches only if “timezone” appears |
Prax runs both searches in parallel and fuses the ranked results using Reciprocal Rank Fusion (RRF) — see below.
References
- Karpukhin et al., “Dense Passage Retrieval for Open-Domain Question Answering” (EMNLP 2020). arXiv:2004.04906. Demonstrated that dense retrieval substantially outperforms BM25 for passage retrieval (9-19% absolute improvement in top-20 accuracy).
- Robertson & Zaragoza, “The Probabilistic Relevance Framework: BM25 and Beyond” (Foundations and Trends in IR, 2009). Formal treatment of BM25 as a probabilistic ranking model.
- Salton & Buckley, “Term-weighting approaches in automatic text retrieval” (Information Processing & Management, 1988). Original TF-IDF framework.
Quick-Reference User Notes
user_notes.md lives at the workspace root and stores the highest-signal,
cross-session facts that should change Prax’s future behavior: timezone,
name, durable preferences, compact aliases, and stable interests. It is not
the same thing as STM or LTM:
| Layer | Scope | Retrieval behavior |
|---|---|---|
user_notes.md |
Canonical quick-reference facts | Deterministic snippet retrieval against the current user message |
| STM | Current/in-progress scratchpad | Bounded working-memory injection |
| LTM | Semantic durable memory | Vector + graph retrieval when memory infra is available |
Context Injection
user_notes.md is not injected wholesale. It can grow over time, and
dumping the full file into every prompt pollutes context and wastes tokens.
Instead, get_workspace_context(user_id, user_input) calls a cheap
deterministic retriever:
- Tokenize the current user message.
- Match against user-note lines and section items.
- Boost high-signal categories such as timezone for reminder/time queries.
- Preserve exact aliases such as
NPR. - Inject at most 8 matching lines and 1200 characters.
If no line is relevant, no user-notes context is injected. The full file
remains available through user_notes_read when the user asks broadly about
stored preferences or personal context.
Update and Compaction
user_notes_update writes the full file content. After each write,
save_user_notes() commits the raw update first, then runs deterministic
compaction only if the file appears too large or messy:
| Trigger | Threshold |
|---|---|
| Size | More than 4096 characters |
| Line count | More than 80 nonblank lines |
| Duplicate scalar key | Example: two timezone: lines |
| Duplicate list item | Same bullet repeated in the same section |
When triggered, the compactor:
- Keeps the latest scalar value for keys such as
timezoneandname. - Deduplicates repeated bullets.
- Canonicalizes section names.
- Caps each list section to the most recent 16 items.
- Writes a second
Compact user notesgit commit.
The raw pre-compaction version is recoverable from workspace git history, so compaction keeps the live file concise without silently destroying data.
Selective LTM Promotion
Compaction can drop older list items when a section exceeds the live-file cap. Some dropped items are still useful long-term preferences. Prax therefore runs a conservative promotion filter over dropped items:
- Promote candidates: durable natural-language preferences, workflows, aliases, formatting/style preferences, and project constraints.
- Do not promote: duplicate bullets, stale scalar values such as an old
timezone:, transient reminders/tasks, dated items, or short junk tokens. - Destination:
MemoryService.remember(..., source="user_notes_compaction")with tags such aspreference,workflow,alias,project, orformatting. - Limit: at most 10 promoted items per compaction.
- Degradation: if LTM infrastructure is unavailable, promotion is skipped and the pre-compaction detail remains recoverable from git history.
This is intentionally selective. The goal is to preserve durable signal that no longer belongs in the quick-reference file, not to dump every removed bullet into semantic memory.
Design Constraints
The compactor and promotion classifier intentionally do not call an LLM. User notes are part of the prompt budget control path; cleanup must be fast, deterministic, and cheap. Rich semantic extraction remains the job of the normal LTM consolidation pipeline.
Short-Term Memory (STM)
Per-user scratchpad stored as workspace JSON files at {workspace}/memory/stm.json. No external infrastructure required — STM works even without the memory Docker profile.
How it works
- Key-value entries with importance scores (0-1) and tags
- Upsert semantics: writing to an existing key updates content and increments access count (reinforcement)
- LLM compaction: when entry count exceeds
MEMORY_STM_MAX_ENTRIES(default 50), the oldest half is summarised by an LLM into a single compacted entry - Automatic injection: STM entries are injected into the orchestrator’s system prompt as “Working Memory (Scratchpad)”
Data format
[
{
"key": "user_timezone",
"content": "America/New_York (EST/EDT)",
"tags": ["preference"],
"created_at": "2026-03-15T14:30:00Z",
"access_count": 3,
"importance": 0.8
}
]
When to use STM vs LTM
| Use STM for | Use LTM for |
|---|---|
| Current session context | Durable facts across conversations |
| Temporary working notes | User preferences and personality |
| Quick key-value lookups | Semantic search (“what do I know about X?”) |
| Always-on (no infra) | Requires Qdrant + Neo4j |
Long-Term Memory (LTM)
Requires MEMORY_ENABLED=true (the default since 2026-04) plus a reachable Qdrant
and Neo4j. There is no memory compose profile: in the compose deployment both
run inside the prax container image (see the Dockerfile), and
make run-local-all starts them as docker run containers (_local-qdrant,
_local-neo4j in the Makefile).
Vector Store (Qdrant)
Qdrant stores memory chunks with both dense and sparse embeddings, enabling hybrid search.
Collection schema:
| Field | Type | Description |
|---|---|---|
dense |
Vector (1536-dim) | Semantic embedding (text-embedding-3-small or local) |
sparse |
Sparse vector | TF-IDF sparse encoding for keyword matching |
user_id |
Keyword (indexed) | Mandatory filter — all queries scoped to user |
content |
Text | Original memory text |
source |
Keyword | “conversation”, “note”, “consolidation” |
importance |
Float | 0-1 as assessed when stored. Not rewritten by decay: the effective value importance × exp(-λ × days since last_accessed) is computed when the prune pass runs (and by retrieval’s own recency term) |
created_at |
Datetime | When the memory was stored |
last_accessed |
Datetime | Last retrieval — the decay clock starts here and is reset by reinforce_memory |
access_count |
Integer | Retrieval count (reinforcement metric) |
tags |
Keyword[] | User-defined tags |
entity_ids |
Keyword[] | Cross-references to graph entities |
summary_level |
Keyword | “raw”, “daily”, “weekly”, “global” |
Operations:
upsert_memory— store a chunk with dense + sparse vectors. RaisesMemoryWriteErrorwhen the write does not land (since 2026-09; it used to return an id regardless, somemory_remember, the TeamWork memory API and consolidation all reported success for writes that never happened)search_dense— cosine similarity search on dense vectorssearch_sparse— keyword search on sparse vectorsreinforce_memory— bump access count and timestamp on retrievaldecay_memories— prune memories whose time-decayed importance is below threshold (idempotent: nothing is written back)
Knowledge Graph (Neo4j)
Neo4j stores entities and typed relations for structured memory.
Multi-graph model (three orthogonal layers, inspired by MAGMA, Jiang et al. 2026):
── Entity Graph (who/what) ──
(:Entity {
id: UUID,
user_id: string, ← mandatory, all queries scoped
name: string, ← canonical (lowercased)
display_name: string, ← original casing
type: string, ← person | topic | project | tool | url | concept | organization
importance: float, ← 0-1, decays over time
mention_count: integer,
first_seen: datetime,
last_seen: datetime,
properties: map
})
-[:RELATES_TO {
type: string, ← works_on | interested_in | prefers | related_to | part_of | caused_by | mentioned_with
weight: float, ← accumulates on each mention
first_seen: datetime,
last_seen: datetime,
evidence: string, ← brief reason, semicolon-separated
valid_from: datetime, ← bi-temporal: when the fact became true
valid_until: datetime ← bi-temporal: when superseded (null = currently valid)
}]->
── Temporal Graph (when) ──
(:TemporalEvent {
id: UUID,
user_id: string,
description: string,
occurred_at: datetime,
importance: float,
created_at: datetime
})
(:Entity)-[:PARTICIPATED_IN]->(:TemporalEvent)
── Causal Graph (why) ──
(:CausalLink {
id: UUID,
user_id: string,
cause: string,
effect: string,
importance: float,
created_at: datetime
})
(:Entity)-[:CAUSED_BY {direction: 'cause'}]->(:CausalLink)
(:CausalLink)-[:CAUSED_BY {direction: 'effect'}]->(:Entity)
Bi-temporal edges (inspired by Rasmussen et al., “Zep” 2025): Every RELATES_TO edge carries valid_from (when the fact became true) and valid_until (when it was superseded). valid_until = null means “currently valid”. When consolidation detects a contradiction (e.g., “user now prefers light mode”), the old “prefers dark mode” edge gets valid_until set rather than deleted — preserving history while keeping retrieval current.
Operations:
merge_entity— upsert with MERGE semantics (increment mention_count on match)add_relation— create or strengthen a typed edge (weight accumulates, bi-temporal)supersede_relation— mark an edge as no longer current (setsvalid_until)merge_temporal_event— create a TemporalEvent and link participating entitiesadd_causal_link— create a CausalLink connecting cause and effect entitiesget_entity— look up entity + all its current relations (filter superseded by default)get_neighbours— k-hop traversal for associative recallsearch_entities— substring search on entity namesdecay_graph— apply exponential decay to importance/weight, prune weak nodes/edges
Hybrid Retrieval (RRF Fusion)
At query time, three retrieval arms run and their results are fused:
Step 1: Classify query intent
A lightweight heuristic (no LLM call) classifies the query to determine retrieval arm weights:
| Signal | Example | Dense | Sparse | Graph |
|---|---|---|---|---|
| Quoted phrases / identifiers | "error E-4012" |
0.8× | 1.5× | 1.3× |
| Named entities (capitalised) | How are Alice and Bob related? |
1.0× | 1.2× | 1.4× |
| Open-ended / semantic | How do you feel about the approach? |
1.4× | 0.9× | 1.0× |
| Neutral | memory test |
1.0× | 1.0× | 1.0× |
Inspired by type-specific weighted RRF (arXiv:2511.18194) — different query types benefit from different retrieval strategies.
Step 2: Generate query representations
- Dense: embed query with same model used for storage
- Sparse: TF-IDF encode query
Step 3: Parallel retrieval
- Dense vector search (Qdrant, top 2k)
- Sparse keyword search (Qdrant, top 2k)
- Graph neighbourhood search:
- Extract key noun phrases from query
- Find matching entities in graph
- Traverse 1-2 hops to find related entities
- Fetch associated memories from vector store via
entity_ids
Step 4: Weighted Reciprocal Rank Fusion (RRF)
For each candidate appearing in any list:
rrf_score = Σ weight_i / (k + rank_i) for each list where candidate appears
where k = 60 (standard constant from Cormack et al., 2009) and weight_i comes from query classification (Step 1). Equal weights reduce to standard RRF.
Step 5: Post-processing
- Time decay:
score *= exp(-λ × days_old)whereλ = ln(2) / halflife - Importance boost:
score *= (0.5 + importance)— higher importance gets up to 1.5× boost - Re-sort and take top-k
Step 6: Reinforcement
- Returned memories get their
access_countandlast_accessedupdated (vector_store.reinforce_memory), which restarts that memory’s decay clock - This implements the “strengthen on recall” pattern (MemoryBank, Zhong et al., 2023)
Optional precision passes (opt-in)
Two flags add recall/precision to the dense arm and the final ranking. Both default off, so retrieval is byte-for-byte unchanged unless enabled:
| Flag | What it does |
|---|---|
RETRIEVAL_QUERY_EXPANSION (+RETRIEVAL_QUERY_EXPANSION_N) |
Generates a few paraphrase/HyDE variants of the query with a cheap LOW-tier model, embeds each, and unions their dense hits before RRF — a recall win when the query’s wording differs from how the memory was stored (retrieval._dense_arm / _expand_queries). |
RETRIEVAL_RERANK (+RETRIEVAL_RERANK_CANDIDATES) |
After fusion, an LLM-judge re-scores the top fused candidates against the query and reorders them, so a low-relevance-but-recent/important memory can’t outrank an on-topic one (retrieval._rerank). |
Both degrade gracefully — if the assist model is unavailable they fall back to the original order, never breaking retrieval.
Knowledge Graph Namespaces
The Neo4j instance contains two logically separate graph spaces:
Same Neo4j Instance
├── Memory Graph (existing) ← about the USER
│ ├── (:Entity) ← people, topics, projects from conversations
│ ├── (:TemporalEvent) ← time-stamped events
│ ├── (:CausalLink) ← cause/effect relationships
│ └── [:RELATES_TO, :PARTICIPATED_IN] ← memory relationships
│
└── Knowledge Graph (new) ← about the WORLD
├── (:KnowledgeConcept) ← concepts from documents/papers/code
├── (:KnowledgeDocument) ← source documents
├── [:KNOWLEDGE_RELATES] ← concept-to-concept relations
├── [:EXTRACTED_FROM] ← document-to-concept provenance
└── [:REFERENCES_ENTITY] ← cross-namespace links to memory
Why separate namespaces?
If document concepts were stored as regular (:Entity) nodes, a user who uploads 50 papers would have their conversational memory flooded with thousands of extracted entities. “What do I know about Alice?” would return paper concepts alongside actual user facts. Separate labels ensure:
- Memory queries (
(:Entity)) return only conversational facts — fast and focused - Knowledge queries (
(:KnowledgeConcept)) return only document-extracted concepts - Cross-links (
[:REFERENCES_ENTITY]) connect the two when genuinely relevant
Namespaces within the knowledge graph
Each KnowledgeConcept has a namespace field that organizes knowledge by source:
| Namespace | Contents | Created by |
|---|---|---|
papers |
Academic papers, research articles | knowledge_ingest on PDFs |
docs |
Documentation, guides, READMEs | knowledge_ingest on markdown |
codebase |
Code structure, modules, APIs | AST tools or knowledge_ingest on code |
uploads |
User-uploaded files | knowledge_ingest on workspace files |
| Custom | User-defined | knowledge_ingest(namespace="...") |
Tools
| Tool | What it does |
|---|---|
knowledge_ingest |
Extract concepts and relations from a document using LLM, store in namespace |
knowledge_search |
Search concepts across namespaces (or within a specific one) |
knowledge_namespaces |
List all namespaces with concept counts — helps Prax know what’s available |
knowledge_connect |
Link a knowledge concept to a memory entity (cross-namespace) |
Query patterns
"What does that paper say about attention?" → knowledge graph (namespace: papers)
"What do I know about Alice?" → memory graph (Entity nodes)
"Connect my notes with what the research says" → cross-namespace join via REFERENCES_ENTITY
Concept search — hybrid (vector + keyword)
knowledge_search does hybrid retrieval, not bare substring matching. Two arms run and
fuse via RRF:
- Keyword arm — multi-variant
CONTAINSmatch over name/display_name/description, ordered by how many terms matched then importance (knowledge_graph._keyword_search). - Semantic arm — dense+sparse vector search over concept embeddings in a dedicated
Qdrant collection
prax_knowledge_concepts(knowledge_vectors.search).
Concepts are vector-indexed automatically on add_concept (and dropped on
delete_namespace). The arms are fused, vector-only hits are hydrated from Neo4j, and the
top-k returned. Graceful degradation: when Qdrant/the embedder is unavailable, the
semantic arm returns nothing and knowledge_search is exactly the keyword arm — so it always
works, just with less recall. Controlled by KNOWLEDGE_HYBRID_ENABLED (default on).
Backfilling existing concepts: new concepts index on write, but concepts created before
hybrid search was enabled have no vectors yet. Run knowledge_graph.reindex_user_concepts(user_id)
once to index the backlog (no-op when the vector backend is unavailable).
Portability / interchange (OKF): the concept graph can be exported to / imported from portable Open Knowledge Format bundles (markdown + YAML frontmatter, cross-linked, with
index.md/log.md) viaknowledge_graph.export_namespace_okf()/import_okf()and the agent toolsknowledge_export_okf/knowledge_import_okf(okf_bridge.py). OKF is used as an interchange format only — the Neo4j+Qdrant vector-hybrid model stays canonical.
Data flow
flowchart TD
DOC[User uploads document] --> INGEST[knowledge_ingest]
INGEST --> LLM[LLM extracts concepts + relations]
LLM --> KG[(Knowledge Graph
:KnowledgeConcept)]
CONV[User conversation] --> CONSOL[Consolidation pipeline]
CONSOL --> MG[(Memory Graph
:Entity)]
KG -.->|REFERENCES_ENTITY| MG
KG --> KSEARCH[knowledge_search]
MG --> RECALL[memory recall]
Implementation
- Same Neo4j driver as the memory graph (shared connection pool)
- Separate indexes on
(:KnowledgeConcept {user_id, namespace, name}) - All queries filter by
user_id(multi-tenant isolation maintained) ingest_document()uses a low-tier LLM to extract concepts — keeps costs downdelete_namespace()cleans up all concepts/relations in a namespace
See knowledge_graph.py and knowledge_tools.py.
Consolidation Pipeline
Converts episodic conversation traces into durable memories.
Triggers
| Trigger | When |
|---|---|
| Auto (per-turn) | Orchestrator calls maybe_consolidate(user_id) after every turn; runs the full pipeline once every 5 turns per user |
| Scheduled | Historical — no longer exists. MEMORY_CONSOLIDATION_INTERVAL is still defined in settings but has no reader; the per-turn trigger is the only automatic one |
| Manual | Agent calls memory_consolidate tool |
History note: Before April 2026, consolidation was documented as “scheduled” but never actually wired up — the function existed but had no callers, so memory stayed empty even though the infrastructure was in place. This was fixed by adding a per-turn auto-consolidation hook in
prax/services/memory_service.py:maybe_consolidate()invoked from the orchestrator’s turn-end block. Frequency is bounded by_CONSOLIDATE_EVERY_N_TURNS = 5to amortize the LLM extraction cost.
Pipeline steps
1. Read unconsolidated trace entries from {workspace}/trace.log, in batches
├── Batch = non-blank lines up to EXTRACTION_CHAR_BUDGET (4 000 chars) —
│ the same cap the extractor enforces, so nothing batched is truncated
├── Up to MAX_BATCHES_PER_RUN (8) batches per run — ≤ 32 KB of trace and
│ at most 8 extraction calls per consolidation. What is left is the
│ BACKLOG: `pending_lines` / `pending_bytes` on the result and
│ `trace_pending_lines` / `trace_pending_bytes` in the state file, logged
│ whenever the cap was hit. Later runs pick it up — the per-run budget is
│ a bound on cost, not a guarantee that consolidation keeps up with a
│ busy trace (a turn can write several lines of up to 5 000 chars)
├── Pointer in {workspace}/memory/consolidation_state.json advances to one
│ past the last RAW line handed to the extractor (blank separators
│ included), saved after EVERY batch
└── Rotation/replacement of trace.log (512 KB rotation, or a changed first
line) resets the pointer to 0. **Everything past the pointer in the old
file is dropped from consolidation** — the archive under
archive/trace_logs/ is not read — so the reset logs at WARNING how many
content lines were pending at the last run (a floor: lines appended
since are lost uncounted). `rotation_resets`, `last_rotation_reset_at`
and `last_rotation_dropped_lines` are recorded in the state file
2. LLM extraction (tier: low, temp: 0.2)
├── Entities: {name, type, display_name, importance, confidence}
├── Relations: {source, type, target, weight, evidence, confidence, valid_from, supersedes}
├── Facts: {content, importance, confidence}
├── Temporal events: {description, occurred_at, importance, participants}
└── Causal links: {cause, effect, cause_entities, effect_entities, importance}
3. Validation gate (confidence ≥ 0.6)
├── High-confidence → proceed to LTM upsert
└── Low-confidence → STM "pending_review" queue (human validation)
4. Entity graph upsert
├── MERGE entities (increment mention_count on match)
└── Relations: strengthen the CURRENT edge (valid_until IS NULL) if one
exists, else CREATE a new open edge — a superseded edge is never reopened
└── If supersedes: set valid_until on old edge
5. Temporal + causal graph upsert
├── Create TemporalEvent nodes, link participants
└── Create CausalLink nodes, link cause/effect entities
6. Vector upsert (high-confidence facts only)
├── Chunk facts into memory units
├── Generate dense + sparse embeddings
└── Store with entity cross-references; a failed write raises and is
counted in ConsolidationResult.memories_failed, never as "created"
7. Time-based decay/prune pass — only if `last_decay_run` is ≥ 24 h old
├── Vector: prune where importance × exp(-λ_t × days_since_last_access) < 0.02
├── Graph: prune entities (no relations) / relations where
│ stored × exp(-λ × total_days_since_last_seen) < 0.05 / 0.025
└── Nothing is written back, so the pass is idempotent for a given moment
8. Daily summary (if new day boundary) — sees the run's FIRST ≤ 4 000-char batch
├── Summarise today's memories (3-5 sentences)
└── Store summary as a "daily" level memory
9. Low-confidence items → STM pending review
10. Update consolidation state (pointer, trace fingerprint, last_decay_run
when the pass ran) — written atomically (temp file + rename)
ConsolidationResult reports batches (extractor batches drained this run),
bytes_seen (UTF-8 bytes handed to the extractor this run), bytes_skipped
(content the pointer passed without the extractor seeing it — only the tail of
a single trace line longer than the budget; such a line is sent truncated
rather than dropped), and pending_lines / pending_bytes (the backlog still
waiting after this run).
History note (2026-09): the pointer counted raw lines on the way in and non-blank lines on the way out, so every blank separator in a batch caused re-consolidation of the batch tail (13 of 50 lines on a real trace); after the first 512 KB rotation it pointed past the end of the new file and consolidation stopped entirely. Separately, the batch was “50 lines” (up to ~250 KB) while the extractor read
text[:4000]. The first fix for that processed exactly one 4 000-char batch per run, which could not keep up with an active trace either — the pointer fell further behind every cycle and each rotation silently discarded the backlog. Runs now drain up toMAX_BATCHES_PER_RUNbatches, report the remaining backlog, and log what a rotation abandons. Coverage of a busy trace is still bounded by that budget; it is a visible lag now, not a silent one.
Importance scoring
The LLM rates each extracted fact on a 0-1 scale:
| Score | Meaning | Examples |
|---|---|---|
| 0.8-1.0 | Critical — core preferences, key decisions | “I’m allergic to shellfish”, “We decided to use Rust” |
| 0.4-0.7 | Useful — recurring topics, context | “Working on project Alpha”, “Interested in quantum computing” |
| 0.1-0.3 | Minor — tangential mentions | “Mentioned having coffee”, “Asked about the weather” |
Memory Decay (Time-Based Forgetting Curve)
Memory decay is time-based only (as of 2026-09 this is the only decay pass; an interaction-count signal existed in code but never had a caller and was removed rather than armed). It follows the Ebbinghaus forgetting curve as operationalised by MemoryBank (Zhong et al. 2023): retention falls exponentially with time since the memory was last recalled, and a recall restarts the clock.
What is computed
effective_importance = stored_importance × exp(-λ_t × days_since_last_access)
where λ_t = ln(2) / MEMORY_DECAY_HALFLIFE_DAYS
With the default half-life of 7 days a memory stored at 0.8 has effective importance 0.4 after 7 unrecalled days, 0.2 after 14, 0.1 after 21. Below the prune threshold (0.02) it is deleted — for a 0.5 memory that is ~33 days without a recall.
What is (and is not) written
The prune pass (vector_store.decay_memories) evaluates the formula and
deletes below-threshold memories. It never writes the decayed value back
to the stored importance. That makes the pass idempotent: running it twice
for the same moment is one pass, and its outcome depends only on how long a
memory has gone unrecalled — not on how often consolidation happened to run.
The same holds for the graph (graph_store.decay_graph): entities with no
relations and relations are pruned where stored × exp(-λ × total days since last_seen) falls below 0.05 / 0.025, evaluated in the prune predicate with
epoch-seconds arithmetic (total days, not the days component of a
month/day/second duration). Graph half-life is 2× the vector one (14 days
default) because entity relationships are more stable than episodic memories.
Cadence
Consolidation runs every 5 turns per user, but the decay pass inside it is
gated on last_decay_run in consolidation_state.json being at least 24 h
old (consolidation.DECAY_MIN_INTERVAL). The mark is advanced only when the
pass actually ran.
Reinforcement
Accessing a memory resets its last_accessed timestamp and bumps
access_count (reinforce_memory, called by retrieval for the returned
top-k). Because the pass does not write decayed values back, a recall
restores the memory to its full stored importance — frequently recalled
memories persist, unused ones fade.
History note (2026-09): before this, the pass multiplied the stored importance by the full factor and wrote it back on every consolidation (every 5 turns), while still measuring from
last_accessed, so the exponent accumulated and a “7-day half-life” pruned an active user’s memories in 4-8 days; the graph additionally usedduration.between(...).days, the days component, so a 45-day gap counted as 15.last_decay_runwas written and never read.
Embedding Providers
The memory system supports multiple embedding backends for dense vectors. Your choice depends on your priorities: quality, privacy, cost, and infrastructure.
Comparison
| OpenAI | Ollama | fastembed (local) | |
|---|---|---|---|
| Model | text-embedding-3-small |
nomic-embed-text (or others) |
BAAI/bge-small-en-v1.5 |
| Dimensions | 1536 | 768 | 384 |
| Quality (MTEB avg) | ~62% | ~56-60% | ~51% |
| Latency | ~50ms/batch (network) | ~20ms/batch (local GPU/CPU) | ~100ms/batch (CPU) |
| Cost | $0.02/1M tokens | Free (your hardware) | Free (in-process) |
| Privacy | Data sent to OpenAI | Fully local | Fully local |
| Infrastructure | None (API call) | Ollama server | None (Python library) |
| GPU needed? | No | Recommended but not required | No |
Which should you choose?
-
OpenAI — Best quality, lowest friction. Use this if you’re already using OpenAI for LLM calls and don’t have strict data privacy requirements. The embedding data sent is just the text being stored/queried — not conversation history.
-
Ollama — Best balance of quality and privacy. Use this if you want no data leaving your machine, have a reasonably capable CPU (or GPU), and don’t mind running one more service.
nomic-embed-textis the recommended model — it’s small, fast, and punches above its weight on retrieval benchmarks. With Docker Compose (--profile local-llm), setup is one command. -
fastembed (local) — Zero-dependency fallback. Use this if you want the absolute simplest setup or as an automatic fallback when other providers fail. Quality is lower (384-dim vs 1536-dim means less semantic resolution), but for a personal assistant’s memory the difference is often acceptable.
Important: Once you choose a provider, stick with it for a given Qdrant collection. Changing providers changes the vector dimensions, which requires re-embedding all stored memories. If you need to switch, delete the prax_memories collection in Qdrant first (memories from the current session’s consolidation will repopulate it).
OpenAI (default)
Uses text-embedding-3-small (1536 dimensions). Requires OPENAI_KEY set.
EMBEDDING_PROVIDER=openai
EMBEDDING_MODEL=text-embedding-3-small
Ollama (local, offline)
For users running local models who don’t want to send data to OpenAI. Uses Ollama’s /api/embed endpoint. Requires Ollama running with an embedding model pulled.
# Docker Compose (recommended) — the Ollama service is behind the `local-llm` profile
docker compose --profile local-llm up --build
docker compose exec ollama ollama pull nomic-embed-text
# Or standalone Ollama (if already installed)
ollama pull nomic-embed-text
EMBEDDING_PROVIDER=ollama
EMBEDDING_MODEL=nomic-embed-text
OLLAMA_BASE_URL=http://localhost:11434 # or http://ollama:11434 in Docker
Other Ollama embedding models exist (mxbai-embed-large, 1024-dim; all-minilm, 384-dim), but see the dimension note below before switching: the collection width for the ollama provider is fixed at 768.
Sentence Transformers / fastembed (local, fallback)
Lightweight local embeddings via the fastembed library. Uses BAAI/bge-small-en-v1.5 (384 dimensions). No external service needed — runs in-process. Also serves as the automatic fallback if the primary provider fails.
EMBEDDING_PROVIDER=local
The vector store sizes the dense vector per provider, not per model: _PROVIDER_DIM = {"openai": 1536, "ollama": 768, "local": 384} in prax/services/memory/vector_store.py, applied when the prax_memories collection is created. A model whose width differs from its provider’s entry (an Ollama model other than a 768-dim one, or text-embedding-3-large on OpenAI) does not fit the collection; switching width means deleting and re-creating the collection as described above.
Sparse vectors
Sparse vectors are always generated locally (no API call) using TF-IDF encoding. This is a pure computation step independent of the dense embedding provider — no matter which dense provider you choose, sparse vectors are always free and instant.
Agent Tools
The memory spoke provides 10 tools via delegate_memory:
| Tool | Type | Description |
|---|---|---|
memory_stm_write |
STM | Write key-value entry to scratchpad |
memory_stm_read |
STM | Read scratchpad entries (all or by key) |
memory_stm_delete |
STM | Remove a scratchpad entry |
memory_remember |
LTM | Store a fact with dense + sparse embeddings |
memory_recall |
LTM | Semantic search across all memories (hybrid) |
memory_forget |
LTM | Delete a specific memory by ID |
memory_entity_lookup |
Graph | Look up entity + all relationships |
memory_graph_query |
Graph | Natural language query over the knowledge graph |
memory_consolidate |
Maint | Trigger consolidation pipeline |
memory_stats |
Maint | Show memory system statistics |
Example interactions
Storing a preference:
“Remember that I prefer dark mode in all my tools” →
memory_remember("User prefers dark mode in all tools", importance=0.8, tags="preference,ui")
Semantic recall:
“What do you know about my coding preferences?” →
memory_recall("coding preferences")→ returns stored preferences about dark mode, Python, etc.
Entity lookup:
“What do you know about Project Alpha?” →
memory_entity_lookup("Project Alpha")→ returns entity + all relations (team members, technologies, dependencies)
Graph query:
“How are quantum computing and my research project connected?” →
memory_graph_query("quantum computing research project connection")
Configuration Reference
| Variable | Default | Description |
|---|---|---|
MEMORY_ENABLED |
true |
Enable LTM (requires Qdrant + Neo4j); default true in prax/settings.py since 2026-04 |
QDRANT_URL |
http://localhost:6333 |
Qdrant vector store endpoint |
NEO4J_URI |
bolt://localhost:7687 |
Neo4j graph database endpoint |
NEO4J_USER |
neo4j |
Neo4j username |
NEO4J_PASSWORD |
prax-memory |
Neo4j password |
EMBEDDING_MODEL |
text-embedding-3-small |
Embedding model name |
EMBEDDING_PROVIDER |
openai |
Embedding provider: openai, ollama, or local |
OLLAMA_BASE_URL |
http://localhost:11434 |
Ollama endpoint (when provider=ollama) |
MEMORY_CONSOLIDATION_INTERVAL |
3600 |
Unused — defined in prax/settings.py but no code reads it (as of 2026-09). Consolidation cadence is the per-turn trigger, once every 5 turns per user |
MEMORY_STM_MAX_ENTRIES |
50 |
Max STM entries before LLM compaction |
MEMORY_DECAY_HALFLIFE_DAYS |
7.0 |
Ebbinghaus decay half-life in days (vector store; the graph uses 2×). The prune pass runs at most once per 24 h per user |
LLM routing for memory components is configured in prax/plugins/configs/llm_routing.yaml:
components:
subagent_memory: # Memory spoke agent
tier: low
temperature: 0.3
memory_consolidation: # Entity/relation extraction + summaries
tier: low
temperature: 0.2
memory_compact: # STM compaction summarisation
tier: low
temperature: 0.2
Deployment
Docker Compose (recommended)
# Core services (Qdrant + Neo4j are bundled inside the prax container image)
docker compose up --build
# With observability too
docker compose --profile observability up --build
There is no memory profile (the profiles defined in docker-compose.yml as of 2026-09 are local-llm, observability, secrets-proxy, tailscale). Qdrant (:6333) and Neo4j (:7474/:7687) run inside the prax container and are not published to the host by compose — the prax service publishes only 3000, 8000, 5001 and 4040. MEMORY_ENABLED defaults to true. (Under make run-local-all they are separate containers published on the host — on all interfaces; see docs/security/network-exposure.md.)
Neo4j Browser
Open http://localhost:7474 to explore the knowledge graph visually (reachable on the host under make run-local-all; under compose it is inside the prax container — docker compose exec prax curl -s localhost:7474, or add your own port mapping). Login with neo4j / prax-memory (or your configured password).
Useful Cypher queries:
-- All entities for a user
MATCH (e:Entity {user_id: 'usr_abc123'})
RETURN e ORDER BY e.mention_count DESC
-- Entity relationship map
MATCH (e:Entity {user_id: 'usr_abc123'})-[r:RELATES_TO]-(other)
RETURN e, r, other
-- Most connected entities
MATCH (e:Entity {user_id: 'usr_abc123'})-[r:RELATES_TO]-()
RETURN e.display_name, e.type, count(r) AS connections
ORDER BY connections DESC
Qdrant Dashboard
Open http://localhost:6333/dashboard to explore stored memories, view collection stats, and run test queries (same reachability note as Neo4j above: host-published under make run-local-all, in-container under compose).
Test Evidence
End-to-end integration tests exercise the full memory stack with real Qdrant, Neo4j, and Ollama — no mocks. Run with uv run pytest tests/e2e/test_memory.py -v -s.
Test Suite
| Test Class | Tests | Coverage |
|---|---|---|
TestSTM |
2 | Write/read/delete/update + tags |
TestLTM |
3 | Store + semantic recall, ranking correctness, hybrid vs dense-only |
TestGraphStore |
4 | Entity lifecycle, bi-temporal edges, temporal events, causal links |
TestMemoryServiceIntegration |
1 | Full remember→recall through MemoryService facade |
TestMemoryContextInjection |
3 | Empty context without memory, enriched context with memory, side-by-side comparison |
TestFullPipeline |
1 | Realistic multi-turn session: STM + LTM + graph + memory context |
(A TestInteractionDecay class was removed in 2026-09 along with the
interaction-epoch code it exercised.)
Keyless unit coverage of the 2026-09 pipeline fixes: tests/test_consolidation_pointer.py,
tests/test_extractor_batching.py, tests/test_decay_idempotence.py,
tests/test_upsert_failure_surfaces.py, tests/test_graph_relation_validity.py
(plus an opt-in live Neo4j check via PRAX_LIVE_NEO4J=1),
tests/test_failure_journal_qdrant.py, and the atomic-write tests in
tests/test_memory_advanced.py.
Sample Output: With vs Without Memory
The test_with_vs_without_memory_comparison test stores user preferences, then shows the context the agent receives:
WITHOUT memory — agent has no personal context:
(empty)
WITH memory — 4 facts recalled and injected into the system prompt:
## Relevant Memories
- [conversation, 2026-04-02] User prefers Rust for systems projects and Python for scripts.
- [conversation, 2026-04-02] User uses NixOS with flake-based project templates.
- [conversation, 2026-04-02] User insists on MIT license for all personal projects.
- [conversation, 2026-04-02] User's preferred editor is Helix with catppuccin theme.
With memory, an agent asked “Can you help me set up a new project?” now knows the user wants Rust + NixOS flakes + MIT license + Helix — and can scaffold accordingly.
Sample Output: Full Pipeline
The test_realistic_user_session test simulates a multi-turn conversation building up STM, LTM, and graph layers, then retrieves context for a follow-up question:
## Working Memory (Scratchpad)
- **user_role**: data scientist at FinCorp
- **current_project**: fraud detection model using XGBoost
## Relevant Memories
- [conversation, 2026-04-02] User is a data scientist at FinCorp working on fraud detection with XGBoost.
- [conversation, 2026-04-02] User prefers polars over pandas for large datasets because of performance.
The agent now knows the user is a data scientist at FinCorp using XGBoost for fraud detection and prefers polars — personalized answers to “optimize my data pipeline” become possible.
Graceful Degradation
The memory system follows Prax’s pattern of graceful degradation:
| Condition | Behaviour |
|---|---|
MEMORY_ENABLED=false |
STM works normally. LTM tools return “Memory system not available.” |
| Qdrant unreachable | Vector reads log warnings and return empty results. Vector writes raise MemoryWriteError (since 2026-09), so MemoryService.remember returns "", the memory_remember tool says “Failed to store memory.”, the TeamWork API answers 500, and consolidation counts memories_failed — never a memory id for a write that did not happen |
| Neo4j unreachable | Graph operations log warnings and return empty results |
| Failure journal: embedding or Qdrant fails | Local JSONL is still written (source of truth); the Qdrant leg logs a WARNING and is skipped |
| STM / consolidation state write interrupted | The previous stm.json / consolidation_state.json stays intact (same-directory temp file + os.replace); no torn file, no silent reset |
| Embedding API fails | Falls back to local fastembed; if that fails too, embed_texts raises EmbeddingUnavailableError and the write fails loudly — zero vectors are never fabricated (changed 2026-08) |
| LLM consolidation fails | Logs warning, skips consolidation run |
| Memory profile not started | Prax starts normally, memory context injection returns empty |
No crashes, no retries, no memory accumulation from failed connections.
Pipeline Coverage Telemetry
Phase 0 of the pipeline evolution roadmap instruments every orchestrator turn so we can measure where the existing spoke library actually fails. The data feeds a Pareto chart that tells us whether to build the L1 dynamic escape hatch or just add more spokes.
Storage and restart robustness
| Aspect | Detail |
|---|---|
| On-disk file | {workspace_dir}/.pipeline_coverage.jsonl — append-only JSONL, ~250 bytes per event |
| In-memory ring buffer | Bounded at 5000 events for fast clustering during the session |
| Embeddings on disk | Not persisted — stripped on write to keep the file ~60× smaller |
| Embeddings on read | Re-computed lazily at report time via the existing memory embedder |
| Restart | On first access after restart, _init() loads events from disk; lazy re-embed runs once on the first report call and caches results |
| Partial writes | Corrupt JSON lines from a process killed mid-write are skipped via try/except |
| Auto-prune | Every 100 turns, prune_old_events() rewrites the file to drop entries older than 30 days; called from the orchestrator’s turn-end block (no separate scheduler) |
| Test mode | set_test_mode(True) routes events to .pipeline_coverage_harness.jsonl so harness data never pollutes real telemetry |
What’s recorded per turn
{
"timestamp": 1743638400.0,
"user_id": "alice",
"request": "Make me a note about gradient descent",
"matched_spoke": "knowledge",
"delegations": ["knowledge"],
"outcome_status": "completed",
"tool_call_count": 3,
"duration_ms": 2156
}
The embedding field is present in memory but stripped on disk to keep the file ~60× smaller. This means a 5000-event store is ~1.3MB instead of ~75MB.
API endpoints
GET /teamwork/pipeline-coverage— full Pareto report withtotal_turns,fallback_rate,clusters,top_failures,coverage_by_spoke,decision_hintGET /teamwork/pipeline-coverage/events— raw events (without embeddings)POST /teamwork/pipeline-coverage/test-mode— toggle test mode for the coverage harness
Why this isn’t part of the memory system proper
Pipeline coverage is observability about Prax, not memory for Prax. It lives in the workspace dir alongside the other telemetry files (.health_telemetry.jsonl, .access_log.json) and is gated by the same HEALTH_MONITOR_ENABLED toggle. It doesn’t write to STM, LTM, or the knowledge graph — those are for user-facing memory.
See pipeline-composition.md for the research that motivated this and PIPELINE_EVOLUTION_TODO.md for the phased roadmap.
AST Code Analysis
The sysadmin and self-improve spokes have access to tree-sitter based AST parsing tools that provide structural code understanding:
| Tool | What it does |
|---|---|
code_structure |
Parse a file and return classes, functions, imports, decorators — without reading the entire file content |
code_dependencies |
Map import dependencies across a directory, detect circular imports, find hub files |
code_search_ast |
Search for functions/classes/methods by name using AST (not text grep — won’t match comments or variable names) |
These tools complement the knowledge graph: code_structure provides real-time AST analysis for specific files, while knowledge_ingest on a codebase creates a persistent, queryable graph of the overall architecture.
Supports: Python, JavaScript, TypeScript. Requires tree-sitter (installed as a dependency).
See ast_tools.py.
Research Foundations
The memory system design draws from established academic work across cognitive science, information retrieval, and LLM agents.
Foundational Work
-
Park et al., “Generative Agents: Interactive Simulacra of Human Behavior” (UIST 2023). arXiv:2304.03442. Introduced the relevance + recency + importance scoring triad with exponential recency decay. Ablation studies showed each component is critical. Prax uses this as the basis for memory ranking.
-
Zhong et al., “MemoryBank: Enhancing Large Language Models with Long-Term Memory” (AAAI 2024). arXiv:2305.10250. Operationalised the Ebbinghaus forgetting curve for LLM memory. Implemented daily-to-global hierarchical summaries, personality aggregation, and strengthen-on-recall reinforcement. Prax’s decay and summary pipeline directly follows this design.
-
Packer et al., “MemGPT: Towards LLMs as Operating Systems” (ICLR 2024). arXiv:2310.08560. Virtual memory paging for LLMs — bounded “RAM” context with unbounded “disk” storage, tool-call-driven paging on memory pressure. Prax’s STM + LTM split and the retrieval injection pattern are inspired by this framing.
-
Lewis et al., “Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks” (NeurIPS 2020). arXiv:2005.11401. The foundational RAG paper combining parametric (LM weights) with non-parametric (dense index) memory. Prax’s vector store retrieval follows this paradigm.
Graph-Based Memory
-
He et al., “HippoRAG: Neurobiologically Inspired Long-Term Memory for Large Language Models” (NeurIPS 2024). arXiv:2405.14831. Knowledge graph + Personalized PageRank for retrieval, inspired by hippocampal indexing theory. Reported strong gains over iterative retrieval baselines. Prax’s graph neighbourhood traversal follows this approach.
-
Edge et al., “From Local to Global: A Graph RAG Approach to Query-Focused Summarization” (2024). arXiv:2404.16130. Entity graph + community summaries for corpus-level sensemaking. Prax’s entity extraction and relation consolidation draw from this methodology.
-
Modarressi et al., “RET-LLM: Towards a General Read-Write Memory for Large Language Models” (2023). arXiv:2305.14322. Extracted triplets as structured, interpretable memory units with temporal reasoning. Validates the graph-based approach to LLM memory.
Information Retrieval
-
Karpukhin et al., “Dense Passage Retrieval for Open-Domain Question Answering” (EMNLP 2020). arXiv:2004.04906. Dense retrieval outperforms BM25 by 9-19% absolute in top-20 passage retrieval accuracy. Establishes embeddings as effective retrieval primitives.
-
Cormack et al., “Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods” (SIGIR 2009). Reports consistent improvements over individual rankers. Prax uses RRF with k=60 as the fusion strategy.
-
Robertson & Zaragoza, “The Probabilistic Relevance Framework: BM25 and Beyond” (Foundations and Trends in IR, 2009). Formal treatment of BM25, the probabilistic ranking model underlying sparse retrieval.
-
Khattab & Zaharia, “ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT” (SIGIR 2020). arXiv:2004.12832. Late interaction retrieval — competitive with cross-encoders at much lower cost. Relevant for future reranking improvements.
Recent Work (2025-2026)
-
Xu et al., “A-MEM: Agentic Memory for LLM Agents” (2025). arXiv:2502.12110. Zettelkasten-inspired dynamic memory framework where agents actively organise memories via structured operations (ADD, UPDATE, DELETE, NOOP) with interconnected knowledge notes. Demonstrates that agent-driven memory organisation outperforms passive storage across six foundation models. Validates Prax’s approach of giving agents explicit memory tools rather than relying on implicit context window management.
-
Gutiérrez et al., “From RAG to Memory: Non-Parametric Continual Learning for Large Language Models” (HippoRAG 2, 2025). arXiv:2502.14802. Extends HippoRAG with deeper passage integration and improved Personalized PageRank, achieving 7% improvement on associative memory tasks over state-of-the-art embedding models (including NV-Embed-v2). Confirms that graph-based retrieval excels at multi-hop associative recall — the pattern Prax’s knowledge graph neighbourhood traversal implements.
-
Guo et al., “LightRAG: Simple and Fast Retrieval-Augmented Generation” (EMNLP 2025 Findings). arXiv:2410.05779. Dual-level retrieval combining knowledge graph structures with vector representations, avoiding GraphRAG’s heavy community-hierarchy overhead while maintaining retrieval quality. Relevant to Prax’s hybrid approach of combining graph traversal with vector search for lightweight yet effective retrieval.
-
Han et al., “Retrieval-Augmented Generation with Graphs (GraphRAG)” (2025). arXiv:2501.00309. Comprehensive survey formalising graph-enhanced RAG across five stages: query processing, retrieval, organisation, generation, and data sources. Provides the theoretical framework for graph + vector hybrid architectures like Prax’s.
-
Peng et al., “Graph Retrieval-Augmented Generation: A Survey” (ACM TOIS 2025). arXiv:2408.08921. Systematic survey of GraphRAG workflows across graph-based indexing, graph-guided retrieval, and graph-enhanced generation. Establishes taxonomies for the design space that Prax’s entity extraction and neighbourhood traversal operate within.
-
Hu et al., “Memory in the Age of AI Agents” (2025). arXiv:2512.13564. 47-author survey proposing an evolutionary framework for agent memory with three stages: Storage (trajectory preservation), Reflection (trajectory refinement), and Experience (trajectory abstraction). Organises memory by scope (individual/collaborative), storage paradigm (cumulative, reflective, textual, parametric, structured), and composition. Prax implements elements from all three stages: STM as storage, consolidation as reflection, and hierarchical summaries as experience.
-
Hu et al., “Evaluating Memory in LLM Agents via Incremental Multi-Turn Interactions” (ICLR 2026). arXiv:2507.05257. First benchmark systematically evaluating memory agent competencies: accurate retrieval, test-time learning, long-range understanding, and selective forgetting. Reveals critical limitations — e.g., 60% accuracy on single-hop retrieval but <7% on multi-hop conflict resolution. Highlights the importance of Prax’s multi-source hybrid retrieval and graph-based associative recall for addressing multi-hop weaknesses.
-
Rasmussen et al., “Zep: A Temporal Knowledge Graph Architecture for Agent Memory” (2025). arXiv:2501.13956. Temporally-aware KG engine (Graphiti) built on Neo4j with a bi-temporal model tracking both event occurrence and ingestion time. Outperforms MemGPT on Deep Memory Retrieval by up to 18.5% with 90% latency reduction. Directly validates Prax’s Neo4j choice and suggests extending edges with temporal validity intervals.
-
Jiang et al., “MAGMA: A Multi-Graph based Agentic Memory Architecture” (2026). arXiv:2601.03236. Orthogonal semantic, temporal, causal, and entity graphs with policy-guided traversal. Achieves 45.5% higher reasoning accuracy on long-context benchmarks while reducing token consumption by 95%. Dual-stream write (fast ingestion + async consolidation) closely mirrors Prax’s STM → consolidation pipeline. Validates the multi-store hybrid approach.
-
Yu et al., “Agentic Memory: Learning Unified Long-Term and Short-Term Memory Management” (2026). arXiv:2601.01885. Unified framework integrating LTM and STM management directly into the agent’s policy, with memory operations (store, retrieve, update, summarise, discard) exposed as tool-based actions. Validates Prax’s tool-based memory interface and suggests future work on policy-learned consolidation.
-
Chhikara et al., “Mem0: Building Production-Ready AI Agents with Scalable Long-Term Memory” (2025). arXiv:2504.19413. Production-focused memory layer with dynamic extraction, consolidation, and retrieval. Graph-enhanced variant achieves 26% improvement over baselines on the LOCOMO benchmark with 91% lower p95 latency. Validates Prax’s production-oriented design and the value of KG augmentation.
-
Du et al., “Rethinking Memory in LLM-based Agents: Representations, Operations, and Emerging Topics” (2025). arXiv:2505.00675. Defines six core memory operations: Consolidation, Updating, Indexing, Forgetting, Retrieval, and Condensation. Directly validates Prax’s consolidation pipeline and Ebbinghaus decay as implementations of the Consolidation and Forgetting operations, and RRF fusion as a multi-index Retrieval strategy.
Industry Practice
- Anthropic, “Effective context engineering for AI agents” (2025). anthropic.com/engineering. Context as finite attention budget, compaction, structured note-taking as agentic memory. Prax’s STM scratchpad and compaction directly follow these patterns.
Cognitive Science
-
Baddeley & Hitch, “Working Memory” (1974). The working memory model: a limited-capacity system with a central executive and an episodic buffer for integrating multi-source information. Motivates the STM/LTM split and bounded context management.
-
Ebbinghaus, “Über das Gedächtnis” (1885). The forgetting curve: retention decays exponentially with time, steep early and slowing later. Rehearsal/recall resets or strengthens memory. Prax’s decay function directly implements this.