dsh-galatea 0.4.3 → 0.4.5

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.5(GitHub Actions 自动发,不需要 token) |
17
17
  | 记忆:读写 Markdown | ✅ 15 条测试 |
18
18
  | 记忆:注入索引 | ✅ **已核实内容真的进了上下文**(不是在会话日志里看到「接上了没告警」) |
19
19
  | 记忆:`remember` / `recall` | ✅ 23 条测试 |
@@ -27,10 +27,14 @@
27
27
  | 心跳:沉默可解释 | ✅ `galatea_beat` 会把最近几次的决定翻成人话 |
28
28
  | 心跳:听得见你说话 | ⚠️ 接了 `agent/pre-step`,**但那条事件有没有真的触发,没验过** |
29
29
  | 设置页可改的字段 | ⚠️ 声明被宿主接受,**表单没在 GUI 里看过** |
30
- | 心跳:定时器 + 自动开口 | ⬜ 未做 |
30
+ | 心跳:定时器自己跑 | ✅ 实测:每 60 秒一次,账本持续累积,跨进程存活 |
31
+ | 心跳:改配置立刻生效 | ✅ 定时器只负责「醒」,跑不跑每次现读配置 |
32
+ | 心跳:自动开口(真的送出去) | ⬜ 未做 —— **这一版只算不发,但算得对了** |
31
33
 
32
- > ⚠️ **它现在还不会自己开口。** 定时器和 `ctx.agents.resume` 都还没接,
33
- > 判定链路完整,但没有出口。别把「测试全绿」读成「它已经在主动找你了」。
34
+ > ⚠️ **它现在还不会自己开口。** 定时器接上了,判定链路也完整了
35
+ > (会算出「该不该说、对谁说、拿哪条当由头」),
36
+ > 但把话真的送出去那一步还没接。
37
+ > 别把「账本在长」读成「它已经在主动找你了」。
34
38
 
35
39
  关于上面那条 ⚠️:`noteUserMessage` 接在 `agent/pre-step` 上,用来记「你刚说过话」
36
40
  (cooldown 要用它)。**本机验证只到「加载不崩」**——那个测试 profile 里没有会话,
@@ -60,7 +64,7 @@
60
64
 
61
65
  ## 设置(不用改代码)
62
66
 
63
- 六个字段声明为 `.volatile()`,所以 DSH 的设置页会照着 schema 自动生成表单,
67
+ 七个字段声明为 `.volatile()`,所以 DSH 的设置页会照着 schema 自动生成表单,
64
68
  改完**不重启插件**:
65
69
 
66
70
  | 字段 | 说明 |
@@ -70,9 +74,14 @@
70
74
  | `minGapMinutes` | 两次心跳最少隔多久 |
71
75
  | `dice` | 没有由头时的开口概率。0 = 只在有素材时说 |
72
76
  | `quietRounds` | 连续多少轮没开口就强制说一次。0 = 关掉这个保底 |
77
+ | `autoBeatMinutes` | 每隔多少分钟自己跑一次。0 = 关(默认)。**改完立刻生效,不用重启** |
73
78
 
74
- 三处容易踩的:
79
+ 四注意:
75
80
 
81
+ - **`autoBeatMinutes` 小于 `minGapMinutes` 时,实际频率由后者决定。** 比如设 1 分钟、
82
+ 最小间隔 20 分钟,就是每 20 分钟才真正判定一次,中间那些跳只记一条 `tooSoon`。
83
+ 这不是没生效,是间隔在压着它 —— 装的时候会把这句话写进 marker 文件。
84
+ 定时器本身固定 60 秒醒一次,跑不跑由这个数现读现定。
76
85
  - **「连续几轮没开口就强制一次」和「最少间隔」是耦合的。** 心跳间隔设得比保底所需的时间还短,保底永远不会触发。
77
86
  - **把 `quietRounds` 关掉,它可能安静到让你以为坏了。** 默认闭嘴需要一条下限,不然沉默和故障长得一样。
78
87
  - **安静时段优先于保底,但保底不会被清零。** 两者撞上时,安静时段赢:那段时间它不说话。可是「连续没开口」的次数照常往上加,所以走出安静时段后的第一次心跳就会走保底 —— 保底只是被推迟,没有被悄悄关掉。
@@ -118,6 +127,66 @@
118
127
 
119
128
  门控可以自动决定,**内容不能自动生成**。空话比不说更糟,它消耗的是「它真的在」那点信用。
120
129
 
130
+ ## 一次教训:心跳把自己的基准刷掉了
131
+
132
+ 0.4.1 的定时器确实在跑,账本也确实在长,但里面永远是同一句:
133
+
134
+ ```
135
+ 16:36:15 tooSoon — 距上一轮心跳 1.0 分钟,不到 20
136
+ 16:37:15 tooSoon — 距上一轮心跳 1.0 分钟,不到 20
137
+ 16:38:15 tooSoon — 距上一轮心跳 1.0 分钟,不到 20
138
+ ```
139
+
140
+ 一个字段干了两件事:`lastBeatAt` 既是「上次跑过」,又是「上次判定的基准」。
141
+ 于是每一跳都把它刷成「现在」⟹ 下一跳读到的间隔永远等于定时器间隔
142
+ ⟹ 只要间隔小于 `minGapMinutes`,就永远 `tooSoon`,永远说不了第一句话。
143
+
144
+ **它看起来完全正常**:进程活着、定时器在响、账本在长。
145
+ 唯一不对的,是它永远不会开口。
146
+
147
+ 现在拆成两个:
148
+
149
+ | 字段 | 谁写 | 干什么 |
150
+ |---|---|---|
151
+ | `lastTickAt` | 每一跳都写 | 只回答「定时器还活着吗」 |
152
+ | `lastBeatAt` | 只有**真的走到判定**的那一跳才写 | `minGapMinutes` 的基准 |
153
+
154
+ 判据不在调用方,在 `decide()` 自己返回的 `touchBeat` 里 ——
155
+ 两处各写一份必然漂移,而漂移的方向永远是「改了一处忘了另一处」。
156
+
157
+ **「被挡住」不算一次心跳。** 安静时段、刚聊过、间隔不够,都只是时钟走了一格,
158
+ 不是一次「说不说」的机会。所以它们也不该让 `quietStreak` 往上加 ——
159
+ 否则 1 分钟一跳的定时器会在 20 分钟内把计数顶到 20,
160
+ 第一次真正判定就撞上下限保护,变成「每 20 分钟强制说一句」。
161
+ 一个 bug 修完,第二个 bug 就躲在同一个地方。
162
+
163
+ 这一条不是读代码读出来的。外面的人真机跑了七分钟,看到七条一模一样的
164
+ `tooSoon`,才把它揪出来 —— **单元测试全绿,因为测试都是按「一分钟一跳不合法」
165
+ 去构造的**,而没有人试过「一小时里每一分钟都跳一次」。
166
+
167
+ ## 又一次教训:第七个字段是快照
168
+
169
+ 0.4.4 修完基准刷新,同样的形状又冒出来一次,方向正好相反:
170
+
171
+ `autoBeatMinutes` 是 volatile 字段 —— 用户改它时插件**不会重新 mount**,
172
+ 这正是 volatile 的意义。可定时器的周期是在 `apply()` 里读一次就钉死的。
173
+ 于是在设置页把 5 改成 30,间隔永远停在 5,**而且没有任何地方会报错**。
174
+
175
+ 同一个文件里,六个字段走 `liveConfig(raw)` 每次现读,第七个成了启动时的快照。
176
+
177
+ 0.4.0 是「忘了读这个字段」,这次是「在错的时机读了它」。
178
+ 两次的表现一模一样:配了完全没反应,而两次的代码看起来都是对的。
179
+
180
+ 现在定时器跟配置解耦:
181
+
182
+ | | 谁负责 |
183
+ |---|---|
184
+ | 定时器周期 | 固定 60 秒,只负责「醒」 |
185
+ | 跑不跑、隔多久 | 每次醒的时候现读 `autoBeatMinutes`,用 `isDue(上次跑的时刻, 现在, 配置)` 判 |
186
+
187
+ 关着(0)也照常装定时器 —— 否则用户从 0 改成 5 的时候没人去装,又回到同一个坑。
188
+ 代价是每分钟空醒一次,读一个数字比大小。
189
+
121
190
  ## 一次教训:模块边界
122
191
 
123
192
  0.3.0 / 0.3.5 / 0.3.6 连着三次,坏的地方都在**两个文件交接处**:
@@ -147,13 +216,14 @@ $DSH_HOME/galatea/heartbeat-state.json # 心跳状态账本
147
216
 
148
217
  文件名就是 id。日期在前,所以目录列表天然按时间排。正文第一行是标题。
149
218
 
150
- 状态账本记的是:上一轮什么时候跳的、上次开口是什么时候、连续几轮没说话、最近的决定。
219
+ 状态账本记的是:上一次**真正判定**是什么时候(`lastBeatAt`)、定时器上一次跑是什么时候
220
+ (`lastTickAt`)、上次开口是什么时候、连续几轮**判定**没说话、最近的决定。
151
221
  它必须落盘——不落盘的话进程一重启计数就归零,下限保护立刻失效。
152
222
 
153
223
  ## 本地开发
154
224
 
155
225
  ```bash
156
- npm test # 92 条,不依赖 DSH,纯 node
226
+ npm test # 101 条,不依赖 DSH,纯 node
157
227
  ```
