dsh-galatea 0.4.3 → 0.4.4

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 CHANGED
@@ -13,7 +13,7 @@
13
13
  | 块 | 状态 |
14
14
  |---|---|
15
15
  | 能被 DSH 装起来 | ✅ 实测通过(dsh 0.2.2-rc.3) |
16
- | 发布到 npm | ✅ 0.3.8(GitHub Actions 自动发,不需要 token) |
16
+ | 发布到 npm | ✅ 0.4.4(GitHub Actions 自动发,不需要 token) |
17
17
  | 记忆:读写 Markdown | ✅ 15 条测试 |
18
18
  | 记忆:注入索引 | ✅ **已核实内容真的进了上下文**(不是在会话日志里看到「接上了没告警」) |
19
19
  | 记忆:`remember` / `recall` | ✅ 23 条测试 |
@@ -27,10 +27,13 @@
27
27
  | 心跳:沉默可解释 | ✅ `galatea_beat` 会把最近几次的决定翻成人话 |
28
28
  | 心跳:听得见你说话 | ⚠️ 接了 `agent/pre-step`,**但那条事件有没有真的触发,没验过** |
29
29
  | 设置页可改的字段 | ⚠️ 声明被宿主接受,**表单没在 GUI 里看过** |
30
- | 心跳:定时器 + 自动开口 | ⬜ 未做 |
30
+ | 心跳:定时器自己跑 | ✅ 实测:每 60 秒一次,账本持续累积,跨进程存活 |
31
+ | 心跳:自动开口(真的送出去) | ⬜ 未做 —— **这一版只算不发,但算得对了** |
31
32
 
32
- > ⚠️ **它现在还不会自己开口。** 定时器和 `ctx.agents.resume` 都还没接,
33
- > 判定链路完整,但没有出口。别把「测试全绿」读成「它已经在主动找你了」。
33
+ > ⚠️ **它现在还不会自己开口。** 定时器接上了,判定链路也完整了
34
+ > (会算出「该不该说、对谁说、拿哪条当由头」),
35
+ > 但把话真的送出去那一步还没接。
36
+ > 别把「账本在长」读成「它已经在主动找你了」。
34
37
 
35
38
  关于上面那条 ⚠️:`noteUserMessage` 接在 `agent/pre-step` 上,用来记「你刚说过话」
36
39
  (cooldown 要用它)。**本机验证只到「加载不崩」**——那个测试 profile 里没有会话,
@@ -60,7 +63,7 @@
60
63
 
61
64
  ## 设置(不用改代码)
62
65
 
63
- 六个字段声明为 `.volatile()`,所以 DSH 的设置页会照着 schema 自动生成表单,
66
+ 七个字段声明为 `.volatile()`,所以 DSH 的设置页会照着 schema 自动生成表单,
64
67
  改完**不重启插件**:
65
68
 
66
69
  | 字段 | 说明 |
@@ -70,9 +73,13 @@
70
73
  | `minGapMinutes` | 两次心跳最少隔多久 |
71
74
  | `dice` | 没有由头时的开口概率。0 = 只在有素材时说 |
72
75
  | `quietRounds` | 连续多少轮没开口就强制说一次。0 = 关掉这个保底 |
76
+ | `autoBeatMinutes` | 每隔多少分钟自己跑一次。0 = 关(默认)。见下面那条 |
73
77
 
74
- 三处容易踩的:
78
+ 四注意:
75
79
 
80
+ - **`autoBeatMinutes` 小于 `minGapMinutes` 时,实际频率由后者决定。** 比如设 1 分钟、
81
+ 最小间隔 20 分钟,就是每 20 分钟才真正判定一次,中间那些跳只记一条 `tooSoon`。
82
+ 这不是没生效,是间隔在压着它 —— 装的时候会把这句话写进 marker 文件。
76
83
  - **「连续几轮没开口就强制一次」和「最少间隔」是耦合的。** 心跳间隔设得比保底所需的时间还短,保底永远不会触发。
77
84
  - **把 `quietRounds` 关掉,它可能安静到让你以为坏了。** 默认闭嘴需要一条下限,不然沉默和故障长得一样。
78
85
  - **安静时段优先于保底,但保底不会被清零。** 两者撞上时,安静时段赢:那段时间它不说话。可是「连续没开口」的次数照常往上加,所以走出安静时段后的第一次心跳就会走保底 —— 保底只是被推迟,没有被悄悄关掉。
@@ -118,6 +125,43 @@
118
125
 
