dsh-galatea 0.4.7 → 0.5.0

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.5.0(GitHub Actions 自动发,不需要 token) |
17
17
  | 记忆:读写 Markdown | ✅ 15 条测试 |
18
18
  | 记忆:注入索引 | ✅ **已核实内容真的进了上下文**(不是在会话日志里看到「接上了没告警」) |
19
19
  | 记忆:`remember` / `recall` | ✅ 23 条测试 |
@@ -203,7 +203,24 @@
203
203
  (同理,日志里那句「有素材,这次没掷中」和「记忆里一条都没有」是两件事。
204
204
  前者什么都别做,后者去记一条。)
205
205
 
206
- ## 三条教训:粒度、上限、旧数据
206
+ ### 它想说的话,劈成两半
207
+
208
+ 每次「该说了」,账本里存两样东西:
209
+
210
+ ```
211
+ 15:32 说了 — 太久没开口,这次强制说一句
212
+    本来想说:11 月 14 日 ·「土豆硬是咖喱放早了」
213
+ ```
214
+
215
+ - **say(A)**:能直接说给用户听的那一句。只有日期和标题,**原样引用**。
216
+ - **prompt(B)**:交给下一轮模型的一段指令 ——「这是你自己记下的一件事……要说的话,就围绕它」。它**不是给用户看的文本**。
217
+
218
+ 为什么劈成两半:语气那一半才可能编。事实那一半是结构生成的,
219
+ 抄错了随时能对回原条目;而编出来的句子,你没法指出它错在哪 —— 它每一句都像真的。
220
+
221
+ 所以 A 由结构保证,B 只负责怎么说。两样都进账本,能并排看。
222
+
223
+ ## 四条教训:粒度、上限、旧数据、清理的对象
207
224
 
208
225
  ### 一 · 天粒度和秒粒度不能比较
209
226
 
@@ -242,6 +259,33 @@ lastSpokeAt = 2026-11-15T09:45:59Z ← 真的时刻
242
259
  它也不会一直错下去:下一次真的开口就归零。
243
260
  但它提醒一件事:**修「某个计数器的累加条件」时,旧值也是新逻辑的输入。**
244
261
 
262
+ ### 四 · 清理函数写对了,但清的不是自己
263
+
264
+ `ctx.interval()` 内部是:
265
+
266
+ ```js
267
+ interval(callback, delay) {
268
+ return this.ctx.effect(() => {
269
+ const timer = setInterval(callback, delay)
270
+ return () => clearInterval(timer)
271
+ })
272
+ }
273
+ ```
274
+
275
+ 清理函数写得很对。问题在 `this.ctx` —— 那是 **TimerService 自己被注册时的 ctx**
276
+ (`service.d.ts` 的构造函数把入参存成 `this.ctx`),不是调用方的。
277
+ 而 `Context` 本身没有 `ctx` 属性,所以通过 `mixin('timer', ...)` 混到 Context 上的
278
+ 这个方法,方法体里的 `this` 只能是 service。
279
+
280
+ 于是定时器挂在 timer 服务的 fiber 上,而那个 fiber 在根上、不销毁。
281
+ **插件每重载一次,旧钟还在响一个。** 重载十次,十个钟同时写账本 ——
282
+ 而账本看起来只是在正常动,只是快了几倍,没有任何地方会报错。
283
+
284
+ 修法:自己的 `ctx.effect` 套一层,卸载时走自己的清理。
285
+
286
+ 这一条和上一条是同一类:**两样东西看起来配套,其实不是一对。**
287
+ 日期和时刻是「粒度不配对」,这里是「所有权不配对」。
288
+
245
289
  ## 一次教训:模块边界
246
290
 
247
291
  0.3.0 / 0.3.5 / 0.3.6 连着三次,坏的地方都在**两个文件交接处**:
@@ -278,7 +322,7 @@ $DSH_HOME/galatea/heartbeat-state.json # 心跳状态账本
278
322
  ## 本地开发
279
323
 
280
324
  ```bash
281
- npm test # 109 条,不依赖 DSH,纯 node
325
+ npm test # 116 条,不依赖 DSH,纯 node
282
326
  ```
283
327
 
284
328
  装了之后验证它真的加载了,只看一个东西:
@@ -299,7 +343,7 @@ dsh plugin add dsh-galatea
299
343
  或者在 profile 的 `package.json` 里手写:
300
344
 
301
345
  ```json
302
- "dependencies": { "dsh-galatea": "0.4.7" }
346
+ "dependencies": { "dsh-galatea": "0.5.0" }
303
347
  ```
304
348
 
305
349
  **版本号钉死,不要写 `^`。** 同一个版本号指向两个不同的包,以后就说不清你装的是哪一份。
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.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * 空话比不说更糟,它消耗的是「它真的在」那点信用。
15
15
  */
16
16
  import { pickTarget } from './target.js';
17
- import { pickMaterial, materialPrompt } from './material.js';
17
+ import { pickMaterial, materialPrompt, entryLine } from './material.js';
18
18
  import { decide, DEFAULTS } from './heartbeat.js';
