Persistent Memory for DeepSeek Harness: Below the Channel Layer

John Rood··5 min read

Tuesday night you work a real decision in the dsh Web UI. The retry worker stays single-writer because the downstream store rejects concurrent uploads, and you walk the agent through the constraint until it sticks. The turn completes. The session closes.

Wednesday morning you pick the same project up in a Telegram session on the same harness. It is a new session assembled fresh, with no trace of the single-writer rule. The first few messages go to re-establishing something you already decided.

Replay and recall are different jobs

The Web UI, the Telegram and Discord gateways, ACP clients, and headless runs are channels on the same dsh core, and each opens its own sessions. The session logs are real, and resuming one replays it faithfully. That is replay, and it is the right tool when you want to continue an older conversation.

Recall is the other job. It serves a session with no path back to the original conversation, pulling the relevant decisions and constraints out of past sessions and putting them in front of the agent at the start of a turn. A transcript on disk does not do this. Reopening the old session does not do this.

The gap shows up at the boundary between channels, because that is where dsh work actually moves. You think in the Web UI while long jobs run headless, and you check in from a phone when you are away from the desk. Memory that attaches to one surface makes every other surface start from zero.

Two hooks, below every channel

dsh-memoryrouter is a community plugin for DeepSeek Harness, with the source at github.com/John-Rood/dsh-memoryrouter. It attaches to two sanctioned extension points in the harness core:

  • Store (session/event): after each completed turn, the clean user message and the assistant's final reply go to your MemoryRouter vault through POST /v1/memory/ingest. Storage is async fire-and-forget with one retry, so it stays out of a turn's hot path.
  • Recall (agent/pre-step): when a new turn carries real human text, the plugin retrieves relevant memories and prepends one clearly delimited context block, tagged as plugin-sourced so the store path can never re-ingest it and create a feedback loop.

Both hooks sit below every channel plugin, and that placement is the design. The boundary is the vault, not the channel. A decision captured from the Web UI is available to the Telegram gateway, and the reverse, with no channel-specific setup.

Install it into one profile

One command, one config block, then a restart:

dsh plugin --profile <your-profile> add dsh-memoryrouter

Add your key to that profile's cordis.patch.yml:

- id: memoryrouter
  config:
    apiKey: mk_your_key_here

Leaving apiKey unset works too, as long as the harness environment exports MEMORYROUTER_API_KEY. With no key anywhere, the plugin loads, logs one warning, and does nothing. It never takes the harness down.

The setup above matches dsh-memoryrouter 0.1.1, the current npm release, and the harness itself is in developer preview, so pin the versions you run and mention your harness release when you file a setup question.

One boundary to keep straight: your model provider stays configured inside dsh, because this plugin moves conversational memory, not model traffic. The full option table is in the docs, and the quickstart lives on the integration page.

Prove it across channels

Use a throwaway phrase before real project history goes anywhere near the vault. The walkthrough in the docs keeps it synthetic, and the shape is four steps:

  1. In the Web UI: "Remember: the synthetic demo release phrase is ORCHID-7419."
  2. Let the turn complete and finish capture. Storage is asynchronous, so a completed turn is not yet a storage receipt.
  3. Open a fresh session on another channel running the same profile, without resuming history.
  4. Ask for the demo release phrase without putting it in the question.

When ORCHID-7419 comes back, you watched memory cross a channel boundary that nothing else in the harness bridges. Then run the negative control: ask from a different Memory Key and confirm nothing returns. An isolation claim you have not tested is a guess.

For the common "recall is broken" report, check the vault mode first. vault: session gives every session its own vault, so an empty fresh session is the feature working as configured. Give the async store a moment before judging a recall, and set debug: true if you want to watch store and recall activity in the log.

Where it stops

  • It does not capture everything. Only human text and the assistant's final visible reply are stored. Tool calls, tool results, thinking blocks, system prompts, skill catalogs, slash commands, and channel envelopes are filtered out. The filter is covered by 56 offline tests, and multi-step turns keep only the final assistant text. If you need full traces, that is what the harness logs are for.
  • It does not sit in the hot path. Recall is bounded by recallTimeoutMs (2500 ms default) and degrades silently, so a slow service costs you context, not the turn. Store retries once and then drops.
  • It is not the first memory plugin for dsh, and local plugins that keep files on disk are a legitimate choice. Preference there is about scope. A vault that lives on one disk serves the machine that holds it, while a hosted vault is what lets the same memory answer from every channel and every machine you run dsh on. The service behind this plugin is hosted, so send what you are authorized to store.
  • Session vault isolation is deliberate. With vault: session set, do not expect cross-session recall, and do not treat a shared key as a privacy boundary between the people who hold it.

Create your MemoryRouter account, install the plugin into a throwaway profile, and prove one cross-channel fact before you route real project history through the vault.