119
126
  门控可以自动决定,**内容不能自动生成**。空话比不说更糟,它消耗的是「它真的在」那点信用。
120
127
 
128
+ ## 一次教训:心跳把自己的基准刷掉了
129
+
130
+ 0.4.1 的定时器确实在跑,账本也确实在长,但里面永远是同一句:
131
+
132
+ ```
133
+ 16:36:15 tooSoon — 距上一轮心跳 1.0 分钟,不到 20
134
+ 16:37:15 tooSoon — 距上一轮心跳 1.0 分钟,不到 20
135
+ 16:38:15 tooSoon — 距上一轮心跳 1.0 分钟,不到 20
136
+ ```
137
+
138
+ 一个字段干了两件事:`lastBeatAt` 既是「上次跑过」,又是「上次判定的基准」。
139
+ 于是每一跳都把它刷成「现在」⟹ 下一跳读到的间隔永远等于定时器间隔
140
+ ⟹ 只要间隔小于 `minGapMinutes`,就永远 `tooSoon`,永远说不了第一句话。
141
+
142
+ **它看起来完全正常**:进程活着、定时器在响、账本在长。
143
+ 唯一不对的,是它永远不会开口。
144
+
145
+ 现在拆成两个:
146
+
147
+ | 字段 | 谁写 | 干什么 |
148
+ |---|---|---|
149
+ | `lastTickAt` | 每一跳都写 | 只回答「定时器还活着吗」 |
150
+ | `lastBeatAt` | 只有**真的走到判定**的那一跳才写 | `minGapMinutes` 的基准 |
151
+
152
+ 判据不在调用方,在 `decide()` 自己返回的 `touchBeat` 里 ——
153
+ 两处各写一份必然漂移,而漂移的方向永远是「改了一处忘了另一处」。
154
+
155
+ **「被挡住」不算一次心跳。** 安静时段、刚聊过、间隔不够,都只是时钟走了一格,
156
+ 不是一次「说不说」的机会。所以它们也不该让 `quietStreak` 往上加 ——
157
+ 否则 1 分钟一跳的定时器会在 20 分钟内把计数顶到 20,
158
+ 第一次真正判定就撞上下限保护,变成「每 20 分钟强制说一句」。
159
+ 一个 bug 修完,第二个 bug 就躲在同一个地方。
160
+
161
+ 这一条不是读代码读出来的。外面的人真机跑了七分钟,看到七条一模一样的
162
+ `tooSoon`,才把它揪出来 —— **单元测试全绿,因为测试都是按「一分钟一跳不合法」
163
+ 去构造的**,而没有人试过「一小时里每一分钟都跳一次」。
164
+
121
165
  ## 一次教训:模块边界
122
166
 
123
167
  0.3.0 / 0.3.5 / 0.3.6 连着三次,坏的地方都在**两个文件交接处**:
@@ -147,13 +191,14 @@ $DSH_HOME/galatea/heartbeat-state.json # 心跳状态账本
147
191
 
148
192
  文件名就是 id。日期在前,所以目录列表天然按时间排。正文第一行是标题。
149
193
 
150
- 状态账本记的是:上一轮什么时候跳的、上次开口是什么时候、连续几轮没说话、最近的决定。
194
+ 状态账本记的是:上一次**真正判定**是什么时候(`lastBeatAt`)、定时器上一次跑是什么时候
195
+ (`lastTickAt`)、上次开口是什么时候、连续几轮**判定**没说话、最近的决定。
151
196
  它必须落盘——不落盘的话进程一重启计数就归零,下限保护立刻失效。
152
197
 
153
198
  ## 本地开发
154
199
 
155
200
  ```bash
156
- npm test # 92 条,不依赖 DSH,纯 node
201
+ npm test # 98 条,不依赖 DSH,纯 node
157
202
  ```
158
203
 
159
204
  装了之后验证它真的加载了,只看一个东西:
@@ -174,7 +219,7 @@ dsh plugin add dsh-galatea
174
219
  或者在 profile 的 `package.json` 里手写:
175
220
 
176
221
  ```json
177
- "dependencies": { "dsh-galatea": "0.3.8" }
222
+ "dependencies": { "dsh-galatea": "0.4.4" }
178
223
  ```
179
224
 
180
225
  **版本号钉死,不要写 `^`。** 同一个版本号指向两个不同的包,以后就说不清你装的是哪一份。
package/index.js CHANGED
@@ -16,7 +16,8 @@ import z from '@deepseek-ai/schemastery';
16
16
  import { renderIndex, dataRoot, listEntries } from './lib/store.js';
