dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
Layered long-term memory plugin for DeepSeek Harness (Cordis): project/global/habit stores, progressive disclosure, zero-LLM core, two-gate habit learning.
Cross-project long-term memory for DeepSeek Harness (DSH 0.2.x), built as a Cordis plugin bundle.
Three abilities, one plugin:
DSH_HOME.memory_recall / memory_expand, with byte-stable injected prefixes for prompt-cache friendliness.observe → threshold → judge (optional) → candidate → human gate 1 → suggested → human gate 2 → auto). A model can never promote a habit.Zero LLM calls is a first-class mode. Write, recall, inject, forget, consolidate, health reporting and the whole habit pipeline work with no model route configured. The optional judge model defaults to off and fails open when configured but broken.
This design was built against the real DSH 0.2.0-rc.1 runtime (inspected services, events, tool contracts and client slots), taking the following four plugins as design starting points. Where their assumptions conflict with the current runtime, the runtime wins; deviations are listed explicitly.
| Reference | Aligned with | Deviation and why |
|---|---|---|
dsh-plugin-memory | Layered stores, index/topic separation, hard truncation budgets, keyword-only retrieval when no LLM route exists | Stores are kept in four roots that are validated as strictly non-nested (the runtime refuses to start memory if project/global/habit roots alias). Retrieval also has a per-session cumulative budget and an explicit, observable degradation ladder, not only per-call truncation. |
dsh-sieve | Judge model as an optional token-pruning decision maker, fail-open semantics, spill archive of originals, disclosure trade-off | Our judge only decides habit-pattern yes/no from ≤5 evidence slices; it never prunes conversation context on the main path. Memory bodies are disk-first and recall/expand are capped; spill primitives exist for originals, but automatic conversation-level offload is a host concern (see "Long-task offload (status in 0.1.0)"). |
dsh-habit | Deterministic correction detection, threshold trigger, candidate zone, two human gates, "model cannot self-promote" | Habit entries live in their own store with their own tombstone file, and the promotion path exists only on the host HTTP UI route. There is no tool parameter that can confirm or promote a habit — this is stricter than an actor check at the tool boundary. |
dsh-progressive-tools | Byte-stable prefix as a constraint on progressive disclosure, accepting a smaller first request | We keep the injected section text byte-identical while its content signature is unchanged, and disclose nothing new per turn: the index only changes when memory content changes (new write / forget / consolidation / health transition). We do not shrink the visible tool table dynamically — see "Cache stability" below for the measurement rationale. |
Deviation from the reference plugins' packaging: this repo ships lib/ (build output) inside the
package and commits it, so a fresh clone of the repository produces a bundle that can be installed
without a build step in the target profile. defineTool is satisfied by a local, dependency-free
implementation of the documented contract (src/tool.ts) rather than importing
@deepseek-ai/dsh-tools from the plugin directory: a bundle installed at $DSH_HOME/plugins/<name>
cannot resolve Harness-internal packages from node_modules that is not an ancestor of the plugin
directory (Cordis loads the plugin as a process-internal ESM import; @deepseek-ai/dsh-tools is not on
that resolution path). The emitted tool definitions match the documented wire contract
(name, description, parameters as JSON Schema, output.schema, execute(args, exec)), and execute
respects exec.signal. If your harness resolves @deepseek-ai/dsh-tools from the bundle location, replace
src/tool.ts's defineTool import with the package export — the rest of the code is unchanged.
These are the assumptions the implementation is written against; every one of them was checked against the installed 0.2.0-rc.1 packages rather than inferred from prose.
dsh.bundle.patch points at a Cordis patch. The
patch has one - insert: row (id, name, config). The Loader imports the package in-process and
calls apply(ctx, config). Host module exports name, inject, apply.ctx.effect(() => disposer, 'label'), so Cordis calls the disposer on unload in
reverse order. No process.on, no unref'd timers, no writes to document/body.src/, built to lib/) owns storage, retrieval, injection, tools
and the HTTP review surface. The Client half (client/index.js, built to lib/client/index.js) is a
window.__ModuleLoader__.load({ id, factory(require) }) bundle that registers a settings page into the
settings.section slot via ctx.slots.register({ name: 'settings.section', id, order, label }, Component).inject (['tools']). Optional services are
acquired with ctx.inject(['systemPrompt' | 'llm' | 'webServer'], …) inside a scoped callback, so a
profile without them simply doesn't get those features. A missing optional service is never an
activation error: the plugin starts, the core works, and the health file/UI says which feature is off.ctx.tools.register(defineTool({ name, description, parameters, output: { schema, render }, execute })). parameters is the author-facing property map (converted to JSON Schema);
execute returns exactly the canonical value declared by output.schema; UI rendering is a pure
projection (render(args, value) -> ContentBlock[]), not business logic.ctx.systemPrompt.section({ name: 'dsh-memory', order: 40, text: () => runtime.refresh().text, interpolate: false }) inside ctx.inject(['systemPrompt'], …), and
removed by the disposer on unload. The section is a function so the assembly can read the cached,
byte-stable block; it never mutates the prompt per turn.session/event emit and reads only user/message content;
derived data lives in the plugin's own stores. ┌──────────────────────────── plugin (in-process ESM) ───────────────────────────┐
DSH Loader ─┤ apply(ctx, config) │
│ ├─ tools: memory_remember / recall / expand / forget / consolidate / status │
│ │ / feedback / habits │
│ ├─ systemPrompt.section("dsh-memory") ← byte-stable index (hard capped) │
│ ├─ session/event subscription ← deterministic correction detection │
│ ├─ optional webServer routes ← /dsh-memory/status, /dsh-memory/habits│
│ └─ optional llm route ← habit judge only (default off) │
└─────────────────────────────────────────────────────────────────────────────────┘
| Layer | Path (default) | Follows | Shared via git | Written by |
|---|---|---|---|---|
| session | in-memory SessionBudget only | the session | no | recall accounting |
| project | <repo>/.dsh/memory/ | repository (.git/.dsh root) | yes (entries.ndjson) | tools, agent |
| global | $DSH_HOME/memory/ | the profile/user | no | explicit promote only |
| habit | $DSH_HOME/storages/habit/ | the profile/user | no | habit pipeline + human gates |
LayeredStore canonicalizes all roots at construction and throws StoreInvariantError if any two roots
are equal or nested; every append additionally asserts the target root matches the layer, so a
cross-store write throws CrossLayerWriteError instead of silently landing in the wrong file. The runtime
catches store-construction failures and turns them into an unavailable health state — DSH still starts.
Storage format is human-readable line-oriented JSON (entries.ndjson, profiles.ndjson,
signals.ndjson, triggers.ndjson, candidates.json) plus audit.ndjson, a hash-chained audit log
(prevHash → hash). Files are UTF-8, diff-friendly, and hand-editable.
- [P0] ★title (layer, id) tags:… :: first sentence, each line
≤ indexMaxLineChars (default 150), ≤ indexMaxLines (24) and ≤ indexMaxChars (3000). Bodies stay on
disk.memory_recall applies maxResults / maxCharsPerMemory / maxTotalRecallChars (defaults
5 / 1200 / 4000), a per-session cumulative budget (recallSessionBudgetChars, default 12000), and
de-duplicates entries already disclosed in that session unless allowRepeat=true.id:version:priority:contentHash of visible entries, id:updatedAt:pattern
of auto habits, tier, warning). Re-rendering the same content returns the identical string, so the
system-prompt prefix stays byte-identical across turns — no per-turn flapping, no reordering. A new
disclosure therefore happens only when a memory category actually changes. The runtime reports
cache.indexHash, cache.prefixStable, tier and sizes through memory_status and /dsh-memory/status,
and this is asserted byte-for-byte in tests/lib.spec.ts and tests/plugin.spec.ts.
Trade-off: compared with dsh-progressive-tools (visible tools 71 → 9, cache share 96% → 81%), we do
not dynamically shrink the tool table. Our first request cost is reduced by the index cap and by
retrieval-on-demand, while the tool surface stays constant — this keeps the prefix stable without paying
the documented cache-share penalty. The judge model is only ever used for a habit yes/no classification,
not on the request path, so it cannot perturb the prefix.budget.degradeOrder):
full → compact → minimal). The tier narrows the index plan (line/char caps,
minPriority); a whole-index fallback to a single line happens when the index cannot be built,minPriority),compact → minimal → single-line → none); every step is
recorded in the audit log and surfaced as tier + degradationReasons in the recall result and in the
status report.habit.judgeEnabled defaults to false; when disabled the habit pipeline uses pure rules and the health
file records the judge feature as off, not as a failure. When enabled, the judge is called at most
once per session after habit.threshold (default 3) correction signals, with at most habit.maxEvidence
(5) evidence slices (≤300 chars each) and the existing habit list. The response must be strict JSON. On
timeout (judgeTimeoutMs), unavailable route, or unparseable output, the judge fails open: the rule
path is used, the originals are kept, and the health state becomes degraded with reason
judge-failed so the user can see it. The main model is never asked to do this classification.
budget.unloadThresholdChars (default 16000) is a declared/reserved budget knob; host-side automatic
offload of oversized tool results is not implemented in 0.1.0 (the plugin never rewrites the
conversation context). What is implemented: memory bodies and spill candidates live on disk under
$DSH_HOME/storages/memory/spill/ (byte-readable through the store API), and recall/expand always return
bounded excerpts, so no memory tool output is unbounded. Offloading main-conversation tool results remains
a host/compaction concern.
$DSH_HOME/storages/memory/health.json always holds status: active | degraded | unavailable, concrete
reasons (code, message, scope) and per-feature flags (storage, index, recall, write, habit,
judge). Degraded/unavailable is visible in three places:
settings.section, id dsh-memory) showing a status badge + warnings;GET /dsh-memory/status (JSON, also used by external tooling);dsh-memory DEGRADED: [index] index-build-failed: … (state: …)) when
the memory runtime exists, plus a warning field on status-style / rejected tool results and on the
HTTP status payload.Tested scenarios: write-guard rejection, index build failure, judge unavailable, storage unavailable.
Every write path (tools, habit writes, promote, spill) runs the deny hook before touching disk.
Patterns: sk-…, AKIA…, ghp_…, GitHub fine-grained tokens, Bearer …, JWT, password=,
api_key=, private-key blocks, npm/slack/google tokens, env-var secret assignments; extendable via
guard.extraDenyPatterns. On a hit the write is rejected with a WRITE_GUARD_REJECT that reports only
line number + pattern name — values are never echoed to the model, the UI or logs (asserted by tests).
session/event ──► deterministic correction detection (0 tokens, fixed phrase list, ≤400-char segments)
──► signals.ndjson, count per session
──► at threshold: ONE optional judge call (≤5 slices + existing habits) [fail open → rules]
──► candidates.json status: pending (NO context injection)
──► gate 1 human confirms → habit profile status: suggested (still no injection)
──► gate 2 human promotes → status: auto (only now injected into recall/index)
mature/auto: promote() refuses until the pattern has
been seen in minSessionsForAuto (default 2) distinct sessions.tombstones in candidates.json + a tombstoned habit-layer entry) so the
pattern is never re-learned; the candidate zone and profiles are listable/editable/disable-able in the UI.memory_habits is read-only; mutations exist
only on the host HTTP route used by the human UI (POST /dsh-memory/habits).Content-hash dedupe on write; a new version of the same title supersedes the old one while the old version
stays in the file as history. Low-value memory follows a chain: expiry (expireDays) → dormant
(dormantDays) → weak renewal (weakRecallDays) → capacity eviction (capacity) → archived → physically
deleted. P0 entries and user-authored memory are protected (lifecycle.userMemoryProtected). Merging,
compaction and forgetting run in memory_consolidate (optionally dryRun) or in the background; they do
not block the main conversation.
| Tool | Zero-LLM | Purpose |
|---|---|---|
memory_remember | ✅ | guarded write into project/global; global requires explicit confirmed=true |
memory_recall | ✅ | bounded, deduped progressive-disclosure retrieval |
memory_expand | ✅ | fetch a stored body by id, bounded by the per-call and session caps |
memory_forget | ✅ | tombstone (P0/user memory protected unless force=true) |
memory_consolidate | ✅ | expire/dormant/evict/archive chain, dryRun supported |
memory_status | ✅ | health, reasons, stores, counts, budgets, cache hash/tier, zeroLlm |
memory_feedback | ✅ | useful/wrong confidence adjustment |
memory_habits | ✅ (read-only) | candidates, profiles, tombstones, gates — no mutation |
The bundle is a normal Cordis plugin bundle. From a checkout of this repo:
pnpm install --frozen-lockfile # runtime deps: none; TypeScript/@types/node are dev-only
pnpm build # TypeScript -> lib/ using Node's own type stripper
pnpm verify # build --check + package-shape gate + tests
Then install it into a DSH profile with the Harness plugin manager (preferred), pointing at the absolute directory of this checkout:
plugin_manager(action="install_bundle", target="/absolute/path/to/dsh-memory-plugin")
or copy the package directory under $DSH_HOME/plugins/dsh-plugin-memory and add its patch through the
profile bundle list. The plugin appears as dsh-memory with one loader row; disable/remove that row to
unload it (all ctx.effect disposers run in reverse).
// tool call
{ "name": "memory_remember", "arguments": {
"title": "Indentation style",
"content": "This repo uses tabs, never spaces.",
"tags": ["style", "formatting"],
"layer": "project"
} }
// -> { "ok": true, "id": "mem_ab12cd34", "version": 1, "layer": "project", "deduped": false }
{ "name": "memory_recall", "arguments": { "query": "indentation style" } }
// -> { "items": [{ "id": "mem_ab12cd34", "title": "Indentation style", "excerpt": "…", "chars": 41 }],
// "tier": "full", "spentChars": 41, "sessionSpentChars": 41, "dedupedIds": [] }
Every response is plain JSON; the model-facing text is the canonical value rendered by output.render.
Loader row defaults live in cordis.patch.yml. All keys are optional and merge over
the defaults in src/config.ts:
| Key | Default | Meaning |
|---|---|---|
projectMemoryDir | .dsh/memory | repo-relative project store |
globalMemoryDir | ~/.dsh/memory | global store (~ = DSH_HOME) |
habitDir | ~/.dsh/storages/habit | habit store |
cacheStable | true | keep the injected prefix byte-stable |
budget.indexMaxLines | 24 | resident index hard cap |
budget.indexMaxLineChars | 150 | one index line cap |
budget.indexMaxChars | 3000 | total index cap |
budget.recallMaxResults | 5 | top-K per recall |
budget.recallMaxCharsPerMemory | 1200 | body cap per recall item |
budget.recallMaxTotalChars | 4000 | total injected chars per call |
budget.recallSessionBudgetChars | 12000 | per-session cumulative recall budget |
budget.habitPromptMaxChars | 800 | habit hint injection cap |
budget.unloadThresholdChars | 16000 | long-output offload threshold |
lifecycle.expireDays | 30 | expiry |
lifecycle.dormantDays | 14 | dormant after |
lifecycle.weakRecallDays | 30 | weak renewal window |
lifecycle.capacity | 500 | entries before eviction |
habit.enabled | true | deterministic observation |
habit.threshold | 3 | corrections per session before one judge call |
habit.maxEvidence | 5 | evidence slices sent to the judge |
habit.judgeEnabled | false | optional judge (off = not a failure) |
habit.judgeProvider / habit.judgeModel | "" | explicit route; never guessed |
habit.judgeTimeoutMs | 4000 | timeout → fail open |
habit.minSessionsForAuto | 2 | distinct sessions required before auto |
guard.blockEnvAssignments | true | also reject FOO_SECRET=… style assignments |
guard.extraDenyPatterns | [] | extra regex sources |
Claim: write, recall, expand, forget, consolidate, feedback, status and the complete habit pipeline
(detection → threshold → rule-mode candidate → gate 1 → suggested → gate 2 → auto) work with no llm
service and no vector backend. The final index/injection/degradation/health paths are pure functions over
local files. habit.judgeEnabled=false is reported as judge off, not degraded.
Verification:
# 1. the built artifact registers and operates with no llm service at all
node tests/lib.spec.ts # "remember -> recall works with no llm service (zero-LLM core)"
# "status tool reports active health ... zeroLlm: true"
# 2. the full acceptance suite, including the zero-LLM end-to-end test
pnpm test # tests/plugin.spec.ts: "zero-LLM mode: write/recall/expand/forget/
# consolidate all work" and "judge failure fails open into rule mode"
# 3. from a live session, ask the plugin itself
# memory_status -> { "zeroLlm": true, "judge": { "enabled": false }, "health": { "status": "active" } }
No vector store, no embedding model, no network: retrieval is deterministic keyword relevance with tags, recency and confidence. Vector search / LLM distillation are intentionally not dependencies; they are listed as optional future backends, and their absence changes no core behavior.
pnpm test # node scripts/test.mjs -> runs every tests/**/*.spec.ts with node:test
pnpm typecheck # local tsc (falls back to syntax-only check with a warning if none is installed)
pnpm verify # build --check + package gate + tests (same gate as CI)
tests/plugin.spec.ts — 23 acceptance tests over an isolated temp DSH_HOME + project fixtures.tests/lib.spec.ts — 15 artifact/packaging/audit tests against the built lib/.tests/audit.spec.ts — 10 regression probes for the second audit pass (BUG-1…BUG-8 + two passing probes).tests/fixes.spec.ts — 4 regressions for the v6' acceptance T0 root causes (project-root detection,
project-layer-only degradation, session-workspace anchoring, lossless memory_status JSON).tests/harness.ts — fixtures (createHarness, fake judge, failing judge, correction feeder).The plugin was audited against the real DSH 0.2.0-rc.1 / Cordis 4.0.4 contracts with executable
probes, not just by re-running the suite. Twelve defects were found and fixed; each has a regression
test in the suite above. Full evidence lives in publish-status.json → audit.
| Severity | Defect | Fix |
|---|---|---|
| high | sessionIdOf() ignored exec.agent.id, so every tool call shared one "unsessioned" bucket and disclosure dedupe + the cumulative recall budget leaked across sessions | prefer the real exec.agent.id; regression test keeps two sessions isolated |
| high | habit observation read event.content instead of the real SessionEvent.data, so correction signals never fired in a live DSH | read event.data first, skip plugin/model-sourced user-role messages |
| high | memory_forget exposed force=true to the model, bypassing P0/user-memory protection | force removed from the model-facing schema; engine keeps the guard |
| high | each recall appended a full copy of the entry (~520 B per recall, forever) | append counter + automatic compaction (last row per id@version, audit type compact) |
| medium | the model could mint priority P0 entries, creating memory it could never delete | memory_remember refuses P0; P0 stays user-authored |
| medium | POST /dsh-memory/habits accepted any method/content-type/origin and unbounded bodies (CSRF + memory exhaustion) | POST + JSON only, same-sit |