Memory Tools
Two-layer tool architecture — memory subagent for most agents, raw file tools for direct control.
MemexAI exposes two layers of tooling. The memory subagent (memory_remember + memory_context) is the right default for assistants and copilots — the model stores and retrieves memory without managing paths. The raw file tools give you direct file-level control when your application needs deterministic writes or has a file plan.
┌─────────────────────────────────────────────────────┐
│ Memory subagent (default) │
│ memory_remember memory_context │
│ (LLM decides what/ (ranked retrieval + │
│ where to write) link expansion) │
└────────────────┬──────────────────┬─────────────────┘
│ │
┌────────────────▼──────────────────▼─────────────────┐
│ Raw file tools │
│ memory_list memory_read memory_write │
│ memory_patch memory_find │
└─────────────────────────────────────────────────────┘
│
┌──────────▼──────────┐
│ Postgres (mx_file) │
│ BM25 · pgvector │
│ revisions · audit │
└─────────────────────┘memory_find is the search primitive in the raw layer — it returns ranked file metadata and snippets and is also what memory_context calls internally during retrieval.
memory_remember
Takes raw text — conversation snippets, user statements, observations — and autonomously decides what durable facts to write and where to store them.
How it works
An LLM reads the incoming text alongside existing memory files, extracts stable facts (ignoring transient conversational noise), and issues a minimal set of memory_write or memory_patch operations. It avoids duplicate writes by reading what is already stored before deciding.
Every write produces a revision snapshot and an access log row, linking back to the originating tool call ID when available.
When to use
Use memory_remember after a completed user turn or at natural breakpoints in a conversation — not on every message. Calling it too frequently creates noise and near-duplicate memory. A good pattern:
// Most common: user message after a completed turn
await memory.remember({ text: userMessage })
// Tool result: pass the durable fragment, not a raw dump
await memory.remember({ text: 'run_sql failed: ILIKE is not supported in this SQL dialect' })
// Assistant observation or stated fact
await memory.remember({ text: assistantResponse })Pass dryRun: true to preview what would be written without committing:
{ "text": "I prefer 2BHK in Indiranagar, budget 1.2 crore", "dryRun": true }{
"writes": [
{ "path": "user/preferences.md", "operation": "patch", "reason": "Added apartment preferences", "dryRun": true }
]
}Write routing
memory_remember always pre-fetches two files before writing: user/index.md and shared/index.md. Everything else is on demand. This means: to reliably route a fact to the right file, put the routing table in shared/index.md — that file is guaranteed to be read before every write decision.
The agent also receives the full list of visible files and has a small read budget (3 reads) to fetch any additional files it deems relevant. You can place a routing table in any shared/ file — for example shared/conventions.md — but for the agent to reliably read it, cite it in shared/index.md. A reference like - shared/conventions.md — file routing rules tells the agent the file exists and is worth reading before writing.
## Memory File Structure
| File | What goes here |
|---------------------|---------------------------------------------|
| user/preferences.md | Apartment type, locations, floor, budget |
| user/family.md | Family members, relationships, pets |
| user/health.md | Allergies, constraints |
| user/visits.md | Property visits, prices, observations |For routing guidance that should be reliably followed, put it in shared/index.md — that file is always pre-fetched before any write decision.
Routing systemic insights to shared/
Facts that benefit every user — tool dialect quirks, API limitations, product conventions — belong in shared/ rather than user/ when the deployment is trusted. Enable this with MEMEX_SHARED_WRITE_MODE=rw on the service, then add a routing table in shared/index.md:
## Memory file structure
| File | What goes here |
|------|----------------|
| user/preferences.md | User preferences and private stated facts |
| shared/tool-quirks.md | SQL dialect errors, API limitations, unsupported syntax |With shared writable mode on, memory_remember can route a systemic fact to shared/tool-quirks.md instead of a user file. Shared writable mode is off by default; only enable it for trusted agents. See Memory Scopes for the full trust model.
memory_context
Assembles a context block — a formatted, character-budgeted digest of the most relevant memory files — ready to inject directly into a system prompt.
How it works
memory_context is an agentic ReAct loop. It spins up a model call with three tools — memory_list, memory_find, and memory_read — and lets the model decide which files to fetch before assembling the final context block.
memory_context("apartment requirements and family")
│
▼ LLM step 1
├── memory_read("user/index.md") ← catalog of all user files
│
▼ LLM step 2
├── memory_find("apartment preferences family")
│ BM25 + optional pgvector hybrid ← see memory_find for details
│ → [user/preferences.md, user/family.md, user/visits.md]
│
▼ LLM step 3
├── memory_read("user/preferences.md")
├── memory_read("user/family.md")
│
▼ LLM final step
└── text response → context block returned to callerThe system prompt instructs the model to:
- Start with
user/index.mdas a catalog of all available files - Use
memory_findto rank candidates by relevance (seememory_findfor how search works) - Use
memory_readto pull full content for files worth including - Fall back to
memory_listonly when the index doesn't give enough signal - For broad queries (full profile, summary), read every file referenced in the index
The loop is capped at 12 steps. The model's final text response becomes the returned context field.
Why agentic instead of deterministic?
The model can reason about which files matter in ways pure ranking cannot. A query about "apartment requirements" might need user/health.md (dust allergy → ventilation) even if neither word appears in the query. The model reads user/index.md, sees a health file exists, and decides whether to include it.
memory_remember is also prompted to write ## See also sections with [[user/related.md]] wikilinks when creating related files. The retrieval agent uses these as signals — seeing [[user/preferences.md]] in a health file is a hint that preferences are relevant to the same query.
Example
{ "query": "apartment requirements and family" }{
"context": "## user/preferences.md\n(updated 2025-06-08)\n\n# Preferences\n- 2BHK in Indiranagar or Koramangala\n- Budget: 1.2 crore\n- Floor 4+\n\n## user/family.md\n(updated 2025-06-08)\n\n# Family\n- Wife: Meera (dust allergy)\n- Pet: Max (golden retriever)",
"filesRead": ["user/index.md", "user/preferences.md", "user/family.md"]
}Parameters
| Field | Type | Default | Description |
|---|---|---|---|
query | string | — | What to retrieve. Passed to the model as the retrieval goal. |
maxChars | number | 24 000 | Character budget hint passed to the model. |
Raw file tools
Raw tools expose the file layer directly. Use them when your application has a file plan, needs deterministic writes, or is building tooling on top of MemexAI.
const tools = memory.createRawToolset()memory_list
Lists all files visible to the current user — their own user/ files plus the shared/ namespace.
{}{
"files": [
{ "path": "user/preferences.md", "updatedAt": "2025-06-08T10:00:00Z" },
{ "path": "user/family.md", "updatedAt": "2025-06-08T10:01:00Z" },
{ "path": "shared/index.md", "updatedAt": "2025-06-01T09:00:00Z" }
]
}memory_read
Reads a single file by its virtual path.
{ "path": "user/preferences.md" }{
"path": "user/preferences.md",
"content": "# Preferences\n- 2BHK in Indiranagar or Koramangala\n- Budget: 1.2 crore\n- Floor 4+",
"updatedAt": "2025-06-08T10:00:00Z"
}memory_write and memory_patch
memory_write creates or fully overwrites a file. Use it when you are constructing a file from scratch or want a clean replacement.
memory_patch makes targeted edits without rewriting the full file:
- Append under a heading — add a new bullet point under
## Visitswithout touching the rest of the file. - Replace exact text — swap a specific substring in place.
Prefer memory_patch when updating a section of a larger file. It produces a smaller revision diff and reduces the risk of accidentally losing adjacent content.
{
"path": "user/visits.md",
"operation": "append_under_heading",
"heading": "## Visited",
"content": "- Prestige Tranquility — 1.45 Cr, above budget, good light"
}memory_find
Searches memory by keyword or semantic query and returns ranked file metadata and snippets. This is the search primitive — it does not synthesize an answer or expand links. Use memory_context when you want a ready-to-inject context block; use memory_find when you need ranked results to drive your own logic.
Search modes
Query string
│
├── BM25 (always)
│ Postgres tsvector GIN index
│ Fast, no API call needed
│ matchReason: "lexical"
│
└── pgvector (when hybrid mode is configured)
Gemini text-embedding-004 (or configured provider)
Cosine similarity over stored embeddings
matchReason: "semantic"
│
▼
Reciprocal Rank Fusion
score = Σ 1 / (k + rank_i) where k = 60
matchReason: "hybrid"Hybrid mode activates automatically when GEMINI_API_KEY is set on the service and MEMEX_SEARCH_MODE is auto (the default). Embeddings are stored at write time. If an embedding could not be stored (for example, due to a transient rate-limit), that file falls back to BM25-only ranking for that query.
Reciprocal Rank Fusion
RRF merges two independent ranked lists without needing their scores to be on the same scale. For each candidate file, its RRF score is the sum of 1 / (k + rank) across both lists, where k = 60 is a stability constant. A file ranked #1 by BM25 and #3 by vector gets a higher combined score than a file that appears in only one list. This rewards files that are relevant by both signal types.
BM25 results Vector results
#1 user/prefs.md #1 user/family.md
#2 user/family.md #2 user/prefs.md
#3 user/health.md #3 user/visits.md
RRF scores:
user/prefs.md = 1/(60+1) + 1/(60+2) = 0.0164 + 0.0161 = 0.0325 ← highest
user/family.md = 1/(60+2) + 1/(60+1) = 0.0161 + 0.0164 = 0.0325
user/health.md = 1/(60+3) + 0 = 0.0159
user/visits.md = 0 + 1/(60+3) = 0.0159Why memory_find does not follow links
memory_find is a pure search primitive — it returns what Postgres ranked, nothing more. Following [[...]] wikilinks embedded in file content is deliberate reasoning: it requires reading a file, parsing its links, and deciding whether those targets are relevant. That reasoning happens in memory_context, where the LLM can make that judgment call. Keeping them separate means you can use memory_find results to drive your own retrieval logic without an LLM in the loop.
Example
{ "query": "apartment budget and location", "limit": 5 }{
"query": "apartment budget and location",
"results": [
{
"path": "user/preferences.md",
"snippet": "Budget: 1.2 crore. Preferred: Indiranagar or Koramangala.",
"rank": 0.91,
"matchReason": "hybrid"
},
{
"path": "user/visits.md",
"snippet": "Prestige Tranquility — 1.45 Cr, above budget",
"rank": 0.74,
"matchReason": "lexical"
}
],
"truncated": false
}Parameters
| Field | Type | Default | Description |
|---|---|---|---|
query | string | required | Keyword or semantic query. |
limit | number | 10 | Max number of results to return. |
prefix | string | — | Filter by virtual path prefix, e.g. user/. |
Which tool set to use
| Situation | Use |
|---|---|
| Conversational assistant that should remember preferences | memory_remember + memory_context |
| Application with a known file plan, deterministic writes | Raw file tools |
| Building a custom retrieval pipeline on top of MemexAI | memory_find + memory_read |
| Debugging what a model stored | Admin console or memory_list + memory_read |
| Checking what a model would write before committing | memory_remember with dryRun: true |
Start with the subagent. Add raw file tools only when your use case requires direct file control.
// Typical assistant setup
const system = await memory.getSystemPrompt('You are a helpful assistant with durable user memory.')
const tools = memory.createMemorySubagentToolset()
// → exposes memory_remember and memory_context to the modelExtraction patterns
memory_remember is a primitive. The question of when to call it and what text to pass belongs to your application. MemexAI does not watch tool results or conversation history by itself.
Hot path vs background path
Both are valid:
| Path | Use when | Tradeoff |
|---|---|---|
| Hot path | The current answer depends on saved memory, or the user expects an immediate durable save | More latency, simpler correctness |
| Background path | Latency matters, or you want batching, dedupe, review, or a cheaper extractor | Memory may affect the next turn, not the current one |
Do not treat background extraction as the only architecture. A direct await memory.remember(...) is right when durability is part of the current interaction. A queued job is right when learning can happen after the response.
Post-turn user input
The simplest pattern is to run memory after a completed turn:
const result = await generateText({ model, system, prompt: userMessage, tools, stopWhen: stepCountIs(5) })
// Hot path: await when the user explicitly asked to save something.
await memory.remember({ text: userMessage })
// Background path: enqueue or fire-and-forget when latency matters.
memory.remember({ text: userMessage }).catch(console.error)Post-tool-result extraction
Tool results are often noisy. Extract a compact durable fact first, then pass that to MemexAI:
for (const step of result.steps) {
for (const toolResult of step.toolResults ?? []) {
if (!isPermanentError(toolResult.result)) continue
const insight = await extractInsight(toolResult) // app-owned policy
if (insight) {
await memory.remember({ text: insight })
}
}
}The extractor can be a cheap model call, deterministic code, or a human-reviewed queue. The important part is to avoid passing raw logs, transient failures, secrets, or routine success payloads.
Framework-owned extraction points
Use the hook that matches the framework:
| Framework | Recommended extraction point |
|---|---|
| Vercel AI SDK | after result.steps or stream step events |
| OpenAI / Anthropic SDK | inside the manual tool-call loop after app tool execution |
| LangChain / LangGraph | callback, runnable wrapper, or graph node after accepted turns/tool nodes |
| LlamaIndex | post-response hook or workflow step |
| CrewAI | task-output extraction after kickoff() |
| Google ADK | native memory service lifecycle via MemexAdkMemoryService |
When to trigger extraction
| Trigger | Worth remembering? |
|---|---|
| Tool failed with a permanent error | Yes — dialect quirk, API limitation, unsupported feature |
| Tool failed transiently | No — timeout, rate limit, or network blip |
| Tool succeeded with a surprising reusable constraint | Sometimes |
| User stated a preference explicitly | Yes |
| Assistant inferred a stable fact | Sometimes, preferably after confirmation |
| Routine turn with no new information | No |
For systemic insights that should help every user, route the extracted text to shared/ through shared/index.md and MEMEX_SHARED_WRITE_MODE=rw.
Tool calls and auditability
Every tool call produces an access log row in mx_access_log. Every write or patch also produces a revision snapshot in mx_revision, making it possible to time-travel back to any prior state of a memory file.
When your framework provides tool call IDs (Vercel AI SDK, Anthropic SDK, LangChain), pass them through adapter handlers — the revision rows link back to the originating model tool call.
Related concepts
- Prompt block — how memory is injected into the system prompt
- Memory scopes —
user/vsshared/namespace rules - Cognitive architecture — how to structure
shared/anduser/files using the procedural/semantic/episodic triad - Revision history — time-travel and snapshot details
- Access logs — per-operation audit trail
- Architecture — hybrid search, Postgres schema, and deployment modes