realheart 0.2.9 → 0.2.10

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/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@
5
5
 
6
6
  ---
7
7
 
8
+ ## 0.2.10(2026-10-31)
9
+
10
+ ### 装:一条命令真的够了 —— 缺的依赖自己在装完后补
11
+
12
+ 2026-10-31 真机(`rt-add`)验出来:只装 `realheart` 一个包,一执行就 `Cannot find package "@deepseek-ai/schemastery"`。
13
+ 它写在 `peerDependencies`,pnpm 不装 peer。qqbot 那台一直没暴露,是因为它的 profile 里本来就有整套 dsh 包,顺带带进来了。
14
+
15
+ 所以「一条命令装好」这条本来是假的,实际要两条。
16
+
17
+ 改法不是往 README 加一行(那一行会被跳过),是**装完自检**:
18
+ 解析一次依赖 → 缺就自动补装 → 再解析一次 → 还缺就停下,并明说该敲哪句。
19
+
20
+ `--dry-run` 里也会说出「缺什么、正式跑会补」。
21
+
22
+ ---
23
+
8
24
  ## 0.2.9(2026-10-31)
9
25
 
10
26
  > **为什么不叫 0.2.8 第二版**:0.2.8 已经装到云端了,但还没发上 registry。
@@ -236,6 +252,14 @@ h-status.pid | 不停,打出是谁在用 |
236
252
  - **面板上这两项 `noInput: true`**:只显示值 + 「只能在命令行改」,不给输入框 ——
237
253
  页面上点一下就能改她的心,等于把「至少不能随便乱改」那半条作废。
238
254
  - 不能改历史:账本只增不改,没有任何改写历史的接口。
255
+ ### 注入快照写不下来时不再静默(2026-10-31 真机验出)
256
+
257
+ - T0-④ 第三路(账本目录不可写)验出来的:注入照常发出、她也照常说话,但 `last-inject.md` 写失败被
258
+ `catch { /* 落盘失败就算了 */ }` 吞掉了。
259
+ - 后果不是「少一段」,是**两件完全不同的事长得一模一样**:真的没注入 / 注入了但写不下来。
260
+ doctor 的「注入」那一项读 mtime —— 两种情况它都说「最近没注入过」。
261
+ - 现在记一条 error:`注入快照没写下来(EACCES)—— 注入本身照常发出`。
262
+ - 单测钉住:`stateDir` 指向「是文件不是目录」的路径时,注入仍返回、日志里必须有那句。
239
263
  ### 注入只读账本尾部(M1-4 的第二步)
240
264
 
241
265
  - 注入每轮都要读 `mood.jsonl`,而账本只增不改。原来每次都整份读进来 ——