158
228
 
159
229
  装了之后验证它真的加载了,只看一个东西:
@@ -174,7 +244,7 @@ dsh plugin add dsh-galatea
174
244
  或者在 profile 的 `package.json` 里手写:
175
245
 
176
246
  ```json
177
- "dependencies": { "dsh-galatea": "0.3.8" }
247
+ "dependencies": { "dsh-galatea": "0.4.5" }
178
248
  ```
179
249
 
180
250
  **版本号钉死,不要写 `^`。** 同一个版本号指向两个不同的包,以后就说不清你装的是哪一份。
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, isDue, 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 插件名。 */
@@ -238,31 +239,89 @@ export function apply(ctx, config) {
238
239
  // 想确认它在动,看心跳账本里有没有新记录就够了。
239
240
  //
240
241
  // 默认关(autoBeatMinutes = 0):一个会自己转的东西,不该在装上的那一刻就开始转。
241
- const everyMin = Number(cfg.autoBeatMinutes ?? 0);
242
- if (Number.isFinite(everyMin) && everyMin > 0) {
243
- if (ctx && typeof ctx.interval === 'function') {
244
- const ms = Math.max(60_000, everyMin * 60_000);
242
+ //
243
+ // ⚠ 间隔不能写进 ctx.interval 的周期里。
244
+ // autoBeatMinutes 是 volatile 字段 —— 用户改它时插件**不会重新 mount**
245
+ // (这正是 volatile 的意义:不重启)。而定时器是装一次就钉死的。
246
+ // 所以「每 N 分钟」一旦写进周期,改完设置页间隔永远不会变,
247
+ // 而且没有任何地方会报错 —— 同一个文件里六个字段是活的,第七个成了快照。
248
+ // (0.4.0 是「忘了读这个字段」,这次是「在错的时机读了它」。)
249
+ //
250
+ // 改成固定一分钟醒一次,每次醒的时候**现读**配置,到点才真的跑。
251
+ // 代价是每分钟空醒一次(读一个数字比大小),换来的是改完立刻生效。
252
+ const TICK_MS = 60_000;
253
+ if (ctx && typeof ctx.interval === 'function') {
254
+ let lastRunAt = 0;
245
255
  // 用 ctx.interval,不用已 deprecated 的 ctx.setInterval —— 它返回 dispose,
246
256
  // 由当前 fiber 管着,宿主热重载时不会叠出第二个定时器。
247
257
  ctx.interval(() => {
248
258
  try {
249
- tick({});
259
+ // 现读 —— volatile 字段唯一的正确用法。
260
+ // 取不到(没配过)就回落到 0,也就是「关着」。
261
+ const everyMin = Number(
262
+ raw && raw.autoBeatMinutes && typeof raw.autoBeatMinutes.get === 'function'
263
+ ? raw.autoBeatMinutes.get()
264
+ : cfg.autoBeatMinutes,
265
+ );
266
+ const nowMs = Date.now();
267
+ // 到点才真的跑。这一句是「改配置立刻生效」的全部秘密:
268
+ // 定时器只负责醒,什么时候真的跑由这个现读的数决定。
269
+ if (!isDue(lastRunAt, nowMs, everyMin)) return;
270
+ lastRunAt = nowMs;
271
+
272
+ const at = new Date(nowMs);
273
+ const state = loadState(stateFile());
274
+ // 走和手动档**同一条**链路(planBeat)。
275
+ //
276
+ // 上一版这里是 `tick({})`,它直接调门控、只带一个 state:
277
+ // 没有候选会话、没有素材、hasHardMaterial 永远 false。
278
+ // 于是自动档只能掷色子,手动档永远走硬素材 —— 两条路都叫心跳,
279
+ // 行为却正好相反,而两边各自的账本都「看起来在动」。
280
+ //
281
+ // 复用 planBeat 的代价是要拿 agents 和 entries,好处是:
282
+ // 自动档终于能算出「本来想说什么、打算对谁说」,而且
283
+ // 以后改目标选择/素材选择时,只需要改一处。
284
+ const r = planBeat({
285
+ now: at,
286
+ state,
287
+ candidates: snapshotAgents(ctx.agents),
288
+ entries: listEntries(),
289
+ config: liveConfig(raw),
290
+ });
291
+ saveState(commitDecision(state, r, at), stateFile());
250
292
  } catch (err) {
251
293
  // 定时器抛出去会打断整个宿主,所以在这里兜住,但要说出来
252
294
  console.warn('[galatea] 自动心跳失败:', err && err.message);
253
295
  }
254
- }, ms);
255
- console.log(`[galatea] 自动心跳已开:每 ${everyMin} 分钟跑一次(只算不发)`);
256
- } else {
257
- // 用户明确要求了、却被静默拒绝 —— 这一次不能只在 console 里说。
258
- // 真机上 console.warn 用户看不见,表现是「配了完全不生效,也没有任何提示」。
259
- const line = '[galatea] 你配了 autoBeatMinutes,但宿主没有 ctx.interval —— 自动心跳不会跑。';
260
- console.warn(line);
296
+ }, TICK_MS);
297
+ console.log('[galatea] 自动心跳的钟已装:每 60 秒醒一次,按设置里的 autoBeatMinutes 决定跑不跑(0 = 不跑)');
298
+
299
+ // 一个很自然但会误会的组合:自动心跳间隔 < 最小间隔。
300
+ // 它**不是错误** —— minGap 会把节奏压回每 minGap 分钟一次。
301
+ // 但用户以为自己设的是 autoBeatMinutes,所以得把实际生效的那个数说出来。
302
+ const gapMin = Number(cfg.minGapMinutes ?? DEFAULTS.minGapMinutes);
303
+ const bootEvery = Number(cfg.autoBeatMinutes ?? 0);
304
+ if (
305
+ Number.isFinite(bootEvery) && bootEvery > 0 &&
306
+ Number.isFinite(gapMin) && gapMin > 0 && bootEvery < gapMin
307
+ ) {
308
+ const note = `[galatea] 自动心跳设的是每 ${bootEvery} 分钟一次,但最小间隔是 ${gapMin} 分钟 —— 实际每 ${gapMin} 分钟才真正判定一次(不是没生效,是被间隔压住了)。`;
309
+ console.warn(note);
261
310
  try {
262
- appendFileSync(marker, ` ${line}\n`);
311
+ appendFileSync(marker, ` ${note}\n`);
263
312
  } catch {
264
313
  // 记录失败不该连累插件
265
314
  }
266
315
  }
316
+ } else if (Number(cfg.autoBeatMinutes ?? 0) > 0) {
317
+ // 用户明确要求了、却被静默拒绝 —— 这一次不能只在 console 里说。
318
+ // 真机上 console.warn 用户看不见,表现是「配了完全不生效,也没有任何提示」。
319
+ const line = '[galatea] 你配了 autoBeatMinutes,但宿主没有 ctx.interval —— 自动心跳不会跑。';
320
+ console.warn(line);
321
+ try {
322
+ appendFileSync(marker, ` ${line}\n`);
323
+ } catch {
324
+ // 记录失败不该连累插件
325
+ }
267
326
  }
268
327
  }
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,14 +77,58 @@ 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
  }
