dsh-round-inject
Periodic prompt injection for the DeepSeek Harness Web GUI.
Every N model invocations — conversation turns and tool-call steps each count as one — the plugin injects a user-configured prompt into the model context as a model-visible, source-attributed user message. The first injection happens at conversation start.
| |
|---|
| Host | agent/pre-step waterfall (counts every entering step, appends the injected message to that step's request) |
| Client | one Settings page with two prompt boxes + round interval (default 50) |
| Config | round-inject settings namespace, persisted by the DSH settings provider |
Features
- Round counting — every model invocation counts once, including steps triggered by tool results (a tool call is followed by another model call = another step). Counting rides the durable
sessionProjections seam (the same mechanism the built-in "N 轮 · M 步" stats line uses): the counter is a persisted projection per session, so compaction, paging, session resume and host restarts cannot reset it.
- Periodic injection — when exactly
interval completed model calls have elapsed since the previous injection, the periodic prompt is appended to that step's messages (default interval 50) — with the start prompt on, injected calls sit at steps 1, 1+interval, 1+2·interval, …; without one, at interval, 2·interval, 3·interval, …. The injected message carries source: { kind: 'plugin', plugin: 'round-inject' }, is persisted to the session log, and is visible to the model.
- Two independent prompts — the conversation-start prompt (first input box) and the periodic prompt (second input box) are separate: the start injection uses only the start prompt, the periodic injection uses only the periodic prompt. Either can be left empty to disable that side.
- Inject at conversation start — when enabled and the start prompt is non-empty, the very first model call of each conversation carries the start prompt; the periodic counter then restarts from that call.
- Settings UI with Save button — Settings → 提示词注入: enable switch, interval (number input, default 50), conversation-start prompt (first textarea), periodic prompt (second textarea), and an "inject at conversation start" switch. All edits are draft-only; a Save button (bottom-right) commits the whole form to the settings namespace at once. Nothing is written until the user clicks Save. IME composition is protected (Chinese input never pollutes the form).
- Safe by default — empty prompt ⇒ nothing is injected; disabled ⇒ no counting and no injection; a step that does not actually call the model is never counted.
Install
The package is a DSH plugin published to npm. From a DSH profile directory:
dsh plugin --profile web add dsh-round-inject
or add it manually to the profile's package.json dependencies and append "dsh-round-inject" to dsh.profile.bundles, then restart the profile.
Configuration
The composition row (also the settings namespace base layer):
- id: round-inject
name: 'dsh-round-inject'
config:
enabled: true # master switch
interval: 50 # model invocations between two injections
prompt: '' # injected prompt text (empty ⇒ no injection)
injectOnStart: true # inject once at conversation start
All values can be changed live from Settings → 提示词注入 without a restart.
How it works
The host registers ONE durable fold on the sessionProjections seam and acts
on it from the agent/pre-step waterfall:
- One fold for counting AND the bookmark — the
round-inject projection
is a pure fold over the session event stream. It consumes only built-in
events — no custom event type is ever appended, so restoring a session can
never fail on this plugin's vocabulary:
- every
step/end (one completed model call, the same event the built-in
"N 轮 · M 步" line counts) increments totalSteps and — once the session
has injected — sinceInject, the completed calls since the last
injected message;
- an injected
user/message (identifiable by its source: {kind:'plugin', plugin:'round-inject'} marker, which the plugin stamps
when appending) records lastInjectSeq and resets sinceInject to 0.
The registry checkpoints the state into <root>/session_projcache/, so
the count and the bookmark survive compaction, paging, session resume and
host restarts. Keeping the bookmark in this derived state (instead of the
settings namespace, which leaked across sessions in 0.1.11, or scanning
session.events, which does not exist and crashed every turn in 0.1.13)
makes the interval exact: one O(1) fold per event, never a per-step
full-log scan.
- Injection (side effect) — the plugin listens to the
agent/pre-step
waterfall (one event per proposed step). After next() yields the loop's
own decision, it:
- ignores
reject decisions and steps with an empty message set (those never call the model);
- reads the latest config from the
round-inject namespace;
- reads the projected state (
totalSteps, sinceInject, lastInjectSeq)
and decides:
- never injected yet + start prompt enabled → append the start prompt to
the very first model call;
- never injected yet + no start prompt → append the periodic prompt to
the
interval-th model call of the session;
- otherwise
sinceInject >= interval → append the periodic prompt to
the next model call, exactly interval completed calls after the
previous injection;
- the whole decision is guarded: on any unexpected error it logs a warning
and lets the step proceed uninjected, so the plugin can never take down
an agent turn.
The injected message is appended as
createUserMessage({ content: [{ type: 'text', text: prompt }], source: { kind: 'plugin', plugin: 'round-inject' } })
and becomes part of the step's durable user messages — model-visible,
audit-able in the session log, and it works with any model route.
Because the injected message becomes part of the step's durable user messages, it is model-visible, audit-able in the session log, and works with any model route.
KV cache note
An injection changes the request content at the injected step, so provider KV/prefix cache is invalidated from that request onward for one turn's worth of steps. With the default interval of 50 this is negligible; lowering the interval increases invalidation frequency.
IME input: pinyin/kana never pollute settings, Chinese typing works (0.1.8)
The settings inputs (prompt textarea, interval number) use a local draft plus a composition gate:
onChange always updates the local draft, so the controlled field keeps
following the user's keystrokes — typing is never swallowed, committed
Chinese characters stay on screen.
- Settings writes are skipped while the IME is composing
(
onCompositionStart … onCompositionEnd, plus nativeEvent.isComposing),
so uncommitted pinyin/kana never reach the settings namespace (the original
"输入法拼音被记录" bug).
onCompositionEnd writes the final committed value (the DOM value at that
moment, i.e. the selected hanzi/kana) once.
Version history:
- 0.1.7 regressed:
onChange was fully ignored during composition, which froze
the React controlled value at the stale snapshot; the next render then reset
the field and swallowed the committed Chinese characters ("选完字上不了屏").
- 0.1.8 (current) fixes this with the local-draft approach above.
Related upstream DSH issue (chat composer): clicking the Send button while composing submitted the uncommitted pinyin — the Enter path already guarded, the button path did not. See docs/dsh-composer-ime-patch.md for the one-line upstream patch.
Changelog
- 0.1.14 — fix "session.events is not iterable" (per-step turn failure):
the bookmark scan used
session.events, which is not an API of the session
object (the public surface is session.snapshotEvents()). The bookmark now
lives in the projection state (derived, O(1) per event, durable) and the
decision is guarded, so an internal error can no longer kill a turn.
Injection timing is exact: with the start prompt on, injected calls are the
session's 1st, 1+interval-th, 1+2·interval-th, …; with it off, the
interval-th, 2·interval-th, … (no more ±1 drift). Default interval lowered
from 80 to 50.
Troubleshooting
DSH shared host packages are peerDependencies (0.1.6)
The plugin declares every DSH-provided runtime package (@deepseek-ai/cordis, @deepseek-ai/dsh-llm, @deepseek-ai/dsh-settings, @deepseek-ai/schemastery) as peerDependencies — not dependencies. DSH installs a shared host-package directory (~/.dsh/profiles/node_modules/@deepseek-ai/) with symlinks to the exact host versions, so plugins must resolve those packages through the host, never via a self-installed copy.
Before 0.1.6 the plugin shipped dsh-llm in dependencies, which made pnpm hoist a separate dsh-llm@0.1.0-rc.8 into the profile root. Node resolution then picked that copy over the host's shared symlink, so the plugin ran against a different dsh-llm instance than the host (host: 0.1.1-rc.2). The two versions had identical createUserMessage signatures, so it worked by luck — but any API drift would break silently. Symptom to look for: require.resolve('@deepseek-ai/dsh-llm/package.json') from the profile resolves to profiles/web/node_modules/... instead of the host install. After updating to 0.1.6, run pnpm install in the profile so the stale hoisted copy is pruned, then verify it resolves to the host:
cd ~/.dsh/profiles/web
pnpm add "dsh-round-inject@0.1.6" # prunes the old hoisted dsh-llm copy
node -p "require.resolve('@deepseek-ai/dsh-llm/package.json')" # → host path, not profiles/web/node_modules
"设置服务不可用" / settings inputs not editable / namespace missing
Symptom: the Settings page renders but shows "设置服务不可用,当前仅使用组合配置默认值", or the inputs are disabled/unfocusable, or settings.describe does not list the round-inject namespace.
Root cause (fixed in 0.1.5): the host plugin read its composition config via ctx.config. Cordis 4 guards property access on the context — reading ctx.config without declaring inject: ['config'] throws (cannot get property "config" without inject), so the host apply() failed on load. The client page still rendered independently (it is a separate bundle), which made the failure look like a settings-service issue. The fix reads config from the second apply(ctx, config) argument (the loader passes the resolved entry config there), and registers the settings namespace with that config as its base layer.
If you still see the symptom after updating to 0.1.5, verify:
# 1. the profile actually resolved 0.1.5 (pnpm autoInstallPeers=false can leave stale peers)
cd ~/.dsh/profiles/web && node -p "require('dsh-round-inject/package.json').version"
# 2. the namespace is registered (from the running instance)
# settings.describe should include round-inject
# 3. restart the profile after the upgrade — host plugins load at startup
"loaded without registering "dsh-round-inject" via ModuleLoader.load"
The client bundle must register the exact npm package name (dsh-round-inject) as its module id — client-modules serves /plugins/<pkg>/client.js and verifies the bundle registers that exact id. Current client.js uses id: 'dsh-round-inject'. Reinstall and hard-refresh the page (the client bundle is cache-busted by a ?rev= query).
Development
├── index.js # Host half (ESM; settings registration + session-start reset + pre-step counting/injection)
├── client.js # Browser half (Settings page; __ModuleLoader__.load factory, no build step)
├── cordis.patch.yml
└── package.json
No build step — both halves are hand-authored ESM/browser factories (same pattern as dsh-strata). Publish with:
npm publish
License
MIT