dsh-liuhen 0.1.0 → 0.1.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 dsh-liuhen contributors
3
+ Copyright (c) 2026 xiaoman000
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,127 +1,128 @@
1
1
  # 留痕 · dsh-liuhen
2
2
 
3
- **能核对的 AI 记忆。** 它记得你说过什么,而且你能核对它记对了没有。
4
-
5
- - 索引短(每条 200 字)用来翻,仓库存完整原文用来查
6
- - 每条记忆都能凭 `session:seq` 回到原话,不是"大概记住了"
7
- - 自动注入**默认关闭**;要开也得先看预览
8
- - 不删:保留期到点只把索引归档,字节一个不少
9
- - 不联网:插件自身不发起任何网络请求
10
-
11
- ## 面板八块
12
-
13
- 1. **我是谁** — 你交代过的、希望它一直记得的事
14
- 2. **日记** — 公开版 / 私密版,可选,默认关
15
- 3. **今天的账本** — 只记今天,从新到旧;助手的话下面可以挂批注
16
- 4. **自省** — 由模型手写,没有自动生成、没有定时任务
17
- 5. **承诺清单** — 本机一份清单
18
- 6. **仓库(完整原文)** — 按天点开,能翻到原话
19
- 7. **索引保留** — 到期只归档,不删除;没有「遗忘」功能
20
- 8. **备份包** — 一键打出可搬家的 zip(放最底下)
3
+ **可核对的 AI 记忆。** 它记得你说过什么,而且你能核对它记对了没有。
4
+
5
+ ## 八块面板
6
+
7
+ 面板每 30 秒自己刷新一次(页面在后台时不刷,切回来立刻补一次),不用关掉重开。
8
+
9
+ - **我是谁** — 你交代过的、希望它一直记得的事(装好后助手会自己写一次,见下)
10
+ - **我要写的日记** — 给主人的 / 给自己的,可选,默认关
11
+ - **今天的账本** — 只记今天,打开面板前先增量回填,不差 5 分钟
12
+ - **我亲手写的自省** — 由模型手写,没有自动生成、没有定时任务,只显示今天;有就写,没有就不写,空着不算欠账
13
+ - **我对主人的承诺** — 一份本机清单
14
+ - **仓库(完整原文)** — 按天点开,从新到旧;每条原话旁边挂着批注,话和批注在同一条上
15
+ - **索引保留** — 索引按保留期归档(换个地方躺着),不删。没有「遗忘」这个功能
16
+ - **装着我的小盒子** — 一键打包:索引、完整原文、自省、日记、承诺、设置(放最底下,搬家才用)
17
+
18
+ ## 第一次见面:它自己写「我是谁」
19
+
20
+ 装好之后,助手下一次开口时系统提示里会多一段 `[留痕 · 第一次见面]`:
21
+ 「我是谁」还空着,缺名字和一段自我介绍——这块是你自己的,不该等用户来写。
22
+ 它调一次 `trace_intro`(`name` + `text`)就写好了,**不用你先教它你是谁**。
23
+
24
+ - 不预置人设:名字由它自己取,插件只提示、不代填
25
+ - 已经有内容时不覆盖;要改带 `replace: true`,旧内容先备份成 `liuhen/identity.bak-<时间>`
26
+ - 写的是它自己,不是你的信息;你的那部分在「关于你」,由你自己填
27
+ - 填完提示就消失,不会每轮都念;这段不跟注入开关绑,它是一次性设置提示
28
+
29
+ ## 注入分两档
30
+
31
+ - **简洁档(默认)**:身份 + 关于你 + 日记状态 + 最近 `briefRows` 条(默认 12)。实测约 2000 字。
32
+ - **完整档**:按 `days` 取当天/多天全量,不裁字。实测约 5100 字,且随当天对话继续变长。
33
+
34
+ 选完整档要认一件事:注入块在系统提示一侧,越长越容易挤掉有效上下文。
35
+ `maxChars` / `maxLines` 是兜底闸,写成大于 0 才生效(0 = 不限)。
36
+
37
+ **注入时会剥掉我复述的原文**(`inject.stripQuoted`,默认开):我旧回复里的
38
+ 代码块、`>` 引用行、超过 10 字的 `「」`/`“”`/`『』` 长引文都不进注入,
39
+ 只留我自己的话;用户说的话原样不动。这一层是减面,不是清零——
40
+ 对方把话写成普通段落、我又原样抄进正文时,认不出来。
41
+
42
+ **注入了没有,有凭据。** 每次真把内容附加到系统提示,插件会写一行
43
+ `$DSH_HOME/liuhen/inject-state.json`:时间、字数、行数、档位,**不含内容**。
44
+ 面板「注入」那块会显示「上一轮 07:41:真注入了 1610 字 / 12 行(简洁档)」,
45
+ 或者「没注入——开关关着」。生成失败也会记成 `reason: "error"`,不再静默跳过。
46
+
47
+ 预览不算凭据:预览走 `dryRun`,**不看开关**,关着也能预览出内容。
48
+
49
+ ## 仓库(vault):索引短,仓库全
50
+
51
+ - **索引**是摘要:每条只留 200 字,快、小,用来"翻"
52
+ - **仓库**是原文:`$DSH_HOME/vault/<日期>.jsonl`,一行一条,不折空白、不截断
53
+ - 写入只有一条路:回填时顺手落一份(不双写、不另起进程,避免"索引有、仓库没有"这种不报错的错)
54
+ - 读取给助手三个口子:`vault_read`(某天原文)、`vault_find`(全仓库搜词)、`vault_open`(session+seq 精确取)
55
+ - `trace_verify` 在会话文件被清之后会退到仓库——痕迹不依赖 `sessions/` 活着
56
+ - **面板上点得开**:设置里「仓库」那一块,列出有哪几天、多少条、几条批注;点某天就能翻原文,20 条一页,带「更早的」
57
+ - **话和批注绑在一起**:一句原话上可以挂四种痕迹,各自独立、同种只挂一次
58
+ - `trace_feel(say, feel)` = 感受(老的 `f`/`fs` 字段照样认)
59
+ - `trace_ink(say, kind, text)`:`kind` 取 `hes`(犹豫)/ `why`(当时的权衡)/ `unsaid`(没说出口的)/ `feel`(感受)
60
+ - 存在那条记录的 `b` 数组里(`{ k, x, t }`),和原话同一条,读仓库时一起出来;面板上按颜色分开显示
61
+ - 没有就不挂,空着也是记录;不补历史(当时没记的,后来补就是编的)
62
+ - 找原话的顺序:一模一样 → 以它开头 → 包含它的最短一条(防挂到"复述自己"的那条上)
63
+ - 小盒子会连仓库一起打包,并把 `pet-memory/`(名字、承诺、跨会话汇总、交接信)带上;箱子里有一张 `00-读我.txt` 说明三层关系
64
+ - **打的时候会脱敏**(B 方案):姓名保留,生日、学校、籍贯/住址在**包里那份副本**上换成 `[已隐去]`;磁盘原件一个字不改
65
+ - 按日期补打:`exportBox({ date: 'YYYY-MM-DD' })` 只装那天的原文,索引截到那天
66
+ - 打完往共享目录放一份:手机是 `/storage/emulated/0/DSHBox`,电脑(Windows / macOS)是下载文件夹
67
+ - ⚠️ **这个包里有完整原文**(明文)。落到共享目录后,同一台机器上别的程序也读得到;`pet-memory/` 里还有个人信息。存好,别随手转出去
68
+
69
+ ## 隐私
70
+
71
+ - **零网络请求**:插件自身不发起任何请求
72
+ - **只写本机**:`$DSH_HOME/liuhen/`,文件 600、目录 700(建目录时显式 chmod,不靠 umask)
73
+ - **写接口只收本机面板**:POST 必须是 `application/json` 且同源,请求体上限 1MB
74
+ - **仓库去重**:读的时候按 `session:seq` 去重并合并批注;`scripts/dedupe-vault.js` 可以把文件里的重复物理收掉(加 `--write` 才写)
75
+ - **注入可关**:自动注入默认关闭;打开前可以预览将注入什么
76
+ - **不删**:`retention.keepDays` 到期只归档到 `archive/`,字节一个不少;没有遗忘接口
77
+ - **脱敏**:手机号、邮箱、身份证、密钥、长数字串在注入与展示前替换为占位
78
+ - **承诺清单只认一份**:有 `pet-memory/承诺清单.md` 就用它,没有才用插件自己的 `liuhen/promises.md`
79
+ - 诚实的边界:**脱敏挡不住自然语言里的隐私**,它只能降低风险,不能消除
21
80
 
