dsh-galatea 0.4.4 → 0.4.6

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.4.4(GitHub Actions 自动发,不需要 token) |
16
+ | 发布到 npm | ✅ 0.4.6(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,68 @@
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
+
190
+ ## 三条教训:粒度、上限、旧数据
191
+
192
+ ### 一 · 天粒度和秒粒度不能比较
193
+
194
+ 条目文件名里只有日期(`2026-11-15-土豆.md`),而心跳要回答的是
195
+ 「这条是写下之后才说的,还是说过了」。两边一个按天、一个按秒:
196
+
197
+ ```
198
+ toMs('2026-11-15') = 2026-11-15T00:00:00Z ← 当天零点
199
+ lastSpokeAt = 2026-11-15T09:45:59Z ← 真的时刻
200
+ ```
201
+
202
+ 于是**当天说过一次话之后,当天再写的任何东西都判成「早于上次开口」**,
203
+ 永远轮不到。用户会觉得「我刚记的事它怎么完全不提」——
204
+ 而这和「设计上就该沉默」长得一模一样,所以它能活到 0.4.5。
205
+
206
+ 现在 `listEntries` 会带上 `at`(文件 mtime),素材层优先用它;
207
+ 读不到 mtime 时退回日期(宁可粗略,也不编一个时刻)。
208
+ `date` 保留不变——注入里给人看的就是它,人不需要秒。
209
+
210
+ ### 二 · 账本满了要说
211
+
212
+ `decisions` 上限 50 条,满了滚动覆盖最旧的,所以**总数永远是 50**:
213
+ 看起来像停了,其实一直在写。
214
+
215
+ 原来那句「想确认它在动,看账本有没有新记录就够了」,在满 50 之后就不成立了。
216
+ 现在满了会明说一句。
217
+
218
+ **假信号比没有信号更坏**——没有信号至少会让人去别处找。
219
+
220
+ ### 三 · 旧数据会跟着版本一起进来
221
+
222
+ 0.4.5 修掉 `quietStreak` 的累加条件之后,**旧账本里那个数还是旧的**。
223
+ 0.4.3 每跳都 +1,可能已经攒到几十;0.4.5 的第一次真实判定就直接撞下限保护。
224
+
225
+ 这里不改代码,因为分不清「真的几十轮没说话」和「旧 bug 累出来的」。
226
+ 它也不会一直错下去:下一次真的开口就归零。
227
+ 但它提醒一件事:**修「某个计数器的累加条件」时,旧值也是新逻辑的输入。**
228
+
165
229
  ## 一次教训:模块边界
166
230
 
167
231
  0.3.0 / 0.3.5 / 0.3.6 连着三次,坏的地方都在**两个文件交接处**:
@@ -198,7 +262,7 @@ $DSH_HOME/galatea/heartbeat-state.json # 心跳状态账本
198
262
  ## 本地开发
199
263
 
200
264
  ```bash
201
- npm test # 98 条,不依赖 DSH,纯 node
265
+ npm test # 107 条,不依赖 DSH,纯 node
202
266
  ```
203
267
 
204
268
  装了之后验证它真的加载了,只看一个东西:
@@ -219,7 +283,7 @@ dsh plugin add dsh-galatea
219
283
  或者在 profile 的 `package.json` 里手写:
220
284
 
221
285
  ```json
222
- "dependencies": { "dsh-galatea": "0.4.4" }
286
+ "dependencies": { "dsh-galatea": "0.4.6" }
223
287
  ```
224
288
 
225
289
  **版本号钉死,不要写 `^`。** 同一个版本号指向两个不同的包,以后就说不清你装的是哪一份。
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
- const everyMin = Number(cfg.autoBeatMinutes ?? 0);
243
- if (Number.isFinite(everyMin) && everyMin > 0) {
244
- if (ctx && typeof ctx.interval === 'function') {
245
- 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;
246
255
  // 用 ctx.interval,不用已 deprecated 的 ctx.setInterval —— 它返回 dispose,
247
256
  // 由当前 fiber 管着,宿主热重载时不会叠出第二个定时器。
248
257
  ctx.interval(() => {
249
258
  try {
250
- const at = new Date();
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
- }, ms);
275
- console.log(`[galatea] 自动心跳已开:每 ${everyMin} 分钟跑一次(只算不发)`);
296
+ }, TICK_MS);
297
+ console.log('[galatea] 自动心跳的钟已装:每 60 秒醒一次,按设置里的 autoBeatMinutes 决定跑不跑(0 = 不跑)');
276
298
 
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
- }
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, ` ${line}\n`);
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
  }
