DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

qwq1223211 /

qwq1223211/dsh-minimal-router

Verified

One DSH package, three modules: stage router + circuit breaker + cache probe.

★ 1 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@a1d0124b

dsh-minimal-router · v2.0.0

Tests License: MIT DeepSeek Harness Tools

Give the model only the tools it needs right now. Fixed prefix, variable tail.

How it runs (the 30-second version)

Two entry points, everything else is bookkeeping:

When Hook What happens
A user message arrives agent/inbox/claimed detect the stage → 4 gates: context size → cost payback → hysteresis → apply → restrict()
Before every tool call tools/pre-execute always-allow list → lint → out-of-stage check → allow / deny

The four gates (in code order; the first one that fires ends the round):

  1. Context gate — over contextGuardThreshold (100k) any automatic switch is refused (force: true still works)
  2. Cost gate — payback > switchPaybackTurns (3) → refuse; explicit switches skip this
  3. Hysteresis — 2 confirmations + 60s cooldown before a switch sticks
  4. Apply — applyStage() → refreshRestriction() → agent.ctx.tools.restrict({ deny })

The per-call chain: ALWAYS_ALLOW → lint (decideLint) → decideOutOfStage → allow/deny. Any exception inside the plugin allows the call.

Untouched tags are never trimmed (the safety net for new plugins); auto never trims; only an explicit strict: true on a stage can override that.

📖 Full runtime logic, data files, defaults and degradation paths: docs/RUNTIME.md

30 seconds

Three commands:

# 1. install (choose the one that works for you)
dsh plugin --profile desktop add github:qwq1223211/dsh-minimal-router
#    or: tell your agent "install this plugin: https://github.com/qwq1223211/dsh-minimal-router"

# 2. reload: Settings → Plugins → toggle "Minimal Router" off and on  (a link install does not hot-reload)

# 3. verify: ask your agent to call route_status

What changes, measured in a fresh session (same machine, same model):

before after
tools sent per request 165 66 −99
tool declarations 131,694 B 43,238 B −67.2%
promptTokens per turn 47,885 25,688 −46.4%
cache hit rate — 99.16%

And the number it exists to protect — a real probe record from this machine:

{
  "tools": 172, "toolsChars": 138711,
  "inputTokens": 563654, "cacheReadTokens": 5504,
  "promptTokens": 569158, "hitRate": 0.0097
}

hitRate: 0.97% means the entire context was re-billed at full price. Every design decision in this plugin (contextGuardThreshold, switchConfirmations, switchCooldownMs, the cost gate) exists to make that line rare. That record was produced by reloading the plugin in a long session — which is exactly why the docs keep saying don't install or change plugins mid-session.

What it does not do (read this before installing): it does not shrink the system prompt (that is a bigger lever), the tokens it saves are mostly cache-priced (so your bill may barely move), and it must not run alongside another tool router. Details.

A DeepSeek Harness host plugin. No build step, no dependencies.

unionEnabled (multi-domain union) is @experimental and off by default; see docs/LIMITS.md.


Install

Option 1 · Just ask the agent (easiest)

Open DSH and tell the AI:

Install this plugin for me: https://github.com/qwq1223211/dsh-minimal-router

⚠️ This step is mandatory — without it the plugin looks installed but does nothing: Settings → Plugins → disable the "Minimal Router" row, then enable it again. (A linked install does not hot-reload, so you would still be running the old code.)

Option 2 · Terminal

dsh plugin --profile desktop add github:qwq1223211/dsh-minimal-router

This forwards its arguments to pnpm inside the profile directory, and DSH additionally adds the package to that profile's dsh.profile.bundles and initialises the profile if needed.

Verified (v1.12.0, brand-new profile): the install lands as a real directory of 34 files at node_modules/dsh-minimal-router (not a link), and the profile's package.json gains both the dependency and the bundle entry. So normally you do not need to enable anything by hand — just go to Settings → Plugins and toggle the row off/on.

Option 3 · Clone and install locally (always works)

git clone https://github.com/qwq1223211/dsh-minimal-router.git

Then hand the agent the absolute path of that folder:

Install this directory as a bundle: <absolute path>/dsh-minimal-router

No registry, no package-name resolution, nothing but the clone. If options 1 and 2 fail, use this one.

Verify

Ask the agent for route_status. A JSON payload containing stages, tagCounts, and modelGuard means the plugin is installed and live.

How the version requirement is actually evaluated

package.json declares engines.dsh: ">=0.2.1-rc.4". The real check inside DSH is:

range.trim() !== "" && semver.satisfies(runtimeVersion, range, { includePrerelease: true })
Fact Meaning
It is a semver range, not an exact version Claims that the version must match exactly are wrong
includePrerelease: true Prerelease hosts match too; -rc.N needs no special handling
An empty string counts as unmet → install is refused Never set engines.dsh to ""

If it blocks you, DSH reports incompatible-version with the unmet packages, and the plugin manager offers set_version_exemption for a specific runtime version (at your own risk).

Runtime data (probe history, your custom_stages.json) lives in ~/.dsh/minimal-router/, never inside the package — a real dependency install puts the package in a directory that gets replaced on upgrade.

Why

DSH sends every installed plugin's tool schemas on every request. With a few dozen plugins that's 100K+ characters of tool descriptions the model re-reads even when it is just chatting.

This plugin groups tools by name, decides which group the current task needs, and (optionally) physically removes the rest from the request — then puts the routing hint on the tail of the context instead of the head, so the prefix stays cacheable.

Numbers (same machine, same model, measured with dsh-cache-probe)

Quality gate: 277 tests (router 177 + guard 21 + probe 31 + fuzz 12 + instrument 28 + install-elsewhere 8), 90.32% line coverage (3649/4040, Node built-in V8 coverage, zero dependencies), plus a repo audit on every push that checks imports, entry points, files coverage, engines.dsh and writable paths. No test framework, no bundler, no linter package — only node:.

Metric Before (auto) After (core stage)
Tools advertised 165 66 (−60%)
Tool declaration bytes 131,694 43,238 (−67.2%)
promptTokens per request (fresh session) 47,885 25,688 (−46.4%)
Cache hit rate 91.4% (2nd step) 99.16%
One-time cost of switching stage (small session) — ~3,000 tokens
One-time cost of switching stage (500k-token session) — 512,000 tokens, full price ⚠️

Measured in a brand-new, clean session (one system prompt, no history debt) — that is the ceiling this design actually reaches.

Also measured, and easy to miss: a long-lived session had 9 copies of the system prompt appended (159,334 chars) versus 17,685 in a fresh session. Starting a fresh session saved more than this plugin does. Use both.

Honest caveat: most of the saved tokens were already cache hits (cheap). The bill moves less than the byte count suggests. The bigger win is the model no longer staring at 165 tools while choosing one.

Install

# in a DSH session, ask the agent to run:
plugin_manager install_bundle target=C:\path\to\dsh-minimal-router

Then Settings → Plugins → disable, then enable the row (a linked install does not hot-reload).

Tools you get

Tool Purpose
route_stage(stage, force?) Switch stage
route_status() Current stage, tag census, guard state, unclaimed groups
route_reset() Back to auto, releases every restriction
route_init() Generate custom_stages.sample.json from your real tools
route_use(groups, turns?) Ad-hoc narrowing without any stage table
route_hint(enabled?) Turn the tail hint on/off for this session (A/B)
route_activity(limit?, kind?) What it actually did: switches, restrictions, denials, blocks

route_* and guard_* are never blocked. Escape hatches are a hard rule here.

"What did it actually do?" — route_activity

route_status tells you the current state; route_activity tells you what happened.

route_activity()                       # last 20 events
route_activity({ "kind": "deny" })     # only blocked tool calls

Seven kinds: switch, restrict, deny, block, grant, reset, degrade. Stored in ~/.dsh/minimal-router/activity.jsonl, capped at the last 200 events, so it never grows into another thing to read.

Default stages

Stage Allowed groups Auto-triggered by
auto (default) everything, no trim —
core base tools only core / general
unity-code unity + 13 Unity subsystems code, script, compile, error, unity
unity-ui unity, ui, uikit, uiflow, dialogue, quest ui, layout, button, panel
art art, meshy, image, asset texture, model, sprite, mesh
audio audio sfx, music, audio

Tags are derived from tool names (unity_object_create → unity, mcp__meshy__x → meshy). Groups no stage claims are always allowed, so a newly installed plugin is never blocked for being unclassified.

This table looks Unity/Meshy-heavy because that is what the author works on — not because the plugin only supports game development. Swapping it for Python, data analysis, writing, or video editing takes about a minute; see the next section.

Bring your own domain

Easiest path: use a bundled preset.

route_init({ "preset": "python-data" })

It writes a ready-to-use custom_stages.json; reload the plugin and you are done. Bundled presets: python-data, web-dev, writing, qq-chat.

Calling route_init with no arguments is the other path: scan your environment and emit a template to edit.

The zero-config path: let the model decide what it needs this turn