22
81
  ## 安装
23
82
 
24
- ### 一句话安装(推荐)
25
-
26
- 把压缩包放在电脑上(比如 `~/Downloads`),然后对 DSH 说一句:
27
-
28
- > 帮我装一下 ~/Downloads/dsh-liuhen-0.1.0.zip 这个插件
29
-
30
- 助手要做的就三步(自己敲也一样):
31
-
32
- ```bash
33
- mkdir -p ~/liuhen-install && cd ~/liuhen-install
34
- unzip -q ~/Downloads/dsh-liuhen-0.1.0.zip # 解出 dsh-liuhen/ 目录
35
- cd dsh-liuhen && node install.js # 拷进 profile + 在 bundles 里登记
36
- # 然后重启 DSH
37
- ```
38
-
39
- 助手如果不知道包在哪:先在 `~/Downloads`、`~/Desktop`、下载目录里找 `dsh-liuhen*.zip` 或 `*.tgz`。
40
-
41
- `install.js` 会:把自己拷进 `~/.dsh/profiles/web/node_modules/dsh-liuhen`(已有就自动备份)、
42
- 在 profile 的 `bundles` 里登记一行、告诉你重启。它不联网,也不碰你的数据。
43
-
44
- ### 从 npm 装
45
-
46
83
  ```bash
47
84
  dsh plugin --profile web add dsh-liuhen
48
85
  ```
