dsh-skill-curator
Automatic skill curation for DeepSeek Harness (DSH). After every N real conversation turns (default 3), the plugin fires a background subagent that reads a digest of the session and edits, merges, prunes and creates SKILL.md files under the user skills directory (~/.dsh/skills/) — the same self-improvement loop Nous Research's Hermes Agent runs, ported to DSH as a zero-intrusion bundle plugin.
English | 简体中文
How it works
turn ends ──▶ agent/turn-stopping (scoped listener, counted per agent)
│ 3 turns reached? (skillNudgeInterval)
▼
async fire-and-forget (never blocks the turn close)
▼
session digest built ──▶ latest N messages verbatim + older turns compressed
▼
spawn subagent (provider: spawn) with digest + review instructions
│ toolFilter allow = skill-library-* only (whitelist, hermes-style)
▼
subagent reads the library first → subtracts (merge / shorten / delete) before it adds
│ every write must echo the sha256 it just read (= hard read-before-write)
▼
writes ~/.dsh/skills/<name>/SKILL.md (Chinese body; original backed up first)
▼
summary logged (host journal) + visible in the settings card status panel
Manual trigger: /skill-refine [focus] runs a review of the current session immediately.
Design decisions (vs Hermes)
| Hermes | dsh-skill-curator |
|---|---|
| turn_finalizer nudge counters (10) | scoped agent/turn-stopping counters (3, configurable) |
| fork of AIAgent in a daemon thread | platform subagent (ctx.subagents.start) — visible in the UI, fully isolated session |
| replay full conversation (warm prefix cache) | digest injection (tail-verbatim + head compression) — DSH has no cache advantage |
| runtime tool whitelist (memory+skills) | toolFilter.allow whitelist + prompt constraint |
| edits only curator-created skills | the whole library is editable, guarded by read-before-write + auto-backup + a protected list |
/refine |
/skill-refine command |
Behavioral parity matrix: docs/COMPARISON.md.
Install
cd /path/to/dsh-skill-curator
dsh plugin --profile web add ./
# restart dsh, then open: Settings → Plugins tab → "Skill Curator" card
Skip the restart? The plugin only takes effect on next start (standard bundle plugin; no dsh source changes, ever).
Settings (Settings → Plugins tab → "Skill Curator" card)
- enabled — master switch (default on)
- skillNudgeInterval — turns between reviews (default 3)
- notifyMode — off / on / verbose (segmented buttons)
- reviewProvider / reviewModel — optional review subagent model override (empty = follow the session's current model)
- reviewBaseUrl / reviewApiKey — optional custom review endpoint (OpenAI-compatible
/chat/completions). WhenreviewBaseUrl+reviewModelare set, the review subagent runs against that endpoint through a dedicated adapter route (provider name =reviewProvider, orskill-curator-reviewby default). Settings are read live on every request — no restart needed - Review history persists to
~/.dsh/skill-curator/reviews.json(last 50 entries) — survives plugin removal/reinstall and restarts - Review resilience — two layers for endpoint/model failures (HTTP/network/auth/rate-limit/model missing/timeout): ① if the custom endpoint fails, the review retries once on the session's own model (the log line is tagged
⚠️已回退主模型, "fell back to the main model", in the host log, the review log and the status panel); ② if the final attempt then dies on an endpoint-class error or a detail-lesskilled, it retries up toreviewRetryCounttimes withreviewRetryDelayMsbackoff — a model connection blip no longer loses the review. Tool-layer errors are never retried - reviewTimeoutMs — review subagent budget (default 15 min)
- reviewRetryCount — extra retries when the final review attempt dies on endpoint/model-layer failures (connection reset, HTTP errors, timeouts) or a detail-less
killed;0disables retrying. Tool-layer errors are never retried. Default 1 - reviewRetryDelayMs — backoff base for retries, n-th retry waits
base × nms (default 5000) - digestTail / digestMaxChars — digest shape
- excludedSkills — comma-separated names the reviewer must never touch (empty = the whole library is editable)
- staleDays / archiveDays — skill lifecycle windows in days (default 30 / 90); see Skill lifecycle
- archiveEnabled — allow automatic archiving (default off: reviews only report, nothing on disk moves)
Skill library tools (whitelist)
The review subagent can only call these eleven (they are also usable by any session):
| Tool | Purpose |
|---|---|
skill-library-list |
list skills (name, description, body line count, protected?) |
skill-library-read |
read any file (SKILL.md, or a support file via filePath) + that file's own sha256 |
skill-library-tree |
list every file in a skill (lines/size) — see what you actually wrote |
skill-library-create |
create a class-level umbrella skill (Chinese body, bilingual description) |
skill-library-patch |
targeted oldString→newString or whole-body replacement (frontmatter preserved) |
skill-library-write-file |
support files; overwriting an existing file backs up its previous content |
skill-library-delete-file |
delete one support file (read-first; backed up) |
skill-library-delete |
delete a redundant/merged-away skill (read-first; whole directory backed up) |
skill-library-adopt |
stamp an externally authored skill with the author marker (provenance only) |
skill-library-lifecycle |
list / archive / restore / pin — inspect or maintain skill lifecycle state (archive is recoverable, never a delete) |
skill-library-git |
self-check the library repo: status / diff / log (no push, no reset, never a shell) |
Four guardrails
- Read before write —
patch/write-file/deletemust echo theexpectedSha256returned byskill-library-read. Writing without reading, or writing against a file that changed since the read, is refused. This is the only mechanical defence against rewriting someone's skill from memory. - Backup before write — every write/delete first copies the original (the whole skill directory on delete) to
~/.dsh/skill-curator/backups/, keeping the latest 30, so a wrong move is recoverable. - Protected list — skills in
excludedSkillsare read-only. - Git backstop — at the end of every review the plugin auto-commits the library (message = change summary + session id), so every review leaves a rollback point. A failed commit only warns and never affects the review result; a non-git skills root is skipped with a recorded reason.
Paths are boundary-checked; writes are atomic (tmp + rename). Bodies over 150 lines come back with a "move detail into references/" nudge in the tool result (advisory, never blocking).
Skill lifecycle
Each skill is tracked as active → stale → archived by real use, not by file mtime. What counts as a use (single source of truth: src/usage-scan.js):
- A
skilltool load — main sessions and ordinary subagents both count (62%~87% of loads for the review/TDD family happen in subagents; counting only main sessions marks them "never used" forever) - The review subagent's own loads do not count (judged by
agentPreset=skill-curator-reviewin the session header) — otherwise every review would refresh the whole library - Any tool whose arguments reference
<...>/skills/<name>/...counts — coversread,grep, and running scripts inside a skill directory skill-library-*maintenance calls never count (2,000+ per week during review)
Windows and state:
staleDays(default 30) /archiveDays(default 90) — the panel follows these settingsarchiveEnabled(default off) — off means the review only reports which skills are eligible; nothing on disk moves- pinning — pin a skill via
skill-library-lifecycle(there is no settings field for it) and it is exempt forever - Archived skills move to
<DSH_HOME>/skill-curator/archived/<name>/— outside the library, so the host does not discover them, andrestorebrings them back
The panel (Settings → Skill lifecycle) shows two collapsible sections, the whole header row clickable and the count in a pill: unused for N+ days (staleDays, rows show name + age) and archiveable at M+ days (archiveDays, checkbox rows with one-click archive). Both are derived views computed on read, with M-day archiveable a subset of N-day unused (the copy switches when the thresholds are inverted). "Never used" is no longer its own group: the day count would just be the registration age, and past the threshold those skills land in N-day unused anyway.
Rebuild historical usage (loads from before the plugin existed, and from subagent sessions) with the one-shot script:
node scripts/backfill-usage.mjs # dry run, prints the diff
Writing requires dsh to be fully stopped (the script probes port 31102 and refuses, because a running plugin rewrites the whole in-memory table and would clobber the backfill). Updates are max-only and backed up under skill-curator/backups/ first.
The plugin also registers a runtime skill named skill-curator so the model knows how to use the eleven skill-library-* tools.
How the reviewer writes skills (plain, not bloated)
The review prompt carries a shape contract aimed squarely at "skills only ever grow":
- Subtract first — priority slot 0 is "read the candidate skills, then delete duplicates, merge siblings, shorten stale parts". A deleted line counts as a result, usually the better one.
- Length — body target ≤150 lines; past 250 the detail must move to
references/. Standard skeleton: one-line conclusion → when to use → how → pitfalls → boundary. - Language — plain human wording; no background/foreword/summary/disclaimers, no restating common knowledge, every term explained in one clause, one fact stated once.
- Adding a section is the last resort — rewrite an existing section before adding a new one.
Requirements
// package.json — machine-readable
"engines": { "node": "^22.19.0 || >=24.0.0" },
"peerDependencies": { // the host runtime is the single source of truth; no bundled dsh-* copies
"@deepseek-ai/dsh-llm": ">=0.1.2-alpha.3 <0.1.8 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.7-alpha.0 <0.1.8 || >=0.2.0-alpha.0 <0.3.0 || >=0.2.1-alpha.0 <0.3.0 || >=0.2.2-alpha.0 <0.3.0",
"@deepseek-ai/dsh-settings": "…same…",
"@deepseek-ai/dsh-subagent": "…same…",
"@deepseek-ai/dsh-tools": "…same…",
"@deepseek-ai/schemastery": "~3.18.4 || ~3.19.0" // .volatile() needs 3.18.4+; the 0.2.2 host ships 3.19.0
},
"devDependencies": { …local copies of the four dsh-* packages + schemastery… } // no @deepseek-ai/dsh umbrella: it drags the whole host tree in
- Verified host:
@deepseek-ai/dsh-desktop-runtime 0.2.2-rc.1(Node v24); 0.2.1-rc.2 was real-machine verified end to end (/skill-refine). - Why the disjunction is load-bearing: npm semver only satisfies a prerelease from a range group that itself contains a prerelease with the same
[major,minor,patch]tuple, so the plain<0.2.0group does not cover0.1.5-rc.1or0.1.7-rc.2, and the0.2.0-alpha.0group does not cover0.2.1-rc.2or0.2.2-rc.1(their tuples are newer).test/entry.test.mjspins this with a decision table, a counter-proof (reverting to the older range turns red), and a cross-check against the host's realsemver.satisfies. - 0.1.7 settings contract (what this adaptation was about):
settings.installSectionwas removed from dsh-settings. The namespace is the cordis row id,Configmust be exported from the plugin module, and every live-editable field must be.volatile()(the host's_commitVolatilerewrites the live reference in place, no fiber restart). The settings card therefore hangs offconfigForms.get(ns)and theplugins.itemslot. - No install scripts, no gyp/native deps: the whole tree is plain ESM;
test/entry.test.mjsguards this (noinstall/postinstall/prepare, nooptionalDependencies, dependency scope limited to@deepseek-ai/*).
Development
pnpm install # needs proxy/cache off the default npm cache on some hosts
npm test # entry + unit + smoke + client-smoke (all suites)
node test/entry.test.mjs # entry load, manifest, engines range, key-set parity, dep hygiene
node test/smoke.mjs # module/tool/whitelist smoke
node test/client-smoke.mjs # client bundle smoke (real component-tree execution)
All tests run inside a disposable DSH_HOME; no deployed instance is touched.
dsh 0.2.2-rc.1 compatibility notes
Verified against the 0.2.2-rc.1 desktop runtime by unpacking its asar and calling the
host's own evaluatePluginCompatibility. 0.2.2 breaks nothing this plugin touches —
no source file changed. The evidence is a method-surface diff against 0.2.1-rc.2, not a
green test run alone:
LlmAdaptersurface unchanged (7 members:providerInfo,providerRetryPolicy,imageRequestPricing,imageRequestTarget,listModels,resolveModel,prepareCall) — so the 0.2.1-era "abstract base gained a method" P0 does not recur, andsrc/custom-adapter.jsstands as written.SubagentRuntimesurface unchanged (20 members incl.startActivation,registerProvider,list; still nostart) —src/reviewer.jskeeps branching on "is the method present".- Root exports intact:
settleRunResult,attributionHeadersanddefineToolare still exported, andsettleRunis still absent from thedsh-subagentroot — the inlinedsrc/run-settlement.jsstays the right shape, and a run handle still publishes{ result, dispose }. - The built-in schemastery moved 3.18.4 → 3.19.0 (
dsh-settingspeer is~3.19.0). Peer widened to~3.18.4 || ~3.19.0and the devDependency pinned to 3.19.0 — the trap is that pnpm otherwise auto-installs 3.18.4 and every test then validates the old fixture..volatile()still behaves the same at 3.19.0. - The 0.2.2 clause is load-bearing for install, not for loading. Against the previous
five-clause range the host gate returns PASS at
0.2.2-rc.1(includePrereleaseadmits the upper bound's neighbour), while strict semver returns false — so pnpm install would refuse.test/entry.test.mjspins both the decision table and that counter-proof. - One stale guard was fixed while here: the
injectservice-name check resolved a hardcoded/usr/lib/node_modules/...path that never exists on Windows, so it had been silently skipping (= fake green) since it was written. It now resolves@deepseek-ai/dsh-settings/dsh-toolsfrom the devDependencies and matchessuper(<anyContext>, "<service>")— 0.2.2 renamed that variable toownerContext, which a literalsuper(ctx, …)assertion would have mis-reported as drift.
dsh 0.2.1-rc.2 compatibility notes
Verified against the DSH 0.2.1-rc.2 desktop runtime by comparing against its unpacked
asar sources and by exercising the plugin end-to-end. 0.2.1 packs several breaking
changes — clearing the compatibility gate is not enough on its own:
- The gate is only the entry ticket.
dsh-subagentno longer exportssettleRunfrom its package root (onlysettleRunResult; the implementation moved intolib/types/run-settlement.jsbut is not reachable through theexportsmap). A top-levelimport { settleRun }throws aSyntaxErrorat ESM link time, soapply()never runs and the entire plugin silently dies — the host only logs a single warning. Fix: inlinesettleRun(src/run-settlement.js), aligned statement by statement with the host's semantics. subagents.start()was removed in favour ofstartActivation(spec)(whose spec carries an extrarequestlayer). Branch at runtime on "is the method present" rather than betting on a version number. Note thatstartActivation'ssignalcovers admission only — a timeout mustdispose()explicitly or the review hangs until the built-in 15-minute self-timeout.- The tool whitelist moved from the
requestlayer to the Agent Preset layer.startLocalonly writescomposition: { agentPreset }, so locally spawned subagents never seerequest.toolFilter; meanwhiledsh-tool-subagentthrows outright onconfig.toolFilterand points you at a preset. The plugin therefore declares its own internal preset (skill-curator-review) mountingsrc/preset-guard.js, which callstools.restrict({ allow })inside the subagent's own scope. - The preset guard must wait for a readiness marker.
restrict()validates names throughview(scope).restrictableNames, which only accepts the inheritance surface; profile entries are created concurrently byPromise.all, and the preset row injects onlytoolswhile this plugin also waits onsettings— so the preset races ahead, the tools are not registered yet, the call throws, and the registry writesrecord.brokenpermanently ("A mount failure is final"). The preset stays broken forever while the Loader row still showsactive. Fix: the plugin callsctx.provide(TOOLS_READY_SERVICE, …)after registering its tools, and the guard waits for it viainject. - The
LlmAdapterbase class gainedimageRequestTarget. A duck-typed adapter missing it throws aTypeError(same origin asimageRequestPricing); a guard test caught this immediately. - The gate reads
peerDependenciesonly. The decision function (evaluatePluginCompatibilityindsh-app-boot) walks only the@deepseek-ai/dsh*entries underpeerDependenciesand never readsdsh.engines.dsh(zero consumers across the whole tree). The two must stay literally identical, which the tests enforce. - Two different semver modes. The host gate runs with
includePrerelease: true, which bypasses the prerelease-visibility rule, so prereleases of the upper bound itself are admitted too (<0.1.8admits0.1.8-rc.1,<0.3.0admits0.3.0-alpha.0). Strict mode (used at pnpm install time) rejects them. The upper bound therefore blocks stable releases, not prereleases; to reject prereleases as well, write it as<0.3.0-0. set_pluginre-mounting does not always take effect. If a package'srealDirchanged (for example right afterpnpm install),ResolutionRouter.replace()throwsResolutionRestartRequiredand returnsrestart-required / restartReason: "package-moved". A dsh restart is then mandatory — and because a broken preset is written down permanently, re-mounting will not rescue it either.
License
MIT
No comments yet. Be the first to write one.