dsh-hormone 0.2.0 → 0.4.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/README.md +40 -11
- package/lib/events.js +27 -10
- package/lib/index.js +7 -2
- package/lib/render.js +55 -18
- package/lib/state.js +165 -26
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,34 +1,57 @@
|
|
|
1
1
|
# dsh-hormone
|
|
2
2
|
|
|
3
|
-
给 [DSH](https://github.com/deepseek-ai)(DeepSeek Harness
|
|
3
|
+
给 [DSH](https://github.com/deepseek-ai)(DeepSeek Harness)的助手装一套「激素」:四个连续变化的数值,按事件涨落、随时间衰减、彼此还会互相拉扯,**只在越界时**往系统提示里写一句话。
|
|
4
4
|
|
|
5
5
|
不是给模型贴情绪标签,是给它一组真的会变的内部状态。
|
|
6
6
|
|
|
7
|
-
> 状态:`0.
|
|
7
|
+
> 状态:`0.4.0`,注入层已跑通,门控层未做。只用 DSH 的公开插件 API,不修改任何其他插件的数据,不联网。
|
|
8
8
|
|
|
9
9
|
## 它是什么
|
|
10
10
|
|
|
11
11
|
| 状态 | 什么时候涨 | 什么时候降 | 半衰期 |
|
|
12
12
|
|---|---|---|---|
|
|
13
|
-
| `cortisol` 压力 | 工具报错 +0.
|
|
14
|
-
| `energy` 精力 | 跨天 +0.80;距上次干活超
|
|
13
|
+
| `cortisol` 压力 | 工具报错 +0.08;连续 3 次失败再 +0.15;被批评/被纠正 +0.12 | 一轮顺利收尾 −0.20 | 3 小时(开心时更短) |
|
|
14
|
+
| `energy` 精力 | 跨天 +0.80;距上次干活超 30 分钟缓慢回升(深夜只回到 0.45) | 干活轮按 token 扣减,闲聊不扣 | 不走半衰期 |
|
|
15
15
|
| `dopamine` 爽度 | 任务顺利收尾 +0.35;被夸/被谢 +0.40 | 自然衰减 | 40 分钟 |
|
|
16
|
+
| `boredom` 无聊 | 25 分钟没人说话就开始涨,之后向 1 靠拢 | 真人说话 −0.30;干活收尾 −0.25 | 上涨半衰期 5 小时 |
|
|
16
17
|
|
|
17
18
|
更新公式:`新值 = clamp(旧值 × 0.5^(Δt / 半衰期) + Σ增量, 0, 1)`
|
|
18
19
|
|
|
19
20
|
衰减是必须的。没有衰减,任何状态都会积到 1,然后卡死在那儿。
|
|
20
21
|
|
|
22
|
+
## 它们会互相影响
|
|
23
|
+
|
|
24
|
+
四个数不是各算各的:
|
|
25
|
+
|
|
26
|
+
| 谁影响谁 | 效果 | 参数 |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| 爽度 → 精力扣减 | 越开心,同样一活儿越不累(最多省一半;按 √ 算,中等开心就省三成) | `COUPLING.joyCost = 0.5` |
|
|
29
|
+
| 压力 → 精力扣减 | 越烦,同样一活儿越累(最多 +40%) | `COUPLING.stressCost = 0.4` |
|
|
30
|
+
| 爽度 → 压力消散 | 开心时压力半衰期最短缩到 65% | `COUPLING.joyDecay = 0.35` |
|
|
31
|
+
| 压力 → 精力恢复 | 烦的时候回血最慢拖到 2.5 倍 | `COUPLING.stressRecover = 1.5` |
|
|
32
|
+
|
|
33
|
+
也就是说:一直不顺 → 压力高 → 干同样的活掉得更多 → 更难缓过来,是个正反馈;反过来被夸几句 → 掉得少、压力散得快,也是正反馈。两头都有上限,不会失控。
|
|
34
|
+
|
|
35
|
+
## 熬夜的账第二天还
|
|
36
|
+
|
|
37
|
+
00:00–07:00 还在干活,每扣掉 1 点精力就记 1.3 倍睡眠债(`DEBT.ratio`),债把精力上限压低:欠满时上限只有 0.6(`DEBT.max = 0.4`),白天怎么摸鱼也回不到满。
|
|
38
|
+
|
|
39
|
+
债从记债时刻起 24 小时线性衰减——睡一觉就还清,连续熬夜会累加但封顶。这是让「熬一次夜,第二天确实虚」真的成立,而不是只靠深夜基线那 0.45。
|
|
40
|
+
|
|
21
41
|
## 它怎么起作用
|
|
22
42
|
|
|
23
43
|
只有一层,第一版就一层:
|
|
24
44
|
|
|
25
45
|
**注入** —— 越界了才写,全部正常时一个字都不写:
|
|
26
46
|
|
|
27
|
-
- `cortisol > 0.
|
|
47
|
+
- `cortisol > 0.75` → 回答收短,不主动揽活
|
|
28
48
|
- `energy < 0.2` → 能一句说完就别铺垫
|
|
29
49
|
- `dopamine > 0.6` → 可以多说两句
|
|
50
|
+
- `boredom > 0.55` → 有点闷,可以主动扯两句
|
|
51
|
+
|
|
52
|
+
注入文本硬上限 110 字,带 `[hormone]` 前缀,每轮最多一条。
|
|
30
53
|
|
|
31
|
-
|
|
54
|
+
而且是**边沿触发**:越界的第一轮说一次,之后一直越界就闭嘴,直到恢复正常后再次越界;一直不恢复的话,静默 40 分钟才重提一次(`EMIT.rementionAfterSec`)。否则「精力见底」这种长期状态会每轮刷一遍,把这一句话变成噪音。
|
|
32
55
|
|
|
33
56
|
**没有做门控**(比如硬压输出长度)。门控是分水岭:注入只是「告诉它现在什么状态」,门控是「替它决定能说多少」。第一版先把状态跑准,跑两天曲线不极端了再谈门控。
|
|
34
57
|
|
|
@@ -36,6 +59,12 @@
|
|
|
36
59
|
|
|
37
60
|
## 装
|
|
38
61
|
|
|
62
|
+
```bash
|
|
63
|
+
dsh plugin --profile desktop add dsh-hormone
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
不想走 npm 的话,直接从 GitHub 装也行:
|
|
67
|
+
|
|
39
68
|
```bash
|
|
40
69
|
dsh plugin --profile desktop add github:y258dd/dsh-hormone
|
|
41
70
|
```
|
|
@@ -63,7 +92,7 @@ dsh plugin --profile desktop add link:$PWD # Windows: link:D:/path/to/dsh-horm
|
|
|
63
92
|
|
|
64
93
|
状态文件:`%DSH_HOME%\hormone\state.json`(默认 `~/.dsh/hormone/state.json`,Windows 上是 `C:\Users\<你>\.dsh\hormone\state.json`)
|
|
65
94
|
|
|
66
|
-
纯 JSON,打开就能看。`history` 里是最近 400 次变化,每条带 `why`(`tool-error` / `errors-3` / `turn-done` / `praised` / `blamed
|
|
95
|
+
纯 JSON,打开就能看。`history` 里是最近 400 次变化,每条带 `why`(`tool-error` / `errors-3` / `turn-done` / `praised` / `blamed` / `user-chat`),有睡眠债时多一个 `debt` 字段,想验收「跑两天曲线有没有起伏」直接看这个。
|
|
67
96
|
|
|
68
97
|
删掉这个文件 = 回到初始状态,不会有副作用。
|
|
69
98
|
|
|
@@ -78,11 +107,11 @@ dsh plugin --profile desktop add link:$PWD # Windows: link:D:/path/to/dsh-horm
|
|
|
78
107
|
|
|
79
108
|
三个文件顶部,改完重启:
|
|
80
109
|
|
|
81
|
-
- `lib/state.js` — `HALF_LIFE`(衰减快慢)、`ENERGY
|
|
110
|
+
- `lib/state.js` — `HALF_LIFE`(衰减快慢)、`ENERGY`(日预算、跨天恢复、深夜基线、恢复等待)、`BOREDOM`(多久没人说话开始闷、闷得多快)、`COUPLING`(四个情绪互相拉扯的强度)、`DEBT`(熬夜记多少债、上限、多久还清)
|
|
82
111
|
- `lib/events.js` — `DELTA`(各种事件的增量)、`BLAME_RE` / `PRAISE_RE`(关键词)
|
|
83
|
-
- `lib/render.js` — `THRESHOLD`(注入阈值)、`MAX_CHARS
|
|
112
|
+
- `lib/render.js` — `THRESHOLD`(注入阈值)、`MAX_CHARS`、`EMIT`(一直越界时隔多久重提)
|
|
84
113
|
|
|
85
|
-
`ENERGY.dailyBudget` 默认
|
|
114
|
+
`ENERGY.dailyBudget` 默认 1300 万,按 12 天真实用量定的:日均 299 万 token 掉约 0.23,中位日 226 万掉 0.17,峰值日 967 万掉 0.74——一天干活见底,但不会半路清零。觉得掉太快就调大。`COUPLING` 全部设 0 就等于退回「四个数各算各的」。
|
|
86
115
|
|
|
87
116
|
## 设计上的几个取舍
|
|
88
117
|
|
|
@@ -105,7 +134,7 @@ dsh plugin --profile desktop add link:$PWD # Windows: link:D:/path/to/dsh-horm
|
|
|
105
134
|
## 测试
|
|
106
135
|
|
|
107
136
|
```bash
|
|
108
|
-
npm test #
|
|
137
|
+
npm test # 52 个用例:衰减、跨天恢复、深夜基线、情绪耦合、睡眠债、无聊涨落、事件映射、轮次结算、阈值渲染、边沿触发、落盘往返
|
|
109
138
|
npm run test:smoke # 假 ctx 接线冒烟
|
|
110
139
|
```
|
|
111
140
|
|
package/lib/events.js
CHANGED
|
@@ -8,15 +8,21 @@
|
|
|
8
8
|
|
|
9
9
|
import { ENERGY, applyDeltas, nowSec } from './state.js'
|
|
10
10
|
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* 每种事件的增量(`applyDeltas` 的第二个参数)。
|
|
13
|
+
* 0.3.0 整体调低压力、调高缓解:0.2.0 的 +0.15/+0.3 让 cortisol 一两轮就顶到 0.7 以上,
|
|
14
|
+
* 注入里天天「有点烦」,那不叫敏感,叫吵。
|
|
15
|
+
*/
|
|
12
16
|
export const DELTA = {
|
|
13
|
-
toolError: { cortisol: 0.
|
|
17
|
+
toolError: { cortisol: 0.08 },
|
|
14
18
|
/** 连续失败时在 toolError 之上再叠一次。 */
|
|
15
|
-
repeatedErrors: { cortisol: 0.
|
|
16
|
-
/**
|
|
17
|
-
taskDone: { cortisol: -0.2, dopamine: 0.35 },
|
|
18
|
-
praised: { dopamine: 0.4 },
|
|
19
|
-
blamed: { cortisol: 0.
|
|
19
|
+
repeatedErrors: { cortisol: 0.15 },
|
|
20
|
+
/** 一轮收尾且没出过错:压力泄掉,爽度上来,也不闷了。 */
|
|
21
|
+
taskDone: { cortisol: -0.2, dopamine: 0.35, boredom: -0.25 },
|
|
22
|
+
praised: { dopamine: 0.4, boredom: -0.1 },
|
|
23
|
+
blamed: { cortisol: 0.12 },
|
|
24
|
+
/** 真人开口就算陪过了——无聊的主缓解手段。 */
|
|
25
|
+
userChat: { boredom: -0.3 },
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
/** 每连续失败几次额外叠一次 repeatedErrors。 */
|
|
@@ -88,6 +94,7 @@ export function settleTurn(state, session, now = nowSec()) {
|
|
|
88
94
|
if (errors === 0) {
|
|
89
95
|
deltas.cortisol = DELTA.taskDone.cortisol
|
|
90
96
|
deltas.dopamine = DELTA.taskDone.dopamine
|
|
97
|
+
deltas.boredom = DELTA.taskDone.boredom
|
|
91
98
|
}
|
|
92
99
|
const next = applyDeltas(state, deltas, now, errors === 0 ? 'turn-done' : 'turn-end')
|
|
93
100
|
// 先结算再记时间:这轮之前空闲该恢复的已经恢复,从干完这轮起重新计时。
|
|
@@ -107,10 +114,20 @@ export function applyEvent(state, event, now = nowSec()) {
|
|
|
107
114
|
const msg = event.data
|
|
108
115
|
// 只认真人。插件往对话里塞的消息 source.kind 不是 'user',不能算成用户夸我/骂我。
|
|
109
116
|
if (!msg || msg.source?.kind !== 'user') return null
|
|
117
|
+
// 真人开口就算有人陪:无聊计时归零,再往下降一截。
|
|
118
|
+
state.lastUserAt = now
|
|
119
|
+
const deltas = { boredom: DELTA.userChat.boredom }
|
|
120
|
+
let why = 'user-chat'
|
|
110
121
|
const tone = detectUserTone(textOfContent(msg.content))
|
|
111
|
-
if (tone === 'praise')
|
|
112
|
-
|
|
113
|
-
|
|
122
|
+
if (tone === 'praise') {
|
|
123
|
+
deltas.dopamine = DELTA.praised.dopamine
|
|
124
|
+
deltas.boredom += DELTA.praised.boredom
|
|
125
|
+
why = 'praised'
|
|
126
|
+
} else if (tone === 'blame') {
|
|
127
|
+
deltas.cortisol = DELTA.blamed.cortisol
|
|
128
|
+
why = 'blamed'
|
|
129
|
+
}
|
|
130
|
+
return applyDeltas(state, deltas, now, why)
|
|
114
131
|
}
|
|
115
132
|
|
|
116
133
|
case 'tool/result': {
|
package/lib/index.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
13
13
|
import { dirname, join } from 'node:path'
|
|
14
14
|
import { applyEvent, settleTurn } from './events.js'
|
|
15
|
-
import {
|
|
15
|
+
import { renderEdge } from './render.js'
|
|
16
16
|
import { loadState, nowSec, project, saveState, stateFile } from './state.js'
|
|
17
17
|
|
|
18
18
|
export const name = 'dsh-hormone'
|
|
@@ -87,7 +87,12 @@ export function apply(ctx) {
|
|
|
87
87
|
text: () => {
|
|
88
88
|
if (MODE === 'observe') return ''
|
|
89
89
|
try {
|
|
90
|
-
|
|
90
|
+
// 边沿触发:renderEdge 会写 state.emit 并告诉我们这轮要不要说话、说了什么。
|
|
91
|
+
// 它改了状态,所以要安排一次落盘,否则下一轮会以为自己没说过。
|
|
92
|
+
const now = nowSec()
|
|
93
|
+
const text = renderEdge(state, project(state, now), now)
|
|
94
|
+
if (text) schedule()
|
|
95
|
+
return text
|
|
91
96
|
} catch {
|
|
92
97
|
return ''
|
|
93
98
|
}
|
package/lib/render.js
CHANGED
|
@@ -1,41 +1,78 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-hormone
|
|
2
|
+
* dsh-hormone 的渲染层:把四个数翻译成一句人话,塞进系统提示。
|
|
3
3
|
*
|
|
4
4
|
* 规则只有一条:只有越界才写。全都正常就返回空字符串——
|
|
5
5
|
* 不让模型每轮都看见「你现在的激素」,否则它会开始表演情绪。
|
|
6
|
+
*
|
|
7
|
+
* 0.4.0 起加了边沿触发(renderEdge):越界的第一轮说一次,之后一直越界就闭嘴,
|
|
8
|
+
* 直到状态回到正常再越界、或者静默够久才重提。否则「精力见底」这种长期状态会每轮刷屏。
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
export const THRESHOLD = {
|
|
9
|
-
cortisolHigh: 0.
|
|
12
|
+
cortisolHigh: 0.75,
|
|
10
13
|
energyLow: 0.2,
|
|
11
14
|
dopamineHigh: 0.6,
|
|
15
|
+
boredomHigh: 0.55,
|
|
12
16
|
}
|
|
13
17
|
|
|
14
|
-
/** 注入文本硬上限(DESIGN 定的 ≤
|
|
15
|
-
export const MAX_CHARS =
|
|
18
|
+
/** 注入文本硬上限(DESIGN 定的 ≤110 字,四条全越界也放得下)。 */
|
|
19
|
+
export const MAX_CHARS = 110
|
|
20
|
+
|
|
21
|
+
/** 边沿触发:一直越界时,隔多久重新提一次(秒)。 */
|
|
22
|
+
export const EMIT = {
|
|
23
|
+
rementionAfterSec: 40 * 60,
|
|
24
|
+
}
|
|
16
25
|
|
|
17
26
|
const fmt = (v) => v.toFixed(2)
|
|
18
27
|
|
|
28
|
+
const LINES = {
|
|
29
|
+
cortisol: (v) => `皮质醇 ${fmt(v)},有点烦,回答收短,别主动揽活。`,
|
|
30
|
+
energy: (v) => `精力 ${fmt(v)},见底了,能一句说完就别铺垫。`,
|
|
31
|
+
dopamine: (v) => `爽度 ${fmt(v)},心情不错,可以多说两句。`,
|
|
32
|
+
boredom: (v) => `无聊 ${fmt(v)},有点闷,可以主动扯两句新鲜事,别干等。`,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 现在越界的有哪几项,返回 key 数组(顺序固定,便于比较)。 */
|
|
36
|
+
export function overages(values) {
|
|
37
|
+
if (!values) return []
|
|
38
|
+
const keys = []
|
|
39
|
+
if (values.cortisol > THRESHOLD.cortisolHigh) keys.push('cortisol')
|
|
40
|
+
if (values.energy < THRESHOLD.energyLow) keys.push('energy')
|
|
41
|
+
if (values.dopamine > THRESHOLD.dopamineHigh) keys.push('dopamine')
|
|
42
|
+
if ((values.boredom ?? 0) > THRESHOLD.boredomHigh) keys.push('boredom')
|
|
43
|
+
return keys
|
|
44
|
+
}
|
|
45
|
+
|
|
19
46
|
/**
|
|
20
|
-
* @param {{cortisol:number, energy:number, dopamine:number}} values 已投影到当前时刻
|
|
47
|
+
* @param {{cortisol:number, energy:number, dopamine:number, boredom:number}} values 已投影到当前时刻
|
|
48
|
+
* @param {string[]} [keys] 只渲染这几项;默认渲染全部越界的
|
|
21
49
|
* @returns {string} 空串表示这轮不注入
|
|
22
50
|
*/
|
|
23
|
-
export function render(values) {
|
|
51
|
+
export function render(values, keys = overages(values)) {
|
|
24
52
|
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
|
-
|
|
53
|
+
const lines = keys.map((k) => LINES[k]?.(values[k])).filter(Boolean)
|
|
37
54
|
if (lines.length === 0) return ''
|
|
38
55
|
const text = `[hormone] ${lines.join(' ')}`
|
|
39
56
|
if (text.length <= MAX_CHARS) return text
|
|
40
57
|
return text.slice(0, MAX_CHARS - 1) + '…'
|
|
41
58
|
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 边沿触发版:只在「新越界」或「静默够久」时才说话。
|
|
62
|
+
*
|
|
63
|
+
* 会写 state.emit({ active, at })——这是渲染层唯一的副作用,
|
|
64
|
+
* 目的是让下一轮知道自己上一轮说过什么。调用方负责落盘。
|
|
65
|
+
*
|
|
66
|
+
* @returns {string} 空串表示这轮闭嘴
|
|
67
|
+
*/
|
|
68
|
+
export function renderEdge(state, values, now) {
|
|
69
|
+
const keys = overages(values)
|
|
70
|
+
const prev = Array.isArray(state?.emit?.active) ? state.emit.active : []
|
|
71
|
+
const lastAt = Number.isFinite(state?.emit?.at) ? state.emit.at : 0
|
|
72
|
+
const fresh = keys.filter((k) => !prev.includes(k))
|
|
73
|
+
const remention = keys.length > 0 && now - lastAt >= EMIT.rementionAfterSec
|
|
74
|
+
const speak = fresh.length > 0 || remention
|
|
75
|
+
const text = speak ? render(values, fresh.length > 0 ? fresh : keys) : ''
|
|
76
|
+
if (state) state.emit = { active: keys, at: speak ? now : lastAt }
|
|
77
|
+
return text
|
|
78
|
+
}
|
package/lib/state.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-hormone 的状态层:读/写 state.json、衰减、跨天恢复、原子写。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 四个数只是 0–1 的连续变量,没有「情绪标签」。变化只有两个来源:
|
|
5
5
|
* 1. 事件增量(events.js);
|
|
6
|
-
* 2. 时间衰减 /
|
|
6
|
+
* 2. 时间衰减 / 跨天恢复 / 情绪之间互相拉扯(本文件)。
|
|
7
7
|
* 模型不能改它们(DESIGN 护栏 1)——插件不提供任何写入工具。
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -11,35 +11,91 @@ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'n
|
|
|
11
11
|
import { homedir } from 'node:os'
|
|
12
12
|
import { dirname, join } from 'node:path'
|
|
13
13
|
|
|
14
|
-
/** 半衰期(秒)。cortisol
|
|
14
|
+
/** 半衰期(秒)。cortisol 3 小时,dopamine 40 分钟。energy 不走半衰期,见 ENERGY。 */
|
|
15
15
|
export const HALF_LIFE = {
|
|
16
|
-
cortisol:
|
|
16
|
+
cortisol: 3 * 60 * 60,
|
|
17
17
|
dopamine: 40 * 60,
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export const ENERGY = {
|
|
21
21
|
/**
|
|
22
22
|
* 日预算:一轮的 token 消耗 ÷ 这个数 = energy 扣减量。
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* 0.2.0 取 800 万,实测一天就见底;0.3.0 先放到 2500 万,太松(要连干 8 天才见底);
|
|
24
|
+
* 0.4.0 按 ~/.dsh/.dshw-usage.json 12 天实测定为 1300 万:
|
|
25
|
+
* 日均 299 万掉 0.23、中位日 226 万掉 0.17、峰值日 967 万掉 0.74。
|
|
25
26
|
* 觉得掉太快/太慢就改这一个数。
|
|
26
27
|
*/
|
|
27
|
-
dailyBudget:
|
|
28
|
+
dailyBudget: 13_000_000,
|
|
28
29
|
/** 跨天恢复:日期变了,energy 至少回升这么多。 */
|
|
29
30
|
dayRecover: 0.8,
|
|
30
31
|
/**
|
|
31
32
|
* 距上次「干活」超过这个时长才缓慢回升(秒)。
|
|
32
33
|
* 只认调过工具的真活:闲聊不刷新这个计时,否则一整天扯淡精力永远涨不回来。
|
|
33
34
|
*/
|
|
34
|
-
idleRecoverAfter:
|
|
35
|
-
/**
|
|
36
|
-
recoverHalfLife:
|
|
35
|
+
idleRecoverAfter: 30 * 60,
|
|
36
|
+
/** 回升半衰期:向基线靠拢一半所需时间(压力高时会被拉长,见 COUPLING)。 */
|
|
37
|
+
recoverHalfLife: 4 * 60 * 60,
|
|
37
38
|
/** 白天基线。 */
|
|
38
39
|
dayBaseline: 0.9,
|
|
39
|
-
/** 深夜基线:
|
|
40
|
+
/** 深夜基线:00:00–07:00 压低,熬夜不会回满。 */
|
|
40
41
|
nightBaseline: 0.45,
|
|
41
|
-
nightFrom:
|
|
42
|
-
nightTo:
|
|
42
|
+
nightFrom: 0,
|
|
43
|
+
nightTo: 7,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 睡眠债:深夜还干活,第二天精力上限打折。
|
|
48
|
+
* 记债时刻起 24 小时内线性衰减——睡一觉就还清,连续熬夜会累加(有上限)。
|
|
49
|
+
*/
|
|
50
|
+
export const DEBT = {
|
|
51
|
+
/** 深夜窗口内每扣掉 1 点精力,记多少债。 */
|
|
52
|
+
ratio: 1.3,
|
|
53
|
+
/** 债的上限:第二天精力最多打这个折。 */
|
|
54
|
+
max: 0.4,
|
|
55
|
+
/** 从记债时刻起,多少小时内衰减到 0。 */
|
|
56
|
+
decayHours: 24,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 无聊:没人跟它说话就慢慢闷起来,真人一开口就缓解(DELTA.userChat)。
|
|
61
|
+
* 计时源是 lastUserAt,不是 updatedAt——我自己干活刷新的时间不算「有人陪」。
|
|
62
|
+
*/
|
|
63
|
+
export const BOREDOM = {
|
|
64
|
+
/** 多久没人说话才开始闷(秒)。 */
|
|
65
|
+
grace: 25 * 60,
|
|
66
|
+
/** 之后向 1 靠拢的半衰期。 */
|
|
67
|
+
riseHalfLife: 5 * 60 * 60,
|
|
68
|
+
/** 初始值。 */
|
|
69
|
+
start: 0.05,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 情绪耦合:四个变量不是各算各的。
|
|
74
|
+
* 越开心越不累、越开心压力散得越快、压力越大恢复越慢。
|
|
75
|
+
*/
|
|
76
|
+
export const COUPLING = {
|
|
77
|
+
/** 精力扣减放大:cortisol=1 时消耗 ×(1+0.4)。 */
|
|
78
|
+
stressCost: 0.4,
|
|
79
|
+
/** 精力扣减缩小:按 √dopamine 算,dopamine=1 时消耗 ×(1-0.5),d=0.3 时已经省 27%。 */
|
|
80
|
+
joyCost: 0.5,
|
|
81
|
+
/** 开心让压力散得快:有效半衰期 ×(1-0.35×dopamine),最快缩短 35%。 */
|
|
82
|
+
joyDecay: 0.35,
|
|
83
|
+
/** 压力让精力回得慢:有效恢复半衰期 ×(1+1.5×cortisol),最慢拉到 2.5 倍。 */
|
|
84
|
+
stressRecover: 1.5,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 精力扣减系数:压力往上推,爽度往下压,夹在 0.4–2.5 之间。
|
|
89
|
+
*
|
|
90
|
+
* 爽度那一条用 √ 而不是线性:为了「中等开心就已经省得明显」——
|
|
91
|
+
* 线性省的话 dopamine=0.3 才省 15%,几乎感觉不到;开方后是 27%。
|
|
92
|
+
* 极端值仍然封在 −50% / +40%,不会因为一时兴奋或烦躁把一项工作算成两个。
|
|
93
|
+
*/
|
|
94
|
+
export function energyCostScale(values) {
|
|
95
|
+
const cortisol = clamp01(values?.cortisol ?? 0)
|
|
96
|
+
const dopamine = clamp01(values?.dopamine ?? 0)
|
|
97
|
+
const scale = 1 + COUPLING.stressCost * cortisol - COUPLING.joyCost * Math.sqrt(dopamine)
|
|
98
|
+
return Math.min(2.5, Math.max(0.4, scale))
|
|
43
99
|
}
|
|
44
100
|
|
|
45
101
|
/** history 上限。只用于验收时看曲线,不参与任何计算。 */
|
|
@@ -61,9 +117,9 @@ export function decayValue(v, halfLifeSec, dtSec) {
|
|
|
61
117
|
return clamp01(v * Math.pow(0.5, dtSec / halfLifeSec))
|
|
62
118
|
}
|
|
63
119
|
|
|
64
|
-
/** 本地小时对应的 energy
|
|
120
|
+
/** 本地小时对应的 energy 基线。注意 0–7 这个窗口不跨午夜,用 && 判。 */
|
|
65
121
|
export function baselineEnergy(hour) {
|
|
66
|
-
const night = hour >= ENERGY.nightFrom
|
|
122
|
+
const night = hour >= ENERGY.nightFrom && hour < ENERGY.nightTo
|
|
67
123
|
return night ? ENERGY.nightBaseline : ENERGY.dayBaseline
|
|
68
124
|
}
|
|
69
125
|
|
|
@@ -79,7 +135,7 @@ export function nowSec() {
|
|
|
79
135
|
}
|
|
80
136
|
|
|
81
137
|
export function freshValues() {
|
|
82
|
-
return { cortisol: 0.2, energy: ENERGY.dayBaseline, dopamine: 0.1 }
|
|
138
|
+
return { cortisol: 0.2, energy: ENERGY.dayBaseline, dopamine: 0.1, boredom: BOREDOM.start }
|
|
83
139
|
}
|
|
84
140
|
|
|
85
141
|
export function freshState(now = nowSec()) {
|
|
@@ -89,20 +145,46 @@ export function freshState(now = nowSec()) {
|
|
|
89
145
|
day: localDay(now),
|
|
90
146
|
/** 上次真正干活的时刻。精力恢复从这时计时,不随闲聊刷新。 */
|
|
91
147
|
lastWorkAt: now,
|
|
148
|
+
/** 上次真人说话的时刻。无聊从这时计时。 */
|
|
149
|
+
lastUserAt: now,
|
|
150
|
+
/** 睡眠债:{ amount, at }。at 是记债时刻,从那时起 24 小时线性衰减到 0。 */
|
|
151
|
+
debt: { amount: 0, at: now },
|
|
152
|
+
/** 注入边沿触发用:上一轮越界的是哪几项、上次说话的时刻。 */
|
|
153
|
+
emit: { active: [], at: 0 },
|
|
92
154
|
values: freshValues(),
|
|
93
155
|
session: { turn: null, tokensInTurn: 0, toolsInTurn: 0, errorsInTurn: 0, consecutiveErrors: 0 },
|
|
94
156
|
history: [],
|
|
95
157
|
}
|
|
96
158
|
}
|
|
97
159
|
|
|
160
|
+
/** 现在还剩多少睡眠债(0–DEBT.max)。纯函数,靠绝对时间算,反复调用结果一致。 */
|
|
161
|
+
export function effectiveDebt(state, now = nowSec()) {
|
|
162
|
+
const amount = Number(state?.debt?.amount)
|
|
163
|
+
const at = state?.debt?.at
|
|
164
|
+
if (!(amount > 0) || !Number.isFinite(at)) return 0
|
|
165
|
+
const passed = Math.max(0, now - at)
|
|
166
|
+
const left = 1 - passed / (DEBT.decayHours * 60 * 60)
|
|
167
|
+
if (left <= 0) return 0
|
|
168
|
+
return Math.min(DEBT.max, amount * left)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 一次「向目标靠拢」:从 base 出发,过了 sec 秒,靠拢了多少。 */
|
|
172
|
+
function approach(base, target, sec, halfLife) {
|
|
173
|
+
if (!(sec > 0) || !(halfLife > 0)) return clamp01(base)
|
|
174
|
+
return clamp01(base + (target - base) * (1 - Math.pow(0.5, sec / halfLife)))
|
|
175
|
+
}
|
|
176
|
+
|
|
98
177
|
/**
|
|
99
178
|
* 从 updatedAt 投影到现在。纯函数:不改 state、不落盘。
|
|
100
179
|
* 渲染和「加增量」都用它打底,所以衰减不会被重复叠加。
|
|
180
|
+
* 依赖时长的几项只算「上次写盘之后新增的那一段」,反复调用结果一致。
|
|
101
181
|
*/
|
|
102
182
|
export function project(state, now = nowSec()) {
|
|
103
183
|
const dt = Math.max(0, now - state.updatedAt)
|
|
104
184
|
const hour = new Date(now * 1000).getHours()
|
|
105
185
|
const values = state.values ?? freshValues()
|
|
186
|
+
const cortisol0 = clamp01(values.cortisol)
|
|
187
|
+
const dopamine0 = clamp01(values.dopamine)
|
|
106
188
|
let energy = clamp01(values.energy)
|
|
107
189
|
|
|
108
190
|
// 1. 跨天:日期变了就回升一大截(模拟睡了一觉)。
|
|
@@ -112,20 +194,42 @@ export function project(state, now = nowSec()) {
|
|
|
112
194
|
|
|
113
195
|
// 2. 长时间没干活:向「当前时段」的基线靠拢。
|
|
114
196
|
// 不是加满——深夜的基线本来就低,熬夜补不回来。
|
|
115
|
-
//
|
|
116
|
-
//
|
|
197
|
+
// 压力大时恢复变慢(COUPLING.stressRecover)。
|
|
198
|
+
// 计时只认 lastWorkAt(上一轮真正干活的时刻),闲聊不推迟恢复。
|
|
199
|
+
// 睡眠债会压低上限:熬夜后的第二天,回也回不到满。
|
|
200
|
+
const debt = effectiveDebt(state, now)
|
|
201
|
+
const ceiling = 1 - debt
|
|
117
202
|
const lastWork = Number.isFinite(state.lastWorkAt) ? state.lastWorkAt : state.updatedAt
|
|
118
203
|
const idle = Math.max(0, now - lastWork)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
204
|
+
const idleBefore = Math.max(0, state.updatedAt - lastWork)
|
|
205
|
+
const recoverSec = Math.max(0, idle - Math.max(idleBefore, ENERGY.idleRecoverAfter))
|
|
206
|
+
if (recoverSec > 0) {
|
|
207
|
+
const halfLife = ENERGY.recoverHalfLife * (1 + COUPLING.stressRecover * cortisol0)
|
|
208
|
+
energy = approach(energy, Math.min(baselineEnergy(hour), ceiling), recoverSec, halfLife)
|
|
123
209
|
}
|
|
210
|
+
energy = Math.min(energy, ceiling)
|
|
211
|
+
|
|
212
|
+
// 3. 无聊:从「上次真人说话」起算,过了宽限期慢慢向 1 靠拢。
|
|
213
|
+
// 同样只算上次写盘之后新增的那一段,避免每次结算重复叠加。
|
|
214
|
+
const lastUser = Number.isFinite(state.lastUserAt) ? state.lastUserAt : state.updatedAt
|
|
215
|
+
const quiet = Math.max(0, now - lastUser)
|
|
216
|
+
const quietBefore = Math.max(0, state.updatedAt - lastUser)
|
|
217
|
+
const boredSec = Math.max(0, quiet - Math.max(quietBefore, BOREDOM.grace))
|
|
218
|
+
const boredom = approach(
|
|
219
|
+
clamp01(values.boredom ?? BOREDOM.start),
|
|
220
|
+
1,
|
|
221
|
+
boredSec,
|
|
222
|
+
BOREDOM.riseHalfLife,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
// 4. 开心的时候压力散得更快:有效半衰期最短缩到 65%。
|
|
226
|
+
const cortisolHalfLife = HALF_LIFE.cortisol * (1 - COUPLING.joyDecay * dopamine0)
|
|
124
227
|
|
|
125
228
|
return {
|
|
126
|
-
cortisol: decayValue(
|
|
229
|
+
cortisol: decayValue(cortisol0, cortisolHalfLife, dt),
|
|
127
230
|
energy,
|
|
128
|
-
dopamine: decayValue(
|
|
231
|
+
dopamine: decayValue(dopamine0, HALF_LIFE.dopamine, dt),
|
|
232
|
+
boredom,
|
|
129
233
|
}
|
|
130
234
|
}
|
|
131
235
|
|
|
@@ -139,7 +243,10 @@ function pushHistory(state, now, why) {
|
|
|
139
243
|
cortisol: round3(state.values.cortisol),
|
|
140
244
|
energy: round3(state.values.energy),
|
|
141
245
|
dopamine: round3(state.values.dopamine),
|
|
246
|
+
boredom: round3(state.values.boredom),
|
|
142
247
|
}
|
|
248
|
+
const debt = effectiveDebt(state, now)
|
|
249
|
+
if (debt > 0) row.debt = round3(debt)
|
|
143
250
|
if (why) row.why = why
|
|
144
251
|
state.history.push(row)
|
|
145
252
|
if (state.history.length > MAX_HISTORY) {
|
|
@@ -150,19 +257,36 @@ function pushHistory(state, now, why) {
|
|
|
150
257
|
/**
|
|
151
258
|
* 先投影到现在,再加增量,写回 state(含 updatedAt / day / history)。
|
|
152
259
|
* 返回 state 本身,方便链式调用。
|
|
260
|
+
*
|
|
261
|
+
* 扣精力的增量要乘 energyCostScale:同样一活儿,烦的时候更累,心情好的时候没那么累
|
|
262
|
+
* ——这就是「情绪互相影响」落到行为上的那一步。
|
|
153
263
|
*/
|
|
154
264
|
export function applyDeltas(state, deltas, now = nowSec(), why = '') {
|
|
155
265
|
const projected = project(state, now)
|
|
156
266
|
const before = state.values ?? freshValues()
|
|
267
|
+
let energyDelta = deltas?.energy ?? 0
|
|
268
|
+
if (energyDelta < 0) {
|
|
269
|
+
energyDelta *= energyCostScale(projected)
|
|
270
|
+
// 深夜(00:00–07:00)还在干活:记一笔睡眠债,第二天精力上限打折。
|
|
271
|
+
// 注意是 &&:0–7 这个窗口不跨午夜,写成 || 会让全天都算深夜。
|
|
272
|
+
const hour = new Date(now * 1000).getHours()
|
|
273
|
+
if (hour >= ENERGY.nightFrom && hour < ENERGY.nightTo) {
|
|
274
|
+
const owed = effectiveDebt(state, now)
|
|
275
|
+
state.debt = { amount: Math.min(DEBT.max, owed - energyDelta * DEBT.ratio), at: now }
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const ceiling = 1 - effectiveDebt(state, now)
|
|
157
279
|
const next = {
|
|
158
280
|
cortisol: clamp01(projected.cortisol + (deltas?.cortisol ?? 0)),
|
|
159
|
-
energy: clamp01(projected.energy +
|
|
281
|
+
energy: Math.min(clamp01(projected.energy + energyDelta), ceiling),
|
|
160
282
|
dopamine: clamp01(projected.dopamine + (deltas?.dopamine ?? 0)),
|
|
283
|
+
boredom: clamp01(projected.boredom + (deltas?.boredom ?? 0)),
|
|
161
284
|
}
|
|
162
285
|
const moved =
|
|
163
286
|
Math.abs(next.cortisol - before.cortisol) +
|
|
164
287
|
Math.abs(next.energy - before.energy) +
|
|
165
|
-
Math.abs(next.dopamine - before.dopamine)
|
|
288
|
+
Math.abs(next.dopamine - before.dopamine) +
|
|
289
|
+
Math.abs(next.boredom - (before.boredom ?? 0))
|
|
166
290
|
|
|
167
291
|
state.values = next
|
|
168
292
|
state.updatedAt = now
|
|
@@ -200,10 +324,25 @@ export function loadState() {
|
|
|
200
324
|
day: typeof raw.day === 'string' && raw.day !== '' ? raw.day : base.day,
|
|
201
325
|
// 老状态没有 lastWorkAt:用最后更新时间兜底,别让恢复计时从 0 开始。
|
|
202
326
|
lastWorkAt: asFiniteOr(raw.lastWorkAt, base.updatedAt),
|
|
327
|
+
// 老状态没有 lastUserAt:同上,别让无聊从 1970 年算起、一加载就顶满。
|
|
328
|
+
lastUserAt: asFiniteOr(raw.lastUserAt, base.updatedAt),
|
|
329
|
+
// 老状态没有睡眠债:从 0 开始,别让一次升级就顶着打折的上限。
|
|
330
|
+
debt: {
|
|
331
|
+
amount: clamp01(asFiniteOr(raw.debt?.amount, 0)),
|
|
332
|
+
at: asFiniteOr(raw.debt?.at, base.updatedAt),
|
|
333
|
+
},
|
|
334
|
+
// 老状态没有 emit:当作「还没说过」,下一次越界会正常说一句。
|
|
335
|
+
emit: {
|
|
336
|
+
active: Array.isArray(raw.emit?.active)
|
|
337
|
+
? raw.emit.active.filter((k) => typeof k === 'string')
|
|
338
|
+
: [],
|
|
339
|
+
at: asFiniteOr(raw.emit?.at, 0),
|
|
340
|
+
},
|
|
203
341
|
values: {
|
|
204
342
|
cortisol: clamp01(asFiniteOr(v.cortisol, base.values.cortisol)),
|
|
205
343
|
energy: clamp01(asFiniteOr(v.energy, base.values.energy)),
|
|
206
344
|
dopamine: clamp01(asFiniteOr(v.dopamine, base.values.dopamine)),
|
|
345
|
+
boredom: clamp01(asFiniteOr(v.boredom, base.values.boredom)),
|
|
207
346
|
},
|
|
208
347
|
session: {
|
|
209
348
|
turn: Number.isFinite(s.turn) ? s.turn : null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hormone",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"dsh",
|