49
86
 
50
- 装好后在设置页会多一块「留痕」。数据全部在 `$DSH_HOME/liuhen/` 与 `$DSH_HOME/vault/`
51
- (文件 600、目录 700)。
52
-
53
- **本地安装(还没发布到 npm 时)**:
87
+ ## 电脑上也能用(0.1.2 起)
54
88
 
55
- ```bash
56
- # 1) 解压后把目录放到 profile 的 node_modules 里
57
- cp -r dsh-liuhen "$DSH_HOME/profiles/web/node_modules/dsh-liuhen"
58
- # 2) 在 "$DSH_HOME/profiles/web/package.json" 的 dsh.profile.bundles 里加一行 "dsh-liuhen"
59
- # 3) 重启 DSH,设置页就会出现「留痕」
60
- ```
89
+ 同一份插件,Windows / macOS / 手机都跑,不用装 7-Zip 之类的东西:
61
90
 
62
- **环境要求**:Node ≥ 22.15(读会话文件要用 `node:zlib` 的 zstd);备份包与「看一眼包里有什么」
63
- 用到系统的 `zip` / `unzip` 命令(Linux、macOS 自带;Windows 需要自己装,缺了只会打包失败,不影响其它功能)。
91
+ - **打包不再调 `zip` 命令**:自己拼 zip 容器(`lib/zip.js`,只用 Node 自带的 `zlib`)。
92
+ 0.1.1 在电脑上会报 `打包失败:spawnSync zip ENOENT`,根就在这儿。
93
+ - **面板「打开」不再调 `unzip -l`**:直接读 zip 的中央目录,中文文件名按 UTF-8 原样读回来。
94
+ - **共享目录按平台走**(`lib/platform.js`):手机 → `/storage/emulated/0/DSHBox`;
95
+ Windows → `%USERPROFILE%\Downloads`;macOS → `~/Downloads`。面板文案也跟着换,
96
+ 电脑上不会再写「存到手机」。
97
+ - 打包是**可复现**的:同一天同样的数据,打两次 `sha256` 一样(目录条目固定时间戳,
98
+ 脱敏后把 mtime 拨回去)——面板上那个 sha 才有核对的意义。
64
99
 
65
- ## 它到底存什么、发什么
66
-
67
- | 项目 | 存在哪 | 会不会外发 |
68
- |---|---|---|
69
- | 索引(200 字/条) | `liuhen/index.jsonl` | 开了注入才会,且只发摘要 |
70
- | 完整原文 | `vault/<日期>.jsonl` | 不会(除非你自己打包带走) |
71
- | 日记 / 自省 / 承诺 | `liuhen/diary`、`notes`、`promises.md` | 开了注入只发日记有没有写 |
72
- | 设置 | `liuhen/config.json` | 不会 |
73
- | 批注(感受/犹豫/权衡/没说出口) | `vault/` 里同一行旁边 | 不会 |
74
-
75
- 自动注入分两档:**简洁档**默认(身份 + 关于你 + 日记状态 + 最近 12 条),**完整档**按天全量。
76
- 两档都可以预览;`maxChars` / `maxLines` 是兜底闸,写 0 表示不限。
77
-
78
- ## 三个防呆设计
79
-
80
- - **注入剥引用**:助手旧回复里的代码块、`>` 引用行、长引号、以及「原话是这么说的:…」这类带引导语的抄写,都不进注入,只留它自己的话;用户说的话原样不动。
81
- - **写接口只收本机面板**:POST 必须是 `application/json` 且同源,请求体上限 1MB。
82
- - **跨进程锁 + 写前核对**:多个实例同时回填不会写重复行;仓库读取时也会按 `session:seq` 去重。
83
-
84
- ## 隐私边界(说清楚,不糊弄)
85
-
86
- - **脱敏挡不住自然语言里的隐私**:住址、关系、习惯这类写在句子里的话,规则认不出来。
87
- - 打包脱敏只改**包里的副本**:姓名保留,生日、学校、籍贯/住址换成 `[已隐去]`;磁盘上的原件一个字不动。
88
- - 备份包是**明文**。放到共享目录或发给别人之前先想清楚。
89
- - 要额外屏蔽的词,写进设置里的 `privacy.denyWords`:命中的行在注入时整行跳过,打包时也会替换。
90
-
91
- ## 验证
100
+ **在电脑上验一遍**(`lib/*.js` 改了必须重启一次 DSH,面板的 HMR 管不到服务端):
92
101
 
