Memory
Durable, per-actor memory: store facts and recall them across sessions, from MCP, the SDK, or the CLI
Memory
Your assistant learns something worth keeping: a preference, a fact about the user, a decision that should hold across sessions. Without memory, it forgets the moment the session ends. With it, the assistant calls memory_store itself, unprompted, and the next session's memory_recall finds the fact again.
Memory is a first-party platform primitive. The same six actions are reachable over MCP, @protoboxai/sdk, and the protobox CLI. All three call the same handlers, so a memory stored from one surface is recalled identically from any other.
The six actions
| Action | What it does |
|---|---|
memory_store | Store a fact, in plain language. Embedded on write when an embedding engine is available. |
memory_recall | Top-K recall for a query (the simple retrieval form). |
memory_search | Same engine as recall, with limit and minScore filtering. |
memory_forget | Delete one memory by id, or every memory matching a content query. |
memory_list_recent | The most recently stored memories, newest first (no search). |
memory_history | The full supersede chain for one memory — what it replaced, and what replaced it. |
Every action is scoped to the calling actor (the end-customer, when one is bound) or the workspace principal otherwise, and to an optional space namespace. Pass the same space on store and recall to keep memories in separate buckets (e.g. one per repo for a coding agent).
MCP
Any connected MCP client calls the tools directly: memory_store, memory_recall, memory_search, memory_forget, memory_list_recent, memory_history. The tool descriptions tell the client's model when to call memory_store unprompted (a stated preference, a fact about the user, a decision worth keeping) and when not to (transient task state, secrets).
SDK
import { ProtoboxSDK } from '@protoboxai/sdk';
const sdk = new ProtoboxSDK({ apiKey: process.env.PROTOBOX_API_KEY! });
await sdk.memory.store({ content: 'the wombat likes tea', source: 'my-agent' });
const hits = await sdk.memory.recall({ query: 'wombat' });
// hits[0] => { id, content, score, source, matchedBy, createdAt, ... }
const filtered = await sdk.memory.search({ query: 'wombat', limit: 5, minScore: 0.5 });
await sdk.memory.forget({ memoryId: hits[0]!.id });
// or: await sdk.memory.forget({ query: 'wombat' });
const recent = await sdk.memory.listRecent({ limit: 20 });
const chain = await sdk.memory.history(hits[0]!.id);
// chain => [{ id, content, replaces, supersededBy, supersededAt, createdAt, ... }, ...] oldest to newestEvery call runs through the platform's existing tool-execute path (POST /api/v1/tools/native:memory_*/execute). It uses the same scoping, audit trail, and metering as an MCP client, not a second API surface.
CLI
protobox memory store "the wombat likes tea" --source cli
protobox memory recall "wombat"
protobox memory list
protobox memory forget <id>
protobox memory forget --query "wombat"
protobox memory history <id>--json is supported on every subcommand for scripting. memory store defaults --source to cli when omitted. See Where a memory came from for why that matters.
Where a memory came from
Every memory carries a source: the CLI table's "Source" column, and the SDK's source field on every MemoryHit.
- Explicit override: pass
source(SDK/CLI) and it wins, always. - MCP clients: when no explicit
sourceis given, the platform stamps the calling MCP client's name from itsinitializehandshake (e.g.claude-desktop). - No handshake to read: the SDK and CLI call the tools' REST execute path directly, which carries no MCP
initialize. Without an explicitsource, that stampsunknown. This is whyprotobox memory storedefaults--sourcetoclirather than leaving it unset.
What memory costs on free
memory_store never triggers a server-side extraction call. Deciding what's worth remembering happens in the calling client's model (per the tool description), not on the platform. Storing is available on every tier.
Embedding a memory on write does cost a provider call, and follows the same engine policy as knowledge base search:
- Free tier, no key of your own:
memory_storestill succeeds, but writes with no vector (embedded: falsein the store response).memory_recall/memory_searchstill find it (see below). - An OpenAI key added under Integrations (any tier): embeddings use your key.
- Paid tiers without a BYO key: embeddings use Protobox's managed key.
How recall works
memory_recall and memory_search share one retrieval engine, fusing up to three signals per query:
- Semantic: vector similarity, when an embedding engine is available (see above). Skipped entirely on free tier without a key (no embedding call is made).
- Keyword: full-text matching, always runs.
- Entity: exact matching on proper nouns, dates, and numbers extracted from the query, so "Dr. Okafor" or "March 14" finds the row that names them, not just a semantically-similar paraphrase.
Results are fused by reciprocal rank fusion; each hit's matchedBy array names which lane(s) found it (e.g. ['keyword', 'entity']), and score is a 0-1 relevance number you can filter on with memory_search's minScore. On free tier, keyword and entity matching alone are enough to find an exact name or date. Only the semantic lane requires a key.
What used to be true
When a fact changes, the old one is superseded, not erased. Storing an update marks the old memory supersededBy the new one and stamps replaces on the new memory — the old row stays in the database, just excluded from ordinary recall.
Two ways a memory gets superseded:
-
Explicit — pass
replaceswith the id of the memory this fact updates:protobox memory store "the wombat now prefers coffee" --replaces <old-id>await sdk.memory.store({ content: 'the wombat now prefers coffee', replaces: oldId }); -
Automatic — omit
replacesand store a near-duplicate of an existing fact (same named entity — a person, date, or number — plus enough word overlap to be "the same subject, updated"), and the platform supersedes the best match on its own. No LLM call: it's a lexical check (entity-term overlap + word-set similarity), so it works on every tier, including free. The store response reports what it superseded:const result = await sdk.memory.store({ content: 'Dr. Okafor prescribed the blue inhaler' }); // result.superseded => [{ id: '...', content: 'Dr. Okafor prescribed the red inhaler' }]
By default, superseded facts don't show up. memory_recall, memory_search, and memory_list_recent only return current facts. Pass includeHistory: true (SDK/API) or --include-history (CLI) to see the whole chain, past and present:
const current = await sdk.memory.recall({ query: 'wombat' }); // only the latest
const everything = await sdk.memory.recall({ query: 'wombat', includeHistory: true }); // current + supersededSee exactly what changed and when with memory_history — the full chain for one memory, oldest to newest, regardless of which id in the chain you pass:
protobox memory history <id>const chain = await sdk.memory.history(memoryId);
// chain[0] is the oldest version; the last entry is current (supersededBy: null)A memory is never deleted by supersession — only memory_forget deletes.
Bring your memories from another assistant
Switching to Protobox from ChatGPT, Claude, or Gemini doesn't mean starting from zero. Export your memories from the other assistant, then import them:
protobox memory import ./export.zip --from chatgpt
protobox memory import ./export.zip --from claude
protobox memory import ./saved-info.json --from gemini
protobox memory import ./export.zip # --from omitted: auto-detectedOr via the SDK:
import { readFileSync } from 'node:fs';
const result = await sdk.memory.import({
file: readFileSync('./export.zip'),
fileName: 'export.zip',
format: 'chatgpt', // or 'claude' | 'gemini' | 'auto'
});
// result => { vendor: 'chatgpt', imported: 12, skippedDuplicates: 0, skippedInvalid: [] }Or POST /api/v1/memory/import (multipart) directly.
What gets imported. Only the export's memory/preferences section — ChatGPT's "memories" and custom instructions, Claude's memory/preferences file (where present), Gemini Takeout's "Saved info" — never the full conversation log. Extracting facts from a raw transcript needs an LLM pass over every message, which the platform doesn't do on your behalf; import only reads what the source assistant already distilled into durable memory.
Attribution. Every imported fact is stored with source: "imported-<vendor>" (e.g. imported-chatgpt), so memory_recall/memory_search results and the CLI's Source column always show where a memory came from.
Idempotent. Re-running the same import (or importing an export that overlaps with facts you've already stored by hand) adds nothing twice — a fact is deduped by its content, scoped to the same actor/space memory_store uses. The response's skippedDuplicates count tells you how many were already there.
Malformed or unsupported exports fail loudly. If the file doesn't carry a memory/preferences section any of the three parsers recognize — or the vendor can't be auto-detected — nothing is imported and the error names what was expected. A partial or malformed import never leaves the workspace half-populated. Per-fact issues (an unusable entry within an otherwise-good export) don't fail the whole import: they're reported in skippedInvalid alongside the facts that did import.
Cap. A single import tops out at 5,000 facts; an export over that is rejected before anything is written.