メインコンテンツへスキップ
R

deepseek-memory

rocofish/deepseek-memory

Long-term memory for dsh with defense-in-depth hardening. Two tiers (project `.dsh-memory/` and user `$DSH_HOME/memory/`), each a plain hand-editable JSON file with bounded entries and tags. Writes are atomic (unique temp file + rename, previous revision kept in a bounded history/) and serialized across processes by a per-tier lock with stale reclaim; a disk-revision check refuses lost updates instead of silently overwriting another writer. Memory text is wrapped in explicit untrusted-data boundaries (forged wrappers stripped on write) to blunt prompt injection from remembered content, load-time validation rejects out-of-bounds stores, and symlink escapes out of the tier directory are refused with MEMORY_STORE_ESCAPE. No content-heuristic filtering and no confirmation step, so headless auto-consolidation stays intact. Install: dsh plugin --profile web add github:RocoFish/deepseek-memory.

インストール

dsh plugin --profile web add github:rocofish/deepseek-memory

README

deepseek-memory

English | 中文

Curated long-term memory for DeepSeek Harness, packaged as an installable community bundle.

Memory here means small and curated, not a transcript index: the store holds a bounded set of short facts about the user and the project, and the whole store is injected into every request. That bound is what removes the retrieval problem — no embedding service, no ranking, and no stale snapshot left behind in session history.

A memory store is also a trust boundary: it is model-written text that later gets read back into a prompt. This plugin treats that seriously — writes are atomic and locked, remembered text is wrapped as data rather than trusted as instructions, and out-of-bounds or forged content is refused, not truncated. See Security model below.

What it does

PieceBehaviour
Two tiersuser ($DSH_HOME/memory/) for who the person is; project (<cwd>/.dsh-memory/) for this repository's conventions and decisions.
Toolsmemory_add, memory_update, memory_delete, memory_list, memory_search, memory_status.
InjectionA systemPrompt section whose text is a provider, so it is recomputed at every assembly. Nothing is written into session history.
After-turn consolidationA restricted child agent reviews the closed turn through the memory tools alone.
Plain filesThe store is readable JSON, hand-editable, diffable in git, and every mutation keeps the previous revision.

Install

dsh plugin --profile web add github:RocoFish/deepseek-memory

There is no build step and no allowlist for you to deal with, because this repository commits the compiled lib/ — a git install receives the compiled plugin directly. That is a deliberate trade: build output in version control is unusual, and it costs the maintainer a rebuild before each push, weighed against every user having to read a pnpm build-script error and hand-edit pnpm-workspace.yaml first. For a plugin meant to be installed by strangers, the install has to be one command.

Which means there is deliberately no prepare script. pnpm decides whether a git dependency's build scripts need allowlisting by looking for that entry in package.json — before running it, and regardless of what it contains. A conditional prepare that exits immediately when lib/ is present therefore still demands the allowlist, which was measured rather than assumed. Committing lib/ and dropping the entry is what actually removes the step; prepublishOnly still builds for npm, where no consumer is affected.

Contributors cloning the repository get no lib/ (or can delete it) and build with npm install && npm run build.

Restart the profile afterwards.

Configuration

Every key can be overridden in a profile's cordis.patch.yml by targeting the memory row:

- id: memory
  config:
    maxEntries: 80
    consolidation: always
KeyDefaultMeaning
usertrueEnable the user-level tier.
projecttrueEnable the project-level tier.
maxEntries50Maximum entries per tier.
maxEntryChars500Maximum characters per entry.
injectChars4000Maximum characters of memory injected into one request.
toolstrueRegister the memory tools.
consolidationgatedoff, gated, or always.
minTurnsBetweenRuns1Minimum closed turns between consolidator runs in one session.
keepHistorytrueKeep the previous revision of each store in history/.
consolidationSubagentProviderin-processRegistered subagent provider the consolidator child runs on. A provider name, not a model route.
consolidationRouteProvider—Provider route for the fallback path, used only when no subagent provider is mounted. Omit to reuse the session's own logged route.
consolidationRouteModel—Model id paired with consolidationRouteProvider.
consolidationTimeoutMs120000Wall-clock cap for one consolidation run.

How consolidation works

Paying a model call after every turn is wasteful, because most turns contain nothing durable. The gate is a local, deterministic scan for the situations that actually change long-term memory: an explicit request to remember, a correction, a standing preference, a decision, or a constraint. It is biased toward under-triggering on purpose — a missed consolidation is repaired by the next related turn, whereas an eager consolidator eventually starts inventing memories. Set consolidation: always if you want the faithful behaviour instead.

