dsh-galatea 0.4.5 → 0.4.7

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.5(GitHub Actions 自动发,不需要 token) |
16
+ | 发布到 npm | ✅ 0.4.6(GitHub Actions 自动发,不需要 token) |
17
17
  | 记忆:读写 Markdown | ✅ 15 条测试 |
18
18
  | 记忆:注入索引 | ✅ **已核实内容真的进了上下文**(不是在会话日志里看到「接上了没告警」) |
19
19
  | 记忆:`remember` / `recall` | ✅ 23 条测试 |
@@ -187,6 +187,61 @@
187
187
  关着(0)也照常装定时器 —— 否则用户从 0 改成 5 的时候没人去装,又回到同一个坑。
188
188
  代价是每分钟空醒一次,读一个数字比大小。
189
189
 
190
+ ### 刚记下的事,不会马上被提起
191
+
192
+ 这是设计,不是缺陷 —— 但必须说清楚,否则用户会一直等一件不会发生的事。
193
+
194
+ `hasHardMaterial` 这一版没有判据,所以「有素材」不等于「必须说」。
195
+ 在接上真正的硬素材(承诺到期、上次没说完的)之前,自动心跳只有两条路开口:
196
+
197
+ - 掷色子中(`dice`,默认 0.15)
198
+ - 连续 `quietRounds` 轮没开口,撞到下限保护(默认 8 轮)
199
+
200
+ 所以「我刚记了一件事,它应该会跟我提」——这一版不成立。
201
+ 素材只让它「有东西可说」,说不说还是门控的事。
202
+
203
+ (同理,日志里那句「有素材,这次没掷中」和「记忆里一条都没有」是两件事。
204
+ 前者什么都别做,后者去记一条。)
205
+
206
+ ## 三条教训:粒度、上限、旧数据
207
+
208
+ ### 一 · 天粒度和秒粒度不能比较
209
+
210
+ 条目文件名里只有日期(`2026-11-15-土豆.md`),而心跳要回答的是
211
+ 「这条是写下之后才说的,还是说过了」。两边一个按天、一个按秒:
212
+
213
+ ```
214
+ toMs('2026-11-15') = 2026-11-15T00:00:00Z ← 当天零点
215
+ lastSpokeAt = 2026-11-15T09:45:59Z ← 真的时刻
216
+ ```
217
+
218
+ 于是**当天说过一次话之后,当天再写的任何东西都判成「早于上次开口」**,
219
+ 永远轮不到。用户会觉得「我刚记的事它怎么完全不提」——
220
+ 而这和「设计上就该沉默」长得一模一样,所以它能活到 0.4.5。
221
+
222
+ 现在 `listEntries` 会带上 `at`(文件 mtime),素材层优先用它;
223
+ 读不到 mtime 时退回日期(宁可粗略,也不编一个时刻)。
224
+ `date` 保留不变——注入里给人看的就是它,人不需要秒。
225
+
226
+ ### 二 · 账本满了要说
227
+
228
+ `decisions` 上限 50 条,满了滚动覆盖最旧的,所以**总数永远是 50**:
229
+ 看起来像停了,其实一直在写。
230
+
231
+ 原来那句「想确认它在动,看账本有没有新记录就够了」,在满 50 之后就不成立了。
232
+ 现在满了会明说一句。
233
+
234
+ **假信号比没有信号更坏**——没有信号至少会让人去别处找。
235
+
236
+ ### 三 · 旧数据会跟着版本一起进来
237
+
238
+ 0.4.5 修掉 `quietStreak` 的累加条件之后,**旧账本里那个数还是旧的**。
239
+ 0.4.3 每跳都 +1,可能已经攒到几十;0.4.5 的第一次真实判定就直接撞下限保护。
240
+
241
+ 这里不改代码,因为分不清「真的几十轮没说话」和「旧 bug 累出来的」。
242
+ 它也不会一直错下去:下一次真的开口就归零。
243
+ 但它提醒一件事:**修「某个计数器的累加条件」时,旧值也是新逻辑的输入。**
244
+
190
245
  ## 一次教训:模块边界
191
246
 
192
247
  0.3.0 / 0.3.5 / 0.3.6 连着三次,坏的地方都在**两个文件交接处**:
@@ -223,7 +278,7 @@ $DSH_HOME/galatea/heartbeat-state.json # 心跳状态账本
223
278
  ## 本地开发
224
279
 
225
280
  ```bash
226
- npm test # 101 条,不依赖 DSH,纯 node
281
+ npm test # 109 条,不依赖 DSH,纯 node
227
282
  ```
228
283
 
229
284
  装了之后验证它真的加载了,只看一个东西:
@@ -244,7 +299,7 @@ dsh plugin add dsh-galatea
244
299
  或者在 profile 的 `package.json` 里手写:
245
300
 
246
301
  ```json
247
- "dependencies": { "dsh-galatea": "0.4.5" }
302
+ "dependencies": { "dsh-galatea": "0.4.7" }
248
303
  ```
249
304
 
250
305
  **版本号钉死,不要写 `^`。** 同一个版本号指向两个不同的包,以后就说不清你装的是哪一份。
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
  : '写了的东西都说过了 —— 没有新的就不开口',
@@ -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
  // 不说明有过一次「说不说」的机会 —— 所以下面两样都不该动,理由各一条:
@@ -93,7 +103,10 @@ export function commitDecision(state, decision, now = new Date(), opts = {}) {
93
103
  //
94
104
  // 默认 true:老调用点(不带这个字段的)行为不变,不会因为升级而卡住。
95
105
  if (decision.touchBeat !== false) {
96
- next.lastBeatAt = at;
106
+ // 手动档(galatea_beat)也算一次判定 —— 它确实问了一遍「要不要说」,
107
+ // 所以 quietStreak 照涨。但它不该推进 lastBeatAt:那是**自动节奏**的基准,
108
+ // 手动查一次状态就把后台往后推 20 分钟,用户完全看不出来。
109
+ if (opts.manual !== true) next.lastBeatAt = at;
97
110
  next.quietStreak = decision.speak ? 0 : (state.quietStreak ?? 0) + 1;
98
111
  }
99
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
@@ -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 = {
@@ -22,9 +23,16 @@ export const REASON_TEXT = {
22
23
  hard: '有具体的事要说',
23
24
  floor: '太久没开口,这次强制说一句',
24
25
  dice: '随机决定要说',
25
- noMaterial: '记忆里没有可用的东西',
26
+ // 「没说」的三种必须分得开 —— 用户看到之后该做什么,三种完全不同:
27
+ // noEntries → 去记一条
28
+ // notChosen → 什么都不用做,这次没掷中
29
+ // nothingNew → 去记一条新的
30
+ noEntries: '记忆里一条都没有',
31
+ notChosen: '有素材,这次没掷中',
26
32
  nothingNew: '记下的都说过了',
27
33
  noHuman: '没有正在进行的对话',
34
+ // 旧账本里躺着的名字。0.4.7 之前,上面 noEntries 和 notChosen 都被记成 noMaterial。
35
+ noMaterial: '(旧版)没有可用的素材',
28
36
  };
29
37
 
30
38
  /** HH:MM,本地时区。认不出的时间原样标出来,不替它猜一个像样的值。 */
@@ -70,6 +78,19 @@ export function renderJournal(state, opts = {}) {
70
78
  );
71
79
  }
72
80
 
81
+ // 账本满了要说一声。
82
+ //
83
+ // 「看它有没有新记录」这个判据,在满 50 条之后就不成立了:
84
+ // 总数恒为 50,看起来像停了,其实一直在写。
85
+ // 不说的话,这就是个假信号——而假信号比没有信号更坏。
86
+ const maxDecisions = Number(opts.maxDecisions ?? MAX_DECISIONS);
87
+ if (Number.isFinite(maxDecisions) && ds.length >= maxDecisions) {
88
+ if (lines.length > 0) lines.push('');
89
+ lines.push(
90
+ `(账本记满 ${maxDecisions} 条了,再来一条就挤掉最旧的一条 —— 条数不会再涨,看时间戳。)`,
91
+ );
92
+ }
93
+
73
94
  // 连续没开口的次数:这是「沉默」和「坏了」唯一能分开的地方
74
95
  const streak = Number(state?.quietStreak ?? 0);
75
96
  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.5",
3
+ "version": "0.4.7",
4
4
  "description": "Galatea — memory, heartbeat and continuity for DeepSeek Harness agents.",
5
5
  "repository": {
6
6
  "type": "git",