dsh-galatea 0.4.6 → 0.4.8

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.6(GitHub Actions 自动发,不需要 token) |
16
+ | 发布到 npm | ✅ 0.4.8(GitHub Actions 自动发,不需要 token) |
17
17
  | 记忆:读写 Markdown | ✅ 15 条测试 |
18
18
  | 记忆:注入索引 | ✅ **已核实内容真的进了上下文**(不是在会话日志里看到「接上了没告警」) |
19
19
  | 记忆:`remember` / `recall` | ✅ 23 条测试 |
@@ -187,7 +187,23 @@
187
187
  关着(0)也照常装定时器 —— 否则用户从 0 改成 5 的时候没人去装,又回到同一个坑。
188
188
  代价是每分钟空醒一次,读一个数字比大小。
189
189
 
190
- ## 三条教训:粒度、上限、旧数据
190
+ ### 刚记下的事,不会马上被提起
191
+
192
+ 这是设计,不是缺陷 —— 但必须说清楚,否则用户会一直等一件不会发生的事。
193
+
194
+ `hasHardMaterial` 这一版没有判据,所以「有素材」不等于「必须说」。
195
+ 在接上真正的硬素材(承诺到期、上次没说完的)之前,自动心跳只有两条路开口:
196
+
197
+ - 掷色子中(`dice`,默认 0.15)
198
+ - 连续 `quietRounds` 轮没开口,撞到下限保护(默认 8 轮)
199
+
200
+ 所以「我刚记了一件事,它应该会跟我提」——这一版不成立。
201
+ 素材只让它「有东西可说」,说不说还是门控的事。
202
+
203
+ (同理,日志里那句「有素材,这次没掷中」和「记忆里一条都没有」是两件事。
204
+ 前者什么都别做,后者去记一条。)
205
+
206
+ ## 四条教训:粒度、上限、旧数据、清理的对象
191
207
 
192
208
  ### 一 · 天粒度和秒粒度不能比较
193
209
 
@@ -226,6 +242,33 @@ lastSpokeAt = 2026-11-15T09:45:59Z ← 真的时刻
226
242
  它也不会一直错下去:下一次真的开口就归零。
227
243
  但它提醒一件事:**修「某个计数器的累加条件」时,旧值也是新逻辑的输入。**
228
244
 
245
+ ### 四 · 清理函数写对了,但清的不是自己
246
+
247
+ `ctx.interval()` 内部是:
248
+
249
+ ```js
250
+ interval(callback, delay) {
251
+ return this.ctx.effect(() => {
252
+ const timer = setInterval(callback, delay)
253
+ return () => clearInterval(timer)
254
+ })
255
+ }
256
+ ```
257
+
258
+ 清理函数写得很对。问题在 `this.ctx` —— 那是 **TimerService 自己被注册时的 ctx**
259
+ (`service.d.ts` 的构造函数把入参存成 `this.ctx`),不是调用方的。
260
+ 而 `Context` 本身没有 `ctx` 属性,所以通过 `mixin('timer', ...)` 混到 Context 上的
261
+ 这个方法,方法体里的 `this` 只能是 service。
262
+
263
+ 于是定时器挂在 timer 服务的 fiber 上,而那个 fiber 在根上、不销毁。
264
+ **插件每重载一次,旧钟还在响一个。** 重载十次,十个钟同时写账本 ——
265
+ 而账本看起来只是在正常动,只是快了几倍,没有任何地方会报错。
266
+
267
+ 修法:自己的 `ctx.effect` 套一层,卸载时走自己的清理。
268
+
269
+ 这一条和上一条是同一类:**两样东西看起来配套,其实不是一对。**
270
+ 日期和时刻是「粒度不配对」,这里是「所有权不配对」。
271
+
229
272
  ## 一次教训:模块边界
230
273
 
231
274
  0.3.0 / 0.3.5 / 0.3.6 连着三次,坏的地方都在**两个文件交接处**:
@@ -262,7 +305,7 @@ $DSH_HOME/galatea/heartbeat-state.json # 心跳状态账本
262
305
  ## 本地开发
263
306
 
264
307
  ```bash
265
- npm test # 107 条,不依赖 DSH,纯 node
308
+ npm test # 110 条,不依赖 DSH,纯 node
266
309
  ```
267
310
 
268
311
  装了之后验证它真的加载了,只看一个东西:
@@ -283,7 +326,7 @@ dsh plugin add dsh-galatea
283
326
  或者在 profile 的 `package.json` 里手写:
