DEV Community

Cover image for Our memory inject into ChatGPT existed nowhere durable
Chad Priest
Chad Priest

Posted on Originally published at blog.vodou.ai

Our memory inject into ChatGPT existed nowhere durable

If your system puts context into a model you don't run, like a browser extension filling ChatGPT's composer, a plugin writing into someone else's IDE, or an MCP server handing a prompt to a host, try this. Pick one of those writes from yesterday and find the exact bytes you sent. Not the retrieval query. Not the memory IDs. The text that went into the other vendor's request.

I ran that exercise on my own stack at the end of August, and the answer was that the bytes were gone. I don't mean they were hard to find. Nothing durable held them.

turn_events had two sources, and the lane we sell wasn't one of them

I built this into Vodou, which keeps your memory in a local database and carries it into ChatGPT and Claude in the browser, as well as into terminals and IDEs. Every turn Vodou assembles writes rows to a turn_events table: which lane contributed what, how many characters, a content hash, and a trust label. It's the log I use to answer "why did the model see that?"

That table had exactly two values in its source column: gateway (turns the console assembles itself) and hook (Vodou adding context to a Cursor or Claude Code turn). The browser inject had no source at all. It's the one moment the product exists for, when your own context reaches a different AI. The handler that does it has a comment calling it "the single moment the product is about." It emitted nothing.

The fix is small and lives in two open-source files, MCP-servers/Vodou-Console/src/vbb/chat.ts and src/turn-events.ts. When the extension asks for a memory pack, the handler now writes a turn of its own before the pack is sent. That turn has a start event, the user's draft, one inject event carrying the exact pack text with source: 'capture' and the provider, and an end event.

The extension requests a memory pack, the console writes a four-event turn record with source capture, then sends the pack into the ChatGPT composer, where ChatGPT assembles a request Vodou never sees

One lane on someone else's turn

Two details matter to anyone copying this. First, the inject event carries slot: none. My derive step rebuilds a request from its events and compares it with what was actually sent. A capture turn is a contribution to a request ChatGPT assembles, not a placement inside one I built, so it must never be fed to that comparison. Second, the turn ends with derive: 'partial' and can never be graded ok. There's no request event to compare against, because this side never sees ChatGPT's request. A grader that said ok there would be lying by construction.

The receipt the extension shows now carries the turn ID. Before, it was a toast that vanished. Now the panel can ask the log what each lane actually said.

Step 14 planned to log Gemini captures: one chunk, last seen August 3

The plan was wrong in two ways, and I only found out by measuring.

Step 14 said "captured ChatGPT, Claude and Gemini turns get event rows, graded partial." Before building anything I counted what capture had actually stored per host.

Captured memory chunks per host: chatgpt 340, claude 67, gemini 1

Captured chunks by host, measured before building

ChatGPT had 340 chunks across 34 conversations, the latest from that day. Claude had 67 across 22. Gemini had one chunk, last seen on August 3. A third of the planned scope was built around a host with no traffic.

The bigger mistake was which half the plan logged. Capture was already durable, because captured turns land in memory. The inject wasn't, and the reason is a design choice I would make again. The composer inject is deliberately unfenced. Wrap injected context in a visible fence or tags and you trip the provider's prompt-injection resistance, so the pack goes in as plain text. When the conversation is captured back, the extension has to strip those same bytes out, or Vodou would re-extract its own memories as new facts and feed itself an echo. It does that with a client-side registry of what it injected.

Follow that through. The handler had the pack, the memory list and a receipt, and it sent all three to the extension. The extension showed a toast, inserted the text, and later removed that text from the capture. The dedupe that stops the memory echo was also destroying the only copy of the evidence.

I also kept one thing the plan wanted and deferred it on purpose: event rows for captured turns. A captured turn is partial by construction, so it can never catch a divergence. That would be coherence, not defect-catching.

A strip-on-capture filter needs a record-on-send writer

Here is the failure class as something you can check in a codebase:

For every surface your system writes context into but does not own, the process that composed the bytes writes a durable record of them before the send. That record can't depend on any downstream capture, because a filter that removes your own bytes from capture guarantees the capture never holds them.

A corollary I enforced in the same commit: the record write can't change the shape of the handler it observes. The whole emission is in a try. If the log write throws, the inject still goes out and the failure goes to stderr. An audit trail that can block the product gets ripped out the first time it does.

