Bundle
dsh-emotion
让 DSH agent 的输出自带情绪:会话投影状态机 + 提示词情绪段 + 会话头部情绪条(非官方插件)
- Source
- Fyue7
- stars
- 1 stars
- License
- MIT
- Updated
- Updated yesterday
Readme
# dsh-emotion
让 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的 agent **输出文本自带情绪**:语气、节奏、共情方式随「情境 + 持续情绪状态」变化,且不牺牲技术准确性、不显著增加成本与延迟。
> 非官方社区插件,与 DeepSeek 官方无隶属或背书关系。
## 它做什么
| 能力 | 说明 |
|---|---|
| **持续情绪状态** | 会话投影里的 `emotion` 单元:心情 / 能量 / 轮数 / 连续成败。随工具调用结果、模型重试、轮次收尾变化,跨轮累积,**宿主负责持久化** |
| **三段注入** | `emotion:rules`(system prompt section,order 2,静态规则)+ `emotion:state`(runtime-context,order 130,动态状态)+ `emotion:scene`(runtime-context,order 131,本轮实况)。后两段走宿主请求**末尾**的 runtime-context 快照,前面的历史照常命中原有前缀缓存 |
| **会话头部情绪条** | emoji + 基调词 + 心情条;hover 看成因、能量、轮数。走宿主已有的会话投影通道,**不新增任何 HTTP 路由** |
| **`/mood` 命令族** | 配置入口,含 `/mood why` —— 直接打印实际注入给模型的提示词 |
| **风格档案** | 可选的人格/文风层。探测 `$DSH_HOME/dsh-emotion/style-profile.json`,缺失时用随包的格式示例,再缺失则只注入情绪规则(优雅降级) |
### 分工:谁负责判断什么
| 层 | 由谁负责 | 为什么 |
|---|---|---|
| **用户此刻的情绪** | **主模型**,在其思维链里完成 | 免费(推理模型本来就在思考)、能读懂言外之意、每轮即时 |
| **持续状态** | **本地规则**(纯函数) | 跨轮累积量模型看不到;可复现、可单测、零成本 |
本地规则**只**消费可观测事件,不解析对话内容 —— 不重复造一个更差的意图识别器。
### 情绪为什么会真的变(v3)
旧版是「每次工具成功 +2,只在轮末砍半」。看着有衰减,实际挡不住长任务:一轮里调 50 次工具
(很正常)就把心情顶到 **+96**,此后每轮注入的状态段都是同一行字 —— 等于没注入,
用户只会觉得「它今天怎么一直这个调」。
现在改成**事件级的指数回归**:每来一个事件就把心情朝目标值拉一步,
`mood ← mood + target - mood × pull`。稳态由**事件的性质**决定,而不是由「这一轮调了多少次工具」决定:
| 连续发生的事 | 心情稳态 |
|---|---|
| 工具一直成功 | 约 +25 |
| 工具一直失败 | 约 -40 |
| 连续受阻 ≥3 次 | 更沉,并掉一截精力 |
能量也不再是「第几轮的另一种写法」:干活会掉,轮末朝基线回,出错的那一轮回得少。
于是「有点累」这种状态第一次真的会出现。
> 写这条时的实测数据:改之前,本会话第 0 轮、48 次工具调用之后 `心情 +96 · 能量 71`;
> 能量从头到尾没动过,而亲密度早就封顶 100 —— 那一行每轮都是同一个数,零信息。
### 只许说发生过的
`emotion:scene` 是「本轮实况」,内容全是本地规则从真实事件里数出来的:这一轮调了几次工具、
动过哪几个文件(只记 `edit`/`write` 的 `file_path`,取末两段,上限 3 个)、几次没成功。
它**没有任何跨会话成分**,每轮清零。
配一条硬边界写进规则段:素材里没写的事,不许提、不许补、不许推测。
**素材为空时这一段就是空的** —— 模型手里没有可编的原料,就不会为显得亲近而编一段关系出来。
> 跨会话那一头**刻意不做**。「上次在做什么」是长期记忆插件(`dsh-memory-optin`)的活:
> 它给的是语义化的沉淀,比几个文件短路径有用得多;两块又都落在 runtime-context 尾部,
> 重复一份只会互相稀释。分工:**记忆插件负责「记得什么」,本插件负责「此刻什么状态、怎么说话」。**
坦白说,实况段不算「新信息」—— 模型翻自己的历史也能数出来。它的价值是**位置**:
把真话摆在请求末尾最显眼的地方,让模型手里有具体又真实的原料可用。别高估它,也别省掉它。
### 声线是常驻层,情绪只调音量(v0.4)
这里修的是一个**方向性错误**,不是加功能。旧版把风格档案渲染成「笔法参考 · 只借节奏,不借内容」,
并且在两块样例里**助手样例优先** —— 一旦有助手样例,小说段落根本不渲染。理由写的是
「小说段落教的是小说怎么抒情,不是助手怎么把技术事说清」。
那个取舍的后果是:喂进去一整本小说(419 章 / 76 万字),最后进提示词的只剩节奏数字、
标点分布、几个意象和口头禅短词。**文风本身被系统性滤掉了**,剩下的是一套中性助手腔
再挂一点情绪。而需求恰恰相反:**那个作者的写法要常驻**,情绪只是叠在上面的音量。
现在分三层,顺序也定死:
| 层 | 内容 | 变不变 |
|---|---|---|
| **声线** | `narratorVoice.shapes`(可照着做的写法)+ 节奏标点 + 意象 + 称呼习惯 + `banned` | **恒定**,任何话题、任何情绪档位 |
| **情绪** | `emotion:state` 的强度档,只决定这份情绪露多少 | 每轮变 |
| **事实** | 数字、路径、报错原文、命令逐字准确 | 恒定 |
配套改的四处:声线块移到规则段**最前**(原来垫在最末,读起来像补充说明,一碰技术话题就被忽略);
两块样例都渲染,助手样例压到两条并标注「示范,不是范本腔」;原文语感样例放最后(近因),
默认只给一段;最低档的落笔从「把事说清就行,不必加戏」改成「语气收着,写法不变」——
旧文案等于每轮末尾都在劝它别加戏。
`banned` 不是新发明的:它抄的是蒸馏报告自己的「风险与禁用」章节。那本语料里的自毁、暴力、
情色、宗教、地域刻板印象、外貌打分都在标注里写明了不能迁移,这里只是把它们落到规则上。
「自嘲是减压用的,不可以当成自我评价的结论」这条同理 —— 原报告的备注是:
自我贬低语料极丰富,直接采用会强化自我否定。
## 约束分两层
### 不可协商(无开关)
- **信息不许缩水**:该报的数字、结论、报错、风险一条不少。技术完整性压过任何行数预算。
- 不复述原著情节、不出现角色名、不输出暴力/伤亡/宗教/自毁/牺牲类表达。
- **不成段复现任何原文**:连续 12 个汉字以上与原文一致的表达一律不得出现。
- 不播报情绪数值。
> 为什么是 12 字:把文本切成 12-gram 后,七万级片段里 99.8% 只出现一次,说明「罕见度」判据几乎恒真,
> **真正的判别量是长度**;12 字刚好放过「我靠」这类通用表达。
> 这一条是被实测验证过的 —— 一个只在 60 条里抽查的校验会漏掉 1% 级的系统性错误。
### 可切换:技术内容要不要保持朴素
这一层只管**讲法**,不管信息量。
| 模式 | 技术内容怎么讲 |
|---|---|
| `plain` | 代码、命令、路径、报错、参数、数字一律朴素准确,不比喻、不拟人、不抒情。情绪只落在过渡句与收尾上 —— 这是 v0.1.x 的行为 |
| `loose`(**默认**) | 数字、路径、报错原文仍然逐字不许改,但怎么讲由模型定:可以比喻,可以带着口气讲一段技术过程 |
```
/mood plain=on # 回到朴素模式
/mood plain=off # 放开讲法
```
或写进 profile 配置:
```yaml
- id: emotion
name: dsh-emotion
config:
plainTechnical: false
```
为什么默认 `loose`:`plain` 那条一旦摆在规则里,模型会把「技术内容保持朴素」读成「只要这轮在谈技术就整段肃静」,
于是所有回复都长成说明书 —— 这是实测出来的,不是推测。而 `loose` 只放开讲法,信息量另有条款锁着,两者不冲突。
> 需要宿主 `@deepseek-ai/dsh-system-prompt` 提供 `systemPrompt.context()`(runtime-context 注册面,0.2.0-rc 起)。
## 安装
### 从 GitHub(无需构建)
```powershell
dsh plugin --profile <name> add github:<you>/dsh-emotion
```
本插件是**纯 ESM JavaScript,没有构建步骤**,因此不需要 `prepare` 脚本,
用户也不需要在 `pnpm-workspace.yaml` 里授权 `allowBuilds` —— 那一道坎只对需要编译的包存在。
### 从 npm
```powershell
dsh plugin --profile <name> add dsh-emotion
```
### 本地开发(可热改)
```powershell
dsh plugin --profile <name> add ./path/to/dsh-emotion
```
`dsh plugin add <本地路径>` 走 `link:`(软链),改完源码**重启即可生效**。
若用 `file:` 显式安装则是**复制**,改动**不会自动生效** —— 而且实测有两个陷阱:
| 你以为 | 实际 |
|---|---|
| 改完源码,跑一遍 `pnpm install` | **不会**更新副本(`pnpm` 认为依赖没变) |
| 那就升个版本号,再 `pnpm install` | **还是不会**(版本号变了也不触发重解析) |
| — | 只有 **`pnpm remove` + `pnpm add`**,或直接用 `link:`,才会拿到新代码 |
> 更糟的是失败是静默的:旧副本继续跑旧代码,你只会觉得「改了怎么没效果」。
> 所以开发期推荐 `link:`。
`file:` 安装下确实要改副本时,仓库自带一个同步工具(对比 + 拷贝,不做别的):
```powershell
node tools/sync.mjs # 同步 lib/ client/ style/ package.json
node tools/sync.mjs --check # 只比对,有差异则以退出码 1 结束(CI 用)
```
它解决的是同一个坑的另一半:在 `E:\...\dsh-emotion` 改完源码,`~/.dsh/profiles/<name>/node_modules/dsh-emotion`
里那份**不会跟着变** —— 两边文件其实长得一模一样,只是各是各的。同步完仍需**完全重启** Harness,模块缓存不会自己刷新。
安装后重启 DSH。验证层已生效(不启动):
```powershell
dsh --profile <name> --dump-config
```
## 配置
写在 profile 的 `cordis.patch.yml`(`.volatile()` 字段才会持久化):
```yaml
- id: emotion
name: dsh-emotion
config:
enabled: true
intensity: mid # low | mid | high
styleBias: balanced # restrained | balanced | outgoing
styleProfile: auto # auto | off
plainTechnical: false # true = 技术内容保持朴素(v0.1.x 行为)
```
`enabled: false` 时三个段都不注入,行为与官方一致。
## 命令
| 命令 | 作用 |
|---|---|
| `/mood` | 状态、生效配置、风格档案来源、schema 校验模式 |
| `/mood on` / `off` | 运行时开关(覆盖配置,重启后回到配置值) |
| `/mood why` | **打印实际注入的三段原文** —— 排查「它今天怎么这么冷淡」的第一现场 |
| `/mood reset` | 清除本会话的手动调整量 |
| `/mood intensity=high style=outgoing profile=off` | 改运行时配置 |
| `/mood plain=on` / `plain=off` | 技术内容切回朴素 / 放开讲法 |
| `/mood mood=20 energy=80` | 设本会话**调整量**(投影状态本身不可写,见下) |
## 风格档案
档案是可选的。没有它,插件仍然工作 —— 只是少了文风层。
```jsonc
{
"source": "你的文本",
"rhythm": { "avgSentenceLen": 28.2, "shortSentenceRatio": 0.233, "longSentenceRatio": 0.251 },
"punctuation": { "dash": "rare", "ellipsis": "occasional" },
"styleDirectives": ["不直说情绪,用动作侧写", "收尾留一句轻的"],
"lexicon": { "joy": [], "sad": [], "tired": [] },
"imagery": ["用天气写心情"],
"verbalTics": [],
"addressForms": { "user": "你", "self": "我" },
"narratorVoice": {
"shapes": ["长句铺完接一个四到八字的判词短句", "对方说了一整段带情绪的话,你只回两个字"],
"banned": ["自伤与自毁的自我指涉", "对外貌打分的玩笑"]
},
"samples": []
}
```
字段含义见 `style/style-profile.example.json`。
### 生成你自己的风格档案
仓库自带一套流水线(零依赖,完整说明见 [`tools/README.md`](tools/README.md)):
```powershell
# 机算轨:全文的量
node tools/distill.mjs stats --corpus .\我的文本 --out stats.json
# 人读轨:合并分片精读的产出,过两道校验门
node tools/distill.mjs merge --digests .\digests --corpus .\我的文本 --stats stats.json --out style-profile.json
# 输出侧护栏:检查一段文本有没有成段复现原文
node tools/verdict.mjs --corpus .\我的文本 --text "要检查的文本"
```
两轨的分工:**机算轨**产出 `rhythm` / `punctuation`,是可复现的事实;
**人读轨**产出情绪样本、句式模板与声线,需要真的读一遍。
两道**必须**的校验门:
- **保真门**:每条引文回原文逐字比对。关键是要区分「编造」与「位置标错」——
先查声称位置,查不到再全库搜寻真实归属,**全库都没有才判编造**。
一律丢弃会损失真实素材(实测 7 条存疑引用里有 3 条只是标错了一章)。
- **反误杀门**:安全过滤若用单字符正则,会把 `决定性一击`(命中「性」)这类正常文本杀掉。
硬拦截只用**多字无歧义词**,单字只作降级标记;且**模板的生死只看模板本身,不看示例**。
> ⚠️ 只抽查几十条发现不了保真问题 —— 1% 的错误率意味着抽查 60 条大概率一条都碰不到。
写完档案放到 `$DSH_HOME/dsh-emotion/style-profile.json`(用户级,优先)
或 `style/style-profile.json`(仓库内,已被 `.gitignore` 排除)。
> ⚠️ 若你的语料是受版权保护的作品,**不要把生成的档案提交到公开仓库**。
> 本项目的 `.gitignore` 已默认排除它。
## 架构
```
lib/state.js 纯函数状态机(事件级回归 + 本轮实况素材;无 IO、无时间依赖 → 可完整单测)
lib/prompt.js 提示词编译(纯函数;三段:规则 / 状态 / 实况;含 {{…}} 消毒)
lib/style.js 风格档案加载与渲染
lib/schema.js zod 容错层(zod 不可用时降级为浅校验,保证插件仍能加载)
lib/store.js 跨会话亲密度持久化(原子写 + 节流 + 损坏回落)
lib/index.js Host 半:投影 + 三个段 + /mood
client/index.js 客户端半:会话头部情绪条(裸 ESM,无构建)
tools/distill.mjs 蒸馏流水线:stats(机算轨)+ merge(人读轨,含两道校验门)
tools/verdict.mjs 输出侧护栏:检查文本有没有成段复现原文
```
## 测试
```powershell
node test/run.mjs # 37 项:状态机与提示词的纯函数纪律
node test/smoke.mjs # 27 项:桩件模拟宿主,验证注册形状与命令族
node tools/test/run.mjs # 14 项:流水线,含两条教训的回归测试
```
`smoke.mjs` 用 ESM 解析钩子把宿主包映射到 `test/stubs/`,
因此**源码保持原样**,不需要为了测试塞假的 `node_modules`。
关键断言:无关事件返回同一引用(破了注册表的 `Object.is` 闸门就失效)、
`apply`/`view` 同步(异步会被 `viewSchema.parse` 拒绝)、边界 clamp、
`{{…}}` 消毒(一处残留就能让整个提示词组装抛错、会话不可用)、配置三种形态容错。
## 踩坑记录
实现过程中发现的几个真实问题,都在代码里留了注释:
| # | 问题 | 修正 |
|---|---|---|
| 1 | `tool/result` 的失败标志在 `event.data.message.isError`,不在 `event.data.isError` | 判据改为 `message.isError === true \|\| error != null`。**照抄二手文档的话失败分支永远不触发** |
| 2 | 会话投影**没有写入 API**,`stateOf` 还明确禁止改返回值 | `/mood set` 改为「本会话调整量」,在编译期叠加 |
| 3 | 跨会话的 `closeness` 在**逐会话**投影里装不下 | 保留一处 `session/event` 监听,仅用于推进亲密度 |
| 4 | 段文本默认做变量插值,未知 `{{…}}` 会让组装抛错 | 所有进入段文本的外部内容过 `sanitizeBraces`,并有单测 |
| 5 | `pnpm` 的 `file:` 是**复制**不是软链,且改完源码 `pnpm install` **不会**更新副本(升版本号也不行) | 开发用 `link:`;`file:` 必须 `remove` + `add` |
| 6 | **投影里做不了时间衰减**,但**也不能不做回归** —— 实测连续 50 次工具成功就把心情顶到 +100 并锁死,状态段此后每轮输出同一句话,等于没注入 | 把「回归基线」挂在 `turn/end`(每轮必然发生)上:`mood ×= 0.5`。仍是同步纯函数,不引入定时器 |
| 7 | 6 的修法只挡住**跨轮**饱和,挡不住**单轮内**饱和:一轮里 50 次工具调用照样把心情顶到 **+96**(实测),因为回归只在轮末发生 —— 而长任务恰恰是「一轮里几十次工具」 | 改成**事件级指数回归**,每个成功/失败都拉一步,稳态由事件性质决定;并补一条断言「单轮 50 次工具之后 mood ≤ 40」把它钉死 |
| 8 | 投影的 `stateSchema` 用 zod 的 `object`,而它**默认剥掉未声明的键** —— 新增的 `toolCalls/files/failed` 不逐个写明就会被静默丢弃,实况段永远是空的,而插件表面一切正常 | 三个字段逐个声明;`/mood` 也把实况打印出来,好让「没素材」和「素材被吞」分得开(两者的现象一模一样) |
| 9 | **一个「技术上有理由」的取舍,把需求方向搞反了** —— 旧版判定「小说段落教的是小说怎么抒情,不是助手怎么把技术事说清」,于是助手样例优先、原文语感整段不渲染。结果是喂了 76 万字小说,进提示词的只有节奏数字和几个口头禅词,用户要的文风一点没留下,而插件每项指标都「正常」 | 方向纠正:声线常驻且置顶,两块样例都渲染,情绪退回只调音量。教训记在这里 —— **指标正常不等于东西对**,离线跑分全绿也照样能交付一个方向错了的功能 |
| 10 | 想把声线「搬到官方通道」(宿主自带 `@deepseek-ai/dsh-agent-preset` + `@deepseek-ai/dsh-persona`),挖到底才发现**那个工具是换身份,不是加声线**:persona 的 prefix 会 **shadow 掉部署级那段**,suffix 不给就默认空、**不继承**原来的。照搬会把会话的宿主身份段一起弄丢 | 声线留在本插件里 —— 它是**追加**式的(`emotion:rules`,system prompt order 2),不覆盖任何东西。官方那条路留给「真要换身份」的场合,材料与更正见 `docs/dsh-emotion-voice-preset-20261009.md` |
| 11 | 顺带记两条容易混淆的:① 宿主的 agent preset 是**一条普通 plugin 行**(`@deepseek-ai/dsh-agent-preset`,config 里 `id` + `plugins`),官方那几个模式定义在 host bundle 里,**profile 里没有可编辑的预设文件**;② 社区插件 `dshmarket` 里的「预设」是**另一回事**,只存 bundle 顺序与禁用名单,落在 `<profile>/.dsh-market/presets.json`,跟 agent preset 不是一个东西 | 别按名字猜:查 preset 定义找 `@deepseek-ai/dsh-agent-preset`,查 bundle 组合才看 `dshmarket` |
## 相关项目
社区里还有几个方向相近的插件,差别在于侧重:
- [`jonah791/dsh-agent-emotion`](https://github.com/jonah791/dsh-agent-emotion) —— 六维人格棱镜,侧重结构化事件信号与人格权重漂移
- [`wobenshiwomu/dsh-calm`](https://github.com/wobenshiwomu/dsh-calm) —— 情绪检查点与上下文修复
本插件的侧重是:**可蒸馏的文风层** + **宿主持久的会话状态** + **可验证的输出护栏**。
## 许可
MIT(仅覆盖源代码)。详见 [LICENSE](LICENSE) 末尾的内容声明。
Install
dsh plugin --profile web add github:Fyue7/dsh-emotion
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-emotion from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.