package/bin/realheart.mjs CHANGED
@@ -178,6 +178,32 @@ function install(opts) {
178
178
  const r2 = spawnSync(process.execPath, args, { stdio: 'inherit' });
179
179
  if (r2.status !== 0) fail('往 bundles 里写失败 —— 上面有原因。这一步没做成,装了也没有任何行为变化。');
180
180
 
181
+ // ②.5 依赖自检(2026-10-31 真机验出来的)
182
+ // realheart 把 @deepseek-ai/schemastery 写在 peerDependencies,pnpm 不装 peer ——
183
+ // 干净 profile 里只装它一个,一执行就 Cannot find package。
184
+ // qqbot 那台没暴露,是因为它的 profile 里本来就有整套 dsh 包,顺带带进来了。
185
+ // 所以装完自检一次:缺就补;补不上就明说要多敲哪句,别让人对着一句报错猜。
186
+ const PEER = '@deepseek-ai/schemastery';
187
+ const peerResolves = () => spawnSync(
188
+ process.execPath,
189
+ ['-e', `try{require.resolve("${PEER}");process.exit(0)}catch(e){process.exit(42)}`],
190
+ { cwd: profileDir, stdio: 'ignore' },
191
+ ).status === 0;
192
+ if (peerResolves()) {
193
+ say(`依赖 ${PEER} 能解析,不用补`);
194
+ } else if (opts.dryRun) {
195
+ say(`[dry-run] 缺 ${PEER},正式跑会自动补装`);
196
+ } else {
197
+ say(`缺 ${PEER}(它写在 peerDependencies,pnpm 不装 peer)—— 自动补装`);
198
+ const r3 = process.platform === 'win32'
199
+ ? spawnSync(`dsh plugin --profile ${profileName} add ${PEER}`, { cwd: profileDir, stdio: 'inherit', shell: true })
200
+ : spawnSync('dsh', ['plugin', '--profile', profileName, 'add', PEER], { cwd: profileDir, stdio: 'inherit' });
201
+ if (r3.status === 0 && peerResolves()) {
202
+ say(`补上了:${PEER}`);
203
+ } else {
204
+ fail(`装完还是解析不到 ${PEER}。手动补一句:` + String.fromCharCode(10) + `dsh plugin --profile ${profileName} add ${PEER}`);
205
+ }
206
+ }
181
207
  if (opts.dryRun) {
182
208
  say('[dry-run] 到此为止,什么都没改。');
183
209
  return;
@@ -0,0 +1,552 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * realheart 的命令行入口
4
+ *
5
+ * npx realheart install --profile qqbot
6
+ *
7
+ * 为什么要这个:在这之前装它是两条命令 —— `dsh plugin add` 然后 `node setup.mjs`。
8
+ * 第二条不是可选项(`dsh plugin` 只是 pnpm 的转发器,它不写 bundles),
9
+ * 所以只要有人漏了第二条,看到的就是「装了、没报错、没反应」。
10
+ * 一条命令的意义不是省一次敲键盘,是**不给漏的机会**。
11
+ *
12
+ * 它只做编排,逻辑不重写:
13
+ * - 装包:调 `dsh plugin add`
14
+ * - 加 bundles / 备份:调 setup.mjs(唯一知道包名和格式的地方)
15
+ */
16
+
17
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
18
+ import { readdir, copyFile, rm, stat, readFile, mkdir, appendFile } from 'node:fs/promises';
19
+ import { join, resolve, dirname, relative, isAbsolute } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { spawnSync } from 'node:child_process';
22
+ import os from 'node:os';
23
+ import { extractVersion, satisfies } from '../lib/version.mjs';
24
+ import { renderForgetEntry, inventoryOf, appendForget } from '../lib/forget.mjs';
25
+ import { loadUserConfig, DEFAULTS } from '../lib/config.mjs';
26
+
27
+ const HERE = dirname(fileURLToPath(import.meta.url));
28
+ const PKG_ROOT = resolve(HERE, '..');
29
+ const PKG = 'realheart';
30
+ const DSH_HOME = process.env.DSH_HOME || join(os.homedir(), '.dsh');
31
+
32
+ function say(msg) {
33
+ console.log(`[realheart] ${msg}`);
34
+ }
35
+ function fail(msg) {
36
+ console.error(`[realheart] ${msg}`);
37
+ process.exit(1);
38
+ }
39
+
40
+ function usage() {
41
+ console.log(`realheart —— 给她一个能记账的心情层
42
+
43
+ 用法:
44
+ npx ${PKG} install [--profile <名字|路径>] [--dry-run] 装上
45
+ npx ${PKG} doctor 自检:在不在工作 / 哪坏了 / 怎么修
46
+ npx ${PKG} off | on 总开关(停 / 恢复,都要重启才生效)
47
+ npx ${PKG} export [--state-dir <目录>] [--out <目录>] 把账本原文导出来
48
+ npx ${PKG} wipe --state-dir <目录> [--yes] [--why "…"] 删整份账本(要先给目录,再确认)
49
+ npx ${PKG} forget --path <文件或目录> --why "…" [--yes] 删单个产物(留标记)
50
+
51
+ 选项:
52
+ --profile 装到哪个 profile。省掉就用当前目录(当前目录得正好是 profile)
53
+ --dry-run 只说要做什么,不动手
54
+ --state-dir 账本目录。不给就试着从运行时 config.json 读;读不到时**不猜**,直接让你给
55
+ --out 导出的目标目录
56
+ --path 要删的东西(只能是账本目录或 work/pieces 底下的)
57
+ --why 为什么删。会写进 forget-log.md —— 不写就留「(没写)」,但那是个洞
58
+ --yes 非交互环境里表示「确实要删/要做」
59
+
60
+ 几个规矩:
61
+ · 一个文件管全停:off 建 $DSH_HOME/realheart/DISABLED,apply 一看到它就不做任何事。
62
+ 它比改配置可靠 —— 改配置要动 profile 的 YAML(热改会打断正在说话的会话)。
63
+ · export 出来的是**原文**,给出去之前自己先看一眼。
64
+ · wipe 不会猜目录,也不会不问就删。
65
+
66
+ 想卸整个插件:sh scripts/uninstall.sh --profile <名字> --dry-run 先看一眼。`);
67
+ }
68
+
69
+ async function doctor() {
70
+ const { runDoctor, formatDoctor } = await import('../lib/doctor.mjs');
71
+ const r = await runDoctor({ dshHome: DSH_HOME });
72
+ console.log('');
73
+ console.log(`realheart doctor · DSH_HOME=${DSH_HOME}`);
74
+ console.log('');
75
+ console.log(formatDoctor(r));
76
+ // 有 FAIL 就非零退出 —— 这样脚本里可以直接 if npx realheart doctor; then ...
77
+ process.exit(r.summary.fail ? 1 : 0);
78
+ }
79
+
80
+ /** 三个来源:显式路径 → profile 名字 → 当前目录。不猜。 */
81
+ function resolveProfile(arg) {
82
+ if (!arg) {
83
+ if (existsSync(join(process.cwd(), 'package.json'))) return process.cwd();
84
+ fail('没给 --profile,当前目录也不是 profile。用 --profile <名字> 或在 profile 目录里跑。');
85
+ }
86
+ if (arg.includes('/') || arg.includes('\\')) return resolve(arg);
87
+ return join(DSH_HOME, 'profiles', arg);
88
+ }
89
+
90
+ function dshVersion() {
91
+ // Windows 下 dsh 是 .cmd,只能经 shell 起;经 shell 时别同时传 args 数组
92
+ // (Node 会警告「args 未转义」—— 那个警告是对的,不该当噪声压掉,该改成不带 args)。
93
+ const r = process.platform === 'win32'
94
+ ? spawnSync('dsh --version', { encoding: 'utf8', shell: true })
95
+ : spawnSync('dsh', ['--version'], { encoding: 'utf8' });
96
+ if (r.error || r.status !== 0) return '';
97
+ return extractVersion(`${r.stdout ?? ''}${r.stderr ?? ''}`);
98
+ }
99
+
100
+ /**
101
+ * engines 校验。
102
+ *
103
+ * 写在 package.json 里的 `engines.dsh` 只是个声明 —— 装的时候没人看它。
104
+ * 宿主版本低于要求的表现是**加载失败或者行为不对**,而且报错看不懂。
105
+ * 所以这里主动问一次宿主版本,对不上就说清楚。
106
+ *
107
+ * ⚠️ **不拦**:拿不到版本(没有 dsh 命令 / 输出格式变了)时只说一句「没法确认」。
108
+ * 拦下来就是把「我不知道」变成「你不许装」,那是两件事。
109
+ */
110
+ function checkEngines() {
111
+ const want = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'))?.engines?.dsh;
112
+ if (!want) return;
113
+ const got = dshVersion();
114
+ if (!got) {
115
+ say(`宿主版本没法确认(没拿到 dsh --version 的输出)。这个包要求 dsh ${want},自己核一下。`);
116
+ return;
117
+ }
118
+ const ok = satisfies(got, want);
119
+ if (ok === false) {
120
+ say(`⚠ 宿主是 dsh ${got},这个包要求 ${want} —— 版本低了。`);
121
+ say(' 装上可能加载失败,或者行为不对。先升 dsh 再装,或者装完不行就回滚。');
122
+ } else if (ok === true) {
123
+ say(`宿主 dsh ${got},满足要求 ${want}`);
124
+ } else {
125
+ say(`宿主 dsh ${got},要求 ${want} 这种写法我认不出来,自己核一下。`);
126
+ }
127
+ }
128
+
129
+ function install(opts) {
130
+ const profileDir = resolveProfile(opts.profile);
131
+ if (!existsSync(join(profileDir, 'package.json'))) {
132
+ fail(`那个目录里没有 package.json:${profileDir}\n(profile 名字写错了,或者这个 profile 还没建过)`);
133
+ }
134
+ const profileName = profileDir.split(/[\\/]/).filter(Boolean).pop();
135
+ // Windows 上调 dsh 得经过 shell(dsh 是 .cmd,Node 从 18.20 起不允许直接 spawn .bat/.cmd)。
136
+ // 经 shell 就意味着参数会被拼接,所以名字必须干净 —— 只允许目录名里正常存在的字符。
137
+ if (!/^[A-Za-z0-9._-]+$/.test(profileName)) {
138
+ fail(`profile 目录名里有不安全的字符,不敢拼进命令行:${profileName}\n(名字里带空格/引号/符号的,请把 dsh plugin add 手动跑一次,再用 --profile 跑这个脚本)`);
139
+ }
140
+ say(`profile:${profileDir}`);
141
+ if (!opts.dryRun) checkEngines();
142
+
143
+ // ① 包本身。已经装过就跳过 —— 这一步重复跑的代价是几十秒的 pnpm 解析,不是错误。
144
+ const installed = existsSync(join(profileDir, 'node_modules', PKG, 'package.json'));
145
+ if (installed) {
146
+ say(`包已经在 node_modules 里了,跳过 add`);
147
+ } else if (opts.dryRun) {
148
+ say(`[dry-run] dsh plugin --profile ${profileName} add ${PKG}`);
149
+ } else {
150
+ if (!dshVersion()) {
151
+ // 没有 dsh CLI 不等于装不了 —— 但那两条替代路径得说清楚,不然人卡在这儿。
152
+ fail(`这台机器上没有 dsh 命令,装不了包。
153
+ 两条替代路径(README「云端部署」有前因后果):
154
+ a) 有 dsh 但不在 PATH:把 dsh 放进 PATH 再跑一次
155
+ b) 直接铺:cp -a <包目录> ${join(profileDir, 'node_modules', PKG)}
156
+ 然后 node ${join(profileDir, 'node_modules', PKG, 'setup.mjs')} ${profileDir}`);
157
+ }
158
+ say(`dsh plugin --profile ${profileName} add ${PKG} ……`);
159
+ const line = `dsh plugin --profile ${profileName} add ${PKG}`;
160
+ const r = process.platform === 'win32'
161
+ ? spawnSync(line, { cwd: profileDir, stdio: 'inherit', shell: true })
162
+ : spawnSync('dsh', ['plugin', '--profile', profileName, 'add', PKG], { cwd: profileDir, stdio: 'inherit' });
163
+ if (r.status !== 0) {
164
+ // 这一步失败不该把整件事判死:最常见的两种失败都跟包本身无关
165
+ // (本地包挡路的 404、profile 被桌面端独占),而 bundles 那一步照样得做完。
166
+ say('add 没成功。两种常见原因,跟这个包无关:');
167
+ say(' ① 报 404 + “No authorization header” —— 不是认证问题,是这棵依赖树里有本地包,pnpm 重新解析时撞上了');
168
+ say(` ② profile 被桌面端独占 —— 那时 CLI 连不上,只能手动铺:cp -a <包目录> ${join(profileDir, 'node_modules', PKG)}`);
169
+ say('继续做下一件(bundles)—— 它才是「装了没反应」的真正原因。');
170
+ }
171
+ }
172
+
173
+ // ② bundles。这一步没有「失败就算了」这一说:漏了它整件事等于没做。
174
+ const setup = join(PKG_ROOT, 'setup.mjs');
175
+ if (!existsSync(setup)) fail(`包里找不到 setup.mjs:${setup}`);
176
+ const args = [setup, profileDir];
177
+ if (opts.dryRun) args.push('--dry-run');
178
+ const r2 = spawnSync(process.execPath, args, { stdio: 'inherit' });
179
+ if (r2.status !== 0) fail('往 bundles 里写失败 —— 上面有原因。这一步没做成,装了也没有任何行为变化。');
180
+
181
+ if (opts.dryRun) {
182
+ say('[dry-run] 到此为止,什么都没改。');
183
+ return;
184
+ }
185
+
186
+ // ③ 装完之后先干什么。用户大概率不看 README,这四句就是他能拿到的全部指引。
187
+ // 免责那两条紧跟在后面 —— 不是法律条款,是「装上之后你会发现什么」。
188
+ console.log('');
189
+ say('装好了。接着做这四件:');
190
+ say(' 1. 重启 dsh —— 配置不热生效(重启会打断正在说话的会话,挑个没人用的时候)');
191
+ say(' 2. 重启后先验一条:日志里出现 rh_mood / rh_pending / rh_control / rh_changes 已注册');
192
+ say(' 3. 头一周只开观测:把 injectMood 设成 false,先看它记了什么,别急着让它影响说话');
193
+ say(' 4. 出事一条命令退:sh scripts/uninstall.sh --profile ' + profileName + ' --dry-run');
194
+ console.log('');
195
+ say('三件得先说清楚:');
196
+ say(' · 适用范围:只推荐私聊与少数固定人员。群聊没做深,效果和后果不作保证。');
197
+ say(' · 它每轮都会往系统提示里塞状态(花 token);账本只记她自己写的短句和事件 id,不抄对话原文。');
198
+ say(' · 它只注入状态,不注入人格 —— 人格是装它的人的责任,它不替那份人格担保。');
199
+ say('随时自检:npx realheart doctor');
200
+ }
201
+
202
+ // ── 总开关(P-3 kill switch)──
203
+ //
204
+ // 一个文件管全停。为什么不是「改配置里的 enabled」:改配置要么动 profile 的 YAML
205
+ // (热改会整份重挂,正在说话的会话被打断),要么指望宿主把配置正确传进来。
206
+ // 「一条命令全停」不该依赖任何一层能不能传对。
207
+ /**
208
+ * 改她的心(P-3b)—— 整份配置里唯一一件「能被人直接写进去」的事,所以成本要高。
209
+ *
210
+ * 四道闸,缺一条都不干:
211
+ * ① `allowManualMoodOverride` 必须是 true(默认关,关着时这条命令等于不存在);
212
+ * ② 四个参数缺一不可,其中 `--i-know-this-is-not-her-feeling` 是那句确认;
213
+ * ③ 一周最多 N 次(默认 3),超了就拒绝;
214
+ * ④ 写进去的记录带 `why_kind: 'manual'`,**注入里会标明「这条是被改写的,不是她自己的」**。
215
+ *
216
+ * 第④条是整条设计的核心。不标明的话,这份账就变成「谁写进去的都算她的」——
217
+ * 那还不如不做。
218
+ */
219
+ async function moodOverride(opts) {
220
+ const user = loadUserConfig(DSH_HOME);
221
+ const cfg = { ...DEFAULTS, ...user.config };
222
+ if (cfg.allowManualMoodOverride !== true) {
223
+ // 关着的时候不提示怎么开 —— 不然等于给了入口。要说也只说「不知道这条命令」。
224
+ fail('不认识的子命令:mood-override');
225
+ }
226
+ const need = [
227
+ ['feeling', '--feeling "<几个字,她的语气>"'],
228
+ ['level', '--level <1-10>'],
229
+ ['why', '--why "<为什么改,会写进账本>"'],
230
+ ['ack', '--i-know-this-is-not-her-feeling'],
231
+ ];
232
+ const missing = need.filter(([k]) => !opts[k]).map(([, hint]) => hint);
233
+ if (missing.length) {
234
+ fail(`缺参数,什么都没写:\n ${missing.join('\n ')}`);
235
+ }
236
+ const level = Number(opts.level);
237
+ if (!Number.isInteger(level) || level < 1 || level > 10) fail('--level 要 1–10 的整数');
238
+ const stateDir = await resolveStateDir(opts);
239
+ if (!stateDir) fail('不知道账本在哪,拒绝写入(不给 --state-dir 就读运行时 config.json,读不到不猜)');
240
+
241
+ const moodPath = join(stateDir, 'mood.jsonl');
242
+ // 数一周内的次数 —— 限额是这条通道「不能随便乱改」的那一半
243
+ let recent = 0;
244
+ if (existsSync(moodPath)) {
245
+ for (const line of (await readFile(moodPath, 'utf8')).split('\n')) {
246
+ const t = line.trim();
247
+ if (!t) continue;
248
+ try {
249
+ const r = JSON.parse(t);
250
+ if (r?.why_kind !== 'manual') continue;
251
+ const at = Date.parse(r.at);
252
+ if (!Number.isNaN(at) && Date.now() - at < 7 * 24 * 3600 * 1000) recent += 1;
253
+ } catch {
254
+ /* 坏行跳过 */
255
+ }
256
+ }
257
+ }
258
+ const cap = Number(cfg.maxManualMoodOverridePerWeek ?? 3);
259
+ if (recent >= cap) {
260
+ fail(`七天内已经改过 ${recent} 次,上限是 ${cap}。\n要再改,先把 maxManualMoodOverridePerWeek 调高 —— 那本身也是一次入账动作。`);
261
+ }
262
+ if (!opts.yes) {
263
+ if (process.stdin.isTTY) fail('没确认。加 --yes 表示你确实要改 —— 她会看到这条不是你写的');
264
+ fail('非交互环境要加 --yes。什么都没写');
265
+ }
266
+
267
+ const pad = (n) => String(n).padStart(2, '0');
268
+ const d = new Date();
269
+ const at = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
270
+ const row = {
271
+ at,
272
+ feeling: String(opts.feeling).trim(),
273
+ level,
274
+ why_ref: '',
275
+ why_kind: 'manual',
276
+ by: 'owner',
277
+ reason: String(opts.why).trim(),
278
+ decay: null,
279
+ };
280
+ await mkdir(stateDir, { recursive: true });
281
+ await appendFile(moodPath, `${JSON.stringify(row)}\n`, 'utf8');
282
+
283
+ // 入账:这条必须和她的账本分开放 —— 它证明的是「有人动过她的心」
284
+ const entry = `\n## ${at} · 人工改写心情(事前)\n\n- 从:${recent ? '(七天内第 ' + (recent + 1) + ' 次)' : '(本周第一次)'}\n- 改成:${row.feeling}(浓 ${level}/10)\n- 为什么:${row.reason}\n- ⚠️ 这条在注入里会标明「不是你自己的」,她看得见\n`;
285
+ try {
286
+ await appendFile(join(stateDir, 'changes.md'), entry, 'utf8');
287
+ } catch {
288
+ /* 记不上账不该让这次写入失败,但下面会说 */
289
+ }
290
+ say(`已写入:${row.feeling}(浓 ${level}/10)`);
291
+ say(`七天内第 ${recent + 1} 次,上限 ${cap}`);
292
+ say('注入里会写明「这条是被改写的,不是她自己的」—— 她下次开口就能看到。');
293
+ say('她下一轮开口就能看到(心情每轮都读,不用重启)。');
294
+ }
295
+
296
+ /**
297
+ * 作废一条心情(他的权限,不是她的)。
298
+ *
299
+ * 为什么写进命令而不是给她一个工具动作:作废是「这份账里有一条不算」——
300
+ * 记账的人不该能给自己划掉一条。他能,因为他不是被记的那个人。
301
+ *
302
+ * 键用 `at` 而不是行号:行号是「第几条」,账本一旦被裁剪/归档,
303
+ * 同一个行号指向的就变成另一条心情了。at 不会。
304
+ */
305
+ async function voidMood(opts) {
306
+ const stateDir = await resolveStateDir(opts);
307
+ if (!stateDir) fail('不知道账本在哪。明说:realheart void --state-dir <账本目录>');
308
+ const moodPath = join(stateDir, 'mood.jsonl');
309
+ if (!existsSync(moodPath)) fail('没有心情账本(mood.jsonl),没什么可作废的');
310
+
311
+ const rows = [];
312
+ for (const line of (await readFile(moodPath, 'utf8')).split('\n')) {
313
+ const t = line.trim();
314
+ if (!t) continue;
315
+ try { rows.push(JSON.parse(t)); } catch { /* 坏行跳过 */ }
316
+ }
317
+ if (!rows.length) fail('心情账本是空的');
318
+
319
+ // 行号要按「整个账本里的第几条」算。之前写成了 rows.length - n + i,
320
+ // 账本不足 n 条时会算出负数(显示成 [-1][0][1])—— 列出来让人照着敲的东西,错一位就没用。
321
+ const show = (n = 5) => {
322
+ const start = Math.max(0, rows.length - n);
323
+ return rows.slice(start).map((r, i) => ` [${start + i + 1}] ${r.at} ${r.feeling ?? ''}(浓 ${r.level ?? '?'})`).join('\n');
324
+ };
325
+
326
+ let target = '';
327
+ if (opts.at) {
328
+ const hit = rows.find((r) => String(r.at) === String(opts.at).trim());
329
+ if (!hit) fail(`没有 at 是「${opts.at}」的那一条。最近几条:\n${show()}`);
330
+ target = String(hit.at);
331
+ } else if (opts.index) {
332
+ const i = Number(opts.index);
333
+ if (!Number.isInteger(i) || i < 1 || i > rows.length) fail(`行号要在 1–${rows.length} 之间。最近几条:\n${show()}`);
334
+ target = String(rows[i - 1].at);
335
+ } else {
336
+ target = String(rows[rows.length - 1].at);
337
+ say(`没指定 --at / --index,默认作废最后一条:${target}`);
338
+ }
339
+
340
+ if (!opts.why) fail('缺 --why。作废一条心情是要留账的事,得写清为什么');
341
+ if (!opts.yes) {
342
+ if (process.stdin.isTTY) fail('没确认。加 --yes 表示确实要作废');
343
+ fail('非交互环境要加 --yes。什么都没动');
344
+ }
345
+
346
+ const at = new Date().toISOString().slice(0, 19).replace('T', ' ');
347
+ await mkdir(stateDir, { recursive: true });
348
+ await appendFile(join(stateDir, 'control.jsonl'),
349
+ `${JSON.stringify({ at, action: 'void', target, reason: String(opts.why).trim(), by: 'owner' })}\n`, 'utf8');
350
+ say(`作废了 at=${target} 那一条。它还在账本里,只是不再被注入 —— 作废不等于删除。`);
351
+ say('下一轮注入就跳过它(不用重启)。');
352
+ }
353
+ const KILL = join(DSH_HOME, 'realheart', 'DISABLED');
354
+
355
+ function switchOff() {
356
+ mkdirSync(dirname(KILL), { recursive: true });
357
+ writeFileSync(KILL, `${new Date().toISOString()} 由 realheart off 停用\n`, 'utf8');
358
+ say(`已停用:建了 ${KILL}`);
359
+ say('不注入、不记账、四个工具都不注册。');
360
+ say('⚠️ 重启 dsh 之后才生效(apply 只在启动时跑一次)—— 重启会打断正在说话的会话。');
361
+ }
362
+
363
+ function switchOn() {
364
+ if (!existsSync(KILL)) {
365
+ say('本来就是开着的(没有 DISABLED 文件)');
366
+ return;
367
+ }
368
+ unlinkSync(KILL);
369
+ say(`已恢复:删了 ${KILL}。同样要重启才生效。`);
370
+ }
371
+
372
+ /** 账本目录:只认运行时 config.json。不猜 —— 猜错会读/删别人的东西。 */
373
+ async function resolveStateDir(opts) {
374
+ if (opts.stateDir) return opts.stateDir;
375
+ const cfgPath = join(DSH_HOME, 'realheart', 'runtime', 'config.json');
376
+ if (!existsSync(cfgPath)) return '';
377
+ try {
378
+ const c = JSON.parse(await readFileSync(cfgPath, 'utf8'));
379
+ return c.stateDir || '';
380
+ } catch {
381
+ return '';
382
+ }
383
+ }
384
+
385
+ /** 她那台机器上的根(pieces 在它下面)。同样只认运行时 config.json。 */
386
+ async function resolveRoot() {
387
+ const cfgPath = join(DSH_HOME, 'realheart', 'runtime', 'config.json');
388
+ if (!existsSync(cfgPath)) return '';
389
+ try {
390
+ return JSON.parse(await readFile(cfgPath, 'utf8')).root || '';
391
+ } catch {
392
+ return '';
393
+ }
394
+ }
395
+ const DATA_RE = /\.(jsonl|md|log|txt)$/;
396
+
397
+ // ── 数据主权(P-3)──
398
+ async function exportData(opts) {
399
+ const stateDir = await resolveStateDir(opts);
400
+ if (!stateDir || !existsSync(stateDir)) {
401
+ fail(`不知道账本在哪(既没给 --state-dir,运行时 config.json 里也没有)。\n 试用:npx realheart export --state-dir <账本目录> --out <目标目录>`);
402
+ }
403
+ const out = opts.out || join(process.cwd(), `realheart-export-${Date.now()}`);
404
+ mkdirSync(out, { recursive: true });
405
+ let n = 0;
406
+ for (const f of await readdir(stateDir)) {
407
+ if (!DATA_RE.test(f)) continue;
408
+ await copyFile(join(stateDir, f), join(out, f));
409
+ n += 1;
410
+ }
411
+ say(`导出 ${n} 个文件:${stateDir} → ${out}`);
412
+ say('里面是**原文**。给出去之前自己先看一眼 —— 那是她写过的话,不是日志。');
413
+ }
414
+
415
+ async function wipe(opts) {
416
+ const stateDir = await resolveStateDir(opts);
417
+ if (!stateDir) {
418
+ // 删账本这条路上不猜。和 uninstall.sh --purge 同一条规矩。
419
+ fail('不知道账本在哪,拒绝执行。明说:npx realheart wipe --state-dir <账本目录>');
420
+ }
421
+ if (!existsSync(stateDir)) {
422
+ say(`目录不存在(${stateDir}),没什么可删`);
423
+ return;
424
+ }
425
+ const files = await readdir(stateDir);
426
+ console.log(`[realheart] 将要删除 ${stateDir} 下的这些(这是唯一的确认依据):`);
427
+ for (const f of files) {
428
+ let size = '?';
429
+ try {
430
+ size = `${(await stat(join(stateDir, f))).size} B`;
431
+ } catch {
432
+ /* 读不到就不报大小 */
433
+ }
434
+ console.log(` ${f} (${size})`);
435
+ }
436
+ say('删了就真没了 —— 里面是她的账本,不是缓存。');
437
+ if (!opts.yes) {
438
+ if (process.stdin.isTTY) fail('没确认,什么都没删。加 --yes 表示你确实要删');
439
+ fail('非交互环境,要真删必须加 --yes。什么都没删');
440
+ }
441
+ // 先留标记,再删。顺序反了就自相矛盾 —— 标记写在被删的东西里,删完一起没了。
442
+ const entry = renderForgetEntry({
443
+ at: new Date().toISOString().slice(0, 16).replace('T', ' '),
444
+ scope: '整份账本',
445
+ target: stateDir,
446
+ inventory: await inventoryOf(stateDir),
447
+ why: opts.why,
448
+ by: `命令行 realheart wipe(${os.hostname()})`,
449
+ });
450
+ const logPath = await appendForget(DSH_HOME, entry);
451
+ say(`标记写在 ${logPath}(在被删的东西之外)`);
452
+ await rm(stateDir, { recursive: true, force: true });
453
+ say(`已删:${stateDir}`);
454
+ }
455
+
456
+ /**
457
+ * 删单个产物 / 单个文件 —— 这就是 M1-9 定案里「人是唯一能删的那一方」的入口。
458
+ *
459
+ * 两条硬边界:
460
+ * ① 只允许删账本目录和 `work/pieces` 底下的东西,别的一律拒绝;
461
+ * ② 删之前必须写下标记(写在被删内容之外),写不上就不删。
462
+ *
463
+ * 她的四个工具里**没有**删除动作 —— 记账的人不能同时握毁账的权。
464
+ */
465
+ async function forget(opts) {
466
+ if (!opts.path) {
467
+ fail('要删什么?用 --path <文件或目录> 指出来,再加 --why "为什么"');
468
+ }
469
+ const stateDir = await resolveStateDir(opts);
470
+ const root = await resolveRoot();
471
+ const allowed = [stateDir, root ? join(root, 'work', 'pieces') : ''].filter(Boolean);
472
+ if (!allowed.length) {
473
+ fail('既不知道账本目录也不知道 pieces 在哪 —— 明说 --state-dir,别让它猜');
474
+ }
475
+ const target = resolve(opts.path);
476
+ const inside = allowed.some((dir) => {
477
+ const rel = relative(resolve(dir), target);
478
+ return rel && !rel.startsWith('..') && !isAbsolute(rel);
479
+ });
480
+ if (!inside) {
481
+ fail(`拒绝:${target} 不在允许删除的范围内。\n只允许删:${allowed.join('\n ')}\n(这条边界是硬的 —— 一个能删任意路径的入口,等于把整台机器交出去)`);
482
+ }
483
+ if (!existsSync(target)) {
484
+ say(`不存在:${target},没什么可删`);
485
+ return;
486
+ }
487
+ const inv = await inventoryOf(target);
488
+ say(`将要删除:${target}`);
489
+ say(`内容:${inv}`);
490
+ say('删了就真没了。这不是缓存。');
491
+ if (!opts.yes) {
492
+ if (process.stdin.isTTY) fail('没确认,什么都没删。加 --yes 表示你确实要删');
493
+ fail('非交互环境,要真删必须加 --yes。什么都没删');
494
+ }
495
+ const entry = renderForgetEntry({
496
+ at: new Date().toISOString().slice(0, 16).replace('T', ' '),
497
+ scope: '',
498
+ target,
499
+ inventory: inv,
500
+ why: opts.why,
501
+ by: `命令行 realheart forget(${os.hostname()})`,
502
+ });
503
+ const logPath = await appendForget(DSH_HOME, entry);
504
+ say(`标记写在 ${logPath}(在被删的东西之外)`);
505
+ await rm(target, { recursive: true, force: true });
506
+ say(`已删:${target}`);
507
+ }
508
+
509
+ const argv = process.argv.slice(2);
510
+ const opts = { profile: '', dryRun: false, stateDir: '', out: '', yes: false, path: '', why: '', feeling: '', level: '', ack: false, at: '', index: '' };
511
+ const rest = [];
512
+ for (let i = 0; i < argv.length; i += 1) {
513
+ const a = argv[i];
514
+ if (a === '--dry-run') opts.dryRun = true;
515
+ else if (a === '--yes') opts.yes = true;
516
+ else if (a === '--profile') opts.profile = argv[++i] ?? '';
517
+ else if (a === '--state-dir') opts.stateDir = argv[++i] ?? '';
518
+ else if (a === '--out') opts.out = argv[++i] ?? '';
519
+ else if (a === '--path') opts.path = argv[++i] ?? '';
520
+ else if (a === '--why') opts.why = argv[++i] ?? '';
521
+ else if (a === '--at') opts.at = argv[++i] ?? '';
522
+ else if (a === '--index') opts.index = argv[++i] ?? '';
523
+ else if (a === '--feeling') opts.feeling = argv[++i] ?? '';
524
+ else if (a === '--level') opts.level = argv[++i] ?? '';
525
+ else if (a === '--i-know-this-is-not-her-feeling') opts.ack = true;
526
+ else if (a === '-h' || a === '--help') { usage(); process.exit(0); }
527
+ else if (a.startsWith('--profile=')) opts.profile = a.slice('--profile='.length);
528
+ else if (a.startsWith('--state-dir=')) opts.stateDir = a.slice('--state-dir='.length);
529
+ else if (a.startsWith('--out=')) opts.out = a.slice('--out='.length);
530
+ else if (a.startsWith('--path=')) opts.path = a.slice('--path='.length);
531
+ else if (a.startsWith('--why=')) opts.why = a.slice('--why='.length);
532
+ else if (a.startsWith('--feeling=')) opts.feeling = a.slice('--feeling='.length);
533
+ else if (a.startsWith('--level=')) opts.level = a.slice('--level='.length);
534
+ else rest.push(a);
535
+ }
536
+
537
+ const cmd = rest[0] || 'install';
538
+ if (cmd === 'install') install(opts);
539
+ else if (cmd === 'doctor') doctor();
540
+ else if (cmd === 'off') switchOff();
541
+ else if (cmd === 'on') switchOn();
542
+ else if (cmd === 'export') await exportData(opts);
543
+ else if (cmd === 'wipe') await wipe(opts);
544
+ else if (cmd === 'forget') await forget(opts);
545
+ else if (cmd === 'void') await voidMood(opts);
546
+ else if (cmd === 'mood-override') await moodOverride(opts);
547
+ else if (cmd === 'help') usage();
548
+ else {
549
+ console.error(`[realheart] 不认识这个子命令:${cmd}`);
550
+ usage();
551
+ process.exit(1);
552
+ }
package/index.mjs CHANGED
@@ -766,8 +766,12 @@ export function apply(ctx, config = {}) {
766
766
  await writeFile(injectPaths.perInstance, payload, W600);
767
767
  await chmod(injectPaths.latest, 0o600).catch(() => {});
768
768
  await chmod(injectPaths.perInstance, 0o600).catch(() => {});
769
- } catch {
770
- /* 落盘失败就算了,注入优先 */
769
+ } catch (err) {
770
+ // 落盘失败不能只是「算了」(2026-10-31 真机验出来的)。
771
+ // 账本目录不可写时:注入照常发出、她也照常说话,但快照写不下来 ——
772
+ // 而 doctor 的「注入」那一项会读成「最近没注入过」。
773
+ // **「注入了但写不下来」和「根本没注入」在页面上长得一模一样**,那正是这套东西最坏的坏法。
774
+ log(`注入快照没写下来(${err?.code ?? err?.message ?? err})—— 注入本身照常发出`, 'error');
771
775
  }
772
776
  return { ...assembled, sections: [...sections, ...parts] };
773
777
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "realheart",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "给她一个能记账的心情层:心情必须有来处,改动必须有账,她自己能停能查",
5
5
  "type": "module",
6
6
  "main": "index.mjs",