No stage table required. The built-in route_use tool narrows the tool set on demand:

route_use({ "groups": ["unity"], "turns": 3 })

It keeps the unity group plus the base tools, trims everything else, and restores automatically after 3 turns — nothing to remember to undo. route_use({ "groups": [] }) releases immediately.

Where do group names come from? route_status → tagCounts. They are derived from tool names, so you never define a workflow up front.

Why not a per-turn judging model? That is one extra request per turn, with the whole context re-sent at full price. Measured here: 99.9% of the tokens this plugin saves are cache-priced, so paying one full-price request to save cheap tokens is a net loss. The judgement goes back to the model that is already in the context — it knows what it is doing; one tool lets it say so, at zero extra cost.

Scope the report to the current session

cache_probe_report reports the average over all history by default. To see only this session:

cache_probe_report({ "scope": "session" })

It returns session (a fingerprint — the raw session id is never written to disk), sessionTotal, and a session-scoped summary.

Measured once: session: "8c144bfe", sessionTotal: 1, lastHitRate: 0.0097. That request happened right after a plugin reload, and 0.97% is exactly what one full-price prefix rebuild looks like.

Verify the hint yourself with route_hint

route_hint({ "enabled": false })   # off for THIS session only
route_hint({ "enabled": true })    # on
route_hint()                       # query only

Takes effect at runtime, no reload. The hint costs 50–126 tokens per turn (~0.1% of the tool declarations), so "the hint is expensive" is arithmetically impossible — only its behavioural effect is worth testing.

# ask the agent:
route_init

It writes custom_stages.sample.json containing:

  • discovered — every tag in your environment, with counts and whether a stage claims it
  • suggestions — one ready-to-edit stage per unclaimed tag
  • _readme — the 5 steps

Rename to custom_stages.json, edit, restart the plugin. Merge order is built-in → custom_stages.json → cordis.patch.yml; a malformed file is ignored with a warning, never fatal.

Why not fully automatic?

Because "never block the wrong tool" outranks "save the most tokens".

For any tool group that no stage claims, the only safe default is to allow it. Guessing what your brand-new tool does would sometimes guess wrong — and a wrong guess blocks you in the middle of a task, which is far worse than not saving a few thousand tokens.

route_init is the compromise: one scan of your tools, one template, about a minute of editing, then routing is automatic forever. It lists every unclaimed group with its tool count, so the decision you are confirming is a small, concrete one.

One line: it is not lazy. Blind guessing is just more expensive than one minute of confirmation.

If none of your tools belong to a built-in stage

That is the most common way this plugin "installs but saves nothing, silently": the built-in table claims unity/ui/art/meshy/image/audio/asset, and if your machine has none of those tags, the deny list is always empty. route_status now says so out loud:

"stageTableUnused": true,
"stageTableHint": "内置阶段表认领的标签在你这里一个都不存在……"

Two ways out:

  1. Run route_init and put your own tags into a stage (recommended), or
  2. Give a stage "strict": true — it then also denies tags no stage claims:
{ "defaultStage": "chat",
  "stages": {
    "auto": { "allow": ["snowluma", "snowluma-host"] },
    "chat": { "allow": ["snowluma", "snowluma-host"], "strict": true }
  } }

strict defaults to false (unknown tags are always allowed — never a false positive). stages may be an object map or an array of { "stage": ... } entries; both are accepted.

Hard rules (not configurable)

  1. Dead-loop lint — before a write-like tool runs, source is scanned for while(true) / for(;;) / do{}while(true) without a break/return/throw in the body. Comments and string literals are stripped first, so grep "while(true)" is not a false positive.
  2. Out-of-stage calls are denied with an imperative message naming the stage to switch to.
  3. Escape hatches stay open, always.

Degradation guard (the important one)

The savings depend on the model supporting in-history system prompt updates. DSH's DeepSeek adapter declares this per model:

{ id: "deepseek-flash",  systemPromptUpdate: "in-history", toolUpdate: "addition-only" }
{ id: "deepseek-v4-pro"  /* neither — equivalent to leading-only */ }

On a model without it, every tail change rewrites the prefix and every step becomes a full cache miss. So:

in-history not supported / unknown
Tail hint on off (empty string)
Physical trim on off
Out-of-stage deny on on (touches no prompt)
Dead-loop lint on on

A boxed warning is printed once per model. Unknown models degrade by default (fail-safe).

Custom / proxied models that you have verified:

trustedModels: ['my-flash']     # precise
unsafeForce: true               # blanket, at your own risk

Demo it without paying for it: simulateModel: deepseek-v4-pro.

