dsh-galatea 0.0.0-stage → 0.3.2

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
@@ -1,3 +1,129 @@
1
- # Temporary Holding Version
1
+ # Galatea
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 让 DeepSeek Harness 上的 Agent 有记忆、会安静、能连续下去。
4
+
5
+ 三块骨头:
6
+
7
+ - **记忆** —— Markdown 落地,**正文不进上下文,只进标题**
8
+ - **心跳** —— 确定性门控,默认闭嘴;用户设的是「什么时候不许叫我」
9
+ - **连续** —— 不写成目标,写成可观察的事实
10
+
11
+ ## 现在到哪了
12
+
13
+ | 块 | 状态 |
14
+ |---|---|
15
+ | 能被 DSH 装起来 | ✅ 实测通过(dsh 0.2.2-rc.3) |
16
+ | 记忆:读写 Markdown | ✅ 15 条测试 |
17
+ | 记忆:注入索引 | ✅ 接上 systemPrompt,实测无告警 |
18
+ | 记忆:`remember` / `recall` | ✅ 23 条测试 |
19
+ | 心跳:门控(要不要开口) | ✅ 15 条测试 |
20
+ | 心跳:状态账本(跨进程) | ✅ 10 条测试 |
21
+ | 心跳:对谁说(排除子代理) | ✅ 10 条测试 |
22
+ | 心跳:说什么(没出处就不说) | ✅ 10 条测试 |
23
+ | 心跳:三段合成一个决定 | ✅ 10 条测试 |
24
+ | 心跳:`galatea_beat` 手动档 | ✅ 实测加载,无告警 |
25
+ | 设置页可改的字段 | ⚠️ 声明被宿主接受,**表单没在 GUI 里看过** |
26
+ | 心跳:定时器 + 自动开口 | ⬜ 未做 |
27
+ | 发布到 npm | ⬜ 未做 |
28
+
29
+ > ⚠️ **它现在还不会自己开口。** 定时器和 `ctx.agents.resume` 都还没接,
30
+ > 判定链路完整,但没有出口。别把「测试全绿」读成「它已经在主动找你了」。
31
+
32
+ ## 怎么用
33
+
34
+ 三个工具,Agent 自己会调:
35
+
36
+ | 工具 | 干什么 |
37
+ |---|---|
38
+ | `galatea_remember` | 记一条。标题进索引,正文不进 |
39
+ | `galatea_recall` | 给 id 读正文,不给 id 列标题 |
40
+ | `galatea_beat` | 手动跑一次心跳:**现在该不该说话、对谁说、拿哪条当由头**。只算不发 |
41
+
42
+ `galatea_beat` 是给「你现在想说什么」这个问题准备的。它不依赖定时器,
43
+ 所以在任何有会话的地方都能立刻看到整条链路通不通。
44
+
45
+ ## 设置(不用改代码)
46
+
47
+ 六个字段声明为 `.volatile()`,所以 DSH 的设置页会照着 schema 自动生成表单,
48
+ 改完**不重启插件**:
49
+
50
+ | 字段 | 说明 |
51
+ |---|---|
52
+ | `quietFrom` / `quietTo` | 安静时段,这段时间它绝不主动开口。跨午夜没问题(23:00 → 08:00) |
53
+ | `cooldownMinutes` | 你刚说完话,它至少安静多久 |
54
+ | `minGapMinutes` | 两次心跳最少隔多久 |
55
+ | `dice` | 没有由头时的开口概率。0 = 只在有素材时说 |
56
+ | `quietRounds` | 连续多少轮没开口就强制说一次。0 = 关掉这个保底 |
57
+
58
+ 两处容易踩的:
59
+
60
+ - **「连续几轮没说就强制一次」和「最少间隔」是耦合的。** 心跳间隔设得比保底所需的时间还短,保底永远不会触发。
61
+ - **把 `quietRounds` 关掉,它可能安静到让你以为坏了。** 默认闭嘴需要一条下限,不然沉默和故障长得一样。
62
+
63
+ ## 唯一的设计判断
64
+
65
+ **正文不进上下文,只进标题。**
66
+
67
+ 常见的做法是每轮把记忆全文塞进提示里。那样一旦超过几十条,要么超预算,要么被迫摘要——
68
+ 而摘要之后,写进去的东西和读回来的东西就不是同一个了。
69
+
70
+ 所以这里注入的是「我记得过什么」(一行一条),正文躺着不动,要用的时候再去读。
71
+ 读的人知道自己读的是什么。
72
+
73
+ 同一个判断延伸出三条规矩,都写进了测试:
74
+
75
+ 1. 一条都没有时,说「还没开始」——不能什么都不显示,那和「功能坏了」长得一样
76
+ 2. 目录读不到时,说「读不到」,不说「0 条」
77
+ 3. 只给标题 + 日期,不给正文
78
+
79
+ ## 心跳为什么默认闭嘴
80
+
81
+ 大多数「主动说话」是:用户设一个时间,到点了 Agent 说话。那叫定时提醒——
82
+ 频率由用户决定,它对世界一无所知。
83
+
84
+ 这里反过来:**用户设的不是「什么时候叫我」,是「什么时候绝对不许叫我」。**
85
+
86
+ 墙之外,说不说取决于有没有**素材**——自己记下的、还没说过的条目。
87
+ 一条都没有,就不说。这不是「找不到素材」的技术失败,是应该沉默。
88
+
89
+ 门控可以自动决定,**内容不能自动生成**。空话比不说更糟,它消耗的是「它真的在」那点信用。
90
+
91
+ ## 数据放在哪
92
+
93
+ ```
94
+ $DSH_HOME/galatea/memory/entries/<YYYY-MM-DD>-<短名>.md # 记忆条目
95
+ $DSH_HOME/galatea/heartbeat-state.json # 心跳状态账本
96
+ ```
97
+
98
+ 文件名就是 id。日期在前,所以目录列表天然按时间排。正文第一行是标题。
99
+
100
+ 状态账本记的是:上一轮什么时候跳的、上次开口是什么时候、连续几轮没说话、最近的决定。
101
+ 它必须落盘——不落盘的话进程一重启计数就归零,下限保护立刻失效。
102
+
103
+ ## 本地开发
104
+
105
+ ```bash
106
+ npm test # 78 条,不依赖 DSH,纯 node
107
+ ```
108
+
109
+ 装了之后验证它真的加载了,只看一个东西:
110
+
111
+ ```
112
+ ~/.dsh/galatea-loaded.txt
113
+ ```
114
+
115
+ 那行 marker 不是日志,是**正控**:只有它出现,才说明这段代码真的跑过。
116
+ 「进程起来了」「配置里写了」都不算证据。
117
+
118
+ ## 安装(还没发布)
119
+
120
+ profile 的 `package.json`:
121
+
122
+ ```json
123
+ "dependencies": { "dsh-galatea": "file:E:/Galatea" },
124
+ "dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "dsh-galatea"] } }
125
+ ```
126
+
127
+ ## 授权
128
+
129
+ MIT
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: galatea
3
+ name: 'dsh-galatea'
package/index.js ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Galatea — 让 Agent 像人的三块骨头。
3
+ *
4
+ * 记忆:Markdown 落地,正文不进上下文,只进标题
5
+ * 心跳:(下一步)
6
+ * 连续:(不写成目标,写成可观察的事实)
7
+ *
8
+ * 这个文件只做接线。规则在 lib/ 里,能单独用 node 跑,不依赖 DSH。
9
+ * 这样测的时候不需要起一个宿主,「测的」和「跑的」才是同一份代码。
10
+ */
11
+ import { appendFileSync, mkdirSync } from 'node:fs';
12
+ import { dirname, join } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+ import z from '@deepseek-ai/schemastery';
15
+
16
+ import { renderIndex, dataRoot, listEntries } from './lib/store.js';
17
+ import { remember, recall } from './lib/tools.js';
18
+ import { makeBeatTool, snapshotAgents } from './lib/beat-tool.js';
19
+ import { loadState } from './lib/heartbeat-runner.js';
20
+ import { DEFAULTS } from './lib/heartbeat.js';
21
+
22
+ /** Cordis 插件名。 */
23
+ export const name = 'galatea';
24
+
25
+ /**
26
+ * 设置页里能改的字段。
27
+ *
28
+ * 为什么全都挂 `.volatile()`:
29
+ * 不挂的字段一改,Cordis 会重新 mount 整个插件 —— 用户点一下保存,
30
+ * 插件重启一次,正在跑的心跳计数也跟着清零。
31
+ * 挂上之后值原地提交,不重启。
32
+ *
33
+ * 为什么是扁平字段而不是 `quiet: [{from,to}]`:
34
+ * DSH 的表单是照着 schema 生成的,它表达不了数组里套对象。
35
+ * 所以这里拆成两个字符串,apply 里再拼回去。
36
+ * 这是为了「能改」付的代价,而能改比结构漂亮重要。
37
+ */
38
+ export const Config = z.object({
39
+ quietFrom: z
40
+ .string()
41
+ .default(DEFAULTS.quiet[0].from)
42
+ .description('安静时段从几点开始。这段时间里它绝不主动开口。格式 23:00')
43
+ .volatile(),
44
+ quietTo: z
45
+ .string()
46
+ .default(DEFAULTS.quiet[0].to)
47
+ .description('安静时段到几点结束。跨午夜没问题,比如 23:00 → 08:00。格式 08:00')
48
+ .volatile(),
49
+ cooldownMinutes: z
50
+ .number()
51
+ .default(DEFAULTS.cooldownMinutes)
52
+ .description('你刚说完话之后,它至少安静多少分钟。设成 0 会变成你一说话它就插嘴。')
53
+ .volatile(),
54
+ minGapMinutes: z
55
+ .number()
56
+ .default(DEFAULTS.minGapMinutes)
57
+ .description('两次心跳之间至少隔多久。注意:设得比「连续几轮没说话就强制开口」所需的时间还短的话,那个保底永远不会触发。')
58
+ .volatile(),
59
+ dice: z
60
+ .number()
61
+ .default(DEFAULTS.dice)
62
+ .description('没有具体由头时的开口概率(0~1)。0 = 只在有素材时说话,越接近 1 越吵。')
63
+ .volatile(),
64
+ quietRounds: z
65
+ .number()
66
+ .default(DEFAULTS.quietRounds)
67
+ .description('连续这么多轮没开口,就强制说一次。0 = 关掉这个保底。关掉的话,它可能安静到让你以为坏了。')
68
+ .volatile(),
69
+ });
70
+
71
+ /** 需要宿主注入的服务。 */
72
+ export const inject = ['systemPrompt', 'tools', 'agents'];
73
+
74
+ /**
75
+ * volatile 字段是**引用**,普通字段是**值**。
76
+ * 这里统一取值:引用就 `.get()`,否则原样返回。
77
+ *
78
+ * 一定要在使用的那一刻取,不能在 apply 里取一次存起来 ——
79
+ * 那样设置页改完看起来生效了,实际跑的还是旧值,而且不报错。
80
+ * (这类「改了但没生效」我今天已经踩过一次,不想再踩。)
81
+ */
82
+ function pick(v, fallback) {
83
+ if (v && typeof v.get === 'function') {
84
+ const got = v.get();
85
+ return got === undefined ? fallback : got;
86
+ }
87
+ return v === undefined ? fallback : v;
88
+ }
89
+
90
+ /** 把扁平的设置字段拼回心跳模块要的形状。表单表达不了数组,所以在这里拼。 */
91
+ function liveConfig(raw) {
92
+ return {
93
+ quiet: [
94
+ {
95
+ from: pick(raw?.quietFrom, DEFAULTS.quiet[0].from),
96
+ to: pick(raw?.quietTo, DEFAULTS.quiet[0].to),
97
+ },
98
+ ],
99
+ cooldownMinutes: pick(raw?.cooldownMinutes, DEFAULTS.cooldownMinutes),
100
+ minGapMinutes: pick(raw?.minGapMinutes, DEFAULTS.minGapMinutes),
101
+ dice: pick(raw?.dice, DEFAULTS.dice),
102
+ quietRounds: pick(raw?.quietRounds, DEFAULTS.quietRounds),
103
+ };
104
+ }
105
+
106
+ /**
107
+ * @param {object} ctx Cordis context
108
+ * @param {object} [config]
109
+ */
110
+ export function apply(ctx, config) {
111
+ const raw = config ?? {};
112
+ const cfg = raw;
113
+
114
+ // ── 正控:这一段真的跑过吗 ────────────────────────────────
115
+ // marker 不是日志。它只回答一个问题:apply() 到底有没有被执行。
116
+ // 「进程起来了」「配置里写了」都不算证据。
117
+ const marker = cfg.markerPath || join(homedir(), '.dsh', 'galatea-loaded.txt');
118
+ try {
119
+ mkdirSync(dirname(marker), { recursive: true });
120
+ appendFileSync(marker, `galatea loaded at ${new Date().toISOString()} pid=${process.pid}\n`);
121
+ } catch (err) {
122
+ console.warn('[galatea] marker 写入失败:', err && err.message);
123
+ }
124
+
125
+ // ── 记忆的注入段 ────────────────────────────────────────
126
+ // text 传函数:每一轮提示组装时现算,不缓存。
127
+ // 缓存过一版,结果和「记忆变了但注入没变」长得一模一样,分不出来。
128
+ if (ctx && ctx.systemPrompt && typeof ctx.systemPrompt.section === 'function') {
129
+ ctx.systemPrompt.section({
130
+ name: 'galatea:memory',
131
+ order: cfg.order ?? 0.6,
132
+ text: () => renderIndex({ limit: cfg.limit ?? 12 }),
133
+ interpolate: false,
134
+ });
135
+ } else {
136
+ console.warn('[galatea] 宿主没有 systemPrompt 服务,记忆注入没接上。dataRoot =', dataRoot());
137
+ }
138
+
139
+ // ── 工具 ────────────────────────────────────────────────
140
+ // 工具是「手」,注入是「眼睛」。
141
+ // 只有眼睛没有手,记忆就只能靠人手动往目录里放文件——那不是 Agent 的记忆。
142
+ if (ctx && ctx.tools && typeof ctx.tools.register === 'function') {
143
+ ctx.tools.register(remember);
144
+ ctx.tools.register(recall);
145
+ // 手动档:只算不发。它不依赖定时器,所以在任何有会话的地方都能立刻验。
146
+ // 自动档(定时器 + resume)是另一件事 —— 那个会真的开口,得单独接。
147
+ ctx.tools.register(
148
+ makeBeatTool({
149
+ listAgents: () => snapshotAgents(ctx.agents),
150
+ listEntries,
151
+ loadState,
152
+ config: () => liveConfig(raw),
153
+ }),
154
+ );
155
+ } else {
156
+ console.warn('[galatea] 宿主没有 tools 服务,记忆只能读不能写。');
157
+ }
158
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * galatea_beat —— 手动跑一次心跳,看它会说什么。
3
+ *
4
+ * 这是自动档之前必须先有的东西:
5
+ * 自动档 = 定时器调同一个函数;手动档 = 有人问的时候调。
6
+ * 先有手动档,才能在**任何有会话的地方**立刻验证整条链路,
7
+ * 而不用等它自己响(也没法等,它还没接定时器)。
8
+ *
9
+ * 它只算不发。真要发出去是另一件事,得单独做、单独开。
10
+ */
11
+ import { defineTool } from '@deepseek-ai/dsh-tools';
12
+
13
+ import { planBeat } from './beat.js';
14
+ import { pickMaterial } from './material.js';
15
+ import { renderJournal } from './journal.js';
16
+
17
+ /**
18
+ * 把 agents 服务里的 live agent 统一成 { id, header }。
19
+ * 读不到就返回空数组 —— 让它走「没人在看」那条路,比抛异常好。
20
+ */
21
+ export function snapshotAgents(agentsService) {
22
+ try {
23
+ const list = typeof agentsService?.list === 'function' ? agentsService.list() : [];
24
+ return (Array.isArray(list) ? list : []).map((a) => ({
25
+ id: a?.session?.id ?? a?.id ?? 'unknown',
26
+ header: a?.session?.header,
27
+ }));
28
+ } catch {
29
+ return [];
30
+ }
31
+ }
32
+
33
+ /**
34
+ * @param {object} deps
35
+ * @param {() => Array} deps.listAgents 返回 [{id, header}]
36
+ * @param {() => Array} deps.listEntries 返回记忆条目
37
+ * @param {() => object} deps.loadState
38
+ * @param {object} [deps.config]
39
+ */
40
+ export function makeBeatTool(deps) {
41
+ return defineTool({
42
+ name: 'galatea_beat',
43
+ description:
44
+ '手动跑一次心跳:现在该不该主动说话、对谁说、拿哪条记忆当由头。' +
45
+ '只算,不发送。想知道「你现在想说什么」的时候用。',
46
+ parameters: {},
47
+ output: {
48
+ schema: {
49
+ type: 'object',
50
+ additionalProperties: false,
51
+ properties: {
52
+ speak: { type: 'boolean' },
53
+ reason: { type: 'string' },
54
+ detail: { type: 'string' },
55
+ sessionId: { type: 'string' },
56
+ materialId: { type: 'string' },
57
+ materialTitle: { type: 'string' },
58
+ prompt: { type: 'string' },
59
+ switched: { type: 'boolean' },
60
+ previous: { type: 'string' },
61
+ text: { type: 'string' },
62
+ journal: { type: 'string' },
63
+ },
64
+ },
65
+ render: (_args, v) => [{ type: 'text', text: v.text }],
66
+ },
67
+ isConcurrencySafe: () => true,
68
+ async execute() {
69
+ const candidates = deps.listAgents();
70
+ const entries = deps.listEntries();
71
+ const state = deps.loadState();
72
+
73
+ const liveCfg = typeof deps.config === 'function' ? deps.config() : (deps.config ?? {});
74
+
75
+ const r = planBeat({
76
+ now: new Date(),
77
+ state,
78
+ candidates,
79
+ entries,
80
+ // 配置可以是函数:设置页改的是引用,每次现读才拿得到新值。
81
+ // 传快照的话,界面上改了、跑起来还是旧的,而且不报错。
82
+ config: liveCfg,
83
+ });
84
+
85
+ const material = entries.find((e) => e.id === r.materialId) ?? null;
86
+ const picked = material ?? pickMaterial(entries, state.lastSpokeAt ?? null).entry;
87
+
88
+ // 给人看的那几行:把它自己最近的决定翻成人话。
89
+ const journal = renderJournal(state, { quietRounds: liveCfg.quietRounds });
90
+
91
+ const lines = [];
92
+ lines.push(r.speak ? '现在会说话。' : '现在不会说话。');
93
+ lines.push(`理由:${r.reason} —— ${r.detail}`);
94
+ lines.push(`对谁说:${r.sessionId ?? '(没有活着的会话)'}`);
95
+ if (r.switched) lines.push(`⚠ 换了目标会话:${r.previous} → ${r.sessionId}(连续在这里断过一次)`);
96
+ if (picked) lines.push(`由头:${picked.id} ${picked.title}`);
97
+ if (r.prompt) lines.push(`会带着这段由头开口:\n${r.prompt}`);
98
+
99
+ return {
100
+ speak: r.speak,
101
+ reason: r.reason,
102
+ detail: r.detail,
103
+ sessionId: r.sessionId ?? '',
104
+ materialId: picked?.id ?? '',
105
+ materialTitle: picked?.title ?? '',
106
+ prompt: r.prompt ?? '',
107
+ switched: r.switched === true,
108
+ previous: r.previous ?? '',
109
+ text: lines.join('\n'),
110
+ journal,
111
+ };
112
+ },
113
+ });
114
+ }
package/lib/beat.js ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * 一次心跳 = 目标 + 素材 + 门控 → 一个决定。
3
+ *
4
+ * 这一层只做「算出该干什么」,不做「真的去干」。
5
+ * 真的去干那一步(resume)放在接线里,因为它会打扰人、会失败、会留下痕迹,
6
+ * 而那些事没有一件适合藏在纯函数里。
7
+ *
8
+ * 顺序是有讲究的,不能换:
9
+ *
10
+ * 目标 → 素材 → 门控
11
+ *
12
+ * 不先选目标:不知道该对谁说,后面全白算。
13
+ * 不先找素材:门控会掷色子,掷中了却没有东西可说 —— 那就是空话。
14
+ * 空话比不说更糟,它消耗的是「它真的在」那点信用。
15
+ */
16
+ import { pickTarget } from './target.js';
17
+ import { pickMaterial, materialPrompt } from './material.js';
18
+ import { decide, DEFAULTS } from './heartbeat.js';
19
+
20
+ /**
21
+ * @param {object} input
22
+ * @param {Date} input.now
23
+ * @param {object} input.state 心跳状态账本
24
+ * @param {Array} input.candidates 活着的会话 [{id, header}]
25
+ * @param {Array} input.entries 记忆条目 [{id, title, at}]
26
+ * @param {object} [input.config]
27
+ * @param {() => number} [input.random]
28
+ * @returns {{speak: boolean, reason: string, sessionId: string|null,
29
+ * prompt: string|null, switched: boolean, previous: string|null,
30
+ * materialId: string|null, detail: string}}
31
+ */
32
+ export function planBeat(input) {
33
+ const cfg = { ...DEFAULTS, ...(input.config ?? {}) };
34
+ const now = input.now instanceof Date ? input.now : new Date(input.now ?? Date.now());
35
+ const state = input.state ?? {};
36
+
37
+ // ── ① 对谁说 ────────────────────────────────────────────
38
+ const target = pickTarget(input.candidates ?? [], state.targetSessionId ?? null);
39
+ if (!target.sessionId) {
40
+ return no(target.reason, '没有活着的会话 —— 没人看着,说了也没人接', {
41
+ switched: false,
42
+ previous: null,
43
+ });
44
+ }
45
+
46
+ // ── ② 说什么 ────────────────────────────────────────────
47
+ const material = pickMaterial(input.entries ?? [], state.lastSpokeAt ?? null);
48
+ if (!material.entry) {
49
+ return no(
50
+ material.reason === 'noEntries' ? 'noMaterial' : 'nothingNew',
51
+ material.reason === 'noEntries'
52
+ ? '记忆里一条可用的都没有 —— 没有出处的话不说'
53
+ : '写了的东西都说过了 —— 没有新的就不开口',
54
+ { switched: target.switched, previous: target.previous, sessionId: target.sessionId },
55
+ );
56
+ }
57
+
58
+ // ── ③ 现在该不该说 ──────────────────────────────────────
59
+ // hasHardMaterial = true:有具体的东西要说,就不掷色子。
60
+ // 色子留给「有点想法但没到非说不可」,这里不是那种情况。
61
+ const decision = decide({
62
+ now,
63
+ lastBeatAt: state.lastBeatAt ?? null,
64
+ lastUserAt: state.lastUserAt ?? null,
65
+ lastSpokeAt: state.lastSpokeAt ?? null,
66
+ hasHardMaterial: true,
67
+ quietStreak: state.quietStreak ?? 0,
68
+ config: cfg,
69
+ random: input.random,
70
+ });
71
+
72
+ return {
73
+ speak: decision.speak,
74
+ reason: decision.reason,
75
+ sessionId: target.sessionId,
76
+ prompt: decision.speak ? materialPrompt(material.entry) : null,
77
+ switched: target.switched,
78
+ previous: target.previous,
79
+ materialId: material.entry.id,
80
+ detail: decision.detail,
81
+ };
82
+
83
+ function no(reason, detail, extra) {
84
+ return {
85
+ speak: false,
86
+ reason,
87
+ sessionId: extra.sessionId ?? null,
88
+ prompt: null,
89
+ switched: extra.switched,
90
+ previous: extra.previous,
91
+ materialId: null,
92
+ detail,
93
+ };
94
+ }
95
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * 心跳的执行层(第一段:**状态账本**,不含真正发消息)。
3
+ *
4
+ * 为什么先只做这一段:门控是纯函数,能离线验;但「上一轮什么时候跳的」
5
+ * 「连续几轮没开口了」这些**必须落盘**,否则进程一重启,下限保护就归零,
6
+ * 那个「安静到像坏了」的老毛病会立刻回来。
7
+ *
8
+ * 所以先把状态写清楚,再做发消息那一步。发消息会真的打扰人,
9
+ * 单独一版做,而且默认关。
10
+ */
11
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
12
+ import { join, dirname } from 'node:path';
13
+
14
+ import { decide, DEFAULTS } from './heartbeat.js';
15
+ import { dataRoot } from './store.js';
16
+
17
+ export function stateFile() {
18
+ return join(dataRoot(), 'heartbeat-state.json');
19
+ }
20
+
21
+ export const EMPTY_STATE = {
22
+ lastBeatAt: null,
23
+ lastSpokeAt: null,
24
+ lastUserAt: null,
25
+ quietStreak: 0,
26
+ decisions: [],
27
+ };
28
+
29
+ export function loadState(file = stateFile()) {
30
+ if (!existsSync(file)) return { ...EMPTY_STATE, decisions: [] };
31
+ try {
32
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
33
+ return { ...EMPTY_STATE, ...parsed, decisions: Array.isArray(parsed.decisions) ? parsed.decisions : [] };
34
+ } catch {
35
+ // 读坏了就当没有 —— 但不能静默:调用方能从 decisions 为空看出来
36
+ return { ...EMPTY_STATE, decisions: [] };
37
+ }
38
+ }
39
+
40
+ export function saveState(state, file = stateFile()) {
41
+ mkdirSync(dirname(file), { recursive: true });
42
+ writeFileSync(file, JSON.stringify(state, null, 2), 'utf8');
43
+ return file;
44
+ }
45
+
46
+ /**
47
+ * 跑一轮心跳:读状态 → 判定 → 写状态。
48
+ *
49
+ * 注意 **先写状态再说话**:说话可能失败(没凭据、会话没了),
50
+ * 如果先说话后记账,失败时这一轮就不计数,下限保护会永远不触发。
51
+ *
52
+ * @returns {{ decision: object, state: object, shouldSpeak: boolean }}
53
+ */
54
+ export function tick(opts = {}) {
55
+ const now = opts.now instanceof Date ? opts.now : new Date(opts.now ?? Date.now());
56
+ const file = opts.file ?? stateFile();
57
+ const prev = opts.state ?? loadState(file);
58
+ const config = { ...DEFAULTS, ...(opts.config ?? {}) };
59
+
60
+ const decision = decide({
61
+ now,
62
+ lastUserAt: prev.lastUserAt,
63
+ lastSpokeAt: prev.lastSpokeAt,
64
+ lastBeatAt: prev.lastBeatAt,
65
+ hasHardMaterial: opts.hasHardMaterial === true,
66
+ quietStreak: prev.quietStreak ?? 0,
67
+ config,
68
+ random: opts.random,
69
+ });
70
+
71
+ const entry = {
72
+ at: now.toISOString(),
73
+ speak: decision.speak,
74
+ reason: decision.reason,
75
+ detail: decision.detail,
76
+ };
77
+
78
+ const next = {
79
+ ...prev,
80
+ lastBeatAt: now.toISOString(),
81
+ quietStreak: decision.speak ? 0 : (prev.quietStreak ?? 0) + 1,
82
+ decisions: [...(prev.decisions ?? []), entry].slice(-(opts.keepDecisions ?? 50)),
83
+ };
84
+ if (decision.speak) next.lastSpokeAt = now.toISOString();
85
+
86
+ if (opts.dryRun !== true) saveState(next, file);
87
+
88
+ return { decision, state: next, shouldSpeak: decision.speak };
89
+ }
90
+
91
+ /** 用户说话了 ⟹ 更新 lastUserAt。冷却判据要用它。 */
92
+ export function noteUserMessage(at = new Date(), file = stateFile()) {
93
+ const prev = loadState(file);
94
+ const next = { ...prev, lastUserAt: (at instanceof Date ? at : new Date(at)).toISOString() };
95
+ saveState(next, file);
96
+ return next;
97
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * 心跳的门控 —— 这一版只做「要不要开口」,不负责开口。
3
+ *
4
+ * ════════════════════════════════════════════════════════════
5
+ * 一条设计判断(这是整个文件存在的理由)
6
+ * ════════════════════════════════════════════════════════════
7
+ *
8
+ * 大多数「主动说话」都是这么做的:用户设一个时间,到点了 Agent 说话。
9
+ * 那个东西叫**定时提醒**,不叫心跳 —— 它的频率由用户决定,它对世界一无所知。
10
+ *
11
+ * 这里反过来:**用户设的不是「什么时候叫我」,是「什么时候绝对不许叫我」。**
12
+ * 安静时段是硬墙。墙之外,说不说取决于两件事:
13
+ *
14
+ * ① 有没有「硬素材」——有具体的事(承诺到期、上次没说完的、真发现了什么)
15
+ * ② 没有硬素材时,掷色子,而且概率压低
16
+ *
17
+ * 为什么分成两档:硬素材的时候掷色子是浪费(有事就该说);
18
+ * 只有「有点想法但没到非说不可」才适合掷。
19
+ *
20
+ * ════════════════════════════════════════════════════════════
21
+ * 第二条判断:沉默必须能被区分
22
+ * ════════════════════════════════════════════════════════════
23
+ *
24
+ * 用户最容易产生的困惑不是「它说了什么」,是**「它今天为什么没响」**。
25
+ * 所以每一次决定都带一个 reason,而且几个 reason 互斥、加起来能解释所有情况:
26
+ *
27
+ * 安静时段 / 刚聊过 / 素材不够且色子没中 / 下限保护触发 / 该说
28
+ *
29
+ * 没有这一条,产品坏了和产品很安静长得一模一样。
30
+ *
31
+ * ════════════════════════════════════════════════════════════
32
+ * 第三条判断:必须有一条「下限保护」
33
+ * ════════════════════════════════════════════════════════════
34
+ *
35
+ * 「默认闭嘴」如果只有上限没有下限,它会安静到让人以为坏了。
36
+ * 我自己踩过这个坑:加了个筛选条件,结果恒为空集,本来想「少说空话」,
37
+ * 实际做成了「永远不说话」。
38
+ * 所以:连续 quietRounds 次都没开口 ⟹ 下一次强制允许。
39
+ */
40
+
41
+ /** 默认配置。全都可以被 profile 配置覆盖。 */
42
+ export const DEFAULTS = {
43
+ /** 安静时段(本地时间,含头不含尾)。跨午夜写成两段会自动合并。 */
44
+ quiet: [
45
+ { from: '23:00', to: '08:00' },
46
+ ],
47
+ /** 距用户上一条消息不到这么久,就别插嘴——他刚说完。单位:分钟。 */
48
+ cooldownMinutes: 10,
49
+ /** 两次心跳之间至少隔这么久,防止定时器抽风连发。单位:分钟。 */
50
+ minGapMinutes: 20,
51
+ /** 弱素材时的开口概率。0 = 从不,1 = 每次都开。 */
52
+ dice: 0.15,
53
+ /** 连续这么多轮没开口 ⟹ 下一轮强制允许一次。0 = 关掉下限保护。 */
54
+ quietRounds: 8,
55
+ };
56
+
57
+ /** "HH:MM" → 分钟数。非法返回 null。 */
58
+ export function parseClock(s) {
59
+ const m = /^(\d{1,2}):(\d{2})$/.exec(String(s ?? '').trim());
60
+ if (!m) return null;
61
+ const h = Number(m[1]);
62
+ const min = Number(m[2]);
63
+ if (h < 0 || h > 23 || min < 0 || min > 59) return null;
64
+ return h * 60 + min;
65
+ }
66
+
67
+ /** 某个时刻落在哪一天的第几分钟(本地时区)。 */
68
+ export function minutesOfDay(date) {
69
+ return date.getHours() * 60 + date.getMinutes();
70
+ }
71
+
72
+ /** 这一分钟在不在安静时段里。支持跨午夜。 */
73
+ export function inQuiet(minuteOfDay, quiet) {
74
+ for (const w of quiet ?? []) {
75
+ const a = parseClock(w.from);
76
+ const b = parseClock(w.to);
77
+ if (a === null || b === null) continue;
78
+ if (a === b) continue; // 空窗口
79
+ if (a < b) {
80
+ if (minuteOfDay >= a && minuteOfDay < b) return true;
81
+ } else {
82
+ // 跨午夜:23:00→08:00
83
+ if (minuteOfDay >= a || minuteOfDay < b) return true;
84
+ }
85
+ }
86
+ return false;
87
+ }
88
+
89
+ /**
90
+ * 决定这一轮要不要开口。
91
+ *
92
+ * @param {object} input
93
+ * @param {Date} input.now 当前时间
94
+ * @param {Date|number|null} input.lastUserAt 用户最后一条消息的时间(null = 不知道)
95
+ * @param {Date|number|null} input.lastSpokeAt 我最后一次主动开口的时间
96
+ * @param {Date|number|null} input.lastBeatAt 上一轮心跳的时间(用于 minGap)
97
+ * @param {boolean} input.hasHardMaterial 有没有硬素材
98
+ * @param {number} input.quietStreak 连续多少轮没开口了
99
+ * @param {object} [input.config] 覆盖 DEFAULTS
100
+ * @param {() => number} [input.random] 注入随机源,测试用
101
+ *
102
+ * @returns {{ speak: boolean, reason: string, detail: string }}
103
+ */
104
+ export function decide(input) {
105
+ const cfg = { ...DEFAULTS, ...(input.config ?? {}) };
106
+ const now = input.now instanceof Date ? input.now : new Date(input.now);
107
+ const random = input.random ?? Math.random;
108
+
109
+ // ── ① 安静时段:硬墙,先挡 ──────────────────────────────
110
+ if (inQuiet(minutesOfDay(now), cfg.quiet)) {
111
+ return { speak: false, reason: 'quiet', detail: '落在用户设的安静时段里' };
112
+ }
113
+
114
+ // ── ② 间隔:定时器抽风或上一轮太近,别叠 ────────────────
115
+ const lastBeat = toMs(input.lastBeatAt);
116
+ if (lastBeat !== null) {
117
+ const gap = (now.getTime() - lastBeat) / 60000;
118
+ if (gap < cfg.minGapMinutes) {
119
+ return {
120
+ speak: false,
121
+ reason: 'tooSoon',
122
+ detail: `距上一轮心跳 ${gap.toFixed(1)} 分钟,不到 ${cfg.minGapMinutes}`,
123
+ };
124
+ }
125
+ }
126
+
127
+ // ── ③ 冷却:他刚说完话,别插嘴 ──────────────────────────
128
+ const lastUser = toMs(input.lastUserAt);
129
+ if (lastUser !== null) {
130
+ const since = (now.getTime() - lastUser) / 60000;
131
+ if (since < cfg.cooldownMinutes) {
132
+ return {
133
+ speak: false,
134
+ reason: 'cooldown',
135
+ detail: `距用户上一条消息 ${since.toFixed(1)} 分钟,不到 ${cfg.cooldownMinutes}`,
136
+ };
137
+ }
138
+ }
139
+
140
+ // ── ④ 硬素材:有就说,不掷 ──────────────────────────────
141
+ if (input.hasHardMaterial === true) {
142
+ // 但也要防「刚说完又马上来一次」——靠 lastSpokeAt 单独挡
143
+ const spoke = toMs(input.lastSpokeAt);
144
+ if (spoke !== null) {
145
+ const since = (now.getTime() - spoke) / 60000;
146
+ if (since < cfg.cooldownMinutes) {
147
+ return {
148
+ speak: false,
149
+ reason: 'cooldown',
150
+ detail: `距上次主动开口 ${since.toFixed(1)} 分钟,不到 ${cfg.cooldownMinutes}`,
151
+ };
152
+ }
153
+ }
154
+ return { speak: true, reason: 'hard', detail: '有具体的事要说' };
155
+ }
156
+
157
+ // ── ⑤ 下限保护:太久没开口,强制允许一次 ────────────────
158
+ if (cfg.quietRounds > 0 && (input.quietStreak ?? 0) >= cfg.quietRounds) {
159
+ return {
160
+ speak: true,
161
+ reason: 'floor',
162
+ detail: `已连续 ${input.quietStreak} 轮没开口,触到下限保护`,
163
+ };
164
+ }
165
+
166
+ // ── ⑥ 弱素材:掷色子 ────────────────────────────────────
167
+ const r = random();
168
+ if (r < cfg.dice) {
169
+ return { speak: true, reason: 'dice', detail: `色子 ${r.toFixed(3)} < ${cfg.dice}` };
170
+ }
171
+ return {
172
+ speak: false,
173
+ reason: 'noMaterial',
174
+ detail: `没硬素材,色子 ${r.toFixed(3)} ≥ ${cfg.dice}`,
175
+ };
176
+ }
177
+
178
+ function toMs(v) {
179
+ if (v === null || v === undefined) return null;
180
+ if (typeof v === 'number') return v;
181
+ if (v instanceof Date) return v.getTime();
182
+ const t = new Date(v).getTime();
183
+ return Number.isFinite(t) ? t : null;
184
+ }
185
+
186
+ /** 所有可能的 reason —— 用来保证「没有第七种情况」。 */
187
+ export const REASONS = ['quiet', 'tooSoon', 'cooldown', 'hard', 'floor', 'dice', 'noMaterial'];
package/lib/journal.js ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * 给"人"看的心跳记录。
3
+ *
4
+ * 来由:外面有人真机试完,说了一句最狠的话 ——
5
+ * **「Galatea 的能力都在用户看不见的地方。」**
6
+ *
7
+ * 门控、状态账本、素材选择全在后台跑;装完打开,用户看见的是一片空白。
8
+ * 更糟的是:**沉默看不见,就等于故障看不见。**
9
+ * 我在代码里写了七种「为什么没说」,但那些字只有读代码的人才看得到。
10
+ *
11
+ * 所以这里只做一件事:把那七种理由翻成人话。
12
+ * 不发明数据,不评价好坏,不排序打分——账本里记了什么就说什么。
13
+ */
14
+ import { DEFAULTS } from './heartbeat.js';
15
+
16
+ /** reason → 一句人话。门控那七种 + 三种"更早就断了"的,一种都不许漏。 */
17
+ export const REASON_TEXT = {
18
+ // 门控给出的
19
+ quiet: '安静时段',
20
+ tooSoon: '离上一轮太近',
21
+ cooldown: '你刚说过话',
22
+ hard: '有具体的事要说',
23
+ floor: '太久没开口,这次强制说一句',
24
+ dice: '随机决定要说',
25
+ noMaterial: '记忆里没有可用的东西',
26
+ nothingNew: '记下的都说过了',
27
+ noHuman: '没有正在进行的对话',
28
+ };
29
+
30
+ /** HH:MM,本地时区。认不出的时间原样标出来,不替它猜一个像样的值。 */
31
+ function clockOf(iso) {
32
+ const d = new Date(iso);
33
+ if (Number.isNaN(d.getTime())) return '--:--';
34
+ const p = (n) => String(n).padStart(2, '0');
35
+ return `${p(d.getHours())}:${p(d.getMinutes())}`;
36
+ }
37
+
38
+ /** 拿不准的一律原样显示,不替它编一个更好听的说法。 */
39
+ export function explain(reason) {
40
+ return REASON_TEXT[reason] ?? `(未登记的理由:${reason})`;
41
+ }
42
+
43
+ /**
44
+ * 把状态账本渲染成人能读的几行。
45
+ *
46
+ * 只读 `decisions`,不推测、不补空白。
47
+ * 一条记录都没有时,明说「还没有记录过」——不能显示空白,
48
+ * 那和「功能坏了」长得一样。这条规矩从记忆模块一路沿用到这里。
49
+ *
50
+ * @param {object} state loadState() 的输出
51
+ * @param {{limit?: number}} [opts]
52
+ * @returns {string}
53
+ */
54
+ export function renderJournal(state, opts = {}) {
55
+ const limit = opts.limit ?? 5;
56
+ const ds = Array.isArray(state?.decisions) ? state.decisions : [];
57
+
58
+ const lines = [];
59
+
60
+ if (ds.length === 0) {
61
+ // 不能直接 return —— 没有记录和"连续很多轮没开口"是两件可以同时成立的事,
62
+ // 早退会把 streak 吞掉,而那正是沉默和坏了唯一的分界。
63
+ lines.push('心跳还没有记录过。它至少跑过一次之后,这里才会有东西。');
64
+ } else {
65
+ lines.push(
66
+ ...ds
67
+ .slice(-limit)
68
+ .reverse()
69
+ .map((d) => `${clockOf(d.at)} ${d.speak ? '说了' : '没说'} — ${explain(d.reason)}`),
70
+ );
71
+ }
72
+
73
+ // 连续没开口的次数:这是「沉默」和「坏了」唯一能分开的地方
74
+ const streak = Number(state?.quietStreak ?? 0);
75
+ if (streak > 0) {
76
+ const floor = Number(opts.quietRounds ?? DEFAULTS.quietRounds);
77
+ if (lines.length > 0) lines.push('');
78
+ lines.push(
79
+ floor > 0
80
+ ? `连续 ${streak} 轮没开口(到 ${floor} 会强制说一次)`
81
+ : `连续 ${streak} 轮没开口(保底已关闭,可能一直不说)`,
82
+ );
83
+ }
84
+
85
+ return lines.join('\n');
86
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * 说什么 —— 心跳的素材来源。
3
+ *
4
+ * 这一块比"什么时候说"难,因为门控错了最多是多说一句或少说一句,
5
+ * 素材错了会说出没有出处的话,而那些话会消耗「它真的在」那点信用。
6
+ *
7
+ * 所以只有一条规则:
8
+ * **素材必须来自自己记下的、还没被说过的条目。没有就不说。**
9
+ *
10
+ * 「还没被说过」的判据是时间:条目写下之后,我有没有开口过。
11
+ * 有 ⟹ 说过;没有 ⟹ 没说。
12
+ *
13
+ * 为什么不按「读过没读过」:读是内部的,说才是对外的。
14
+ * 一条我读了三遍但一句都没提的记忆,对人来说仍然等于不存在。
15
+ */
16
+
17
+ /**
18
+ * @typedef {{ id: string, title: string, at: string|number|Date }} Entry
19
+ */
20
+
21
+ /** 统一成毫秒;认不出返回 null。 */
22
+ export function toMs(v) {
23
+ if (v === null || v === undefined) return null;
24
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
25
+ if (v instanceof Date) return Number.isFinite(v.getTime()) ? v.getTime() : null;
26
+ const t = new Date(v).getTime();
27
+ return Number.isFinite(t) ? t : null;
28
+ }
29
+
30
+ /**
31
+ * 挑一条可以说的素材。
32
+ *
33
+ * 规则:
34
+ * ① 只挑「写下时间晚于上次开口时间」的条目 —— 那些是写了还没说过的
35
+ * ② 从最老的一条开始挑 —— 按顺序,不跳。跳着说会让中间那些永远排不上队
36
+ * ③ 一条都没有 ⟹ null。这时候不该说话,不是"找不到素材"这种技术失败
37
+ *
38
+ * @param {Entry[]} entries
39
+ * @param {string|number|Date|null} lastSpokeAt 上次主动开口的时间
40
+ * @returns {{ entry: Entry|null, reason: string, pending: number }}
41
+ */
42
+ export function pickMaterial(entries, lastSpokeAt) {
43
+ const list = Array.isArray(entries) ? entries : [];
44
+ const spokeAt = toMs(lastSpokeAt);
45
+
46
+ // 时间认不出的条目不用 —— 宁可少说一条,不要拿一条没时间的去凑
47
+ const dated = list
48
+ .map((e) => ({ entry: e, ms: toMs(e.at ?? e.date) }))
49
+ .filter((x) => x.ms !== null);
50
+
51
+ if (dated.length === 0) {
52
+ return { entry: null, reason: 'noEntries', pending: 0 };
53
+ }
54
+
55
+ if (spokeAt === null) {
56
+ // 从来没主动说过:从最老的一条开始,不从最新的一条开始
57
+ dated.sort((a, b) => a.ms - b.ms);
58
+ return { entry: dated[0].entry, reason: 'neverSpoke', pending: dated.length };
59
+ }
60
+
61
+ const fresh = dated.filter((x) => x.ms > spokeAt).sort((a, b) => a.ms - b.ms);
62
+ if (fresh.length === 0) {
63
+ return { entry: null, reason: 'nothingNew', pending: 0 };
64
+ }
65
+ return { entry: fresh[0].entry, reason: 'newSinceLastSpoke', pending: fresh.length };
66
+ }
67
+
68
+ /**
69
+ * 把一条素材变成一句话的由头。
70
+ *
71
+ * 注意它只给**出处**,不给句子。说什么由那一轮自己决定——
72
+ * 如果这里直接写好句子,那我的心跳就是在念稿子,不是说话。
73
+ */
74
+ export function materialPrompt(entry) {
75
+ if (!entry) return null;
76
+ return [
77
+ '这是你自己记下的一件事。',
78
+ `标题:${entry.title}`,
79
+ `编号:${entry.id}`,
80
+ '要说的话,就围绕它。不要复述标题,也不要念稿。',
81
+ ].join('\n');
82
+ }
package/lib/store.js ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Galatea 的记忆 —— 用 Markdown 落地,靠目录当索引。
3
+ *
4
+ * 这里只有一个判断,但它决定了整个模块长什么样:
5
+ *
6
+ * **正文不进上下文,只进标题。**
7
+ *
8
+ * 常见的做法是每轮把记忆全文塞进提示里。那样一旦超过几十条,要么超预算,
9
+ * 要么被迫摘要 —— 而摘要之后,写进去的东西和读回来的东西就不是同一个了。
10
+ * 这个项目里最贵的一条教训就是「写进去 ≠ 被读到」:如果读回来的是别人替你压过的版本,
11
+ * 那等于从来没写过。
12
+ *
13
+ * 所以:注入的是「我记得过什么」(一行一条,标题),正文躺着不动,
14
+ * 需要的时候再去翻。翻的人知道自己翻的是什么。
15
+ */
16
+
17
+ import { readdirSync, readFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { homedir } from 'node:os';
20
+
21
+ /** DSH 家目录。和宿主保持一致:优先环境变量,退回 ~/.dsh。 */
22
+ export function resolveHome() {
23
+ const fromEnv = process.env.DSH_HOME;
24
+ return fromEnv && fromEnv.trim() ? fromEnv.trim() : join(homedir(), '.dsh');
25
+ }
26
+
27
+ /** 这个插件自己的数据根。 */
28
+ export function dataRoot() {
29
+ return join(resolveHome(), 'galatea');
30
+ }
31
+
32
+ function memoryDir() {
33
+ return join(dataRoot(), 'memory');
34
+ }
35
+
36
+ function entriesDir() {
37
+ return join(memoryDir(), 'entries');
38
+ }
39
+
40
+ /** 文件名即 id:`YYYY-MM-DD-短名.md`。日期在前 ⟹ 目录列表天然按时间排。 */
41
+ const ID_RE = /^(\d{4}-\d{2}-\d{2})-([\w\-\u4e00-\u9fff]+)\.md$/;
42
+
43
+ /**
44
+ * 列出所有记忆的**标题**。只读首行,不读正文。
45
+ * 返回按日期倒序(最新在前)。
46
+ */
47
+ export function listEntries() {
48
+ const dir = entriesDir();
49
+ if (!existsSync(dir)) return [];
50
+ const out = [];
51
+ for (const name of readdirSync(dir)) {
52
+ const m = ID_RE.exec(name);
53
+ if (!m) continue;
54
+ let title = '';
55
+ try {
56
+ const raw = readFileSync(join(dir, name), 'utf8');
57
+ // 第一个非空行;如果是 `# 标题` 就去掉井号
58
+ for (const line of raw.split(/\r?\n/)) {
59
+ const t = line.trim();
60
+ if (!t) continue;
61
+ title = t.replace(/^#+\s*/, '');
62
+ break;
63
+ }
64
+ } catch {
65
+ title = '(读不到)';
66
+ }
67
+ out.push({ id: name.slice(0, -3), date: m[1], slug: m[2], title });
68
+ }
69
+ out.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0));
70
+ return out;
71
+ }
72
+
73
+ /** 读一篇的正文。读不到返回 null —— 不编。 */
74
+ export function readEntry(id) {
75
+ if (!ID_RE.test(`${id}.md`)) return null;
76
+ const p = join(entriesDir(), `${id}.md`);
77
+ if (!existsSync(p)) return null;
78
+ try {
79
+ return readFileSync(p, 'utf8');
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /** 写一篇。已存在就追加到后面(同一个 id 的后续补充)。 */
86
+ export function appendEntry(id, title, body) {
87
+ if (!ID_RE.test(`${id}.md`)) throw new Error(`galatea: 不合法的 id "${id}"(应为 YYYY-MM-DD-短名)`);
88
+ const dir = entriesDir();
89
+ mkdirSync(dir, { recursive: true });
90
+ const p = join(dir, `${id}.md`);
91
+ if (existsSync(p)) {
92
+ writeFileSync(p, readFileSync(p, 'utf8') + '\n\n' + String(body ?? ''), 'utf8');
93
+ } else {
94
+ writeFileSync(p, `# ${title}\n\n${String(body ?? '')}\n`, 'utf8');
95
+ }
96
+ return p;
97
+ }
98
+
99
+ /**
100
+ * 渲染要注入的那一段。
101
+ *
102
+ * 三条规矩:
103
+ * ① 一条都没有时,说「一条都没有」——不能什么都不显示,那和「功能坏了」长得一样。
104
+ * ② 目录读不到时,说「读不到」,不说「0 条」。
105
+ * ③ 只给标题 + 日期,不给正文。
106
+ */
107
+ export function renderIndex({ limit = 12 } = {}) {
108
+ const dir = entriesDir();
109
+ if (!existsSync(dir)) {
110
+ return '【Galatea 记忆 · 还没开始】\n这个插件还没有任何记忆条目。不是读不到——是目录还不存在。';
111
+ }
112
+ let list;
113
+ try {
114
+ list = listEntries();
115
+ } catch (err) {
116
+ return `【Galatea 记忆 · 读不到】\n目录存在,但列不出来:${err && err.message}`;
117
+ }
118
+ if (list.length === 0) {
119
+ return '【Galatea 记忆 · 空】\n目录在,里面没有条目。这是空,不是读不到。';
120
+ }
121
+ const shown = list.slice(0, limit);
122
+ const more = list.length - shown.length;
123
+ const lines = shown.map((e) => `- ${e.date} · ${e.title} (${e.id})`);
124
+ return [
125
+ `【Galatea 记忆 · 共 ${list.length} 条,这里是最新的 ${shown.length} 条】`,
126
+ ...lines,
127
+ more > 0 ? `(还有 ${more} 条更早的没列出来)` : '',
128
+ '正文不在上面。要用哪条,按 id 去读原文。',
129
+ ]
130
+ .filter(Boolean)
131
+ .join('\n');
132
+ }
package/lib/target.js ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * 心跳对谁说话 —— 目标会话的选择。
3
+ *
4
+ * 为什么这件事不能随便:
5
+ * 「对哪个会话开口」等于「决定跟谁说」,而且选错一次,那段上下文就接上了别人。
6
+ * 所以这里的判据全部写成纯函数,能离线验,不需要起宿主。
7
+ *
8
+ * 字段依据(不是猜的):
9
+ * dsh-agent/lib/types/index.d.ts:63-70 的 CreateAgentOptions.meta
10
+ * 有 cwd / parentSession / isSeeded / origin / delegationDepth / agentPreset
11
+ * 其中 origin 的类型只有 'subagent' 一个取值。
12
+ */
13
+
14
+ /**
15
+ * 这个会话算不算「一个人正在看的那个」。
16
+ *
17
+ * 四道排除,任何一道命中就不是:
18
+ * ① 读不到 header —— 不能确定,就不认(宁可不说,不要认错人)
19
+ * ② origin === 'subagent'
20
+ * ③ 有 parentSession —— fork / 派生
21
+ * ④ delegationDepth > 0 —— 递归深度
22
+ */
23
+ export function isHumanSession(header) {
24
+ if (!header || typeof header !== 'object') return false;
25
+ if (header.origin === 'subagent') return false;
26
+ if (header.parentSession) return false;
27
+ if (Number(header.delegationDepth ?? 0) > 0) return false;
28
+ return true;
29
+ }
30
+
31
+ /** 把候选列表里不合适的挑出去,顺便留下每一条被排除的理由。 */
32
+ export function partitionSessions(candidates) {
33
+ const humans = [];
34
+ const rejected = [];
35
+ for (const c of candidates ?? []) {
36
+ if (isHumanSession(c.header)) humans.push(c);
37
+ else rejected.push({ id: c.id, why: rejectReason(c.header) });
38
+ }
39
+ return { humans, rejected };
40
+ }
41
+
42
+ function rejectReason(header) {
43
+ if (!header || typeof header !== 'object') return 'noHeader';
44
+ if (header.origin === 'subagent') return 'subagent';
45
+ if (header.parentSession) return 'child';
46
+ if (Number(header.delegationDepth ?? 0) > 0) return 'depth';
47
+ return 'unknown';
48
+ }
49
+
50
+ /**
51
+ * 选一个目标会话。
52
+ *
53
+ * 顺序:
54
+ * ① 记住的那个还在活着的列表里 → 继续用它(连续才真的是连续的)
55
+ * ② 不在 → 从剩下的「人」里取一个,并把这次更换记下来
56
+ * ③ 一个「人」都没有 → null(没人看着,说了也没人接)
57
+ *
58
+ * @param {Array<{id: string, header?: object, lastSeenAt?: number}>} candidates
59
+ * @param {string|null} rememberedId 上一次用过的会话
60
+ * @returns {{sessionId: string|null, switched: boolean, previous: string|null, reason: string}}
61
+ */
62
+ export function pickTarget(candidates, rememberedId = null) {
63
+ const { humans } = partitionSessions(candidates);
64
+
65
+ if (rememberedId && humans.some((c) => c.id === rememberedId)) {
66
+ return { sessionId: rememberedId, switched: false, previous: null, reason: 'remembered' };
67
+ }
68
+
69
+ if (humans.length === 0) {
70
+ return { sessionId: null, switched: false, previous: rememberedId ?? null, reason: 'noHuman' };
71
+ }
72
+
73
+ // 都没有时间戳时取最后一个:调用方按「从旧到新」的顺序给,最后一个最新。
74
+ const picked = humans[humans.length - 1];
75
+
76
+ if (!rememberedId) {
77
+ return { sessionId: picked.id, switched: false, previous: null, reason: 'first' };
78
+ }
79
+ // 有过目标、但它不见了 —— 这是一次真正的断点,必须有记录
80
+ return { sessionId: picked.id, switched: true, previous: rememberedId, reason: 'switched' };
81
+ }
package/lib/tools.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Galatea 的工具。
3
+ *
4
+ * 只做两个动作:**记一条**、**翻一条**。
5
+ *
6
+ * 为什么不是「搜索」:搜索需要一个相关度判据,而判据一旦给了,
7
+ * 用它的人就会照着它优化。这里宁可让人(或 Agent)先看标题,自己点进去。
8
+ * 「我记得有这件事,点开看看」比「系统认为这两条最相关」更像记忆。
9
+ */
10
+ import { defineTool } from '@deepseek-ai/dsh-tools';
11
+
12
+ import { listEntries, readEntry, appendEntry } from './store.js';
13
+
14
+ /** 今天(本地时区)的 YYYY-MM-DD。 */
15
+ function today() {
16
+ const d = new Date();
17
+ const p = (n) => String(n).padStart(2, '0');
18
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
19
+ }
20
+
21
+ /** 从标题造一个文件名片段。中文保留,标点换成连字符。 */
22
+ function slugify(title) {
23
+ const s = String(title)
24
+ .trim()
25
+ .replace(/[^\w\u4e00-\u9fff]+/g, '-')
26
+ .replace(/^-+|-+$/g, '');
27
+ return (s || 'untitled').slice(0, 24);
28
+ }
29
+
30
+ export const remember = defineTool({
31
+ name: 'galatea_remember',
32
+ description:
33
+ '记住一件事,写进你自己的长期记忆(Markdown,落在磁盘上,跨会话可用)。' +
34
+ '标题会出现在每轮的索引里,正文不会——所以标题要能让人认出「这是哪件事」。' +
35
+ '写一条就够,不要一次塞一大段。',
36
+ parameters: {
37
+ title: {
38
+ type: 'string',
39
+ required: true,
40
+ description: '一句话标题,会出现在每轮的记忆索引里。要能认出是哪件事。',
41
+ },
42
+ body: {
43
+ type: 'string',
44
+ required: true,
45
+ description: '正文。想写多长写多长——它不会进上下文,只有用到时才被读出来。',
46
+ },
47
+ date: {
48
+ type: 'string',
49
+ description: '可选,YYYY-MM-DD。默认今天。',
50
+ },
51
+ },
52
+ output: {
53
+ schema: {
54
+ type: 'object',
55
+ additionalProperties: false,
56
+ properties: {
57
+ id: { type: 'string' },
58
+ total: { type: 'integer' },
59
+ },
60
+ },
61
+ render: (_args, v) => [
62
+ { type: 'text', text: `记下了:${v.id}(现在共 ${v.total} 条)` },
63
+ ],
64
+ },
65
+ isConcurrencySafe: () => false,
66
+ async execute(args) {
67
+ const title = String(args.title ?? '').trim();
68
+ const body = String(args.body ?? '').trim();
69
+ if (!title) throw new Error('galatea_remember: title 不能为空');
70
+ if (!body) throw new Error('galatea_remember: body 不能为空');
71
+ const date = String(args.date ?? today());
72
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
73
+ throw new Error(`galatea_remember: date 要是 YYYY-MM-DD,收到 "${date}"`);
74
+ }
75
+ const id = `${date}-${slugify(title)}`;
76
+ appendEntry(id, title, body);
77
+ return { id, total: listEntries().length };
78
+ },
79
+ });
80
+
81
+ export const recall = defineTool({
82
+ name: 'galatea_recall',
83
+ description:
84
+ '翻记忆。给 id 读那一条的正文;不给 id 就列出所有条目的标题。',
85
+ parameters: {
86
+ id: {
87
+ type: 'string',
88
+ description: '条目 id(形如 2026-11-14-土豆)。不传则列出全部标题。',
89
+ },
90
+ },
91
+ output: {
92
+ schema: {
93
+ type: 'object',
94
+ additionalProperties: false,
95
+ properties: {
96
+ found: { type: 'boolean' },
97
+ text: { type: 'string' },
98
+ },
99
+ },
100
+ render: (_args, v) => [{ type: 'text', text: v.text }],
101
+ },
102
+ isConcurrencySafe: () => true,
103
+ async execute(args) {
104
+ const id = args.id ? String(args.id).trim() : '';
105
+ if (!id) {
106
+ const list = listEntries();
107
+ if (list.length === 0) return { found: false, text: '记忆里一条都没有。' };
108
+ return {
109
+ found: true,
110
+ text: list.map((e) => `${e.id} ${e.title}`).join('\n'),
111
+ };
112
+ }
113
+ const body = readEntry(id);
114
+ if (body === null) {
115
+ // 明确说「没有这一条」,不编,也不假装成功
116
+ return { found: false, text: `没有 ${id} 这一条。` };
117
+ }
118
+ return { found: true, text: body };
119
+ },
120
+ });
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "dsh-galatea",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.2",
4
+ "description": "Galatea — memory, heartbeat and continuity for DeepSeek Harness agents.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "dsh",
10
+ "dsh-plugin",
11
+ "agent",
12
+ "memory",
13
+ "continuity"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20.18"
17
+ },
18
+ "scripts": {
19
+ "test": "node --test test/store.test.mjs test/apply.test.mjs test/tools.test.mjs test/heartbeat.test.mjs test/heartbeat-runner.test.mjs test/target.test.mjs test/material.test.mjs test/beat.test.mjs test/contract.test.mjs test/journal.test.mjs"
20
+ },
21
+ "peerDependencies": {
22
+ "@deepseek-ai/dsh-tools": "*",
23
+ "@deepseek-ai/schemastery": "*"
24
+ },
25
+ "peerDependenciesMeta": {
26
+ "@deepseek-ai/dsh-tools": {
27
+ "optional": true
28
+ }
29
+ },
30
+ "dsh": {
31
+ "bundle": {
32
+ "patch": "./cordis.patch.yml"
33
+ },
34
+ "compatibility": {
35
+ "dsh": ">=0.2.0-rc.2 <0.3.0"
36
+ }
37
+ },
38
+ "files": [
39
+ "index.js",
40
+ "lib/",
41
+ "cordis.patch.yml",
42
+ "README.md"
43
+ ]
44
+ }