Memory trust and portability are live — error codes, receipts, duplicates, and export

A reader's email about ChatGPT's memory quietly failing on him turned into a punch list. Here's what we shipped in response, and how to run it from ChatGPT, Claude, or a terminal.

Generated editorial hero image for "Memory trust and portability are live — error codes, receipts, duplicates, and export

I reached out to Empirical users to ask whether anything had gone wrong for them. Most replies were short. One stood out as one of the best customer-development responses I've ever received.

The email

Thomas wrote back with a real diagnosis, not a complaint. ChatGPT's own memory had started failing him mid-project: an internal to=bio error claiming the context was full when the visible memory count wasn't, memories that read fine but silently rejected writes, and older saved memories that just stopped being retrievable. He was careful to say he couldn't prove the cause and didn't want to assign one. He didn't need to. The practical result was the same either way — he couldn't depend on it anymore.

Empirical gave me a separate memory layer when ChatGPT's built-in memory became unreliable. For long-running continuity work, that extra memory has been more than useful—it has been a blessing.

That's the line he gave me when I asked if I could use his feedback publicly. He said I could use it "anywhere," so it's now on the homepage too.

What made the email valuable wasn't the compliment. It was the second half, where he listed exactly what was missing:

That's not a wishlist. That's a spec. So we built it.

Rejections now come with a reason

Before this release, a rejected write from an MCP tool call gave you a message and nothing else. Now every rejection carries a stable error code and, where we can compute one, a suggested fix — visible to whatever agent is holding the conversation, not just buried in a log you'll never read.

text
Agent: record_graph_memory({
  category: "project",
  nodeType: "observation",
  summary: "Auth middleware looks redundant but removing it breaks role-based access on staging."
})

Empirical → 422 rejected
{
  "status": "error",
  "code": "LENGTH_LIMIT_EXCEEDED",
  "message": "summary exceeds 480 characters (612 provided).",
  "suggestedFix": "Trim summary to the core fact and move supporting detail into a follow-up note via add_note_by_query."
}

Agent: "That write didn't go through — the summary was too long.
Splitting it into a shorter core memory plus a follow-up note."

No more guessing whether it was wording, size, structure, permissions, or the service itself. The code tells you which bucket you're in, and the suggested fix tells you the move.

Every save comes back with a receipt

The other half of trust is knowing a write actually landed. Every create/update/delete across the MCP tools and the REST API now returns a receipt: what operation ran, which memory it touched, and when.

text
Agent: record_graph_memory({
  category: "preference",
  nodeType: "observation",
  summary: "Prefers snake_case for Python variable names."
})

Empirical → 201 created
{
  "status": "success",
  "action": "create",
  "node": { "id": "6a1f00aa11bb22cc33dd44ee", "category": "preference", ... },
  "receipt": {
    "operation": "create",
    "memoryId": "6a1f00aa11bb22cc33dd44ee",
    "timestamp": "2026-07-28T14:02:11.408Z"
  }
}

No receipt, no silent success. If the call returns 200 without one, something upstream is lying to you.

See why a memory was retrieved

Recall used to be a black box — you got results, not reasons. Retrieval diagnostics expose the ranking math behind every result: how much of the score came from semantic similarity versus keyword match versus recency versus how connected the memory is in the graph.

text
POST /api/v1/memories/query
{ "query": "empirical", "topK": 3, "includeDiagnostics": true }

→ results[0].diagnostics
{
  "finalScore": 0.5216,
  "contributors": {
    "semanticSimilarity": 0,
    "keywordMatch": 0.7273,
    "gravity": 0.538,
    "recency": 0.927,
    "graphConnectivity": 1
  },
  "matchedTags": [],
  "matchedFilters": []
}

That response shape is real — pulled from a live query against the production API while we were verifying this release, not mocked up for the post. finalScore correctly reports as null instead of a misleading 0 when a result comes from a mode that doesn't rank (a bug we caught and fixed while building this). The portal surfaces the same breakdown next to each result in Memories → any result → Why this matched.

Finding and merging duplicates

Long-running projects accumulate near-duplicate memories — the same fact saved twice with slightly different wording. There's now a dedicated pass to surface candidates and a guided merge that keeps tags, relationships, and audit history intact.