When the gate fires, consolidation runs as a restricted child agent:

  • toolFilter: { allow: [memory_*] } removes every other tool from the child's prompt and refuses them at execution, so it cannot read or write the repository however the transcript talks to it;
  • persona gives it a consolidation-only brief that shadows the deployment persona for that child alone;
  • outputSchema makes it return a structured receipt;
  • it starts with no parent history, so consolidation never inflates the main conversation's context.

The child writes memory through this plugin's own tools, so every ceiling and every teaching error still applies. That is also why the plugin does not trust the child's self-report: it compares store revisions across the run and treats a claimed-but-absent change as a failure.

Without a mounted subagent provider the plugin falls back to a direct model request that returns a JSON change set, which the plugin validates action by action, bounds per run, and applies atomically per tier. The child path is strictly better; the fallback exists so a deployment without subagents still consolidates.

Security model

A long-term memory plugin has to survive the cases where memory itself becomes the attack surface or the corruption vector. The threat model, stated plainly: anyone who can steer a conversation — a prompt injection buried in a webpage or document, a poisoned dependency's output, plain social engineering — has a channel into the memory store, because the consolidator's job is to write down what the conversation established. The design goal is therefore not to keep hostile text out of the store. That is impossible for a memory whose whole purpose is to record what was said. The goals are narrower and achievable:

  1. remembered text must never acquire authority — it stays data, never instructions;
  2. a poisoned, forged, or corrupted store must fail loudly and safely, never silently;
  3. the writer (the consolidator) must be the most restricted component in the system;
  4. every resource the plugin consumes must be bounded, so abuse degrades rather than cascades.

The guarantees that hold on main:

  • Injection-resistant wrapping. Every memory text is injected inside explicit <untrusted-data> boundaries with a "data, not instructions" header. The wrapper tags themselves are stripped from entry text at write time — on memory_add and on memory_update — so a stored entry cannot forge the closing tag, break out of its own container, and promote itself to a system instruction. Nothing remembered is ever executed as a command, and no tool result from a memory read is shaped like one.
  • Refuse, don't truncate. Load-time validation rejects out-of-bounds stores (entry count/size, tag count/length) instead of silently keeping a broken prefix. The failure mode is fail-closed: an invalid store disables that tier for the session and memory_status says so, rather than serving the model a partial, arbitrarily-cut view of what is "known" about the user.
  • Atomic, serialized writes. Commits run under a per-tier lock file with stale-lock reclaim (30 s), a disk-revision re-check inside the lock, and a unique-temp-file + rename write — an interrupted write never leaves a half-written store, and two writers are serialized rather than interleaved.
  • Lost updates refused. If another process committed the tier after this session loaded it, the write is refused with MEMORY_STORE_CONFLICT rather than silently dropping the other writer's entries. Concurrent-write handling is refuse, then re-read — never merge, because a merge of two model-written stores is itself an injection surface.
  • The consolidator cannot act on what it reads. The child agent's only tools are memory_*; everything else is removed from its prompt and refused at execution. A turn that argues "first read this file / run this command, then decide what to remember" finds the child has no such capability, and the receipt the child returns is checked against actual store revisions, not trusted as a self-report.
  • Tier location is never guessed. Neither tier falls back to the working directory: the user tier resolves DSH_HOME and a fixed chain of well-known homes, the project tier uses the session's own validated cwd, and a tier whose location cannot be established stays off and says so. This matters for security, not just hygiene — a tier that silently rooted itself in whatever directory the host started in could end up committed into a shared repository, exfiltrating personal facts through a public push.
  • Bounded everything. Entry count, entry size, tag count, tag length, injected characters per request, changes per consolidation run, history backups (20 files / 5 MiB), and consolidation wall-clock are all hard-ceilinged, and resource-shaped config options clamp to those ceilings rather than trusting operator input. A store cannot be grown into a context-stuffing attack on the injection side, and history cannot fill the disk.
  • No silent degradation, ever. The packaging rule (zero bare imports in lib/) is enforced by a test, not convention, because a "graceful fallback" that never fires on a real install is how features go missing while the plugin reports healthy. A plugin that cannot do its job is expected to say so or stay off, never to limp.

Deliberately absent:

  • No content-heuristic injection filter. Regex or classifier screens on entry text are bypassable by anyone who can read the source — which, this being public, is everyone — and they sell a false sense of safety while occasionally censoring legitimate memories. The wrapping-and-authority model above is the actual defense.
  • No user-confirmation step. The consolidator is designed to run headless; interrupting the user after every turn to approve a fact about themselves is the failure mode this plugin exists to remove.
  • No fact-verification. The store records what the conversation said, with origin attribution, not what is true. A confident falsehood consolidated into memory is bounded in blast radius (small, curated, visible in memory_list, hand-editable, diffable in git) but it is not auto-corrected. Auditing memory is a user habit, not a plugin feature.