Safety gate for huge contexts

Switching stage in a very long session costs a full prefix rebuild. When the last request exceeded contextGuardThreshold (default 100000 tokens), route_stage is refused with an explanation, and requires force: true to proceed.

Cost gate: pay it back in how many turns?

Mined from 615 real probe records (60 prefix changes), the cost profile is bimodal:

Type Count Share of context that missed
Cheap 35 0.2% – 1.2%
Catastrophic 11 98% – 99%

And it does not correlate with how much changed: a 98,778-byte tool-list change cost 708 tokens, while an 481-byte change cost 581,830.

Cheap is luck; expensive is risk. So the estimate assumes the worst case (the whole context at full price) and computes:

paybackTurns = worstCaseCost ÷ tokensSavedPerTurn (trimmed bytes × 0.3)

An automatic switch is refused when payback exceeds switchPaybackTurns (default 3). Explicit commands are exempt — if you asked for it, the accounting does not get to say no.

Want a tool list that only grows?

accumulateDisclosure: true

Once a group has been visible in this session it is never taken away, so consecutive switches differ less and the prefix cache is more stable — at the cost of weaker trimming as a session ages. Off by default; route_reset clears the accumulated set.

Scope: before, not after

This plugin only trims before the fact; it does not compress history. Use /trim (dsh-command-context-trim) or a compaction plugin for that. Cheapest order in a long session: /trim first, then switch stage — the full-price rebuild then has a smaller base.

Coexisting with other plugins

Situation Behaviour
Another plugin also decides in tools/pre-execute Safe. For tools it allows, this plugin always calls next() and never overrides someone else's denial; it only short-circuits when it denies itself
Compaction / /trim plugins Recommended pairing. Do not switch stage while a compaction is running — compaction reuses the prefix, and moving the tool list under it wastes the work
Another tool router (loadout-style) Do not run both. Two plugins narrowing the same tool set is unpredictable; disable one
A message/QQ gate Coexists as long as that gate also calls next(). If messages stop sending, turn enforceStage off first to bisect

In one line: this plugin always errs toward denying one time fewer — if it cannot compute, it allows; if a service is missing, it degrades; on an exception it calls next().

Configuration

- insert:
    - id: minimal-router
      name: 'dsh-minimal-router'
      config:
        defaultStage: auto
        enforceStage: true
        enforceByRestrict: true        # physically trim tools
        requireInHistoryUpdates: true  # keep true when publishing
        contextGuardThreshold: 100000

Smarter routing (v1.16)

Three additions, all zero-request — no extra model call, no second copy of the context.

Feature Default What it buys you
Semantic scoring (lib/intent.js) on Picks a stage even when no keyword matched ("move that button" → unity-ui)
Dead-tool profile (lib/usage.js) on route_status.toolUsage tells you which tools were never called — i.e. which plugins you can uninstall
Adaptive stage (lib/adaptive.js) off Learns from the last 20 real calls; proposes a switch only when one group hits ≥6 calls and ≥60% share
intentRouting: true
intentMinScore: 2.0        # raise to be more conservative
intentMinMargin: 0.25      # must lead the runner-up by 25%
usageTracking: true
adaptiveStage: false       # off by default: switching stages can force a full prefix rebuild
adaptiveWindow: 20
adaptiveMinCalls: 6
adaptiveMinShare: 0.6

Semantic guesses go through the same gates as keywords (context guard → cost gate → cooldown), so a wrong guess cannot switch immediately. When unsure it returns null and the current stage stays put.

Proactive behaviour (v1.18)

Feature Default What it decides
Smart trim (autoRestrictPolicy) on Only physically trims when the rebuild pays back within switchPaybackTurns (default 3). A 500K-token session is told to keep the tools rather than burn 500K to save 26K/turn
Suggestion on When the intent score is close but not enough, it does not switch — it appends one line to the tail hint: Tip: looks like art; if so, route_use(["art"],3).
Deny recovery on Counts whether the model retries with another tool within 2 minutes after a denial. route_status.denyRecovery reports the rate, which tells you if the gate is too strict
autoRestrictPolicy: true   # explicit route_stage is never blocked by this

Tune against your own data (nothing leaves the machine):

node scripts/tune.mjs --days 7

Tests

node --test test/*.test.mjs   # 312 tests

Companion plugins: dsh-cache-probe (measures cacheReadTokens / prefix fingerprint) and dsh-minimal-guard (consecutive-error circuit breaker).

License

MIT.

—/ 5

No ratings yet

Verified DSH bundle

Commit a1d0124b9eb9

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout