runfali/dsh-skill-curator

dsh-skill-curator

为 dsh 打造的自动技能策展插件:每 N 轮真实对话,后台起一个评审子代理阅读会话摘要,主动把值得沉淀的经验提炼为 ~/.dsh/skills/<name>/SKILL.md - 把 Hermes 的「后台评审自我改进」闭环移植到 DSH,零侵入 bundle 插件,不改 dsh 源码。

待分类dshdsh-bundledsh-plugins

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)

Hermesdsh-skill-curator
turn_finalizer nudge counters (10)scoped agent/turn-stopping counters (3, configurable)
fork of AIAgent in a daemon threadplatform 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 skillsthe 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). When reviewBaseUrl + reviewModel are set, the review subagent runs against that endpoint through a dedicated adapter route (provider name = reviewProvider, or skill-curator-review by 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-less killed, it retries up to reviewRetryCount times with reviewRetryDelayMs backoff — 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; 0 disables retrying. Tool-layer errors are never retried. Default 1
  • reviewRetryDelayMs — backoff base for retries, n-th retry waits base × n ms (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):

ToolPurpose
skill-library-listlist skills (name, description, body line count, protected?)
skill-library-readread any file (SKILL.md, or a support file via filePath) + that file's own sha256
skill-library-treelist every file in a skill (lines/size) — see what you actually wrote
skill-library-createcreate a class-level umbrella skill (Chinese body, bilingual description)
skill-library-patchtargeted oldString→newString or whole-body replacement (frontmatter preserved)
skill-library-write-filesupport files; overwriting an existing file backs up its previous content
skill-library-delete-filedelete one support file (read-first; backed up)
skill-library-deletedelete a redundant/merged-away skill (read-first; whole directory backed up)
skill-library-adoptstamp an externally authored skill with the author marker (provenance only)
skill-library-lifecyclelist / archive / restore / pin — inspect or maintain skill lifecycle state (archive is recoverable, never a delete)
skill-library-gitself-check the library repo: status / diff / log (no push, no reset, never a shell)

Four guardrails

  1. Read before write — patch / write-file / delete must echo the expectedSha256 returned by skill-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.
  2. 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.
  3. Protected list — skills in excludedSkills are read-only.
  4. 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 (a skill tool load), not by file mtime:

  • staleDays (default 30) / archiveDays (default 90) — windows
  • archiveEnabled (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, and restore brings them back

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",
  "@deepseek-ai/dsh-settings": "…same…",
  "@deepseek-ai/dsh-subagent": "…same…",
  "@deepseek-ai/dsh-tools":    "…same…",
  "@deepseek-ai/schemastery":  "~3.18.4"     // .volatile() needs 3.18.4+
},
"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.1-rc.2 (Node v24) — real-machine verified, /skill-refine runs end to end.
  • 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.0 group does not cover 0.1.5-rc.1 or 0.1.7-rc.2, and the 0.2.0-alpha.0 group does not cover 0.2.1-rc.2 (its tuple is 0.2.1). test/entry.test.mjs pins this with a decision table, a counter-proof (reverting to the older range turns red), and a cross-check against the host's real semver.satisfies.
  • 0.1.7 settings contract (what this adaptation was about): settings.installSection was removed from dsh-settings. The namespace is the cordis row id, Config must be exported from the plugin module, and every live-editable field must be .volatile() (the host's _commitVolatile rewrites the live reference in place, no fiber restart). The settings card therefore hangs off configForms.get(ns) and the plugins.item slot.
  • No install scripts, no gyp/native deps: the whole tree is plain ESM; test/entry.test.mjs guards this (no install/postinstall/prepare, no optionalDependencies, 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.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-subagent no longer exports settleRun from its package root (only settleRunResult; the implementation moved into lib/types/run-settlement.js but is not reachable through the exports map). A top-level import { settleRun } throws a SyntaxError at ESM link time, so apply() never runs and the entire plugin silently dies — the host only logs a single warning. Fix: inline settleRun (src/run-settlement.js), aligned statement by statement with the host's semantics.
  • subagents.start() was removed in favour of startActivation(spec) (whose spec carries an extra request layer). Branch at runtime on "is the method present" rather than betting on a version number. Note that startActivation's signal covers admission only — a timeout must dispose() explicitly or the review hangs until the built-in 15-minute self-timeout.
  • The tool whitelist moved from the request layer to the Agent Preset layer. startLocal only writes composition: { agentPreset }, so locally spawned subagents never see request.toolFilter; meanwhile dsh-tool-subagent throws outright on config.toolFilter and points you at a preset. The plugin therefore declares its own internal preset (skill-curator-review) mounting src/preset-guard.js, which calls tools.restrict({ allow }) inside the subagent's own scope.
  • The preset guard must wait for a readiness marker. restrict() validates names through view(scope).restrictableNames, which only accepts the inheritance surface; profile entries are created concurrently by Promise.all, and the preset row injects only tools while this plugin also waits on settings — so the preset races ahead, the tools are not registered yet, the call throws, and the registry writes record.broken permanently ("A mount failure is final"). The preset stays broken forever while the Loader row still shows active. Fix: the plugin calls ctx.provide(TOOLS_READY_SERVICE, …) after registering its tools, and the guard waits for it via inject.
  • The LlmAdapter base class gained imageRequestTarget. A duck-typed adapter missing it throws a TypeError (same origin as imageRequestPricing); a guard test caught this immediately.
  • The gate reads peerDependencies only. The decision function (evaluatePluginCompatibility in dsh-app-boot) walks only the @deepseek-ai/dsh* entries under peerDependencies and never reads dsh.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.8 admits 0.1.8-rc.1, <0.3.0 admits 0.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_plugin re-mounting does not always take effect. If a package's realDir changed (for example right after pnpm install), ResolutionRouter.replace() throws ResolutionRestartRequired and returns restart-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