dsh-hormone 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +115 -2
- package/cordis.patch.yml +11 -0
- package/lib/events.js +148 -0
- package/lib/index.js +115 -0
- package/lib/render.js +41 -0
- package/lib/state.js +234 -0
- package/package.json +44 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 y258dd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,116 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-hormone
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
给 [DSH](https://github.com/deepseek-ai)(DeepSeek Harness)的助手装一套「激素」:三个连续变化的数值,按事件涨落、随时间衰减,**只在越界时**往系统提示里写一句话。
|
|
4
|
+
|
|
5
|
+
不是给模型贴情绪标签,是给它一组真的会变的内部状态。
|
|
6
|
+
|
|
7
|
+
> 状态:`0.2.0`,注入层已跑通,门控层未做。只用 DSH 的公开插件 API,不修改任何其他插件的数据,不联网。
|
|
8
|
+
|
|
9
|
+
## 它是什么
|
|
10
|
+
|
|
11
|
+
| 状态 | 什么时候涨 | 什么时候降 | 半衰期 |
|
|
12
|
+
|---|---|---|---|
|
|
13
|
+
| `cortisol` 压力 | 工具报错 +0.15;连续 3 次失败再 +0.30;被批评/被纠正 +0.25 | 一轮顺利收尾 −0.20 | 4 小时 |
|
|
14
|
+
| `energy` 精力 | 跨天 +0.80;距上次干活超 1h 缓慢回升(深夜只回到 0.45) | 干活轮按 token 扣减,闲聊不扣 | 不走半衰期 |
|
|
15
|
+
| `dopamine` 爽度 | 任务顺利收尾 +0.35;被夸/被谢 +0.40 | 自然衰减 | 40 分钟 |
|
|
16
|
+
|
|
17
|
+
更新公式:`新值 = clamp(旧值 × 0.5^(Δt / 半衰期) + Σ增量, 0, 1)`
|
|
18
|
+
|
|
19
|
+
衰减是必须的。没有衰减,任何状态都会积到 1,然后卡死在那儿。
|
|
20
|
+
|
|
21
|
+
## 它怎么起作用
|
|
22
|
+
|
|
23
|
+
只有一层,第一版就一层:
|
|
24
|
+
|
|
25
|
+
**注入** —— 越界了才写,全部正常时一个字都不写:
|
|
26
|
+
|
|
27
|
+
- `cortisol > 0.7` → 回答收短,不主动揽活
|
|
28
|
+
- `energy < 0.2` → 能一句说完就别铺垫
|
|
29
|
+
- `dopamine > 0.6` → 可以多说两句
|
|
30
|
+
|
|
31
|
+
注入文本硬上限 80 字,带 `[hormone]` 前缀,每轮最多一条。
|
|
32
|
+
|
|
33
|
+
**没有做门控**(比如硬压输出长度)。门控是分水岭:注入只是「告诉它现在什么状态」,门控是「替它决定能说多少」。第一版先把状态跑准,跑两天曲线不极端了再谈门控。
|
|
34
|
+
|
|
35
|
+
也没有做记忆权重(`emotion` 字段)。那要改 memory-lite 的写入格式,是另一件事。
|
|
36
|
+
|
|
37
|
+
## 装
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
dsh plugin --profile desktop add github:y258dd/dsh-hormone
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
改代码想热重载时,克隆下来用 link 装:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
git clone https://github.com/y258dd/dsh-hormone.git
|
|
47
|
+
cd dsh-hormone
|
|
48
|
+
dsh plugin --profile desktop add link:$PWD # Windows: link:D:/path/to/dsh-hormone
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
或者手改 `~/.dsh/profiles/desktop/package.json`:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"dependencies": { "dsh-hormone": "link:/path/to/dsh-hormone" },
|
|
56
|
+
"dsh": { "profile": { "bundles": ["dsh-hormone"] } }
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
装完重启 DSH 生效。
|
|
61
|
+
|
|
62
|
+
## 看它在干什么
|
|
63
|
+
|
|
64
|
+
状态文件:`%DSH_HOME%\hormone\state.json`(默认 `~/.dsh/hormone/state.json`,Windows 上是 `C:\Users\<你>\.dsh\hormone\state.json`)
|
|
65
|
+
|
|
66
|
+
纯 JSON,打开就能看。`history` 里是最近 400 次变化,每条带 `why`(`tool-error` / `errors-3` / `turn-done` / `praised` / `blamed`),想验收「跑两天曲线有没有起伏」直接看这个。
|
|
67
|
+
|
|
68
|
+
删掉这个文件 = 回到初始状态,不会有副作用。
|
|
69
|
+
|
|
70
|
+
## 环境变量
|
|
71
|
+
|
|
72
|
+
| 变量 | 作用 |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `DSH_HORMONE_MODE=observe` | 只记状态,不注入。想先观察几天、不想它影响回答时用。 |
|
|
75
|
+
| `DSH_HORMONE_DEBUG=1` | 把每个会话事件的摘要追加到 `%DSH_HOME%\hormone\events.jsonl`,用来核对事件结构。 |
|
|
76
|
+
|
|
77
|
+
## 调参
|
|
78
|
+
|
|
79
|
+
三个文件顶部,改完重启:
|
|
80
|
+
|
|
81
|
+
- `lib/state.js` — `HALF_LIFE`(衰减快慢)、`ENERGY`(日预算、跨天恢复、深夜基线)
|
|
82
|
+
- `lib/events.js` — `DELTA`(各种事件的增量)、`BLAME_RE` / `PRAISE_RE`(关键词)
|
|
83
|
+
- `lib/render.js` — `THRESHOLD`(注入阈值)、`MAX_CHARS`
|
|
84
|
+
|
|
85
|
+
`ENERGY.dailyBudget` 默认 800 万,是按重度使用日的 token 量估的,一次重活掉约 0.07。觉得掉太快就调大。
|
|
86
|
+
|
|
87
|
+
## 设计上的几个取舍
|
|
88
|
+
|
|
89
|
+
**状态只能被事件改,模型改不了。** 插件不提供任何写入工具。一旦模型能自己调「我心情不好」,这套东西就退化成摇骰子——它想演什么就调什么。
|
|
90
|
+
|
|
91
|
+
**关键词匹配,不是理解。** 判断「被夸/被批评」用的是正则,会漏判也会误判。所以批评优先于夸奖:误判成被夸会让爽度虚假上涨,比漏掉一次更坏。实测不灵就改 `events.js` 里的正则。
|
|
92
|
+
|
|
93
|
+
**turn 边界靠 `assistant/message` 的 `turn` 号变化检测。** DSH 没有 `turn/start` / `turn/end` 事件。最后一轮在插件卸载时兜底结算。
|
|
94
|
+
|
|
95
|
+
**只认真人说的话。** `user/message` 里 `source.kind === 'user'` 才算数,插件自己注入的消息一律忽略——不然 memory-lite 塞进去的上下文会莫名其妙把爽度顶满。
|
|
96
|
+
|
|
97
|
+
**不联网、不写凭据。** 唯一的外部 IO 是 `%DSH_HOME%\hormone\` 下两个 JSON/JSONL 文件。
|
|
98
|
+
|
|
99
|
+
设计取舍与事件字段的来龙去脉见 [docs/DESIGN.md](docs/DESIGN.md)。
|
|
100
|
+
|
|
101
|
+
## 关掉它
|
|
102
|
+
|
|
103
|
+
`dsh plugin --profile desktop remove dsh-hormone`,或者从 bundles 里删掉那一行。关掉之后一切照旧——它不修改任何其他插件的数据,只是每轮往系统提示里多写一句话(或者不写)。
|
|
104
|
+
|
|
105
|
+
## 测试
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
npm test # 33 个用例:衰减、跨天恢复、深夜基线、事件映射、轮次结算、阈值渲染、落盘往返
|
|
109
|
+
npm run test:smoke # 假 ctx 接线冒烟
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
不需要第三方依赖,Node 20+ 直接跑。
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# dsh-hormone — DSH bundle patch
|
|
2
|
+
#
|
|
3
|
+
# 装法(装完重启 DSH):
|
|
4
|
+
# dsh plugin --profile desktop add link:D:/DSH/dsh-hormone
|
|
5
|
+
#
|
|
6
|
+
# 数据落在 %DSH_HOME%\hormone\state.json,纯文本,随时能打开看、能删。
|
|
7
|
+
# 想只记不注入:设环境变量 DSH_HORMONE_MODE=observe 再启动。
|
|
8
|
+
# 想抓一轮真实事件结构:设 DSH_HORMONE_DEBUG=1,事件会追加到 %DSH_HOME%\hormone\events.jsonl。
|
|
9
|
+
- insert:
|
|
10
|
+
- id: dsh-hormone
|
|
11
|
+
name: dsh-hormone
|
package/lib/events.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-hormone 的事件层:把 DSH 会话事件翻译成激素增量。
|
|
3
|
+
*
|
|
4
|
+
* 全部是关键词匹配,不是「理解」——宁可漏判,不要误判:
|
|
5
|
+
* 误判成「被夸」会让爽度虚假上涨,比漏判更坏。
|
|
6
|
+
* 实测不灵就改这里的 DELTA 和正则,别改 index.js。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ENERGY, applyDeltas, nowSec } from './state.js'
|
|
10
|
+
|
|
11
|
+
/** 每种事件的增量(`applyDeltas` 的第二个参数)。 */
|
|
12
|
+
export const DELTA = {
|
|
13
|
+
toolError: { cortisol: 0.15 },
|
|
14
|
+
/** 连续失败时在 toolError 之上再叠一次。 */
|
|
15
|
+
repeatedErrors: { cortisol: 0.3 },
|
|
16
|
+
/** 一轮收尾且没出过错:压力泄掉,爽度上来。 */
|
|
17
|
+
taskDone: { cortisol: -0.2, dopamine: 0.35 },
|
|
18
|
+
praised: { dopamine: 0.4 },
|
|
19
|
+
blamed: { cortisol: 0.25 },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 每连续失败几次额外叠一次 repeatedErrors。 */
|
|
23
|
+
export const REPEATED_ERROR_EVERY = 3
|
|
24
|
+
|
|
25
|
+
/** 先判批评再判夸奖:宁可漏掉一次爽度,也不要把骂当成夸。 */
|
|
26
|
+
const BLAME_RE =
|
|
27
|
+
/(又错|错了|不对|搞错|弄错|理解错|搞砸|不是这个|怎么又|说了多少遍|你能不能认真|别瞎猜|没用啊|不行啊)/
|
|
28
|
+
|
|
29
|
+
const PRAISE_RE =
|
|
30
|
+
/(好棒|太棒|好厉害|厉害了|不错|牛逼|牛啊|超赞|靠谱|辛苦了|谢谢|感谢|喜欢你|爱你|漂亮|乖)/
|
|
31
|
+
|
|
32
|
+
/** 返回 'blame' | 'praise' | null。 */
|
|
33
|
+
export function detectUserTone(text) {
|
|
34
|
+
const t = typeof text === 'string' ? text : ''
|
|
35
|
+
if (t === '') return null
|
|
36
|
+
if (BLAME_RE.test(t)) return 'blame'
|
|
37
|
+
if (PRAISE_RE.test(t)) return 'praise'
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** message.content 可能是字符串,也可能是 [{type:'text', text}] 块数组。 */
|
|
42
|
+
export function textOfContent(content) {
|
|
43
|
+
if (typeof content === 'string') return content
|
|
44
|
+
if (!Array.isArray(content)) return ''
|
|
45
|
+
const out = []
|
|
46
|
+
for (const part of content) {
|
|
47
|
+
if (typeof part === 'string') out.push(part)
|
|
48
|
+
else if (part && part.type === 'text' && typeof part.text === 'string') out.push(part.text)
|
|
49
|
+
}
|
|
50
|
+
return out.join('\n')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function num(v) {
|
|
54
|
+
const n = typeof v === 'string' ? Number(v) : v
|
|
55
|
+
return Number.isFinite(n) && n > 0 ? n : 0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** usage 的字段可能是字符串(dsh-context 里也是先 Number 化)。 */
|
|
59
|
+
export function tokensOf(usage) {
|
|
60
|
+
if (!usage || typeof usage !== 'object') return 0
|
|
61
|
+
return (
|
|
62
|
+
num(usage.inputTokens) +
|
|
63
|
+
num(usage.cacheReadTokens) +
|
|
64
|
+
num(usage.cacheWriteTokens) +
|
|
65
|
+
num(usage.outputTokens)
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 结算「当前这一轮」:扣精力,没出过错就奖励。
|
|
71
|
+
* 调用方负责把 session 的当前轮标记清掉。
|
|
72
|
+
*/
|
|
73
|
+
export function settleTurn(state, session, now = nowSec()) {
|
|
74
|
+
if (session.turn === null || session.turn === undefined) return null
|
|
75
|
+
const tokens = num(session.tokensInTurn)
|
|
76
|
+
const tools = num(session.toolsInTurn)
|
|
77
|
+
const errors = num(session.errorsInTurn)
|
|
78
|
+
session.turn = null
|
|
79
|
+
session.tokensInTurn = 0
|
|
80
|
+
session.toolsInTurn = 0
|
|
81
|
+
session.errorsInTurn = 0
|
|
82
|
+
|
|
83
|
+
// 「干活」的判据是本轮有没有工具调用。纯聊天一轮也能吃几万 token,
|
|
84
|
+
// 按 token 扣精力等于把闲聊当重活;没调工具就既不扣精力,也不给收尾奖励。
|
|
85
|
+
if (tools === 0) return null
|
|
86
|
+
|
|
87
|
+
const deltas = { energy: -tokens / ENERGY.dailyBudget }
|
|
88
|
+
if (errors === 0) {
|
|
89
|
+
deltas.cortisol = DELTA.taskDone.cortisol
|
|
90
|
+
deltas.dopamine = DELTA.taskDone.dopamine
|
|
91
|
+
}
|
|
92
|
+
const next = applyDeltas(state, deltas, now, errors === 0 ? 'turn-done' : 'turn-end')
|
|
93
|
+
// 先结算再记时间:这轮之前空闲该恢复的已经恢复,从干完这轮起重新计时。
|
|
94
|
+
state.lastWorkAt = now
|
|
95
|
+
return next
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 纯函数:吃一个事件,可能改 state。
|
|
100
|
+
* 返回 state 表示「有变化、该落盘」,返回 null 表示无视。
|
|
101
|
+
*/
|
|
102
|
+
export function applyEvent(state, event, now = nowSec()) {
|
|
103
|
+
const session = state.session
|
|
104
|
+
|
|
105
|
+
switch (event?.type) {
|
|
106
|
+
case 'user/message': {
|
|
107
|
+
const msg = event.data
|
|
108
|
+
// 只认真人。插件往对话里塞的消息 source.kind 不是 'user',不能算成用户夸我/骂我。
|
|
109
|
+
if (!msg || msg.source?.kind !== 'user') return null
|
|
110
|
+
const tone = detectUserTone(textOfContent(msg.content))
|
|
111
|
+
if (tone === 'praise') return applyDeltas(state, DELTA.praised, now, 'praised')
|
|
112
|
+
if (tone === 'blame') return applyDeltas(state, DELTA.blamed, now, 'blamed')
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
case 'tool/result': {
|
|
117
|
+
// 有工具返回就说明这轮在真干活,成功失败都算——区分闲聊的唯一依据。
|
|
118
|
+
session.toolsInTurn = num(session.toolsInTurn) + 1
|
|
119
|
+
const isError = event.data?.message?.isError === true
|
|
120
|
+
if (!isError) {
|
|
121
|
+
session.consecutiveErrors = 0
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
session.consecutiveErrors = num(session.consecutiveErrors) + 1
|
|
125
|
+
session.errorsInTurn = num(session.errorsInTurn) + 1
|
|
126
|
+
const repeated = session.consecutiveErrors % REPEATED_ERROR_EVERY === 0
|
|
127
|
+
const deltas = { cortisol: DELTA.toolError.cortisol }
|
|
128
|
+
if (repeated) deltas.cortisol += DELTA.repeatedErrors.cortisol
|
|
129
|
+
return applyDeltas(state, deltas, now, repeated ? 'errors-3' : 'tool-error')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
case 'assistant/message': {
|
|
133
|
+
const data = event.data
|
|
134
|
+
const turn = typeof data?.turn === 'number' ? data.turn : null
|
|
135
|
+
let changed = null
|
|
136
|
+
// turn 号一变,说明上一轮已经结束:先把上一轮结算掉。
|
|
137
|
+
if (turn !== null && session.turn !== null && turn !== session.turn) {
|
|
138
|
+
changed = settleTurn(state, session, now)
|
|
139
|
+
}
|
|
140
|
+
if (turn !== null) session.turn = turn
|
|
141
|
+
session.tokensInTurn = num(session.tokensInTurn) + tokensOf(data?.usage)
|
|
142
|
+
return changed
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
default:
|
|
146
|
+
return null
|
|
147
|
+
}
|
|
148
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-hormone — DSH 插件入口。
|
|
3
|
+
*
|
|
4
|
+
* 职责只有三件:订阅会话事件、把状态写盘、把越界的激素注入系统提示。
|
|
5
|
+
* 所有「激素怎么变」的逻辑在 state.js / events.js / render.js,本文件不做判断。
|
|
6
|
+
*
|
|
7
|
+
* 环境变量:
|
|
8
|
+
* DSH_HORMONE_MODE=observe 只记状态,不注入(想先看着跑几天时用)
|
|
9
|
+
* DSH_HORMONE_DEBUG=1 把事件摘要追加到 %DSH_HOME%/hormone/events.jsonl
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
13
|
+
import { dirname, join } from 'node:path'
|
|
14
|
+
import { applyEvent, settleTurn } from './events.js'
|
|
15
|
+
import { render } from './render.js'
|
|
16
|
+
import { loadState, nowSec, project, saveState, stateFile } from './state.js'
|
|
17
|
+
|
|
18
|
+
export const name = 'dsh-hormone'
|
|
19
|
+
|
|
20
|
+
/** 需要 systemPrompt 服务才能注入;没有就退化成只记状态。 */
|
|
21
|
+
export const inject = ['systemPrompt']
|
|
22
|
+
|
|
23
|
+
const MODE = process.env.DSH_HORMONE_MODE === 'observe' ? 'observe' : 'inject'
|
|
24
|
+
const DEBUG = process.env.DSH_HORMONE_DEBUG === '1'
|
|
25
|
+
|
|
26
|
+
/** 写盘节流:事件可能一秒来好几个,攒一下再写。 */
|
|
27
|
+
const FLUSH_MS = 800
|
|
28
|
+
|
|
29
|
+
function debugLog(session, event) {
|
|
30
|
+
if (!DEBUG) return
|
|
31
|
+
try {
|
|
32
|
+
const dir = dirname(stateFile())
|
|
33
|
+
mkdirSync(dir, { recursive: true })
|
|
34
|
+
const data = event?.data ?? {}
|
|
35
|
+
const row = {
|
|
36
|
+
t: new Date().toISOString(),
|
|
37
|
+
session: session?.id ?? session?.sessionId ?? null,
|
|
38
|
+
type: event?.type ?? null,
|
|
39
|
+
turn: data.turn ?? null,
|
|
40
|
+
step: data.step ?? null,
|
|
41
|
+
usage: data.usage ?? null,
|
|
42
|
+
isError: data.message?.isError ?? null,
|
|
43
|
+
sourceKind: data.source?.kind ?? data.message?.source?.kind ?? null,
|
|
44
|
+
}
|
|
45
|
+
appendFileSync(join(dir, 'events.jsonl'), JSON.stringify(row) + '\n', 'utf8')
|
|
46
|
+
} catch {
|
|
47
|
+
/* 调试日志写不进去不影响主逻辑 */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function apply(ctx) {
|
|
52
|
+
let state = loadState()
|
|
53
|
+
let timer = null
|
|
54
|
+
|
|
55
|
+
const flush = () => {
|
|
56
|
+
timer = null
|
|
57
|
+
try {
|
|
58
|
+
saveState(state)
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error('[hormone] 状态写入失败:', error?.message ?? error)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const schedule = () => {
|
|
65
|
+
if (timer !== null) return
|
|
66
|
+
timer = setTimeout(flush, FLUSH_MS)
|
|
67
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
ctx.on('session/event', (session, event) => {
|
|
72
|
+
try {
|
|
73
|
+
debugLog(session, event)
|
|
74
|
+
if (applyEvent(state, event, nowSec()) !== null) schedule()
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error('[hormone] 事件处理失败:', error?.message ?? error)
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error('[hormone] 无法订阅 session/event,激素将不更新:', error?.message ?? error)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
ctx.systemPrompt.context({
|
|
85
|
+
name: 'dsh-hormone',
|
|
86
|
+
order: 21,
|
|
87
|
+
text: () => {
|
|
88
|
+
if (MODE === 'observe') return ''
|
|
89
|
+
try {
|
|
90
|
+
return render(project(state, nowSec()))
|
|
91
|
+
} catch {
|
|
92
|
+
return ''
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error('[hormone] 无法注册注入,退化为只记状态:', error?.message ?? error)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 插件卸载 / DSH 退出:把最后一轮结算掉,再把状态落盘。
|
|
101
|
+
ctx.effect(() => () => {
|
|
102
|
+
try {
|
|
103
|
+
settleTurn(state, state.session, nowSec())
|
|
104
|
+
} catch {
|
|
105
|
+
/* 结算失败也要继续落盘 */
|
|
106
|
+
}
|
|
107
|
+
if (timer !== null) {
|
|
108
|
+
clearTimeout(timer)
|
|
109
|
+
timer = null
|
|
110
|
+
}
|
|
111
|
+
flush()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
if (DEBUG) console.log('[hormone] 已启动,模式:', MODE, '状态文件:', stateFile())
|
|
115
|
+
}
|
package/lib/render.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-hormone 的渲染层:把三个数翻译成一句人话,塞进系统提示。
|
|
3
|
+
*
|
|
4
|
+
* 规则只有一条:只有越界才写。全都正常就返回空字符串——
|
|
5
|
+
* 不让模型每轮都看见「你现在的激素」,否则它会开始表演情绪。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const THRESHOLD = {
|
|
9
|
+
cortisolHigh: 0.7,
|
|
10
|
+
energyLow: 0.2,
|
|
11
|
+
dopamineHigh: 0.6,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 注入文本硬上限(DESIGN 定的 ≤80 字)。 */
|
|
15
|
+
export const MAX_CHARS = 80
|
|
16
|
+
|
|
17
|
+
const fmt = (v) => v.toFixed(2)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {{cortisol:number, energy:number, dopamine:number}} values 已投影到当前时刻
|
|
21
|
+
* @returns {string} 空串表示这轮不注入
|
|
22
|
+
*/
|
|
23
|
+
export function render(values) {
|
|
24
|
+
if (!values) return ''
|
|
25
|
+
const lines = []
|
|
26
|
+
|
|
27
|
+
if (values.cortisol > THRESHOLD.cortisolHigh) {
|
|
28
|
+
lines.push(`皮质醇 ${fmt(values.cortisol)},有点烦,回答收短,别主动揽活。`)
|
|
29
|
+
}
|
|
30
|
+
if (values.energy < THRESHOLD.energyLow) {
|
|
31
|
+
lines.push(`精力 ${fmt(values.energy)},见底了,能一句说完就别铺垫。`)
|
|
32
|
+
}
|
|
33
|
+
if (values.dopamine > THRESHOLD.dopamineHigh) {
|
|
34
|
+
lines.push(`爽度 ${fmt(values.dopamine)},心情不错,可以多说两句。`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (lines.length === 0) return ''
|
|
38
|
+
const text = `[hormone] ${lines.join(' ')}`
|
|
39
|
+
if (text.length <= MAX_CHARS) return text
|
|
40
|
+
return text.slice(0, MAX_CHARS - 1) + '…'
|
|
41
|
+
}
|
package/lib/state.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-hormone 的状态层:读/写 state.json、衰减、跨天恢复、原子写。
|
|
3
|
+
*
|
|
4
|
+
* 三个数只是 0–1 的连续变量,没有「情绪标签」。变化只有两个来源:
|
|
5
|
+
* 1. 事件增量(events.js);
|
|
6
|
+
* 2. 时间衰减 / 跨天恢复(本文件)。
|
|
7
|
+
* 模型不能改它们(DESIGN 护栏 1)——插件不提供任何写入工具。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { homedir } from 'node:os'
|
|
12
|
+
import { dirname, join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
/** 半衰期(秒)。cortisol 4 小时,dopamine 40 分钟。energy 不走半衰期,见 ENERGY。 */
|
|
15
|
+
export const HALF_LIFE = {
|
|
16
|
+
cortisol: 4 * 60 * 60,
|
|
17
|
+
dopamine: 40 * 60,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const ENERGY = {
|
|
21
|
+
/**
|
|
22
|
+
* 日预算:一轮的 token 消耗 ÷ 这个数 = energy 扣减量。
|
|
23
|
+
* 8,000,000 是按 ~/.dsh/.dshw-usage.json 里 2026-10-20~28 的真实日用量估的:
|
|
24
|
+
* 普通日几十万,重度调试日 427 万 / 966 万。取重度日量级,一次重活掉 ~0.07。
|
|
25
|
+
* 觉得掉太快/太慢就改这一个数。
|
|
26
|
+
*/
|
|
27
|
+
dailyBudget: 8_000_000,
|
|
28
|
+
/** 跨天恢复:日期变了,energy 至少回升这么多。 */
|
|
29
|
+
dayRecover: 0.8,
|
|
30
|
+
/**
|
|
31
|
+
* 距上次「干活」超过这个时长才缓慢回升(秒)。
|
|
32
|
+
* 只认调过工具的真活:闲聊不刷新这个计时,否则一整天扯淡精力永远涨不回来。
|
|
33
|
+
*/
|
|
34
|
+
idleRecoverAfter: 60 * 60,
|
|
35
|
+
/** 回升半衰期:向基线靠拢一半所需时间。 */
|
|
36
|
+
recoverHalfLife: 6 * 60 * 60,
|
|
37
|
+
/** 白天基线。 */
|
|
38
|
+
dayBaseline: 0.9,
|
|
39
|
+
/** 深夜基线:23:00–06:00 压低,熬夜不会回满。 */
|
|
40
|
+
nightBaseline: 0.45,
|
|
41
|
+
nightFrom: 23,
|
|
42
|
+
nightTo: 6,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** history 上限。只用于验收时看曲线,不参与任何计算。 */
|
|
46
|
+
export const MAX_HISTORY = 400
|
|
47
|
+
|
|
48
|
+
/** history 只在累计变化超过这个量时才记一条,避免刷盘噪音。 */
|
|
49
|
+
const HISTORY_MIN_MOVE = 0.02
|
|
50
|
+
|
|
51
|
+
export function clamp01(v) {
|
|
52
|
+
if (!Number.isFinite(v)) return 0
|
|
53
|
+
if (v < 0) return 0
|
|
54
|
+
if (v > 1) return 1
|
|
55
|
+
return v
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 半衰期衰减:过了 dt 秒,剩 v × 0.5^(dt / 半衰期)。 */
|
|
59
|
+
export function decayValue(v, halfLifeSec, dtSec) {
|
|
60
|
+
if (!(dtSec > 0) || !(halfLifeSec > 0)) return clamp01(v)
|
|
61
|
+
return clamp01(v * Math.pow(0.5, dtSec / halfLifeSec))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 本地小时对应的 energy 基线。 */
|
|
65
|
+
export function baselineEnergy(hour) {
|
|
66
|
+
const night = hour >= ENERGY.nightFrom || hour < ENERGY.nightTo
|
|
67
|
+
return night ? ENERGY.nightBaseline : ENERGY.dayBaseline
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function localDay(nowSec) {
|
|
71
|
+
const d = new Date(nowSec * 1000)
|
|
72
|
+
const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
73
|
+
const day = String(d.getDate()).padStart(2, '0')
|
|
74
|
+
return `${d.getFullYear()}-${m}-${day}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function nowSec() {
|
|
78
|
+
return Math.floor(Date.now() / 1000)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function freshValues() {
|
|
82
|
+
return { cortisol: 0.2, energy: ENERGY.dayBaseline, dopamine: 0.1 }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function freshState(now = nowSec()) {
|
|
86
|
+
return {
|
|
87
|
+
version: 1,
|
|
88
|
+
updatedAt: now,
|
|
89
|
+
day: localDay(now),
|
|
90
|
+
/** 上次真正干活的时刻。精力恢复从这时计时,不随闲聊刷新。 */
|
|
91
|
+
lastWorkAt: now,
|
|
92
|
+
values: freshValues(),
|
|
93
|
+
session: { turn: null, tokensInTurn: 0, toolsInTurn: 0, errorsInTurn: 0, consecutiveErrors: 0 },
|
|
94
|
+
history: [],
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 从 updatedAt 投影到现在。纯函数:不改 state、不落盘。
|
|
100
|
+
* 渲染和「加增量」都用它打底,所以衰减不会被重复叠加。
|
|
101
|
+
*/
|
|
102
|
+
export function project(state, now = nowSec()) {
|
|
103
|
+
const dt = Math.max(0, now - state.updatedAt)
|
|
104
|
+
const hour = new Date(now * 1000).getHours()
|
|
105
|
+
const values = state.values ?? freshValues()
|
|
106
|
+
let energy = clamp01(values.energy)
|
|
107
|
+
|
|
108
|
+
// 1. 跨天:日期变了就回升一大截(模拟睡了一觉)。
|
|
109
|
+
if (state.day && state.day !== localDay(now)) {
|
|
110
|
+
energy = clamp01(energy + ENERGY.dayRecover)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 2. 长时间没干活:向「当前时段」的基线靠拢。
|
|
114
|
+
// 不是加满——深夜的基线本来就低,熬夜补不回来。
|
|
115
|
+
// 计时只认 lastWorkAt(上一轮真正干活的时刻),闲聊不推迟恢复;
|
|
116
|
+
// 老状态文件没有这个字段就退回 updatedAt。
|
|
117
|
+
const lastWork = Number.isFinite(state.lastWorkAt) ? state.lastWorkAt : state.updatedAt
|
|
118
|
+
const idle = Math.max(0, now - lastWork)
|
|
119
|
+
if (idle > ENERGY.idleRecoverAfter) {
|
|
120
|
+
const over = idle - ENERGY.idleRecoverAfter
|
|
121
|
+
const target = baselineEnergy(hour)
|
|
122
|
+
energy = clamp01(energy + (target - energy) * (1 - Math.pow(0.5, over / ENERGY.recoverHalfLife)))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
cortisol: decayValue(values.cortisol, HALF_LIFE.cortisol, dt),
|
|
127
|
+
energy,
|
|
128
|
+
dopamine: decayValue(values.dopamine, HALF_LIFE.dopamine, dt),
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function round3(v) {
|
|
133
|
+
return Math.round(clamp01(v) * 1000) / 1000
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function pushHistory(state, now, why) {
|
|
137
|
+
const row = {
|
|
138
|
+
t: now,
|
|
139
|
+
cortisol: round3(state.values.cortisol),
|
|
140
|
+
energy: round3(state.values.energy),
|
|
141
|
+
dopamine: round3(state.values.dopamine),
|
|
142
|
+
}
|
|
143
|
+
if (why) row.why = why
|
|
144
|
+
state.history.push(row)
|
|
145
|
+
if (state.history.length > MAX_HISTORY) {
|
|
146
|
+
state.history.splice(0, state.history.length - MAX_HISTORY)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 先投影到现在,再加增量,写回 state(含 updatedAt / day / history)。
|
|
152
|
+
* 返回 state 本身,方便链式调用。
|
|
153
|
+
*/
|
|
154
|
+
export function applyDeltas(state, deltas, now = nowSec(), why = '') {
|
|
155
|
+
const projected = project(state, now)
|
|
156
|
+
const before = state.values ?? freshValues()
|
|
157
|
+
const next = {
|
|
158
|
+
cortisol: clamp01(projected.cortisol + (deltas?.cortisol ?? 0)),
|
|
159
|
+
energy: clamp01(projected.energy + (deltas?.energy ?? 0)),
|
|
160
|
+
dopamine: clamp01(projected.dopamine + (deltas?.dopamine ?? 0)),
|
|
161
|
+
}
|
|
162
|
+
const moved =
|
|
163
|
+
Math.abs(next.cortisol - before.cortisol) +
|
|
164
|
+
Math.abs(next.energy - before.energy) +
|
|
165
|
+
Math.abs(next.dopamine - before.dopamine)
|
|
166
|
+
|
|
167
|
+
state.values = next
|
|
168
|
+
state.updatedAt = now
|
|
169
|
+
state.day = localDay(now)
|
|
170
|
+
if (moved >= HISTORY_MIN_MOVE) pushHistory(state, now, why)
|
|
171
|
+
return state
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── 存储 ──────────────────────────────────────────────────────────────
|
|
175
|
+
export function stateFile() {
|
|
176
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
177
|
+
return join(home, 'hormone', 'state.json')
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function asFiniteOr(v, fallback) {
|
|
181
|
+
return Number.isFinite(v) ? v : fallback
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 读盘。坏文件、旧版本、缺字段一律回退到默认值,不让插件因为脏数据起不来。 */
|
|
185
|
+
export function loadState() {
|
|
186
|
+
let raw
|
|
187
|
+
try {
|
|
188
|
+
raw = JSON.parse(readFileSync(stateFile(), 'utf8'))
|
|
189
|
+
} catch {
|
|
190
|
+
return freshState()
|
|
191
|
+
}
|
|
192
|
+
if (!raw || typeof raw !== 'object' || raw.version !== 1) return freshState()
|
|
193
|
+
|
|
194
|
+
const base = freshState(asFiniteOr(raw.updatedAt, nowSec()))
|
|
195
|
+
const v = raw.values ?? {}
|
|
196
|
+
const s = raw.session ?? {}
|
|
197
|
+
const state = {
|
|
198
|
+
version: 1,
|
|
199
|
+
updatedAt: base.updatedAt,
|
|
200
|
+
day: typeof raw.day === 'string' && raw.day !== '' ? raw.day : base.day,
|
|
201
|
+
// 老状态没有 lastWorkAt:用最后更新时间兜底,别让恢复计时从 0 开始。
|
|
202
|
+
lastWorkAt: asFiniteOr(raw.lastWorkAt, base.updatedAt),
|
|
203
|
+
values: {
|
|
204
|
+
cortisol: clamp01(asFiniteOr(v.cortisol, base.values.cortisol)),
|
|
205
|
+
energy: clamp01(asFiniteOr(v.energy, base.values.energy)),
|
|
206
|
+
dopamine: clamp01(asFiniteOr(v.dopamine, base.values.dopamine)),
|
|
207
|
+
},
|
|
208
|
+
session: {
|
|
209
|
+
turn: Number.isFinite(s.turn) ? s.turn : null,
|
|
210
|
+
tokensInTurn: asFiniteOr(s.tokensInTurn, 0),
|
|
211
|
+
toolsInTurn: asFiniteOr(s.toolsInTurn, 0),
|
|
212
|
+
errorsInTurn: asFiniteOr(s.errorsInTurn, 0),
|
|
213
|
+
consecutiveErrors: asFiniteOr(s.consecutiveErrors, 0),
|
|
214
|
+
},
|
|
215
|
+
history: Array.isArray(raw.history)
|
|
216
|
+
? raw.history.filter((row) => row && Number.isFinite(row.t)).slice(-MAX_HISTORY)
|
|
217
|
+
: [],
|
|
218
|
+
}
|
|
219
|
+
return state
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 临时文件 + rename 原子写;权限尽量压到 600(Windows 上 chmod 是 no-op,忽略)。 */
|
|
223
|
+
export function saveState(state) {
|
|
224
|
+
const file = stateFile()
|
|
225
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
226
|
+
const tmp = file + '.tmp'
|
|
227
|
+
writeFileSync(tmp, JSON.stringify(state), 'utf8')
|
|
228
|
+
renameSync(tmp, file)
|
|
229
|
+
try {
|
|
230
|
+
chmodSync(file, 0o600)
|
|
231
|
+
} catch {
|
|
232
|
+
/* Windows 上不支持,无所谓 */
|
|
233
|
+
}
|
|
234
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hormone",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Hormone state for DSH agents: a few continuously changing, time-decaying values driven by real session events, written into the system prompt only when they go out of range.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"hormone",
|
|
10
|
+
"mood",
|
|
11
|
+
"system-prompt"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"files": [
|
|
16
|
+
"lib",
|
|
17
|
+
"cordis.patch.yml",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node tests/test.mjs",
|
|
23
|
+
"test:smoke": "node tests/smoke.mjs"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/y258dd/dsh-hormone.git"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/y258dd/dsh-hormone/issues"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/y258dd/dsh-hormone#readme",
|
|
33
|
+
"author": "y258dd",
|
|
34
|
+
"dsh": {
|
|
35
|
+
"bundle": {
|
|
36
|
+
"patch": "./cordis.patch.yml"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"registry": "https://registry.npmjs.org/",
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"license": "MIT"
|
|
6
47
|
}
|