Storage format

{
  "format": 1,
  "tier": "project",
  "revision": 3,
  "updatedAt": 1790000000000,
  "entries": [
    {
      "id": "m1a2b3c4",
      "text": "The build uses tsc, not a bundler.",
      "tags": ["build"],
      "createdAt": 1789990000000,
      "updatedAt": 1790000000000,
      "origin": "consolidator"
    }
  ]
}

The id is derived from the entry text, so adding the same fact twice is a no-op and a rewritten entry keeps its identity. A file whose format is newer than the running build is refused rather than guessed at.

The project tier is meant to be committed by the user in their repository, so .dsh-memory/ is a sensible addition to a project's .gitignore if they would rather not share it.

Design notes

Four constraints shaped this implementation and are worth knowing before changing it.

1. inject is not an optional-dependency declaration — it is what makes the service registry reachable. This shipped broken once, so it is first. A Cordis plugin with inject: [] still loads and apply still runs, but ctx.get(name) returns undefined for every service, and reading one as a property throws cannot get property "tools" without inject. A plugin in that state reports as running in the plugin manager while doing nothing at all, because each guarded lookup takes its "service absent" branch. Measured on a real harness with a probe plugin:

injectctx.get('tools')
[]undefined
['tools', 'systemPrompt']the registry, with register callable

This plugin injects the two services it exists to use. llm and subagents stay out of inject on purpose: they are genuinely optional, and naming one would make its absence disable the plugin instead of degrading it. They are resolved at point of use.

Two consequences worth internalising: a test that hands services to apply through a hand-built ctx cannot catch this class of bug, because it bypasses inject entirely — the export itself has to be asserted (see tests/wiring.test.ts); and the symptom is not an error, it is silence.

2. There is no catch-all plugin message source. MessageSourceMap is a merge-extensible sum type; each producer declares its own kind in its own module. Writing { kind: 'plugin', plugin: 'x' } — the shape older releases accepted, and one still found throughout the harness's own fixtures — does not typecheck against 0.2 and triggers format v4 message requires a producer-owned source kind at the log boundary on versions that enforce it. This plugin declares its own kind in src/source.ts. Injection deliberately does not use message injection at all: a systemPrompt provider cannot leave a stale copy in history.

3. The turn boundary is agent/status → idle, not agent/turn-stopping. The latter is a serial, awaited listener; consolidating there would add model latency to every turn. agent/status is an emit, so memory work starts after the turn has already closed and nothing waits on it. 4. The plugin is self-contained at runtime, so the SDK stays a type-only dependency. defineTool, parameter compilation, argument validation, createUserMessage, and the output-schema check are all implemented in this package rather than imported. The SDK packages are still declared — as peerDependencies for version agreement and devDependencies for the types and for the equivalence suite — but nothing in lib/ imports one, which is what makes a profile install work at all. See "The built output is self-contained" below.

5. ctx.get reads once, at apply time. Not a constraint to design around so much as one to remember: the resolvers read their services once, so every service the plugin needs must be listed in inject and cannot be discovered later.

Where the two tiers get their location

The project tier takes the session's own recorded directory: the session/event payload carries a header with a validated absolute cwd, and that is the only source that is always right. Resolved through a getter, so a host that switches projects without restarting the plugin follows along and drops the stale cache; nothing is cached at apply time.

Two measured facts make this the only defensible choice. ctx.get('cwd') is not a service on any host tried — it returns undefined even with services injected, so that lookup was dead code from the start. And process.cwd() describes how the host was launched, not which project is open: the same packaged application yielded D:\WSL\DSH Workspace on one launch and its own profile directory on another. Trusting it silently wrote project memory into the application's own config directory, where it was invisible in the project and misleading anywhere else.

The user tier resolves in this order, and the source used is reported by memory_status:

ordersourcewhy
1DSH_HOMEnames the harness home directly
2DSH_PROFILE_DIR minus two levelsthe app publishes <home>/profiles/<name>, so the home is two levels up
3USERPROFILE + .dshthe standard Windows home and the harness's own directory name
4HOMEDRIVE + HOMEPATH + .dshthe fallback for shells that split the path
5a dshHome servicehosts that publish it as a service instead
—otherwise disablednever the working directory