78
102
 
103
+ /**
104
+ * 自动心跳的节流:这一跳**该不该真的跑一次**。
105
+ *
106
+ * 为什么单独一个函数,而不是直接把间隔写进定时器周期:
107
+ *
108
+ * `autoBeatMinutes` 是 volatile 字段 —— 用户改它时插件**不会重新 mount**
109
+ * (这正是 volatile 的意义:不重启)。而定时器的周期是装配那一刻钉死的。
110
+ *
111
+ * 所以「每 N 分钟」一旦写进 interval 的周期,用户改完设置页,
112
+ * 间隔永远不会变,而且**没有任何地方会报错** ——
113
+ * 同一个文件里六个字段是活的,第七个成了快照,看起来完全正常。
114
+ * (0.4.0 是「忘了读这个字段」,这次是「在错的时机读了它」。)
115
+ *
116
+ * 拆开之后:定时器固定一分钟醒一次,醒的时候现读配置、用这个函数判。
117
+ *
118
+ * @param {number} lastRunAtMs 上次真的跑过的时刻(毫秒;<=0 表示从没跑过)
119
+ * @param {number} nowMs 现在(毫秒)
120
+ * @param {number} everyMinutes 配置里的间隔。<=0 或不是数字 ⟹ 关着,永不跑
121
+ * @returns {boolean}
122
+ */
123
+ export function isDue(lastRunAtMs, nowMs, everyMinutes) {
124
+ const every = Number(everyMinutes);
125
+ if (!Number.isFinite(every) || every <= 0) return false;
126
+ const last = Number(lastRunAtMs);
127
+ // 从没跑过 ⟹ 立刻就绪。这样重启之后不会平白多等一个完整周期。
128
+ if (!Number.isFinite(last) || last <= 0) return true;
129
+ return Number(nowMs) - last >= every * 60_000;
130
+ }
131
+
79
132
  export function tick(opts = {}) {
80
133
  const now = opts.now instanceof Date ? opts.now : new Date(opts.now ?? Date.now());
81
134
  const file = opts.file ?? stateFile();
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.5",
4
4
  "description": "Galatea — memory, heartbeat and continuity for DeepSeek Harness agents.",
5
5
  "repository": {
6
6
  "type": "git",