19
19
 
20
20
  /**
@@ -83,6 +83,10 @@ export function planBeat(input) {
83
83
  speak: decision.speak,
84
84
  reason: decision.reason,
85
85
  sessionId: target.sessionId,
86
+ // A:能直接说给用户听的那一句。只有事实(日期 + 标题),可核对。
87
+ say: decision.speak ? entryLine(material.entry) : null,
88
+ // B:交给**下一轮的模型** —— 它读这段,决定怎么说。
89
+ // 注意 prompt 不是给用户看的文本,两者别混。
86
90
  prompt: decision.speak ? materialPrompt(material.entry) : null,
87
91
  switched: target.switched,
88
92
  previous: target.previous,
@@ -96,6 +100,7 @@ export function planBeat(input) {
96
100
  speak: false,
97
101
  reason,
98
102
  sessionId: extra.sessionId ?? null,
103
+ say: null,
99
104
  prompt: null,
100
105
  switched: extra.switched,
101
106
  previous: extra.previous,
@@ -84,6 +84,11 @@ export function commitDecision(state, decision, now = new Date(), opts = {}) {
84
84
  speak: decision.speak,
85
85
  reason: decision.reason,
86
86
  detail: decision.detail,
87
+ // A:本来要说给用户听的那一句。只有事实(日期 + 标题),可核对。
88
+ say: decision.say ?? null,
89
+ // B:交给**下一轮模型**的那段指令 —— 不是给用户看的文本。
90
+ // 两样都存,才能并排看「它想说什么」和「它打算怎么说」。
91
+ prompt: decision.prompt ?? null,
87
92
  };
88
93
  const next = {
89
94
  ...state,
package/lib/journal.js CHANGED
@@ -70,12 +70,15 @@ export function renderJournal(state, opts = {}) {
70
70
  // 早退会把 streak 吞掉,而那正是沉默和坏了唯一的分界。
71
71
  lines.push('心跳还没有记录过。它至少跑过一次之后,这里才会有东西。');
72
72
  } else {
73
- lines.push(
74
- ...ds
75
- .slice(-limit)
76
- .reverse()
77
- .map((d) => `${clockOf(d.at)} ${d.speak ? '说了' : '没说'} — ${explain(d.reason)}`),
78
- );
73
+ // 每条决定下面多一行「本来想说什么」。
74
+ //
75
+ // say(A)是**事实**:哪天记的、标题是什么,原样引用。
76
+ // 不印出来,「说了 / 没说」就只是一句判决 ——
77
+ // 用户看不到它到底想说什么,那和空账本没区别。
78
+ for (const d of ds.slice(-limit).reverse()) {
79
+ lines.push(`${clockOf(d.at)} ${d.speak ? '说了' : '没说'} — ${explain(d.reason)}`);
80
+ if (d.say) lines.push(`   本来想说:${d.say}`);
81
+ }
79
82
  }
80
83
 
81
84
  // 账本满了要说一声。
package/lib/material.js CHANGED
@@ -80,3 +80,35 @@ export function materialPrompt(entry) {
80
80
  '要说的话,就围绕它。不要复述标题,也不要念稿。',
81
81
  ].join('\n');
82
82
  }
83
+
84
+ /**
85
+ * 日期写成「11 月 14 日」。
86
+ *
87
+ * 同时吃 `2026-11-14`(文件名里那种)和完整的 ISO 时刻(mtime 那种)——
88
+ * 两种在系统里都真实存在,只认一种,另一种就会渲染成半句话。认不出就原样返回,不编。
89
+ */
90
+ export function shortDate(value) {
91
+ const s = String(value ?? '');
92
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
93
+ if (!m) return s;
94
+ return `${Number(m[2])} 月 ${Number(m[3])} 日`;
95
+ }
96
+
97
+ /**
98
+ * **A:可以原样说给用户听的那一句。**
99
+ *
100
+ * 只有事实,没有语气。日期和标题都原样引用,一个字不改。
101
+ *
102
+ * 为什么把「说什么」劈成 A 和 B 两半:
103
+ *
104
+ * 语气那一半才可能编。事实这一半是结构生成的 —— 抄错了随时能对回原条目,
105
+ * 而编出来的句子,你没法指出它错在哪(它每一句都像真的)。
106
+ *
107
+ * 所以:A 由结构保证,B(materialPrompt,交给下一轮的模型)只负责怎么说。
108
+ * 两者都进账本,能并排看。
109
+ */
110
+ export function entryLine(entry) {
111
+ if (!entry) return null;
112
+ // 优先用文件名里的天(人读着舒服);没有就退回 mtime 的 ISO 时刻。
113
+ return `${shortDate(entry.date ?? entry.at)} ·「${entry.title}」`;
114
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-galatea",
3
- "version": "0.4.7",
3
+ "version": "0.5.0",
4
4
  "description": "Galatea — memory, heartbeat and continuity for DeepSeek Harness agents.",
5
5
  "repository": {
6
6
  "type": "git",