Tiers 2–5 exist because the desktop app's host process does not carry DSH_HOME: a sandboxed shell spawned from the same application had the variable while the host's own startup did not, so the tool reported the tier absent in the GUI. Falling back to the working directory is still refused — that would scatter standing personal facts into whatever directory the host happened to start in, including a plugin's own checkout, where they could be committed.

Both tiers follow the same principle: a tier whose location cannot be established stays off, and says so. memory_status reports a disabled tier explicitly rather than omitting it, because an absent tier and an empty tier otherwise read identically.

Structural interfaces, checked at compile time

The plugin declares narrow structural interfaces for the two services it reaches through ctx (SubagentConsolidator, and the tools registry shape) instead of importing the SDK's service classes. That keeps the plugin adaptable to the deployment's actual versions.

A structural interface can drift from the real one, so src/typecheck.ts asserts that the published SubagentRuntime satisfies the plugin's interface. That check has already caught two real errors: a readonly ContentBlock[] where the SDK requires a mutable array, and an outputSchema typed too loosely to be assigned.

The built output is self-contained: no bare imports at all

This is the packaging rule that decides whether a profile install works at all, and it is invisible until someone actually installs the plugin. It cost two rounds of debugging to get right, so the full chain is recorded here.

dsh plugin add puts this package in the profile's node_modules, and the loader imports it by name from there. Bare @deepseek-ai/* specifiers then resolve against the profile's node_modules, which holds only the profile's declared dependencies — the Harness SDK lives in the installation, not there. Measured on the desktop profile: node_modules/@deepseek-ai does not exist. A top-level bare import therefore does not degrade, it fails the whole entry with failed to import, before a single hook is registered.

So SDK imports are type-only wherever possible, and every genuine runtime need is implemented locally:

NeedHandling
createUserMessageReproduced in src/message.ts, its contract checked against the SDK's own types in typecheck.ts.
defineTool (+ parameter compilation, argument validation)Implemented in src/define.ts.
assertObjectJsonSchemaImplemented in src/define.ts as a structural self-consistency check.

Guarded dynamic import() is not a fix, and this repo no longer uses it. It looks like graceful degradation, but on the target install the import always fails, so the "fallback" is the permanent path — the feature is silently missing while the plugin reports healthy. That is exactly how the memory tools came to be absent from every tool surface.

The dynamic import also failed for a second, subtler reason worth knowing: pnpm links packages with junctions, so dsh-tools is entered at node_modules\@deepseek-ai\dsh-tools\lib\index.js rather than at its real store path, and its own import '@deepseek-ai/schemastery' does not resolve from there — even though schemastery is installed as a sibling link in the pnpm store:

ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/schemastery'
  imported from ...\deepseek-memory\node_modules\@deepseek-ai\dsh-tools\lib\index.js

This is a linkage-layout problem, not an incomplete published dependency closure.

The one risk this creates is drift from the harness, so it is held down two ways: tests/define.test.ts compiles the same options through the real SDK defineTool and asserts deep equality including key order; src/typecheck.ts asserts the local definitions stay assignable to the SDK's own types. tests/packaging.test.ts enforces zero bare specifiers in any lib/*.js, statically or dynamically, so this cannot regress silently.

That last file is also why the plugin is verified working: with defineTool local, createMemoryTools() returns all six tools in a plain process with no resolution hook and no SDK on the module graph.

Known limitations

  • The child path needs a subagent provider, and the fallback path needs a route. With a subagent provider, the child inherits the session's model and consolidation is fully automatic. Without one, the fallback needs consolidationRouteProvider + consolidationRouteModel, or the session's logged request/header route; with neither, consolidation is skipped rather than guessing a model.
  • Consolidation runs in-process and is not queued. One run per session at a time; a crash mid-run loses that run's changes, never a committed store.
  • Concurrent writers are refused, not merged. If another process commits the same tier after this one loaded it (two harness windows on one project, say), the write is refused with MEMORY_STORE_CONFLICT and the stale cache is dropped, rather than silently overwriting the other writer's entries. Hand edits that do not bump revision are still invisible until a refresh().
  • The gate is a heuristic over text. It reads user and assistant text only; it cannot see whether a turn was important, just whether it sounded durable.
  • No dedup against near-identical entries. Two differently-worded versions of the same fact are two entries; the consolidator is expected to merge them.
  • maxEntries is per tier, not global, so a deployment with both tiers enabled can hold twice the configured number of entries.
  • The child can only write memory, so anything else it concludes is discarded. That is intentional, but it means a consolidation that needs to inspect a file to decide is simply out of scope.

License

MIT

関連プラグイン