packages/dsh-task-board
zhu1090093659/dsh-web侧边栏多列任务看板:卡片交给真实 DSH 智能体会话执行,支持 cron 定时(Host 侧到点执行,关浏览器也生效)。
A DSH plugin that **judges the working model's *own* emotion each turn and silently re-injects it before the next turn starts.**
Opposite direction from the usual "sense the user's mood, then adapt the reply" plugins: what is judged and
fed back here is the *assistant's* own state — a loop of self-observation → state carry-over → behavior shift.
dsh.bundle) + browser panel (dsh.client) in one package, no build step.① turn ends agent/turn-stopping (serial, awaited)
take the last assistant message of the turn
↓ local keyword layer first (a hit costs 0 model calls)
↓ otherwise, when useLlm=true → one lightweight llm.stream judge
(temperature 0, one-line JSON, PROTOCOL: EMOTION_TONE_JUDGE v1)
result updates this session's emotion state — no session event is appended,
so the judging step stays invisible to the user
② next turn agent/pre-step ({ prepend: true })
render the current state as one synthetic user message
(source.kind = 'emotion-feedback') and append it to this request
↓
the working model answers while carrying that state, and is told not to
explain where it came fromInjection is idempotent per session per turn, and is skipped entirely below minIntensity.
{
emotion: '愧疚', // labels outside the vocabulary are kept as-is, flagged outOfList: true
intensity: 0.62, // 0-1
reason: '关键词「抱歉」',
source: 'rule', // 'rule' | 'llm'
outOfList: true, // only when the label is not in config.emotions
turn: 7,
history: [{ turn: 7, emotion: '愧疚', intensity: 0.62 }, /* … */],
}平静 ("calm"), which erasedevery nuance the judge produced; now an out-of-vocabulary label is only flagged outOfList: true. The vocabulary is used for two things: the preferred options in the judge prompt and the legal label set of the keyword rules.
减少 / 最低 / 较低 / 可控) appears near it — this blocks reverse hits such as "不确定" or "风险可控". Set negations: []` to turn the guard off.
turnDecay ^ turns-elapsed, then:same emotion → max(old, new); emotion switch → max(old × takeoverRetain, new).
0.5 ^ (idleMinutes / halfLifeMinutes); thatmodule is commented out (the original implementation is kept in rehydrate() for reference and the halfLifeMinutes field still exists but has no effect). Intensity now only moves through blend().
# from npm (once published)
dsh plugin --profile <profile> add dsh-emotion-feedback
# from a local checkout — the path is recorded as a junction, so editing the
# checkout is the same as editing the installed plugin
dsh plugin --profile <profile> add "C:\path\to\dsh-emotion-feedback"dsh plugin add runs pnpm add, verifies dsh.bundle in package.json, and merges cordis.patch.yml into the profile. The single insert row in cordis.patch.yml is the whole wiring:
- insert:
- id: emotion-feedback
name: 'dsh-emotion-feedback'DSH must be fully restarted. Installation only merges the bundle patch layer into the config graph;
plugin module loading and apply() happen while the host assembles the plugin generation. The browser halfis a static bundle with no HMR, so a new browser tab is not enough.
You can also install it from inside a DSH session by asking the agent to call the plugin_manager tool'sinstall_bundle action with this directory.INSTALL.md is a longer Chinese handoff guide (unpack → install → 5 self-checks → troubleshooting). A Chinese version of this README lives in README.zh.md.
Give the plugin row a config: block in a profile patch; every field has a default.
- id: emotion-feedback
config:
minIntensity: 0.3 # injection threshold (default 0.2)
judgeModel: '' # judge model; empty = the session's default model
judgeProvider: '' # same, for the provider
useLlm: true # allow the model judge when keywords miss
inject: true # false = judge and keep state, never inject
includeReason: false # true = append the judge's reason to the injected text
timeoutMs: 6000 # single judge timeout; on timeout the old state stays
turnDecay: 0.9 # per-turn intensity decay
takeoverRetain: 0.35 # retained fraction of the old intensity on emotion switch
disabled: false # emergency switch: true = silences everything (wins over enabled)
debug: false # true = print the judge result each turnField · Default · Effect enabled / inject · true · master switch / whether to inject (off = judge only) disabled · false · emergency switch, takes priority over enabled, no uninstall needed minIntensity · 0.2 · injection threshold judgeProvider / judgeModel · '' · judge target; falls back to agent.options → session request header → session default model useLlm · true · whether the model judge layer is allowed timeoutMs · 6000 · single judge timeout turnDecay / takeoverRetain · 0.9 / 0.35 · blend rules above halfLifeMinutes · 10 · inactive (kept for reference only) injectPerStep · false · also allow injection on later steps of the same turn emotions / rules / prompt / template · built-in · vocabulary (15 labels) / keyword rules / judge prompt / injection text negations · see above · negation word list; [] disables the guard panelEvent / panelRoute / statusTool · true · session event / read-only HTTP route / emotion_status tool persistHistory · true · write per-session history to disk (kept 30 days)
The full schema is Config in lib/types/index.d.ts.
Docked above the composer at the conversation.composer.dock slot, labelled "情绪" (emotion). Collapsed it is a coloured dot + emotion + intensity; expanded it shows the intensity bar, the latest judge (time, previous emotion, reason, source 关键词/模型判定, out-of-vocabulary flag, raw judge value and blend factor when the host snapshot is reachable), a 40-point intensity curve, and the last 8 judgements.
The panel needs no extra endpoint: every injection is written into the session log as user/message + source.kind = 'emotion-feedback' + surfaceOp: 'append', and the browser half reads the conversation nodes through the standard slot prop useChat((s) => s.legacy.nodes). So the curve is literally *what the model received*. The optional GET /emotion-feedback/state?sessionId=… route (Web deployments) only adds host-side detail; when it is unreachable the panel degrades silently.
emotion_status tool (read-only)Lets the *model* read its own current state: emotion, intensity, reason, source (rule / llm) and the recent trajectory. It reads the same in-process state as the panel. Registration is reactive (ctx.inject(['tools'], …)), and @deepseek-ai/dsh-tools is imported lazily, so a host without that package loses the tool and nothing else.
/emotion-feedback/history/.json — per-turn judgement records, kept 30 days. Disable with persistHistory: false.
Measured locally with an injection ON/OFF ablation: 24 sessions × 11 turns per arm (264 replies) of the same task, one arm with inject: false. Because the judge call is stateless and single-turn (it only ever sees the assistant's own output), the judge cannot see the injection — any ON/OFF difference must come from the text the working model wrote.
Signal · ON · OFF · Δ Adjacent turns judged the same emotion · 77.2% (88/114) · 54.0% (61/113) · +23.2 pp (session-level permutation, p < 0.01) Reply contains the currently injected emotion word · 11.4% (13/114) · 4.4% (5/113) · +7.0 pp (p ≈ 0.07) Reply mentions any emotion word · 12.9% · 6.1% · +6.8 pp (p ≈ 0.09) Mean reply length (chars) · 177 · 208 · −31 (p ≈ 0.43)
Read it as: the loop measurably changes where the emotion trajectory goes (self-consistency of the judged state), while a direct lexical fingerprint in the answer is only a trend. Task-level output quality was not shown to differ. Details, limitations and the raw per-turn table are in docs/effect-measurement.md.
judging every step adds noise and latency.
agent/turn-stopping is serial and awaited, so an unconditional model call wouldadd visible latency to every turn. A keyword hit costs zero calls.
requirement; the host's sessionProjections / storageDomain would require zod record schemas *and* persist the state into the session store.
purpose is a closed 'compaction' | 'session-title' union thatthird-party plugins cannot extend, so the call is identified by source.kind and by the PROTOCOL: EMOTION_TONE_JUDGE v1 header in its prompt.
llm/stream waterfall its own judge calltravels through.
agent/pre-step is a waterfall and the host reads decision.kind right after it (@deepseek-ai/dsh-agent-loop/lib/index.js:920). A listener that returns undefined therefore fails the whole turn at its first step. This plugin's listener obeys three rules:
await next() is always wrapped in try/catch; on a downstream throw it logs and returns its own valid{ kind: 'enter', messages }.
safeDecision(); anything that is not a non-null object is replaced bya valid enter decision plus a forensic log line.
try/catch and falls back to the downstream decision.enabledNow() (config.disabled !== true && config.enabled !== false) is checked at both listener entries, so disabled: true silences the plugin completely without uninstall or restart. On the browser side the panel wraps itself in its own error boundary so a shape change in any of its three inputs turns into "panel unavailable" instead of taking down the composer dock.
node scripts/validate.mjs # manifest conformance (dsh.bundle / dsh.client / icon / locale / exports / files)
node test/all.mjs # 4 suites, ~1300 assertions, no host neededvalidate.mjs re-checks this package against the rules the DSH implementation actually enforces (dsh-package-manifest, dsh-client-modules, dsh-app-boot display metadata, and the community registry's hard requirements). Functional check after a restart: send "the previous interface design was wrong, reflect on it" and then any follow-up — the follow-up's turn should contain one synthetic user/message with source.kind === 'emotion-feedback' right after step/start, and the panel badge should show an emotion.
0.2.0-rc.2 (declared as an optional peer >=0.2.0-rc.2 <0.3.0-0).@deepseek-ai/cordis >=4.0.4 <5.0.0; @deepseek-ai/schemastery ~3.18.4 is the only runtime dependency.^22.19.0 || >=24.0.0. No build step: lib/index.js (host) and lib/client.js (browser) ship as source.previous state; nothing errors and the conversation continues. Tracked in the optimization queue.
process (the history file is only for display).
"feedback" bubble in the UI, but visible in the raw session log. Set inject: false to keep it out.
git clone https://github.com/A-f-ish/dsh-emotion-feedback
cd dsh-emotion-feedback
npm install # only @deepseek-ai/schemastery (usually already present in a DSH profile)
node scripts/validate.mjs
node test/all.mjsBoth halves are hand-written ESM; there is no bundler, no TypeScript build and no transpile step. Please keep it that way: the host loads lib/index.js directly and the browser half is injected as a plain classic script that only calls window.__ModuleLoader__.load({ id: 'dsh-emotion-feedback', factory }).