PLUR Blog · 2026-08-15

Giving DeepSeek Harness Persistent Memory (And What I Learned Writing a Plugin)

The short answer: DeepSeek Harness re-renders its system prompt on every request, and a plugin can contribute a section whose text is a function rather than a string. That makes memory injection possible without a tool call and without the memory accumulating in context. @plur-ai/dsh does this, and the pattern is available to any plugin.

DeepSeek open-sourced Harness on 13 August 2026. Two days later it had passed 107,000 stars, and the dsh-plugin topic on GitHub held more than three thousand repositories. We spent that window building memory for it.

The interesting part turned out not to be the memory. It was what the plugin architecture made possible, and how much of it we got without writing any code.

Memory is usually a message. That is the problem.

Most agent memory integrations work the same way: recall some memories, append them to the conversation as a message, let the model read them.

It works, and the cost is easy to miss until sessions run long. That message is now permanently in the context window. Every subsequent turn pays for it. You cannot take it back out without rewriting history, so eventually you need eviction logic — and eviction logic needs a policy, and the policy is always wrong for somebody.

The alternative most frameworks do not offer: contribute memory as part of the system prompt, and let the host re-render it every turn.

What Harness does differently

A prompt section in Harness has a text field, and that field is typed:

readonly text: string | ((context: AssembleContext) => string)

A function. The host calls it on every assembly and uses whatever comes back. Nothing you return persists anywhere.

ctx.systemPrompt.section({
  name: 'plur:memory',
  order: 120,
  text: (assembly) => renderMemoryFor(assembly.agent),
})

Three consequences fall out of that signature, and we built none of them.

Memory cannot accumulate. The section is replaced, not appended. We measured a 60-turn session with recall running every turn: the memory block stayed the same length, and so did the total prompt. A hundred-turn session costs what a one-turn session costs.

There is no eviction problem. Append-based memory eventually has to decide what to drop. This design never creates the situation.

No tool call. The memories are simply present. Tool-exposed memory is a bet on the model choosing to look, and when it doesn’t, the memory may as well not exist. “Why didn’t it remember?” is the complaint that kills trust in a memory system, and it is usually a routing failure rather than a retrieval one.

This matters more for local models, not less. A smaller model is exactly the one you cannot rely on to route a tool call correctly. Injected memory works regardless of how good the model is at deciding to look something up.

The constraint is that the function must be cheap and synchronous — prompt assembly should never wait on a database. So the recall runs off the turn path and the section reads a cache. The honest consequence: the block lands from the second assembly of a session onward, and a turn is never delayed waiting on memory. We think that is the right trade, and it is worth stating rather than hiding.

MCP cannot do this, incidentally, and that is not a criticism — MCP is tool-shaped by design, so memory over MCP is a tool call by construction. If you are on Harness, the section route exists and is better for this particular job.

Nine things that cost us time

The ecosystem is days old, so almost none of this is written down anywhere. Everything below is verified against 0.1.0-rc.6.

1. latest on npm is stale

Five of the six host packages we checked point latest at 0.0.1-rc.1, while the actual line is 0.1.0-rc.6, published under next:

@deepseek-ai/dsh-tools          latest=0.0.1-rc.1   next=0.1.0-rc.6
@deepseek-ai/dsh-commands       latest=0.0.1-rc.1   next=0.1.0-rc.6
@deepseek-ai/dsh-skill          latest=0.0.1-rc.1   next=0.1.0-rc.6
@deepseek-ai/dsh-system-prompt  latest=0.0.1-rc.1   next=0.1.0-rc.6

npm i @deepseek-ai/dsh-tools silently installs something months old. Pin the version explicitly.

2. Cordis throws when you read an undeclared service

Not when you call it — when you touch the property:

if (typeof ctx.skills?.register === 'function') { /* … */ }
// throws: cannot get property "skills" without inject

The optional-chaining guard never runs, because the access throws first. This took down a boot for us after a clean typecheck and a green suite.

Either declare the service in your plugin’s inject, or — better for optional surfaces — mount it in its own scoped fiber:

ctx.inject(['skills'], scoped => registerSkills(scoped))

Your plugin then still works on a minimal profile that composes no skill registry at all.

3. Commands take handler, not execute

And the handler returns a CommandResult — a union discriminated on kind, not a string:

ctx.commands.register({
  name: 'plur',
  description: 'Memory status.',
  handler: () => ({ kind: 'success', text: '…' }),
})

Get the field name wrong and registration throws inside a contained fiber, so your command silently never appears and nothing is logged anywhere.

4. Skills take content, and source is required

ctx.skills.register({
  name: 'plur-memory',
  description: '…',
  source: 'runtime',
  content: SKILL_BODY,
})

The failure mode is nastier than a missing command. Register-time validation only checks name and description, so the wrong shape registers successfully and appears in the catalog. It throws later, when someone actually opens the skill. Advertised and broken is worse than absent.

5. Event payloads are objects, not the subject

agent/turn-stopping gives you { agent, turn, signal }. agent/disposed gives you { agent }. Treat either as the agent itself and you read undefined, and the feature does nothing at all — quietly, forever.

6. Register one prompt section, not one per agent

Duplicate section names throw by contract. If you register per agent under the same name and catch the error, later agents silently render the first agent’s content — which, for a memory plugin, means one project’s memories appearing in another project’s prompt.

Register once, and key off the agent inside the text function:

text: (assembly) => {
  const id = assembly.agent?.id
  return id === undefined ? '' : cache.read(id)
}

Guard the no-agent case: diagnostic assemblies carry no agent, and you do not want to hand out whichever agent happened to be cached last. @deepseek-ai/dsh-plan-mode does exactly this, and reading a first-party plugin was how we settled it after two wrong attempts of our own.

7. ctx.effect(fn, label) is the disposal seam

There is no dispose event.

8. ctx.plugin(plugin, config) passes two arguments

If your apply takes a third parameter so tests can inject a fake, the host never passes it. A test that “injects” a dependency is testing nothing, while production quietly constructs the real one.

9. Check whether a filter is visibility or authorization

Specific to us, but the shape generalises. Our engine has a scope option that reads like isolation and is not — it is a visibility filter that deliberately lets some scopes through. A separate scopes allow-list does the authorization. We passed the first and assumed we had the second. Read what a filter actually promises before you rely on it to keep things apart.

The meta-lesson

Every one of those survived a test suite with more than two hundred passing tests.

They survived because the tests stubbed the host. { register: () => () => {} } accepts any shape you hand it, including the wrong one — a double encodes the assumption you are trying to test. Our event payloads were hand-written to match what our code already expected, so of course they matched.

What found them was a single test file that boots the real dsh-commands, dsh-skill, dsh-system-prompt and dsh-tools registries against a real store on disk, and asserts on what actually happens. If you write a Harness plugin, write that file early. It is the highest-value test in our repository by a distance.

Try it

dsh plugin --profile web add @plur-ai/dsh

Memory is local: BM25 and BGE embeddings fused on your own machine, zero API calls, storage as plain YAML at ~/.plur that you can read, edit and delete. /plur-memory opens a local page showing every engram, what actually gets recalled, and how often. Apache-2.0.

Retrieval scores 76.7% Hit@5 on a 30-question subset of LongMemEval-S in the configuration this plugin ships. That is a smoke test rather than a leaderboard — one question is worth 3.3 points — and the reproducible harness is public in plur-bench.

Source, in English and 中文: github.com/plur-ai/dsh-plugin.