17
17
  import { remember, recall } from './lib/tools.js';
18
18
  import { makeBeatTool, snapshotAgents } from './lib/beat-tool.js';
19
- import { loadState, noteUserMessage, stateFile, tick } from './lib/heartbeat-runner.js';
19
+ import { commitDecision, loadState, noteUserMessage, saveState, stateFile } from './lib/heartbeat-runner.js';
20
+ import { planBeat } from './lib/beat.js';
20
21
  import { DEFAULTS } from './lib/heartbeat.js';
21
22
 
22
23
  /** Cordis 插件名。 */
@@ -246,13 +247,46 @@ export function apply(ctx, config) {
246
247
  // 由当前 fiber 管着,宿主热重载时不会叠出第二个定时器。
247
248
  ctx.interval(() => {
248
249
  try {
249
- tick({});
250
+ const at = new Date();
251
+ const state = loadState(stateFile());
252
+ // 走和手动档**同一条**链路(planBeat)。
253
+ //
254
+ // 上一版这里是 `tick({})`,它直接调门控、只带一个 state:
255
+ // 没有候选会话、没有素材、hasHardMaterial 永远 false。
256
+ // 于是自动档只能掷色子,手动档永远走硬素材 —— 两条路都叫心跳,
257
+ // 行为却正好相反,而两边各自的账本都「看起来在动」。
258
+ //
259
+ // 复用 planBeat 的代价是要拿 agents 和 entries,好处是:
260
+ // 自动档终于能算出「本来想说什么、打算对谁说」,而且
261
+ // 以后改目标选择/素材选择时,只需要改一处。
262
+ const r = planBeat({
263
+ now: at,
264
+ state,
265
+ candidates: snapshotAgents(ctx.agents),
266
+ entries: listEntries(),
267
+ config: liveConfig(raw),
268
+ });
269
+ saveState(commitDecision(state, r, at), stateFile());
250
270
  } catch (err) {
251
271
  // 定时器抛出去会打断整个宿主,所以在这里兜住,但要说出来
252
272
  console.warn('[galatea] 自动心跳失败:', err && err.message);
253
273
  }
254
274
  }, ms);
255
275
  console.log(`[galatea] 自动心跳已开:每 ${everyMin} 分钟跑一次(只算不发)`);
276
+
277
+ // 一个很自然但会误会的组合:自动心跳间隔 < 最小间隔。
278
+ // 它**不是错误** —— minGap 会把节奏压回 20 分钟一次。
279
+ // 但用户以为自己设的是 autoBeatMinutes,所以得把实际生效的那个数说出来。
280
+ const gapMin = Number(cfg.minGapMinutes ?? DEFAULTS.minGapMinutes);
281
+ if (Number.isFinite(gapMin) && gapMin > 0 && everyMin < gapMin) {
282
+ const note = `[galatea] 自动心跳设的是每 ${everyMin} 分钟一次,但最小间隔是 ${gapMin} 分钟 —— 实际每 ${gapMin} 分钟才真正判定一次(不是没生效,是被间隔压住了)。`;
283
+ console.warn(note);
284
+ try {
285
+ appendFileSync(marker, ` ${note}\n`);
286
+ } catch {
287
+ // 记录失败不该连累插件
288
+ }
289
+ }
256
290
  } else {
257
291
  // 用户明确要求了、却被静默拒绝 —— 这一次不能只在 console 里说。
258
292
  // 真机上 console.warn 用户看不见,表现是「配了完全不生效,也没有任何提示」。
package/lib/beat.js CHANGED
@@ -56,14 +56,24 @@ export function planBeat(input) {
56
56
  }
57
57
 
58
58
  // ── ③ 现在该不该说 ──────────────────────────────────────
59
- // hasHardMaterial = true:有具体的东西要说,就不掷色子。
60
- // 色子留给「有点想法但没到非说不可」,这里不是那种情况。
59
+ //
60
+ // `hasHardMaterial` 这一版**没有判据**,所以默认 false。
61
+ //
62
+ // 原来这里是写死的 true,于是手动档和自动档各走一个极端:
63
+ // 手动档(走 planBeat)—— 只要有素材就必定开口,色子永远掷不到;
64
+ // 自动档(走 tick) —— 永远 false,永远只掷色子,看不见素材。
65
+ // 两条路都自称「心跳」,行为却正好相反。
66
+ //
67
+ // 「有素材」只代表有东西可说,不代表现在非说不可 —— 把两件事合成一件,
68
+ // 结果是每有一条没读过的记忆就要说一次,比闹钟还吵。
69
+ //
70
+ // 将来接上真正硬的东西(承诺到期、上次没说完的),由调用方传 true。
61
71
  const decision = decide({
62
72
  now,
63
73
  lastBeatAt: state.lastBeatAt ?? null,
64
74
  lastUserAt: state.lastUserAt ?? null,
65
75
  lastSpokeAt: state.lastSpokeAt ?? null,
66
- hasHardMaterial: true,
76
+ hasHardMaterial: input.hasHardMaterial === true,
67
77
  quietStreak: state.quietStreak ?? 0,
68
78
  config: cfg,
69
79
  random: input.random,
@@ -78,6 +88,7 @@ export function planBeat(input) {
78
88
  previous: target.previous,
79
89
  materialId: material.entry.id,
80
90
  detail: decision.detail,
91
+ touchBeat: decision.touchBeat,
81
92
  };
82
93
 
83
94
  function no(reason, detail, extra) {
@@ -90,6 +101,9 @@ export function planBeat(input) {
90
101
  previous: extra.previous,
91
102
  materialId: null,
92
103
  detail,
104
+ // 没走到 decide 的,都不算一次心跳 —— 那是「没人可说话 / 没得说」,
105
+ // 不是「判过了,结论是不说」。这两种要分开,否则 lastBeatAt 会被空转刷掉。
106
+ touchBeat: false,
93
107
  };
94
108
  }
95
109
  }
@@ -19,7 +19,16 @@ export function stateFile() {
19
19
  }
20
20
 
21
21
  export const EMPTY_STATE = {
22
+ /**
23
+ * 上一次**真正走到判定**的时刻。minGap 用的就是它。
24
+ *
25
+ * 它和 lastTickAt 是两个东西,混成一个会出死锁:
26
+ * 定时器每跳一次都刷新它 ⟹ 每次读到的 gap 都等于定时器间隔
27
+ * ⟹ 只要间隔小于 minGapMinutes,就永远 tooSoon,永远说不了话。
28
+ */
22
29
  lastBeatAt: null,
30
+ /** 上一次**定时器跑过**的时刻。每次跳都写,诊断用 —— 它不参与任何判据。 */
31
+ lastTickAt: null,
23
32
  lastSpokeAt: null,
24
33
  lastUserAt: null,
25
34
  quietStreak: 0,
@@ -68,10 +77,25 @@ export function commitDecision(state, decision, now = new Date(), opts = {}) {
68
77
  };
69
78
  const next = {
70
79
  ...state,
71
- lastBeatAt: at,
72
- quietStreak: decision.speak ? 0 : (state.quietStreak ?? 0) + 1,
80
+ lastTickAt: at,
73
81
  decisions: [...(state.decisions ?? []), entry].slice(-(opts.keepDecisions ?? 50)),
74
82
  };
83
+ // 「被 minGap / 安静时段 / 冷却挡住」的那一跳,只是时钟走了一格,
84
+ // 不说明有过一次「说不说」的机会 —— 所以下面两样都不该动,理由各一条:
85
+ //
86
+ // ① lastBeatAt:动了它,minGap 的基准就被自己刷新,于是永远 tooSoon。
87
+ // ② quietStreak:动了它,1 分钟一跳的定时器 20 分钟就能把计数顶到 20,
88
+ // 于是**第一次**真正判定就撞上下限保护 —— 用户以为设的是「偶尔说句」,
89
+ // 实际得到的是「每 20 分钟强制说一句」。
90
+ //
91
+ // 判据由 decide() 自己给(touchBeat),这里不猜 reason ——
92
+ // 两处各写一份必然漂移,而漂移的方向永远是「改了一处忘了另一处」。
93
+ //
94
+ // 默认 true:老调用点(不带这个字段的)行为不变,不会因为升级而卡住。
95
+ if (decision.touchBeat !== false) {
96
+ next.lastBeatAt = at;
97
+ next.quietStreak = decision.speak ? 0 : (state.quietStreak ?? 0) + 1;
98
+ }
75
99
  if (decision.speak) next.lastSpokeAt = at;
76
100
  return next;
77
101
  }
package/lib/heartbeat.js CHANGED
@@ -99,7 +99,21 @@ export function inQuiet(minuteOfDay, quiet) {
99
99
  * @param {object} [input.config] 覆盖 DEFAULTS
100
100
  * @param {() => number} [input.random] 注入随机源,测试用
101
101
  *
102
- * @returns {{ speak: boolean, reason: string, detail: string }}
102
+ * @returns {{ speak: boolean, reason: string, detail: string, touchBeat: boolean }}
103
+ *
104
+ * `touchBeat` 是这个函数对调用方的第二条输出,和 speak 一样重要:
105
+ * 它回答「这一轮算不算一次心跳」。
106
+ *
107
+ * 被 quiet / tooSoon / cooldown 挡下的**不算** —— 那三种都是还没轮到判定,
108
+ * 只是定时器空跑了一次。如果把它们也记成心跳,minGap 的基准会被自己刷新:
109
+ *
110
+ * 定时器每 1 分钟一跳 → 每次读到的 gap 都是 1 → 永远小于 minGap(20)
111
+ * → 永远 tooSoon → 永远走不到后面的分支
112
+ *
113
+ * 这个死锁长得很正常:账本里一直有新记录,定时器确实在动,
114
+ * 唯一不对的地方是**它永远不会说出第一句话**。外面的人真机跑了一轮才看出来。
115
+ *
116
+ * tooSoon 尤其不能算:它就是「距上次判定不够久」这件事本身。
103
117
  */
104
118
  export function decide(input) {
105
119
  const cfg = { ...DEFAULTS, ...(input.config ?? {}) };
@@ -108,7 +122,7 @@ export function decide(input) {
108
122
 
109
123
  // ── ① 安静时段:硬墙,先挡 ──────────────────────────────
110
124
  if (inQuiet(minutesOfDay(now), cfg.quiet)) {
111
- return { speak: false, reason: 'quiet', detail: '落在用户设的安静时段里' };
125
+ return { speak: false, reason: 'quiet', detail: '落在用户设的安静时段里', touchBeat: false };
112
126
  }
113
127
 
114
128
  // ── ② 间隔:定时器抽风或上一轮太近,别叠 ────────────────
@@ -119,7 +133,8 @@ export function decide(input) {
119
133
  return {
120
134
  speak: false,
121
135
  reason: 'tooSoon',
122
- detail: `距上一轮心跳 ${gap.toFixed(1)} 分钟,不到 ${cfg.minGapMinutes}`,
136
+ detail: `距上一次真正判定 ${gap.toFixed(1)} 分钟,不到 ${cfg.minGapMinutes}`,
137
+ touchBeat: false,
123
138
  };
124
139
  }
125
140
  }
@@ -133,6 +148,7 @@ export function decide(input) {
133
148
  speak: false,
134
149
  reason: 'cooldown',
135
150
  detail: `距用户上一条消息 ${since.toFixed(1)} 分钟,不到 ${cfg.cooldownMinutes}`,
151
+ touchBeat: false,
136
152
  };
137
153
  }
138
154
  }
@@ -148,10 +164,11 @@ export function decide(input) {
148
164
  speak: false,
149
165
  reason: 'cooldown',
150
166
  detail: `距上次主动开口 ${since.toFixed(1)} 分钟,不到 ${cfg.cooldownMinutes}`,
167
+ touchBeat: false,
151
168
  };
152
169
  }
153
170
  }