text
$ empirical memory duplicates --memory-id 6a1f00aa11bb22cc33dd44ee
{"candidates":[
  { "memoryId": "6a1f00bb22cc33dd44ee55ff", "similarity": 0.94, "summary": "Auth middleware is load-bearing, don't remove it." }
]}

$ empirical memory merge \
    --canonical-id 6a1f00aa11bb22cc33dd44ee \
    --duplicate-id 6a1f00bb22cc33dd44ee55ff
✓ Merged 6a1f00bb22cc33dd44ee55ff into 6a1f00aa11bb22cc33dd44ee
  tags merged · relationships merged · audit logged

Merge writes an audit log entry and emits a memory:delete event for the absorbed memory, same as any other deletion path — nothing about a merge is a silent, untracked operation.

Take it all with you

The last item on Thomas's list was the most important one philosophically: continuity should never depend on staying inside one system. Every memory — personal or workspace-scoped — now exports as portable, versioned JSON.

text
$ empirical memory export --scope personal --out ./my-memories.json
{"exported":true,"path":"./my-memories.json","memoryCount":842}

$ head -c 220 my-memories.json
{
  "schemaVersion": "1",
  "generatedAt": "2026-07-28T14:20:04.185Z",
  "scope": "personal",
  "workspaceId": null,
  "memoryCount": 842,
  "truncated": false,
  "memories": [ ... ]
}

Export is rate-limited per user (so it can't be used to hammer the API) and sits behind a kill switch on our side we can flip instantly if we ever need to. Neither of those affects you — they're operational guardrails, not friction.

A bonus, outside the original list

While this work was in flight we also shipped workspace lifecycle tools that weren't on Thomas's list but follow the same "own your context" logic: create a workspace, and soft-delete one without losing the underlying record.

text
$ empirical workspace create "Client Project" --description "Scoped memory for the Q3 engagement"
✓ Workspace created: Client Project (ws_team_4c19f2a08b6e51fd)

$ empirical workspace list
Selectable workspaces:
  1. Default Brain (current)
  2. Client Project
  3. Ideas

$ empirical workspace delete ws_team_4c19f2a08b6e51fd --confirm
Soft-deleting workspace Client Project (ws_team_4c19f2a08b6e51fd)...
✓ Soft-deleted workspace Client Project (ws_team_4c19f2a08b6e51fd).

Delete is soft on purpose — the workspace, its memberships, invites, and bot config all flip to inactive rather than disappearing, so a wrong click doesn't mean unrecoverable data loss.

Run it yourself

None of this required a new MCP tool. That was deliberate — adding tools to the ChatGPT connector means a resubmission that can take weeks to clear review, so everything above ships through the existing 9 MCP tools, the REST API, and the CLI instead.

ChatGPTadd Empirical from the app directory, authorize with your Empirical account, then just ask:

text
"Why did that memory get rejected?"
"Show me why you retrieved that memory."
"Find duplicates of my note about the auth middleware."

Claude Code, GitHub Copilot CLI, Codex — one command detects the tool, installs the native plugin, and wires MCP:

text
npm install -g https://empirical.gauzza.com/downloads/cli/empirical-cli-latest.tgz
empirical --install

Cursor, Windsurf, Claude Desktop — these don't auto-detect yet, so add Empirical as a remote MCP server by hand: point the config at https://empirical.gauzza.com/mcp with an Authorization: Bearer header. Exact file paths and a copy-paste config are on the install docs.

Any terminal / automation / CI — bootstrap a token once, then script against the REST API or CLI directly:

text
empirical oauth bootstrap headless --write-env
empirical doctor

empirical memory query --match "auth middleware" --top-k 5 --pretty
empirical memory duplicates --memory-id <id>
empirical memory merge --canonical-id <id> --duplicate-id <id>
empirical memory export --scope personal --out ./export.json

Raw HTTP — for anything that isn't the CLI:

text
curl -X POST https://empirical.gauzza.com/api/v1/memories/query \
  -H "Authorization: Bearer $EMPIRICAL_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query":"auth middleware","topK":5,"includeDiagnostics":true}'

Thomas, if you're reading this — thank you for the email. It's rare to get a bug report that also doubles as a product roadmap.

Make your context portable

Stop starting from zero.

Keep your notes where they already live. Let Empirical make the decisions, lessons, and preferences you saved available to the AI tools you already use.

All Empirical blog posts