@@ -18,6 +18,16 @@ export function stateFile() {
18
18
  return join(dataRoot(), 'heartbeat-state.json');
19
19
  }
20
20
 
21
+ /**
22
+ * 账本最多留几条。
23
+ *
24
+ * 满了就滚动覆盖最旧的一条 —— **这件事必须让看账本的人知道**。
25
+ * 否则「账本里多了几条」这个判据在满 50 之后就不成立了:
26
+ * 总数永远是 50,看起来像停了,其实一直在写。
27
+ * (这是外面的人真机跑了 40 分钟才发现的。)
28
+ */
29
+ export const MAX_DECISIONS = 50;
30
+
21
31
  export const EMPTY_STATE = {
22
32
  /**
23
33
  * 上一次**真正走到判定**的时刻。minGap 用的就是它。
@@ -78,7 +88,7 @@ export function commitDecision(state, decision, now = new Date(), opts = {}) {
78
88
  const next = {
79
89
  ...state,
80
90
  lastTickAt: at,
81
- decisions: [...(state.decisions ?? []), entry].slice(-(opts.keepDecisions ?? 50)),
91
+ decisions: [...(state.decisions ?? []), entry].slice(-(opts.keepDecisions ?? MAX_DECISIONS)),
82
92
  };
83
93
  // 「被 minGap / 安静时段 / 冷却挡住」的那一跳,只是时钟走了一格,
84
94
  // 不说明有过一次「说不说」的机会 —— 所以下面两样都不该动,理由各一条:
@@ -100,6 +110,35 @@ export function commitDecision(state, decision, now = new Date(), opts = {}) {
100
110
  return next;
101
111
  }
102
112
 
113
+ /**
114
+ * 自动心跳的节流:这一跳**该不该真的跑一次**。
115
+ *
116
+ * 为什么单独一个函数,而不是直接把间隔写进定时器周期:
117
+ *
118
+ * `autoBeatMinutes` 是 volatile 字段 —— 用户改它时插件**不会重新 mount**
119
+ * (这正是 volatile 的意义:不重启)。而定时器的周期是装配那一刻钉死的。
120
+ *
121
+ * 所以「每 N 分钟」一旦写进 interval 的周期,用户改完设置页,
122
+ * 间隔永远不会变,而且**没有任何地方会报错** ——
123
+ * 同一个文件里六个字段是活的,第七个成了快照,看起来完全正常。
124
+ * (0.4.0 是「忘了读这个字段」,这次是「在错的时机读了它」。)
125
+ *
126
+ * 拆开之后:定时器固定一分钟醒一次,醒的时候现读配置、用这个函数判。
127
+ *
128
+ * @param {number} lastRunAtMs 上次真的跑过的时刻(毫秒;<=0 表示从没跑过)
129
+ * @param {number} nowMs 现在(毫秒)
130
+ * @param {number} everyMinutes 配置里的间隔。<=0 或不是数字 ⟹ 关着,永不跑
131
+ * @returns {boolean}
132
+ */
133
+ export function isDue(lastRunAtMs, nowMs, everyMinutes) {
134
+ const every = Number(everyMinutes);
135
+ if (!Number.isFinite(every) || every <= 0) return false;
136
+ const last = Number(lastRunAtMs);
137
+ // 从没跑过 ⟹ 立刻就绪。这样重启之后不会平白多等一个完整周期。
138
+ if (!Number.isFinite(last) || last <= 0) return true;
139
+ return Number(nowMs) - last >= every * 60_000;
140
+ }
141
+
103
142
  export function tick(opts = {}) {
104
143
  const now = opts.now instanceof Date ? opts.now : new Date(opts.now ?? Date.now());
105
144
  const file = opts.file ?? stateFile();
package/lib/journal.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * 不发明数据,不评价好坏,不排序打分——账本里记了什么就说什么。
13
13
  */
14
14
  import { DEFAULTS } from './heartbeat.js';
15
+ import { MAX_DECISIONS } from './heartbeat-runner.js';
15
16
 
16
17
  /** reason → 一句人话。门控那七种 + 三种"更早就断了"的,一种都不许漏。 */
17
18
  export const REASON_TEXT = {
@@ -70,6 +71,19 @@ export function renderJournal(state, opts = {}) {
70
71
  );
71
72
  }
72
73
 
74
+ // 账本满了要说一声。
75
+ //
76
+ // 「看它有没有新记录」这个判据,在满 50 条之后就不成立了:
77
+ // 总数恒为 50,看起来像停了,其实一直在写。
78
+ // 不说的话,这就是个假信号——而假信号比没有信号更坏。
79
+ const maxDecisions = Number(opts.maxDecisions ?? MAX_DECISIONS);
80
+ if (Number.isFinite(maxDecisions) && ds.length >= maxDecisions) {
81
+ if (lines.length > 0) lines.push('');
82
+ lines.push(
83
+ `(账本记满 ${maxDecisions} 条了,再来一条就挤掉最旧的一条 —— 条数不会再涨,看时间戳。)`,
84
+ );
85
+ }
86
+
73
87
  // 连续没开口的次数:这是「沉默」和「坏了」唯一能分开的地方
74
88
  const streak = Number(state?.quietStreak ?? 0);
75
89
  if (streak > 0) {
package/lib/store.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * 需要的时候再去翻。翻的人知道自己翻的是什么。
15
15
  */
16
16
 
17
- import { readdirSync, readFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
17
+ import { readdirSync, readFileSync, mkdirSync, writeFileSync, existsSync, statSync } from 'node:fs';
18
18
  import { join } from 'node:path';
19
19
  import { homedir } from 'node:os';
20
20
 
@@ -51,9 +51,11 @@ export function listEntries() {
51
51
  for (const name of readdirSync(dir)) {
52
52
  const m = ID_RE.exec(name);
53
53
  if (!m) continue;
54
+ const p = join(dir, name);
54
55
  let title = '';
56
+ let at = null;
55
57
  try {
56
- const raw = readFileSync(join(dir, name), 'utf8');
58
+ const raw = readFileSync(p, 'utf8');
57
59
  // 第一个非空行;如果是 `# 标题` 就去掉井号
58
60
  for (const line of raw.split(/\r?\n/)) {
59
61
  const t = line.trim();
@@ -61,10 +63,20 @@ export function listEntries() {
61
63
  title = t.replace(/^#+\s*/, '');
62
64
  break;
63
65
  }
66
+ // 写入时刻(mtime)。**这一行是必需的,不是锦上添花。**
67
+ //
68
+ // 文件名里的日期只有天粒度,而心跳比较的是「这条什么时候写的」和
69
+ // 「上次开口是什么时候」——一个天、一个秒。同一天里说过一次话之后,
70
+ // 当天再写的任何东西,toMs('2026-11-15') 都是当天零点,一律早于
71
+ // lastSpokeAt,于是全被判成「已经说过了」,那条记忆永远轮不到。
72
+ //
73
+ // 这个 bug 的表现和「设计上就该沉默」一模一样,所以它能活到现在。
74
+ at = statSync(p).mtime.toISOString();
64
75
  } catch {
65
76
  title = '(读不到)';
77
+ // at 留 null:素材层会退回去用 date 比。宁可粗略,也不编一个时刻出来。
66
78
  }
67
- out.push({ id: name.slice(0, -3), date: m[1], slug: m[2], title });
79
+ out.push({ id: name.slice(0, -3), date: m[1], slug: m[2], title, at });
68
80
  }
69
81
  out.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0));
70
82
  return out;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-galatea",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Galatea — memory, heartbeat and continuity for DeepSeek Harness agents.",
5
5
  "repository": {
6
6
  "type": "git",