dsh-galatea 0.4.4 → 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 +29 -4
- package/index.js +52 -27
- package/lib/heartbeat-runner.js +29 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
| 块 | 状态 |
|
|
14
14
|
|---|---|
|
|
15
15
|
| 能被 DSH 装起来 | ✅ 实测通过(dsh 0.2.2-rc.3) |
|
|
16
|
-
| 发布到 npm | ✅ 0.4.
|
|
16
|
+
| 发布到 npm | ✅ 0.4.5(GitHub Actions 自动发,不需要 token) |
|
|
17
17
|
| 记忆:读写 Markdown | ✅ 15 条测试 |
|
|
18
18
|
| 记忆:注入索引 | ✅ **已核实内容真的进了上下文**(不是在会话日志里看到「接上了没告警」) |
|
|
19
19
|
| 记忆:`remember` / `recall` | ✅ 23 条测试 |
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
| 心跳:听得见你说话 | ⚠️ 接了 `agent/pre-step`,**但那条事件有没有真的触发,没验过** |
|
|
29
29
|
| 设置页可改的字段 | ⚠️ 声明被宿主接受,**表单没在 GUI 里看过** |
|
|
30
30
|
| 心跳:定时器自己跑 | ✅ 实测:每 60 秒一次,账本持续累积,跨进程存活 |
|
|
31
|
+
| 心跳:改配置立刻生效 | ✅ 定时器只负责「醒」,跑不跑每次现读配置 |
|
|
31
32
|
| 心跳:自动开口(真的送出去) | ⬜ 未做 —— **这一版只算不发,但算得对了** |
|
|
32
33
|
|
|
33
34
|
> ⚠️ **它现在还不会自己开口。** 定时器接上了,判定链路也完整了
|
|
@@ -73,13 +74,14 @@
|
|
|
73
74
|
| `minGapMinutes` | 两次心跳最少隔多久 |
|
|
74
75
|
| `dice` | 没有由头时的开口概率。0 = 只在有素材时说 |
|
|
75
76
|
| `quietRounds` | 连续多少轮没开口就强制说一次。0 = 关掉这个保底 |
|
|
76
|
-
| `autoBeatMinutes` | 每隔多少分钟自己跑一次。0 =
|
|
77
|
+
| `autoBeatMinutes` | 每隔多少分钟自己跑一次。0 = 关(默认)。**改完立刻生效,不用重启** |
|
|
77
78
|
|
|
78
79
|
四注意:
|
|
79
80
|
|
|
80
81
|
- **`autoBeatMinutes` 小于 `minGapMinutes` 时,实际频率由后者决定。** 比如设 1 分钟、
|
|
81
82
|
最小间隔 20 分钟,就是每 20 分钟才真正判定一次,中间那些跳只记一条 `tooSoon`。
|
|
82
83
|
这不是没生效,是间隔在压着它 —— 装的时候会把这句话写进 marker 文件。
|
|
84
|
+
定时器本身固定 60 秒醒一次,跑不跑由这个数现读现定。
|
|
83
85
|
- **「连续几轮没开口就强制一次」和「最少间隔」是耦合的。** 心跳间隔设得比保底所需的时间还短,保底永远不会触发。
|
|
84
86
|
- **把 `quietRounds` 关掉,它可能安静到让你以为坏了。** 默认闭嘴需要一条下限,不然沉默和故障长得一样。
|
|
85
87
|
- **安静时段优先于保底,但保底不会被清零。** 两者撞上时,安静时段赢:那段时间它不说话。可是「连续没开口」的次数照常往上加,所以走出安静时段后的第一次心跳就会走保底 —— 保底只是被推迟,没有被悄悄关掉。
|
|
@@ -162,6 +164,29 @@
|
|
|
162
164
|
`tooSoon`,才把它揪出来 —— **单元测试全绿,因为测试都是按「一分钟一跳不合法」
|
|
163
165
|
去构造的**,而没有人试过「一小时里每一分钟都跳一次」。
|
|
164
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
|
+
|
|
165
190
|
## 一次教训:模块边界
|
|
166
191
|
|
|
167
192
|
0.3.0 / 0.3.5 / 0.3.6 连着三次,坏的地方都在**两个文件交接处**:
|
|
@@ -198,7 +223,7 @@ $DSH_HOME/galatea/heartbeat-state.json # 心跳状态账本
|
|
|
198
223
|
## 本地开发
|
|
199
224
|
|
|
200
225
|
```bash
|
|
201
|
-
npm test #
|
|
226
|
+
npm test # 101 条,不依赖 DSH,纯 node
|
|
202
227
|
```
|
|
203
228
|
|
|
204
229
|
装了之后验证它真的加载了,只看一个东西:
|
|
@@ -219,7 +244,7 @@ dsh plugin add dsh-galatea
|
|
|
219
244
|
或者在 profile 的 `package.json` 里手写:
|
|
220
245
|
|
|
221
246
|
```json
|
|
222
|
-
"dependencies": { "dsh-galatea": "0.4.
|
|
247
|
+
"dependencies": { "dsh-galatea": "0.4.5" }
|
|
223
248
|
```
|
|
224
249
|
|
|
225
250
|
**版本号钉死,不要写 `^`。** 同一个版本号指向两个不同的包,以后就说不清你装的是哪一份。
|
package/index.js
CHANGED
|
@@ -16,7 +16,7 @@ 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 { commitDecision, loadState, noteUserMessage, saveState, stateFile } from './lib/heartbeat-runner.js';
|
|
19
|
+
import { commitDecision, isDue, loadState, noteUserMessage, saveState, stateFile } from './lib/heartbeat-runner.js';
|
|
20
20
|
import { planBeat } from './lib/beat.js';
|
|
21
21
|
import { DEFAULTS } from './lib/heartbeat.js';
|
|
22
22
|
|
|
@@ -239,15 +239,37 @@ export function apply(ctx, config) {
|
|
|
239
239
|
// 想确认它在动,看心跳账本里有没有新记录就够了。
|
|
240
240
|
//
|
|
241
241
|
// 默认关(autoBeatMinutes = 0):一个会自己转的东西,不该在装上的那一刻就开始转。
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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;
|
|
246
255
|
// 用 ctx.interval,不用已 deprecated 的 ctx.setInterval —— 它返回 dispose,
|
|
247
256
|
// 由当前 fiber 管着,宿主热重载时不会叠出第二个定时器。
|
|
248
257
|
ctx.interval(() => {
|
|
249
258
|
try {
|
|
250
|
-
|
|
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);
|
|
251
273
|
const state = loadState(stateFile());
|
|
252
274
|
// 走和手动档**同一条**链路(planBeat)。
|
|
253
275
|
//
|
|
@@ -271,32 +293,35 @@ export function apply(ctx, config) {
|
|
|
271
293
|
// 定时器抛出去会打断整个宿主,所以在这里兜住,但要说出来
|
|
272
294
|
console.warn('[galatea] 自动心跳失败:', err && err.message);
|
|
273
295
|
}
|
|
274
|
-
},
|
|
275
|
-
console.log(
|
|
296
|
+
}, TICK_MS);
|
|
297
|
+
console.log('[galatea] 自动心跳的钟已装:每 60 秒醒一次,按设置里的 autoBeatMinutes 决定跑不跑(0 = 不跑)');
|
|
276
298
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
} else {
|
|
291
|
-
// 用户明确要求了、却被静默拒绝 —— 这一次不能只在 console 里说。
|
|
292
|
-
// 真机上 console.warn 用户看不见,表现是「配了完全不生效,也没有任何提示」。
|
|
293
|
-
const line = '[galatea] 你配了 autoBeatMinutes,但宿主没有 ctx.interval —— 自动心跳不会跑。';
|
|
294
|
-
console.warn(line);
|
|
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);
|
|
295
310
|
try {
|
|
296
|
-
appendFileSync(marker, ` ${
|
|
311
|
+
appendFileSync(marker, ` ${note}\n`);
|
|
297
312
|
} catch {
|
|
298
313
|
// 记录失败不该连累插件
|
|
299
314
|
}
|
|
300
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
|
+
}
|
|
301
326
|
}
|
|
302
327
|
}
|
package/lib/heartbeat-runner.js
CHANGED
|
@@ -100,6 +100,35 @@ export function commitDecision(state, decision, now = new Date(), opts = {}) {
|
|
|
100
100
|
return next;
|
|
101
101
|
}
|
|
102
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
|
+
|
|
103
132
|
export function tick(opts = {}) {
|
|
104
133
|
const now = opts.now instanceof Date ? opts.now : new Date(opts.now ?? Date.now());
|
|
105
134
|
const file = opts.file ?? stateFile();
|