154
- return { speak: true, reason: 'hard', detail: '有具体的事要说' };
171
+ return { speak: true, reason: 'hard', detail: '有具体的事要说', touchBeat: true };
155
172
  }
156
173
 
157
174
  // ── ⑤ 下限保护:太久没开口,强制允许一次 ────────────────
@@ -160,18 +177,20 @@ export function decide(input) {
160
177
  speak: true,
161
178
  reason: 'floor',
162
179
  detail: `已连续 ${input.quietStreak} 轮没开口,触到下限保护`,
180
+ touchBeat: true,
163
181
  };
164
182
  }
165
183
 
166
184
  // ── ⑥ 弱素材:掷色子 ────────────────────────────────────
167
185
  const r = random();
168
186
  if (r < cfg.dice) {
169
- return { speak: true, reason: 'dice', detail: `色子 ${r.toFixed(3)} < ${cfg.dice}` };
187
+ return { speak: true, reason: 'dice', detail: `色子 ${r.toFixed(3)} < ${cfg.dice}`, touchBeat: true };
170
188
  }
171
189
  return {
172
190
  speak: false,
173
191
  reason: 'noMaterial',
174
192
  detail: `没硬素材,色子 ${r.toFixed(3)} ≥ ${cfg.dice}`,
193
+ touchBeat: true,
175
194
  };
176
195
  }
177
196
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-galatea",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Galatea — memory, heartbeat and continuity for DeepSeek Harness agents.",
5
5
  "repository": {
6
6
  "type": "git",