284
327
 
285
328
  ```json
286
- "dependencies": { "dsh-galatea": "0.4.6" }
329
+ "dependencies": { "dsh-galatea": "0.4.8" }
287
330
  ```
288
331
 
289
332
  **版本号钉死,不要写 `^`。** 同一个版本号指向两个不同的包,以后就说不清你装的是哪一份。
package/index.js CHANGED
@@ -252,9 +252,27 @@ export function apply(ctx, config) {
252
252
  const TICK_MS = 60_000;
253
253
  if (ctx && typeof ctx.interval === 'function') {
254
254
  let lastRunAt = 0;
255
- // 用 ctx.interval,不用已 deprecated 的 ctx.setInterval —— 它返回 dispose,
256
- // 由当前 fiber 管着,宿主热重载时不会叠出第二个定时器。
257
- ctx.interval(() => {
255
+
256
+ // ⚠️ 必须包在**自己的** ctx.effect 里。
257
+ //
258
+ // 这里原来写的是「ctx.interval 返回 dispose,由当前 fiber 管着,
259
+ // 宿主热重载时不会叠出第二个定时器」。**那是假的,我从没验过就写上去了。**
260
+ //
261
+ // 翻 cordis-plugin-timer 才看见:
262
+ // interval(callback, delay) { return this.ctx.effect(() => { ... }) }
263
+ // 而这个 this.ctx 是 **TimerService 自己被注册时的 ctx**
264
+ // (service.d.ts 构造函数里把入参存成 this.ctx),
265
+ // 又因为 Context 本身没有 `ctx` 属性(context.d.ts 只有
266
+ // events/logger/reflect/registry),所以通过 `mixin('timer', ...)`
267
+ // 混到 Context 上的这个方法,方法体里的 this 只能是 service。
268
+ //
269
+ // ⟹ 定时器挂在 timer 服务的 fiber 上,那个 fiber 在根上、不销毁。
270
+ // ⟹ 插件每重载一次,旧钟还在响一个。重载十次,十个钟同时写账本 ——
271
+ // 而账本看起来只是在正常动,只是快了几倍,没有任何地方会报错。
272
+ //
273
+ // 用 ctx.effect 套住:这个 ctx 是**我们自己**的 fiber,卸载时会走清理。
274
+ ctx.effect(() => {
275
+ const stop = ctx.interval(() => {
258
276
  try {
259
277
  // 现读 —— volatile 字段唯一的正确用法。
260
278
  // 取不到(没配过)就回落到 0,也就是「关着」。
@@ -294,7 +312,10 @@ export function apply(ctx, config) {
294
312
  console.warn('[galatea] 自动心跳失败:', err && err.message);
295
313
  }
296
314
  }, TICK_MS);
297
- console.log('[galatea] 自动心跳的钟已装:每 60 秒醒一次,按设置里的 autoBeatMinutes 决定跑不跑(0 = 不跑)');
315
+ return () => stop();
316
+ }, 'galatea:auto-beat');
317
+
318
+ console.log('[galatea] 自动心跳的钟已装:每 60 秒醒一次,按设置里的 autoBeatMinutes 决定跑不跑(0 = 不跑)');
298
319
 
299
320
  // 一个很自然但会误会的组合:自动心跳间隔 < 最小间隔。
300
321
  // 它**不是错误** —— minGap 会把节奏压回每 minGap 分钟一次。
package/lib/beat-tool.js CHANGED
@@ -143,7 +143,20 @@ export function makeBeatTool(deps) {
143
143
  let committed = null;
144
144
  let saveError = null;
145
145
  try {
146
- committed = commitDecision(state, { speak: r.speak, reason: r.reason, detail: r.detail }, new Date());
146
+ // 手动档是「看一眼」,不是「跳一次」。
147
+ //
148
+ // 它照常记账(decisions 会多一条,quietStreak 也照实算),
149
+ // 但**不推进 lastBeatAt** —— 否则用户每查一次状态,自动心跳就往后推一个
150
+ // minGap(默认 20 分钟)。连查三次,一小时内不会真正判定,
151
+ // 而用户完全不知道,只会觉得「它怎么不动了」。
152
+ //
153
+ // 这一条是外面的人真机测出来的:18:37 手动 beat,19:08 才跨过 minGap。
154
+ committed = commitDecision(
155
+ state,
156
+ { speak: r.speak, reason: r.reason, detail: r.detail },
157
+ new Date(),
158
+ { manual: true },
159
+ );
147
160
  saveState(committed, stateFile());
148
161
  } catch (err) {
149
162
  // 失败必须让人看见。
package/lib/beat.js CHANGED
@@ -47,7 +47,7 @@ export function planBeat(input) {
47
47
  const material = pickMaterial(input.entries ?? [], state.lastSpokeAt ?? null);
48
48
  if (!material.entry) {
49
49
  return no(
50
- material.reason === 'noEntries' ? 'noMaterial' : 'nothingNew',
50
+ material.reason === 'noEntries' ? 'noEntries' : 'nothingNew',
51
51
  material.reason === 'noEntries'
52
52
  ? '记忆里一条可用的都没有 —— 没有出处的话不说'
53
53
  : '写了的东西都说过了 —— 没有新的就不开口',
@@ -103,7 +103,10 @@ export function commitDecision(state, decision, now = new Date(), opts = {}) {
103
103
  //
104
104
  // 默认 true:老调用点(不带这个字段的)行为不变,不会因为升级而卡住。
105
105
  if (decision.touchBeat !== false) {
106
- next.lastBeatAt = at;
106
+ // 手动档(galatea_beat)也算一次判定 —— 它确实问了一遍「要不要说」,
107
+ // 所以 quietStreak 照涨。但它不该推进 lastBeatAt:那是**自动节奏**的基准,
108
+ // 手动查一次状态就把后台往后推 20 分钟,用户完全看不出来。
109
+ if (opts.manual !== true) next.lastBeatAt = at;
107
110
  next.quietStreak = decision.speak ? 0 : (state.quietStreak ?? 0) + 1;
108
111
  }
109
112
  if (decision.speak) next.lastSpokeAt = at;
package/lib/heartbeat.js CHANGED
@@ -186,10 +186,13 @@ export function decide(input) {
186
186
  if (r < cfg.dice) {
187
187
  return { speak: true, reason: 'dice', detail: `色子 ${r.toFixed(3)} < ${cfg.dice}`, touchBeat: true };
188
188
  }
189
+ // 名字要准:这次「不说」不是因为没素材 —— 素材层早就给出结论了。
190
+ // 是「有东西可说,但这次没掷中」。叫 noMaterial 会让 journal 翻成
191
+ // 「记忆里没有可用的东西」,用户第一反应是去查库是不是没存上。
189
192
  return {
190
193
  speak: false,
191
- reason: 'noMaterial',
192
- detail: `没硬素材,色子 ${r.toFixed(3)} ≥ ${cfg.dice}`,
194
+ reason: 'notChosen',
195
+ detail: `有素材,但这次色子 ${r.toFixed(3)} ≥ ${cfg.dice}`,
193
196
  touchBeat: true,
194
197
  };
195
198
  }
@@ -203,4 +206,4 @@ function toMs(v) {
203
206
  }
204
207
 
205
208
  /** 所有可能的 reason —— 用来保证「没有第七种情况」。 */
206
- export const REASONS = ['quiet', 'tooSoon', 'cooldown', 'hard', 'floor', 'dice', 'noMaterial'];
209
+ export const REASONS = ['quiet', 'tooSoon', 'cooldown', 'hard', 'floor', 'dice', 'notChosen'];
package/lib/journal.js CHANGED
@@ -23,9 +23,16 @@ export const REASON_TEXT = {
23
23
  hard: '有具体的事要说',
24
24
  floor: '太久没开口,这次强制说一句',
25
25
  dice: '随机决定要说',
26
- noMaterial: '记忆里没有可用的东西',
26
+ // 「没说」的三种必须分得开 —— 用户看到之后该做什么,三种完全不同:
27
+ // noEntries → 去记一条
28
+ // notChosen → 什么都不用做,这次没掷中
29
+ // nothingNew → 去记一条新的
30
+ noEntries: '记忆里一条都没有',
31
+ notChosen: '有素材,这次没掷中',
27
32
  nothingNew: '记下的都说过了',
28
33
  noHuman: '没有正在进行的对话',
34
+ // 旧账本里躺着的名字。0.4.7 之前,上面 noEntries 和 notChosen 都被记成 noMaterial。
35
+ noMaterial: '(旧版)没有可用的素材',
29
36
  };
30
37
 
31
38
  /** HH:MM,本地时区。认不出的时间原样标出来,不替它猜一个像样的值。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-galatea",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "Galatea — memory, heartbeat and continuity for DeepSeek Harness agents.",
5
5
  "repository": {
6
6
  "type": "git",