The privacy rules come along too. If the session is a guest, a record of an inject into a third-party model stores no text, just redacted: guest and the character count. Having a record of what left the machine doesn't mean you have to keep a second copy of someone's private notes.

Grep your outbound writes against your event writers

You can do this in five minutes without knowing anything about my code.

List every function that sends text to a surface you don't control. List every file that writes to your trace or event store. Then diff the two.

# 1. Files that hand text to a surface you don't own
rg -l "postMessage\(|chrome\.tabs\.sendMessage|\.insertText\(|execCommand\('insertText'|fetch\(" src/ | sort -u > outbound.txt

# 2. Files that write your trace/event log (adjust the names to your stack)
rg -l "emitEvent|recordSpan|tracer\.start|INSERT INTO (events|traces|turn_events)" src/ | sort -u > recorded.txt

# 3. Outbound writers with no recorder in the same file
comm -23 outbound.txt recorded.txt
Enter fullscreen mode Exit fullscreen mode

An empty result, or only files you can prove call a recorder through a helper, is a pass. A fail is the file that builds your pack or fills a composer showing up on that list. Then check the store itself:

SELECT source, COUNT(*) AS rows, MAX(created_at) AS latest
FROM events
GROUP BY source
ORDER BY latest DESC;
Enter fullscreen mode Exit fullscreen mode

Write down every surface your context reaches: web chat, IDE hook, API, extension. Any surface that has real users but no row in that output, or whose latest is weeks old, is a lane your log can't see. Last, take one string your client definitely inserted (copy it from devtools) and run SELECT COUNT(*) FROM events WHERE payload LIKE '%' || :snippet || '%'. Zero means that write is invisible, whatever the first two checks said.

Stored-injection research traces payloads in, not out

The security literature on this is good, and it looks inward. What If Prompt Injection Never Left? shows how untrusted text crossing into memories and filesystems becomes a cross-session attack. The kill-chain canary work follows a token through Exposed, Persisted, Relayed and Executed. A cross-vendor inject is exactly a Relayed hop, though, and their method assumes you can observe the relay. If your relay strips its own bytes, the canary disappears at the stage you most need to see.

Agent Data Injection argues the core weakness is untrusted data being read as trusted data. An unfenced inject erases that boundary inside the receiving model on purpose, so the provenance survives only in a record on your side. The architectural patterns people recommend (Dual LLM, Plan-Then-Execute, CaMeL) all constrain an agent you own. When the model belongs to another vendor, all you control is your write, and your record of it.

Sixteen days after the commit, still zero capture rows

I'll be plain about what isn't proven. The change has four tests that pass in both directions (the default source stays gateway, the capture inject records source, lane, provider and slot: none, the turn ends partial, and the guest rule holds), and the console suite passed 1,310 tests across 136 files. It hasn't been verified against a live ChatGPT inject. When I committed, the gateway restart script refused because a turn was in flight, which is the mid-turn guard doing its job. Tonight the table holds 20,071 gateway rows and 13,604 hook rows and no capture rows at all. Until a real inject writes one, this lane is tested, not proven.

Your memory leaving your machine should leave a receipt on it

This bug is why Vodou's inject path writes to the same turn log as everything else. If your memory is going to cross into a chat window you don't control, the least a system owes you is a local answer to "what exactly did it send, and where."

That is the job Vodou does. Your memory lives in a database on your machine and nothing leaves until you send it. Facts get extracted from your conversations automatically, so you don't have to take notes for it. The Vodou Bridge extension carries that memory into ChatGPT and Claude in the browser, and MCP plus hooks carry it into Cursor, VS Code and Claude Code, so you stop re-explaining your project every time you switch tools. Retrieval is hybrid vector and keyword search with a cross-encoder reranker and a precision floor. When nothing clears the bar, it injects nothing, which also keeps that record short and honest.

Governance is built in: permissions, approvals and an audit trail, with turn_events as the part this post is about. Proactive loops watch for the quiet failures, like capture that stopped or extraction that stalled, so you hear about them before you notice a model has forgotten you. The client side is open source, including the two files named above, and skills, MCP servers, scripts and schedules are yours to add and change.

Vodou is for engineers and operators who use more than one AI every day and want the context behind all of them to be theirs, local and inspectable. If that's you, start at vodou.ai.

If you want your own memory to reach ChatGPT, Claude and your IDE, with a local record of every byte it sent, you can get that at vodou.ai.


Source: Our memory inject into ChatGPT existed nowhere durable by Chad Priest, from Building Vodou in Public.

Top comments (0)