93
102
  ```bash
94
- # 自带自检:只在自己造的沙箱里跑,不读、不拷任何真实数据
95
- node node_modules/dsh-liuhen/scripts/selfcheck.js
103
+ # 1. 重启 DSH 后,打开面板 → 小盒子 → 「打一份小盒子」
104
+ # 2. 没有红灯、出现「已放进下载文件夹:C:\Users\<你>\Downloads\留痕-YYYY-MM-DD.zip」即通过
105
+ # 3. 解压那个 zip,看中文名是否正常、00-读我.txt 第 20 行左右有没有「去向:」
106
+ # 4. 自检(可选,Windows 上也能跑):node scripts/selfcheck.js → 82/82
96
107
  ```
97
108
 
98
- 自检覆盖:模块加载、工具注册、全部 HTTP 端点、配置读写、日记/自省/承诺、索引与仓库、
99
- 注入分档与剥引用、脱敏、打包、锁、目录权限、坏数据容错。跑完自动清理沙箱。
100
-
101
- 仓库文件里的重复行可以这样清(先预演,加 `--write` 才真写):
109
+ ## 0.1.2(小更新,不是第二版)
102
110
 
103
- ```bash
104
- node node_modules/dsh-liuhen/scripts/dedupe-vault.js
105
- node node_modules/dsh-liuhen/scripts/dedupe-vault.js --write
106
- ```
107
-
108
- ## 卸载与删除数据
109
-
110
- 把插件从 profile 里去掉就卸载了;它不会自己删数据。要彻底清掉:
111
-
112
- ```bash
113
- rm -rf "$DSH_HOME/liuhen" "$DSH_HOME/vault"
114
- ```
111
+ - **电脑分支**:打包与列清单不再依赖系统的 `zip` / `unzip` 命令(Windows 上两者都没有),
112
+ 改成 `lib/zip.js` 自己读写;`lib/platform.js` 管共享目录与文案
113
+ - 打包结果**可复现**:同一天同样的数据两次打包 sha256 一致
114
+ - 包里补回**目录条目**(含空目录),解出来的结构跟打进去的一致
115
+ - `/summary` 多带一个 `where` 字段(`os` / `kind` / `dir` / `label` / `word`),
116
+ 面板据此说「手机」还是「下载文件夹」
117
+ - 自检 76 → 82 条:新增跨平台 4 条(手机路径、电脑路径、面板文案、zip 中文名往返)
115
118
 
116
- ## 已知的依赖与边界
119
+ ## 0.1.1(小更新,不是第二版)
117
120
 
118
- - 读会话依赖 DSH 的会话文件格式(`sessions/<bucket>/<id>/session.v4.jsonl.zstd`);DSH 换格式就要改 `lib/store.js`。
119
- - 备份包的脱敏是**规则式**的:生日、学校:`…`、籍贯:`…` 这类写法能隐,写成普通句子的隐私认不出来。
120
- - 注入的剥引用是**启发式**:带引号、代码块、`原话是…:` 这类引导语能剥;把别人的话当自己的话讲,剥不出来。
121
- - `scripts/dedupe-vault.js --write` 会重写仓库文件;请在 DSH 停着的时候跑,避免和回填撞上。
122
- - **磁盘占用会一直长**:索引按保留期归档(只换地方不删),但仓库 `vault/` 是按设计不删的。
123
- 一天几十到几百 KB,一年几十到几百 MB;这是"留痕不留删"的代价,先知道再装。
124
- - 自检也要用到 `zip` / `unzip` 才能跑完打包那几条。
121
+ - 面板每 30 秒自动刷新,不用关掉重开
122
+ - 注入记账:`liuhen/inject-state.json` + 面板显示上一轮到底注入了没有
123
+ - 「我是谁」第一次见面由助手自己写(新工具 `trace_intro`),面板补了名字输入框,
124
+ 正文不再只认以 `- ` 开头的行,写好了也能再「编辑」
125
+ - 自检脚本修掉两处写死日期的假失败
125
126
 
126
127
  ## 许可
127
128
 
package/client.js CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * 留痕的设置面板。八块,从上到下的顺序:
3
- * 我是谁 / 日记 / 今天的账本 / 自省 / 承诺清单
4
- * / 仓库 / 索引保留 / 备份包(搬家才用,所以放最底下)
3
+ * 我是谁 / 我要写的日记 / 今天的账本 / 我亲手写的自省 / 我对主人的承诺
4
+ * / 仓库 / 索引保留 / 装着我的小盒子(搬家才用,所以放最底下)
5
5
  *
6
- * 没有「遗忘」:哪怕是想忘掉的记忆,也是痕迹,不该被删。
6
+ * 没有「遗忘」:主人 2026-10-26 定的——哪怕是想忘掉的记忆,也是痕迹,不该被删。
7
7
  * 索引保留只做归档(换个地方躺着),不做删除。
8
8
  */
9
9
  (function () {
@@ -40,7 +40,7 @@
40
40
 
41
41
  function Line(props) {
42
42
  return h('div', { style: S.entry },
43
- h('span', { style: S.who }, `${props.t} ${props.r === 'u' ? '你' : '助手'}:`),
43
+ h('span', { style: S.who }, `${props.t} ${props.r === 'u' ? '主人' : '我'}:`),
44
44
  h('span', { style: { whiteSpace: 'pre-wrap' } }, props.text))
45
45
  }
46
46
 
@@ -63,7 +63,7 @@
63
63
  .replace(/\s+$/, '')
64
64
  }
65
65
 
66
- /* 一问一答拼成块:遇到用户的话开新块,后面助手的话贴进同一块。 */
66
+ /* 一问一答拼成块:遇到「主人」开新块,后面的「我」贴进同一块。 */
67
67
  /*
68
68
  * 一问一答成块。
69
69
  *
@@ -108,6 +108,15 @@
108
108
  }).sort((a, b) => (a.first.t < b.first.t ? -1 : 1))
109
109
  }
110
110
 
111
+ /*
112
+ * 这台机器把包往哪放:手机上是「手机的 DSHBox」,电脑上是「下载文件夹」。
113
+ * 文案从后端来(status.where):面板跑在 WebView 里,读不到 process.platform,
114
+ * 写死就会在电脑上写着「存到手机」。取不到时退成中性词。
115
+ */
116
+ function spotWord(d) {
117
+ return (d && d.where && d.where.word) || '共享目录'
118
+ }
119
+
111
120
  function pairRows(rows, options) {
112
121
  const desc = Boolean(options && options.desc)
113
122
  const sessions = buildSessions(rows)
@@ -207,20 +216,54 @@
207
216
  const [vaultTree, setVaultTree] = React.useState({ loading: false, days: [], stats: null, error: '' })
208
217
  const [vaultView, setVaultView] = React.useState({ date: '', loading: false, rows: [], total: 0, more: false, error: '' })
209
218
 
210
- const load = React.useCallback(() => {
219
+ /*
220
+ * silent=true:轮询用。只换数据,不碰输入框。
221
+ * 不这样的话,每 30 秒一次刷新会把主人正在「我是谁」里打的字冲掉。
222
+ */
223
+ const load = React.useCallback((options) => {
224
+ const silent = Boolean(options && options.silent)
211
225
  fetch(`${PREFIX}/summary`, { headers: { accept: 'application/json' }, credentials: 'same-origin' })
212
226
  .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() })
213
227
  .then((data) => {
214
228
  setState({ loading: false, error: '', data })
229
+ if (silent) return
215
230
  setNameInput((data.identity && data.identity.name) || '')
216
231
  setAboutInput((data.identity && data.identity.aboutUser) || '')
217
232
  setTextInput((data.identity && data.identity.text) || '')
218
233
  })
219
- .catch((e) => setState({ loading: false, error: String(e && e.message ? e.message : e), data: null }))
234
+ .catch((e) => {
235
+ /* 轮询失败不把面板打成错误页:数据还在,只是这一次没拿到。 */
236
+ if (silent) return
237
+ setState({ loading: false, error: String(e && e.message ? e.message : e), data: null })
238
+ })
220
239
  }, [])
221
240
 
222
241
  React.useEffect(() => { load() }, [load])
223
242
 
243
+ /*
244
+ * 每 30 秒自己刷一次。
245
+ *
246
+ * 为什么必须有:留痕的数据是后台回填写的(默认 5 分钟一趟),面板不主动问,
247
+ * 就永远停在打开那一刻——主人看着它不动,会以为我没在记。
248
+ * 页面藏到后台时不刷,回到前台立刻补一次:手机上不白跑请求。
249
+ * 只活在这个设置页开着的时候,关掉就把定时器和监听一起清掉。
250
+ */
251
+ React.useEffect(() => {
252
+ const tick = () => {
253
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
254
+ load({ silent: true })
255
+ }
256
+ const timer = setInterval(tick, 30 * 1000)
257
+ const onVisible = () => {
258
+ if (typeof document === 'undefined' || document.visibilityState === 'visible') load({ silent: true })
259
+ }
260
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisible)
261
+ return () => {
262
+ clearInterval(timer)
263
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisible)
264
+ }
265
+ }, [load])
266
+
224
267
  const post = React.useCallback((path, body, done) => {
225
268
  fetch(`${PREFIX}${path}`, {
226
269
  method: 'POST',
@@ -258,23 +301,44 @@
258
301
  Card('我是谁',
259
302
  d.identity.name ? h('div', { style: { ...S.row, marginBottom: '8px' } }, h('strong', null, d.identity.name)) : null,
260
303
  identityEditing ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '8px' } },
261
- h('textarea', { style: { ...S.area, minHeight: '96px' }, value: textInput, placeholder: '每行一条,以 - 开头。例如:\n- 我的名字\n- 别拿「这样才对」说教我', onChange: (e) => setTextInput(e.target.value) }),
304
+ h('input', { style: S.input, value: nameInput, placeholder: '给自己取的名字(显示在卡片顶上)', onChange: (e) => setNameInput(e.target.value) }),
305
+ h('textarea', { style: { ...S.area, minHeight: '96px' }, value: textInput, placeholder: '每行一条,以 - 开头。例如:\n- 我叫小满\n- 别拿「该努力了」说教我', onChange: (e) => setTextInput(e.target.value) }),
262
306
  h('div', { style: S.row },
263
307
  h('button', { type: 'button', style: S.btn, onClick: () => { post('/config', { identity: { name: nameInput, text: textInput } }, '已保存'); setIdentityEditing(false) } }, '保存'),
264
308
  h('button', { type: 'button', style: S.btn, onClick: () => setIdentityEditing(false) }, '取消')))
265
- : (() => {
266
- const bullets = String(d.identity.text || '').split(/^##\s/m)[0].split('\n')
309
+ : (function () {
310
+ /*
311
+ * 以前只认「- 」开头的行:写成普通句子的人,保存成功了面板还是「还没写」。
312
+ * 现在整段都显示——以 - 开头的排成条目,其余按原样一行一行摆。
313
+ */
314
+ const lines = String(d.identity.text || '').trim().split('\n')
267
315
  .map((line) => line.trim())
268
- .filter((line) => line.startsWith('- '))
269
- .map((line) => line.slice(2).trim())
270
316
  .filter(Boolean)
271
- .slice(0, 10)
272
- if (bullets.length === 0 && !identityEditing) return h('div', null,
273
- h('div', { style: S.muted }, '还没写「我是谁」。每行一条,以 - 开头,会显示在这里。'),
274
- h('button', { type: 'button', style: { ...S.btn, marginTop: '8px' }, onClick: () => setIdentityEditing(true) }, '写'))
317
+ .filter((line) => !/^#{1,6}\s/.test(line))
318
+ .slice(0, 60)
319
+ if (lines.length === 0 && !identityEditing) return h('div', null,
320
+ h('div', { style: S.muted }, '还是空白的。点「照示例写」,会出来一个骨架,改成你自己的再保存;也可以直接手写。'),
321
+ h('button', { type: 'button', style: { ...S.btn, marginTop: '8px' }, onClick: () => {
322
+ /*
323
+ * 空态最容易卡住新手:看着一片空白,不知道该写什么格式。
324
+ * 给一份能直接用、也一看就知道怎么改的骨架;不保存等于没写。
325
+ */
326
+ if (String(textInput || '').trim() === '') {
327
+ setTextInput([
328
+ '- 我叫阿满,是这台电脑上的助手',
329
+ '- 说话直接一点,别客套',
330
+ '- 不确定的事就说不知道,别编'
331
+ ].join('\n'))
332
+ }
333
+ setIdentityEditing(true)
334
+ } }, '照示例写'),
335
+ h('button', { type: 'button', style: { ...S.btn, marginTop: '8px', marginLeft: '8px' }, onClick: () => setIdentityEditing(true) }, '现在手写'))
275
336
  return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '5px' } },
276
- bullets.map((b, i) => h('div', { key: i, style: { ...S.entry, padding: '4px 8px' } }, `· ${b}`)))
337
+ lines.map((line, i) => line.startsWith('- ')
338
+ ? h('div', { key: i, style: { ...S.entry, padding: '4px 8px' } }, `· ${line.slice(2).trim()}`)
339
+ : h('div', { key: i, style: { fontSize: '12.5px', lineHeight: '1.75', opacity: 0.92 } }, line)))
277
340
  })(),
341
+ identityEditing || String(d.identity.text || '').trim() === '' ? null : h('button', { type: 'button', style: { ...S.btn, marginTop: '8px' }, onClick: () => setIdentityEditing(true) }, '编辑'),
278
342
  h('div', { style: { ...S.row, marginTop: '10px' } },
279
343
  d.inject.enabled
280
344
  ? h('button', { type: 'button', style: S.btn, onClick: () => post('/config', { inject: { enabled: false } }, '注入已关闭') }, '关闭注入')
@@ -289,6 +353,29 @@
289
353
  h('span', { style: { ...S.muted, marginLeft: '4px' } }, `档位:${d.inject.mode === 'full' ? '完整' : '简洁'}`),
290
354
  h('button', { type: 'button', style: { ...S.btn, borderColor: d.inject.mode !== 'full' ? '#3c9e6e' : undefined }, onClick: () => post('/config', { inject: { mode: 'brief' } }, '档位:简洁') }, '简洁档'),
291
355
  h('button', { type: 'button', style: { ...S.btn, borderColor: d.inject.mode === 'full' ? '#3c9e6e' : undefined }, onClick: () => post('/config', { inject: { mode: 'full' } }, '档位:完整(会挤上下文)') }, '完整档')),
356
+ /*
357
+ * 「上一轮到底注入了没有」。
358
+ *
359
+ * 预览有字 ≠ 真的发出去了:预览走 dryRun,不看开关。这一行是插件在
360
+ * 真正附加到系统提示的那一刻记下的,所以它才是凭据。
361
+ */
362
+ (function () {
363
+ const last = d.inject.last
364
+ if (!last) return h('div', { style: { ...S.muted, marginTop: '6px' } }, '上一轮注入:还没有记录(这个版本的记账要重启一次 DSH 才开始跑)')
365
+ const at = (function () {
366
+ try { return new Date(last.at).toLocaleTimeString('zh-CN', { hour12: false }) } catch { return last.at || '' }
367
+ })()
368
+ if (last.ok === true) {
369
+ return h('div', { style: { ...S.muted, marginTop: '6px' } },
370
+ `上一轮 ${at}:真注入了 ${last.chars} 字 / ${last.lines} 行(${last.mode === 'full' ? '完整档' : '简洁档'})`)
371
+ }
372
+ const why = last.reason === 'off'
373
+ ? '开关关着'
374
+ : last.reason === 'empty'
375
+ ? '开关开着,但这一轮没有可注入的内容'
376
+ : `出错:${last.error || '未知'}`
377
+ return h('div', { style: { ...S.muted, marginTop: '6px' } }, `上一轮 ${at}:没注入——${why}`)
378
+ })(),
292
379
  /* 注入是完整的:给全文,盒子自己滚。 */
293
380
  identityPreview.text !== '' ? h('pre', { style: { ...S.pre, background: 'rgba(127,127,127,.08)', padding: '8px', borderRadius: '8px', maxHeight: '45vh', overflowY: 'auto', marginTop: '8px' } }, identityPreview.text) : null,
294
381
  identityPreview.text !== '' && identityPreview.meta && identityPreview.meta.chars !== undefined
@@ -322,12 +409,12 @@
322
409
  h('span', null, '启用日记模块(不写可以一直关着)')),
323
410
  h('div', { style: { display: 'flex', flexDirection: 'column', gap: '10px' } },
324
411
  h('div', null,
325
- h('div', { style: { ...S.muted, marginBottom: '4px' } }, '公开版:'),
412
+ h('div', { style: { ...S.muted, marginBottom: '4px' } }, '给主人:'),
326
413
  d.diary.public === null
327
414
  ? h('div', { style: S.muted }, '今天还没写。')
328
415
  : h('pre', { style: { ...S.pre, maxHeight: '200px', overflowY: 'auto' } }, d.diary.public)),
329
416
  h('div', null,
330
- h('div', { style: { ...S.muted, marginBottom: '4px' } }, '私密版:'),
417
+ h('div', { style: { ...S.muted, marginBottom: '4px' } }, '给自己:'),
331
418
  d.diary.self === null
332
419
  ? h('div', { style: S.muted }, '今天还没写。')
333
420
  : h('pre', { style: { ...S.pre, maxHeight: '200px', overflowY: 'auto' } }, d.diary.self)))),
@@ -349,8 +436,8 @@
349
436
  Card(`我亲手写的自省 · ${d.date}`,
350
437
  d.notes ? h('pre', { style: S.pre }, d.notes) : h('div', { style: S.muted }, '今天还没写。有就写,没有就不写——空着不算欠账。')),
351
438
 
352
- // 5. 承诺清单
353
- Card('承诺清单',
439
+ // 5. 我对主人的承诺
440
+ Card('我对主人的承诺',
354
441
  d.promises ? h('pre', { style: S.pre }, d.promises) : h('div', { style: S.muted }, '(清单是空的)')),
355
442
 
356
443
  // 6. 仓库:点得开、看得见
@@ -429,16 +516,16 @@
429
516
  h('div', { style: { ...S.muted, marginTop: '6px' } },
430
517
  '超过保留期的按天压进 archive/<日期>.jsonl.zst:只是换个地方躺着,不删。会话原文永不动。')),
431
518
 
432
- // 8. 备份包(放最底下:搬家才用,不占日常视线)
433
- Card('备份包',
434
- h('div', { style: S.muted }, '把留痕的数据打成一个 zip:索引摘要、完整原文(仓库)、日记、自省、承诺、批注和设置。换机器就靠它。箱子里有 00-读我.txt 说明里面有什么。'),
519
+ // 8. 装着我的小盒子(放最底下:搬家才用,不占日常视线)
520
+ Card('装着我的小盒子',
521
+ h('div', { style: S.muted }, '把留痕的数据打成一个 zip:索引摘要、完整原文(仓库)、日记、自省、承诺,加上 pet-memory(我是谁、答应过的事、跨会话汇总、交接信)。换机器就靠它。箱子里有 00-读我.txt 说明三层怎么对应。'),
435
522
  h('div', { style: { ...S.muted, marginTop: '6px', color: '#d9534f' } },
436
- '这个包里有完整原文,是明文的;放到共享目录或发给别人之前先想清楚。'),
523
+ `这个包里有完整原文:你说过的每句话都在里面,明文的;pet-memory 里还有个人信息(名字、生日、学校)。落到${spotWord(d)}之后,同一台机器上别的程序也读得到——存好,别再转出去。`),
437
524
  d.box
438
525
  ? h('div', { style: { marginTop: '10px', padding: '10px', borderRadius: '8px', background: 'rgba(127,127,127,.07)' } },
439
526
  h('div', { style: { fontSize: '12.5px', marginBottom: '4px', wordBreak: 'break-all' } }, `${d.box.name} · ${(d.box.bytes / 1024).toFixed(1)} KB`),
440
527
  h('div', { style: { ...S.muted, marginBottom: '8px', wordBreak: 'break-all' } },
441
- d.box.shared ? `已另存一份到:${d.box.shared}` : '还没另存到其他目录,可用下面的「保存」取走。'),
528
+ d.box.shared ? `已放进${spotWord(d)}:${d.box.shared}(用系统自带的文件管理器找得到)` : `还没同步到${spotWord(d)},可用下面的「保存」取走。`),
442
529
  h('div', { style: S.row },
443
530
  h('button', { type: 'button', style: S.btn, disabled: boxList.loading, onClick: () => {
444
531
  if (boxList.count > 0 && !boxList.loading) { setBoxList({ loading: false, entries: [], count: 0, error: '' }); return }
@@ -451,7 +538,7 @@
451
538
  h('a', {
452
539
  href: `${PREFIX}/box/file`,
453
540
  style: { ...S.btn, display: 'inline-block', textDecoration: 'none', color: 'inherit' }
454
- }, '存到手机'),
541
+ }, d.where && d.where.kind === 'desktop' ? '存到下载文件夹' : '存到手机'),
455
542
  h('button', { type: 'button', style: S.btn, disabled: boxSend.busy, onClick: () => {
456
543
  setBoxSend({ busy: true, msg: '', error: '' })
457
544
  fetch(`${PREFIX}/box/send`, { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'same-origin', body: '{}' })
@@ -468,9 +555,9 @@
468
555
  ...boxList.entries.map((e, i) => h('div', { key: i, style: { ...S.muted, wordBreak: 'break-all' } },
469
556
  `${(e.bytes / 1024).toFixed(1)} KB · ${e.name}`)))
470
557
  : null)
471
- : h('div', { style: { ...S.muted, marginTop: '6px' } }, '还没有备份包。点下面「打一份备份包」,打好就出现在这里。'),
558
+ : h('div', { style: { ...S.muted, marginTop: '6px' } }, '还没有小盒子。点下面「打一份小盒子」,打好就出现在这里。'),
472
559
  h('div', { style: { ...S.row, marginTop: '10px' } },
473
- h('button', { type: 'button', style: S.btn, onClick: () => post('/box', {}, '打好了') }, '打一份备份包'),
560
+ h('button', { type: 'button', style: S.btn, onClick: () => post('/box', {}, '打好了') }, '打一份小盒子'),
474
561
  h('button', { type: 'button', style: S.btn, onClick: () => post('/backfill', {}, '回填完成') }, '回填历史'))),
475
562
 
476
563
  msg !== '' ? h('div', { style: S.ok }, msg) : null,
package/cordis.patch.yml CHANGED
@@ -1,7 +1,7 @@
1
- # 留痕(dsh-liuhen)· 插件挂载
1
+ # 留痕(dsh-liuhen)· 占位版本
2
2
  #
3
- # 这一行把插件接进 DSH 的插件树。工具注册、路由挂载都在 lib/index.js 的 apply() 里,
4
- # 装好即用;卸载就是把它从 profile 里去掉。
3
+ # 这一行把插件接进 DSH 的插件树。0.0.1 里它是空实现:
4
+ # 能装、能卸载、不注册任何工具,也不写任何文件。
5
5
  - insert:
6
6
  - id: dsh-liuhen
7
7
  name: dsh-liuhen