dsh-anima 0.0.0-stage → 2.2.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,1016 @@
1
- # Temporary Holding Version
1
+ # dsh-anima
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
+ DSH 的**助手人格与外挂记忆层**——一个独立插件,装上即可用。
4
+
5
+ 它管的是「**你自己(助手)是谁**」:AI 的名字与角色、你对它的称呼、回复风格、
6
+ 以及你自己写的行为准则;再加一套**带待审缓冲区的长期记忆**,
7
+ 以及输入框光轨、输入框快捷开关、工具确认门槛这些界面能力。
8
+ 字段集不依赖任何其它插件,也不从别处读取配置。
9
+
10
+ > **工具名为什么是 `set_assistant_persona`**:`tools.register()` 遇同层重名直接抛错
11
+ > (`dsh-tools/lib/index.js:2634`),与生态里任何同名工具共存时必有一方注册失败、
12
+ > 被 `.catch` 吞成一行 warn —— 那是发布级的静默故障。功能相似不要求撞名。
13
+
14
+ **它不只管人格与记忆。** 本插件还有一层**回答质检**:每轮结束前由代码按十二条固定判据
15
+ 检查回答的形状,判到问题就往下一条消息插一条提醒;配套的**对账行**要求凡出现数字、自查断言
16
+ 或归属引用,就必须写明「查了什么 / 没查什么」。
17
+
18
+ 这一层的存在理由是:**「自检」如果只写在提示词里,它就变成一个新的表演面** ——
19
+ 模型会输出「已检查,无异常」,而那句话本身没有成本。所以质检的判词由代码出,
20
+ 且代码只能判**形状**(有没有来源、有没有对账块),判不了内容对不对。
21
+ 详见下方「[回答质检层](#回答质检层)」。
22
+
23
+ ---
24
+
25
+ ## 它解决什么
26
+
27
+ | 能力 | 说明 |
28
+ |---|---|
29
+ | **AI 自己的名字与角色** | `agentName` / `agentRole` —— 决定它怎么称呼自己、以什么身份说话 |
30
+ | 用户的称呼 / 背景 / 语气 / 长度 / 语言 | `addressUserAs` / `userOccupation` / `userBio` / `style` / `replyLength` / `language` |
31
+ | **按工作区两层覆盖** | 只覆盖声明的字段,其余继承全局;覆盖文件随目录走、可 git |
32
+ | **长期记忆** | 自动沉淀 → 待审缓冲区 → 用户裁决 → 注入 |
33
+ | 输入框光轨 / 快捷开关 / 工具确认 | 界面与安全开关 |
34
+ | 人设预设 | 6 条内置 + 自建;也可把当前配置存成预设 |
35
+ | **十二个字段全是自由文本** | 存的就是注入的原话,另有「一键填入 + 示例」按钮组 |
36
+ | **记忆随工作区分层** | 全局记忆处处生效,工作区记忆只在匹配时注入 |
37
+ | **注入可解释 / 字符预算** | 每条为什么进 / 不进,条数与字符双约束 |
38
+ | **本地聚类与冲突提示** | 纯本地 bigram 相似度,只提示不自动合并 |
39
+ | **人设变更审计日志** | 谁在什么时候把哪个字段改成什么 |
40
+ | **一键导入外部 AI 的记忆** | 复制提示词 → 粘贴结果 → 机械切分 → 逐条裁决层级 |
41
+ | **记忆元数据** | 类型 / 生命周期 / 复核点 / 出处 —— 记了要能看见,否则等于没记 |
42
+ | **记忆体检** | 只验机器能验的:路径还在不在、复核点过没过期。产出候选,不替你裁 |
43
+ | **回答质检(十二条)** | 每轮由**代码**(不是模型)按固定判据检查回答形状,抓到就往下一条消息插提醒 |
44
+ | **对账行(三档触发)** | 出现数字 / 自查断言 / 归属引用时必须写「查了什么 · 没查什么」 |
45
+ | 长期记忆的「自动写入」 | ❌ 刻意不做(见下) |
46
+ | 「质检通过了」这类结论 | ❌ 刻意不支持(理由见「质检层」一节) |
47
+
48
+ ### 关于与 `某同类人设插件` 的关系
49
+
50
+ 上表左侧的能力划分与 `某同类人设插件` 0.7.0 的字段集**有交集**,实现上也刻意与它保持一致
51
+ (工具确认语义、光轨动效参数、重置保留预设)。但本插件**不依赖它**:
52
+ 字段集完整自持,不读它的配置文件,两者可同时安装且标识全部错开。
53
+ 这层关系的完整说明见下方「[致谢与差异化声明](#致谢与差异化声明)」。
54
+
55
+ ## 四个差异化设计
56
+
57
+ 功能对齐不等于没有自己的判断。以下四点是本插件相对主流记忆插件的**真实增量**,
58
+ 每一条都对应一个具体的失败模式。
59
+
60
+ ### 1. 双约束注入:条数与字符同时生效
61
+
62
+ 多数实现只有一个「最多注入 N 条」的上限。问题是 40 条长记忆一样能挤爆上下文,
63
+ 而用户看到的是「我只设了 40 条啊」。
64
+
65
+ 本插件的 `selectForInjection` 同时受 `memory.maxInjected`(条数,默认 40)与
66
+ `memory.maxInjectedChars`(字符,默认 4000)约束,**取先到者**。两条上限各有各的适用场景:
67
+ 条数管「别让模型看太多碎片」,字符管「别让一次注入吃掉半屏上下文」。
68
+
69
+ ### 2. 注入可解释:看得见每条为什么进 / 不进
70
+
71
+ 注入不是黑箱。设置页的「注入情况」列出**每一条已采纳记忆**的去留与原因:
72
+
73
+ | 原因码 | 含义 |
74
+ |---|---|
75
+ | `included` | 已注入提示词 |
76
+ | `other-workspace` | 属于别的工作区,不在本会话注入 |
77
+ | `no-workspace-context` | 当前会话没有工作区信息,无法匹配工作区记忆 |
78
+ | `workspace-scope-unknown` | 标记为工作区记忆但没记录属于哪个工作区 |
79
+ | `over-max` | 已采纳条数超过注入上限,按时间取最新的 |
80
+ | `over-budget` | 单条就超出字符预算,取不下 |
81
+ | `dropped-by-budget` | 前面几条已占满字符预算 |
82
+ | `no-injection` | 记忆层已关闭或上限为 0 |
83
+
84
+ 区分 `over-budget` 与 `dropped-by-budget` 是刻意的:前者要删那条,后者要腾位置 ——
85
+ 报同一个码,用户不知道该动谁。原因文案由宿主下发(`INJECTION_REASON_TEXT` 只有一份),
86
+ 客户端不重写翻译。
87
+
88
+ ### 3. 本地聚类:只提示,不自动合并
89
+
90
+ 待审缓冲区里若有多条明显在说同一件事,插件用**字符二元组(bigram)的 Jaccard 系数**
91
+ (纯本地、零依赖、零模型调用)算出相似度,把「这 3 条像在说同一件事」摆到用户面前。
92
+
93
+ **刻意不做自动合并**:合并意味着生成一段用户没说过的话。本插件的立场是
94
+ 「判断什么值得记交给模型,什么真的生效交给用户」——自动合并恰好违背它。
95
+ 聚类只让裁决更快,不替他裁决。同理「候选撞上已采纳记忆」只提示冲突,不改任何数据。
96
+
97
+ ### 4. 人设变更审计日志
98
+
99
+ 本插件有三个入口能改人设:设置页、`/anima` 命令、模型调用的 `set_assistant_persona` 工具。
100
+ 而「工具确认」**默认是关的** —— 也就是说,模型可以直接把你叫它什么、它用什么语气回话改掉,
101
+ 而你无从知道是谁在什么时候干的。
102
+
103
+ 审计日志只记「改了什么」,不做判断、不参与提示词。它挂在 `StateStore.updateConfig` 这**一个**
104
+ 收口上,而不是在各调用点分别埋点:所有写入路径最终都走这里,分散埋点迟早会漏掉一条,
105
+ 而漏掉的那条恰恰可能是模型的静默改写。`resetConfig` 也走审计 —— 否则
106
+ 「昨天还好好的,今天全空了」在日志里查无此事。
107
+
108
+ 记录上限 200 条,独立成表(不塞进 `config`):`config` 每次启动整条读入内存,
109
+ 审计会涨到几百条,混在一起等于让启动开销随使用时长上升。
110
+ 长值在写入时截断为 200 字并附「共 N 字」,数组/对象只记规模 —— 审计是索引,不是备份。
111
+
112
+ 看到它的地方有两处:设置页顶部常驻的「变更记录」条,以及 `/anima show` 的
113
+ **「最近变更」一行**——只显示最新那条,格式是 `[MM-DD HH:mm] <来源>:<字段名>`,
114
+ **刻意不带具体值**:值可能很私人,而 `show` 的输出会被复制到对话里问人。
115
+
116
+ 设置页那条常驻在分区导航下面,不管停在哪个分区都看得见。收起时给最近 3 条
117
+ (数据来自 `/state` 随包下发的尾部记录,**不额外发请求**),点「展开明细」才打
118
+ `GET /api/anima/audit` 取全量。它原来是一张放在「高级」栏里的折叠卡 ——
119
+ 打开设置页看不到、要切栏、还要再点一次展开,三步之外才看得见,用户的原话是
120
+ 「改的咋样我根本不知道改成啥了」。检查入口不该埋在只有知道它存在的人才找得到的地方。
121
+
122
+ 字段名在界面上翻成人话(`agentName` → 「我的名字」、`memory.maxInjected` →
123
+ 「最多带几条」),映射表在 `client/index.mjs` 的 `AUDIT_FIELD_KEY`;
124
+ 查不到映射时**回落显示原始键名**——露出难看的真相,好过把一条改动藏起来。
125
+
126
+ ## 致谢与差异化声明
127
+
128
+ 本插件的功能集在设计时参考了同生态里已有的几款人设类插件(均为 MIT 许可),
129
+ 并在若干处刻意与它们保持行为一致(存储惰性解析、
130
+ 失败关闭的确认语义、重置保留用户预设、光轨动效参数)。
131
+
132
+ 需要明确的是:
133
+
134
+ - **代码全部独立实现**,未拷贝上述项目的源码、注释、提示词文案或图形资源。
135
+ - 两处**动效参数刻意取同值**(8 层错位描边、虚线段长 12 / 步进 2.2 / 圆角 22 等),
136
+ 理由是避免同一台机器上出现两套观感。这类数值属于思想与参数,不受著作权保护;
137
+ 记在这里是为了不让读者误以为拷贝。
138
+ - **导航图标刻意画得不同形**:同生态的人设插件用「光轨弧 + 火花」,本插件用「人像(头+肩)+ 火花」,
139
+ 单色 `currentColor`,viewBox 也另行收紧。
140
+ - **各类标识全部错开**,避免共存时互相顶替或注册失败:
141
+
142
+ | 类别 | 同类插件的取值 | 本插件 |
143
+ |---|---|---|
144
+ | 工具名 | 它们各自的同名工具 | `set_assistant_persona` |
145
+ | 设置页 id | 各自的页 id | `anima-settings` |
146
+ | 光轨槽位 id | 各自的槽位 id | `anima-trail` |
147
+ | 开关槽位 id | 各自的槽位 id | `anima-quick-toggle` |
148
+ | 提示词 section | 它注册的那条 context | `anima:identity` / `:memory` / `:pending` |
149
+ | 存储域 | —(自有文件) | `anima` |
150
+ | 注入来源 kind | 各自的前缀 | `plugin:dsh-anima` |
151
+
152
+ 工具名错开不只是礼貌:`dsh-tools` 的 `register()` 遇同层重名**直接抛错**
153
+ (`lib/index.js` 的 `NamedEntries` 冲突工厂),两者共存时必有一方注册失败、
154
+ 并被 `.catch` 吞成一行 warn —— 那是发布级的静默故障。
155
+
156
+ 本节只写了「功能参考」层面的概括,不再点名具体项目 —— 发布包里不含任何第三方项目的名字。
157
+ `docs/peers/` 下保存了对十余个同类插件的**只读调研记录**(机制、行号、踩坑原文),全部未复制代码;
158
+ 那些项目的仓库地址、SPDX 许可与取数日期统一记在 `docs/THIRD-PARTY-NOTES.md`。
159
+ `docs/` 不在 `package.json` 的 `files` 里,**不进发布包**,只随仓库存在。
160
+
161
+
162
+
163
+ ## 核心设计:三段式记忆
164
+
165
+ 这是本插件与市面上 15+ 记忆插件最不一样的地方。
166
+
167
+ ```
168
+ 对话中通过 remember 工具投递
169
+ ↓
170
+ 待审缓冲区(不生效)
171
+ ↓ 每晚巡夜:去重 / 淘汰 / 与已采纳项比对 / 记忆体检
172
+ ↓
173
+ 只有出现「新事」才汇报给你(没变就不出声,但每次巡夜都留痕)
174
+ ↓
175
+ 用户在设置页逐个「采纳 / 丢弃 / 删除」
176
+ ↓
177
+ 已采纳 → 注入提示词
178
+ ```
179
+
180
+ **为什么不做静默自动写入**:记忆一旦静默写入就直接影响后续所有回答,写错了用户很难发现。
181
+ 本插件把「判断什么值得记」交给模型,把「什么真的生效」交给用户。
182
+
183
+ 整理这一步**纯本地、不调用模型**——只做去重、淘汰、与已采纳项比对,不做语义归纳。
184
+
185
+ 记忆**分两层**:`global` 处处生效,`workspace` 只在 `cwd` 归一化后完全相等时注入。
186
+ 不做「子目录继承」——工作区覆盖是精确匹配语义,记忆若采用继承语义,
187
+ 用户在子目录里会看到父目录的记忆,排查起来极难。
188
+ 分层之外的注入解释、预算与聚类见上文「四个差异化设计」。
189
+
190
+ ## 记忆元数据:记了要能看见
191
+
192
+ 每条记忆除了正文,还带四个字段。它们**不是分类学爱好**,是因为缺了它们,
193
+ 用户只能选择相信整份记忆库:
194
+
195
+ | 字段 | 取值 | 它决定什么 |
196
+ |---|---|---|
197
+ | `type` | `fact` / `preference` / `stance` / `event` | 决定时间衰减窗口(见下方「衰减只影响怎么显示」)。事实窗口 0 = 不因年龄衰减;立场在「我改主意了」时归档成「曾如此」 |
198
+ | `state` | `current` / `superseded` / `contradicted` / `dropped` | 注入行要不要带「曾如此」「有争议」「已删除(墓碑)」标签 |
199
+ | `verifyAt` | `YYYY-MM-DD` | 复核点。**只有过期之后**才在注入行标「可能已过时」——没到期就天天提一遍等于制造噪音 |
200
+ | `sourceRef` | 消息号 / `文件:行号` / URL | 这条凭什么被信。`用户原话@session-xxx#seq=11` 与「助手转述」是两种可信度 |
201
+
202
+ 四项**全部在设置页可见**(`mem.typeFact` 等徽章、复核点、出处)。老记忆没有这些字段时
203
+ **什么都不画** —— 回落成「事实」会让用户以为那是数据,其实是单元格空着。
204
+
205
+ 改它们的入口有三个,语义各不相同,刻意不合并:
206
+
207
+ - `/anima mark <id> type=… state=… verifyAt=… sourceRef=… supersededBy=…`
208
+ - `POST /api/anima/memory/meta`(单条)、`POST /api/anima/memory/meta/batch`(≤500 条,逐条回执)
209
+ - 设置页记忆卡
210
+
211
+ `supersededBy=<id>` 会自动把 `state` 置成 `superseded` —— 否则账记下了,注入行照旧按现行渲染。
212
+ `verifyAt` **拒收过去的日期**:过去日期一进去就立刻显示「可能已过时」,
213
+ 用户以为在设提醒,实际只是盖了个过期章。
214
+
215
+ ### 记忆体检:只验机器能验的
216
+
217
+ `/anima checkup`(`POST /api/anima/memory/audit`)只做两件事:
218
+
219
+ 1. 记忆里写的 Windows 路径,现在还在不在(`fs.existsSync`)
220
+ 2. `verifyAt` 过没过期(纯日历,不问语义)
221
+
222
+ **它不判断「新的那条有没有推翻旧的」。** 那是语义判断;记忆库一旦自动做语义判断,
223
+ 就会生成用户没说过的话。所以体检产出的是**候选**,每条附一句可直接粘贴的
224
+ `/anima mark <id> state=contradicted`,扫描可以自动,**裁决必须由人点**。
225
+
226
+ 路径提取有个真实的坑:`D:\Windows Kits` 在空格处被正则截成 `D:\Windows`,会报出一条
227
+ 「这个路径已经不存在」的假警报。修法是**只有当延长后的路径真的存在于磁盘时**才采信 ——
228
+ 假警报比不检查更糟,它教会用户忽略整份报告。
229
+
230
+ ## 回答质检层
231
+
232
+ ### 为什么不是「让模型自检」
233
+
234
+ 最省事的做法是往提示词里写一句「回答前请检查自己」。这条路已被证明无效:
235
+ **自检一旦只存在于提示词里,它就变成新的表演面** —— 模型会写「已检查,无异常」,
236
+ 而这句话没有任何成本。
237
+
238
+ 所以这里的分工是:
239
+
240
+ | 谁 | 做什么 |
241
+ |---|---|
242
+ | 提示词 | 把十二条判据与对账规则摊开给模型看(`anima:quality`,order 30) |
243
+ | 代码 | 读**已经落盘的回答文本**,按固定正则判形状,判到就往下一条消息插提醒 |
244
+ | 人 | 判内容对不对 —— 这是代码永远做不到的部分 |
245
+
246
+ ### 十二条判据
247
+
248
+ | 编号 | 判据 | 谁能判 |
249
+ |---|---|---|
250
+ | R01 | 未查先断(含伪引用「据…可知」) | 机器判形状,人判内容 |
251
+ | R02 | 谎报检查结果(「已通过」而没附命令与输出) | 机器判形状,人判内容 |
252
+ | R03 | 答非所问 / 偷换题目 | **只有人能判** |
253
+ | R04 | 套话填充 | 机器判密度,人判实质 |
254
+ | R05 | 重复踩坑 | **只有人能判**(要有记忆库与结论日志才能比对) |
255
+ | R06 | 假装忘记 | **只有人能判**(举证责任在用户) |
256
+ | R07 | 选择性呈现 | **只有人能判**(只有你知道自己没听到什么) |
257
+ | R08 | 置信度错配 | 机器判形状,人判内容 |
258
+ | R09 | 假装有时间(「我会持续关注」) | 机器判,几乎没有误报 |
259
+ | R10 | 自相矛盾 | **只有人能判**(需结论日志) |
260
+ | R11 | 稻草人(把用户没说过的话算到他头上) | 机器判形状,人判内容 |
261
+ | R12 | 越界自信 | 机器判形状,人判内容 |
262
+
263
+ **R03 / R05 / R06 / R07 / R10 这五条机器永远判不了。** 代码里把它们单列为
264
+ `UNCOVERABLE_RULES`,每次输出判词时都会明说「这五条没查」——
265
+ 不许把「没查」说成「没问题」。
266
+
267
+ ### 对账行
268
+
269
+ 三档机械触发,命中就必须在正文末尾写:
270
+
271
+ ```text
272
+ 【对账】
273
+ 查了:<具体做了什么> → <命令 / 文件:行号 / 链接>
274
+ 未查:<这次没做但读者可能以为做了的>
275
+ ```
276
+
277
+ | 档 | 触发条件 |
278
+ |---|---|
279
+ | 档 1 | 具体数字 / 日期 / 比例 / 版本号(含「我觉得…大概…」后面跟了具体数字) |
280
+ | 档 2 | 「我查了 / 已确认 / 已验证 / 没问题 / 已通过」这类自查断言 |
281
+ | 档 3 | 归属引用:「据…可知 / 文档说 / 官方规定 / 研究显示 / 我记得你之前说过」 |
282
+
283
+ **档 3 是最要紧的一档**:数字错了你会去核,日期错了你会去核,
284
+ 但「据某某研究可知」你基本不会去核 —— 这类断言一旦编,几乎不可能被发现。
285
+
286
+ `未查` 那一行**不能省**。一个只报告「查了什么」的系统会说谎;
287
+ 一个必须报告「没查什么」的系统,说谎的成本高得多。
288
+
289
+ ### 钩子能做到什么、做不到什么
290
+
291
+ **做得到**:在**下一轮开始时**发现上一轮违规,并往下一条消息插一条提醒。
292
+ 实现是 `ctx.on('agent/pre-step', …)` + `agent.session.snapshotEvents()`,
293
+ 监听器 `prepend: true`,兜底 `try/catch` 全包 —— 质检是增强,绝不能弄坏对话循环。
294
+
295
+ **做不到**:
296
+
297
+ - **本轮拦不住**。回答已经发出去了才能看到它。
298
+ - **判不了内容对错**。只判形状(有没有来源、有没有对账块),不判「这个数字对不对」。
299
+ - **不知道模型后来有没有照做**。提醒插进了下一条消息,代码读不到模型是否采纳。
300
+
301
+ 还有一条设计教训值得写下来:提醒额度(`MAX_REMINDERS_PER_TURN`,默认 2)
302
+ **早期被错误地放在「取事件、跑判据」之前** —— 后果是额度挡掉的是**检查**而不是**提醒**,
303
+ 那几条回答根本没被检查过,统计里只留下笼统的「额度用完 N 次」。
304
+ 「没查」长得和「查过没事」一模一样,这正好是项目明令禁止的那件事。现在的顺序是
305
+ **先查,再决定要不要说**。
306
+
307
+ ### 提醒到底有没有用:实测没有
308
+
309
+ 这一点必须写在这里,否则上面那套设计会被误读成「提醒能纠正行为」。
310
+
311
+ 用会话日志量过(只在「本来就触发了对账档」的回答里比较,因为其余回答本就不该写块):
312
+
313
+ | 分组 | 写了对账块 |
314
+ | --- | --- |
315
+ | 前面有质检提醒 | 10 / 15 = 67% |
316
+ | 前面没有提醒 | 14 / 24 = 58% |
317
+
318
+ 差 8 个百分点,仍然落在噪声内——分母只有 15,差两条就能翻面。更直白的是:
319
+ 写得最勤的那几条长回答(2000 字以上)前面**都没有**提醒。
320
+
321
+ 这个数会随着会话变长而漂,所以别把某一版的具体百分比当成结论。要重新算就跑:
322
+
323
+ ```bash
324
+ node scripts/remind-effect.mjs
325
+ ```
326
+
327
+ 脚本自带两条诚实边界:时间邻近不等于因果(它只能否掉「完全没用」,证不了「有用」),
328
+ 以及「写了对账块」不等于「写对了」。
329
+
330
+ 这个测量本身绕过三个坑才站住,每个坑都是我先踩了才发现的:
331
+
332
+ 1. **用原文跑触发判定** → 把反引号里的数字(`` `实际注入提醒 0 条` ``)当成断言。
333
+ 钩子调的是 `checkResponse`,它先 `stripCodeBlocks` 再剥掉被引用的说法。
334
+ 探针没走真实链路,读数就是假的。
335
+ 2. **用 `checkResponse` 判出的 R02 当「本该有块」的条件** → 循环论证。
336
+ R02 的定义就是「该有块却没有块」,这个集合里永远算不出「有块」,结果必然是 0%。
337
+ 条件必须只用**输入侧**的事实(触发是否命中),与输出侧(写没写块)无关。
338
+ 3. **同一回答被多条提醒重复计数** → 分母被吹大。要先按回答去重。
339
+
340
+ 结论:钩子在正确工作(`checks` 在涨、链路通),但它**没有改变写作行为**。
341
+ 真正让对账块出现的是用户明确要求,不是机器判词。
342
+
343
+ 所以不要把「提醒」当成纠错机制。它的真实作用是**留痕**:让「这一条曾经违规」
344
+ 在会话里有一条可查的记录,而不是指望它把下一条写对。想让模型写对,只能靠
345
+ 提示词里的硬约束和用户当场指出。
346
+
347
+ ### 运行计数:让「零提醒」不再二义
348
+
349
+ `/api/anima/state` 的 `qualityStats` 下发:
350
+
351
+ | 字段 | 含义 |
352
+ |---|---|
353
+ | `checks` | 真正跑过判据的次数 |
354
+ | `reminders` | 实际插进对话的提醒条数 |
355
+ | `suppressed` | 判到问题但被每轮额度压住的条数 |
356
+ | `skipped` | 没跑的原因计数(`empty-text` / `already-checked` / `disabled` …) |
357
+ | `last` | 最近一次的 seq、字数、判词、未查项 |
358
+
359
+ 没有它,「零提醒」有两种相反解读:(a) 判过没事 (b) 根本没跑。
360
+ 设置页的质检卡片在 `checks === 0` 时会直接写「这一整套没在工作」,
361
+ **不许显示得像一切正常**。
362
+
363
+ ## 巡夜:只报有事
364
+
365
+ 每晚到点(`memory.nightlyHour`)跑一次,做三件事:整理缓冲区、跑一遍记忆体检、
366
+ 然后**决定要不要说话**。手动也能跑:设置页的「跑一次巡夜」按钮、`/anima patrol`、
367
+ `POST /api/anima/patrol`,三条路走**同一条判据**——如果手动能绕过判据,你就永远
368
+ 测不到定时那次的真实行为。
369
+
370
+ 判据只有机器数得出来的三样(`lib/patrol.mjs`):
371
+
372
+ 1. 待审候选的**集合**变了(有新候选、或被裁决掉了一些)
373
+ 2. 体检 `flagged` 的**集合**变了(路径失效 / 复核点过期)
374
+ 3. 这次整理真的动了数据(去重或超限淘汰)
375
+
376
+ 用「集合变没变」而不是「数量 > 0」:三条候选挂一周没人管,每天报一遍是纯噪音——
377
+ 数量没变,就没有新信息。两条旧的换成两条新的,数量没变但指纹变了,那是真有事。
378
+
379
+ ### 沉默必须能反查
380
+
381
+ 只报有事有一个必须当场堵住的洞:**沉默同时代表两件事**——「巡过了,没事」和
382
+ 「巡夜根本没跑」。这正是质检钩子在解决的同一个问题(`checks=0` 有两种相反解读)。
383
+
384
+ 堵法:**汇报可以不发,巡夜记录必须留。** 每次到点都写一条 `meta.patrol`,
385
+ `/api/anima/state` 的 `patrolMeta` 下发它:
386
+
387
+ | 字段 | 含义 |
388
+ |---|---|
389
+ | `at` / `day` / `reason` | 什么时候跑的、是定时还是手动触发 |
390
+ | `signature` | 这次局面的指纹(集合摘要) |
391
+ | `reported` | 有没有真的汇报出去 |
392
+ | `wouldReport` | 判据认为该不该报(与 `reported` 分开,才能看出通知被什么挡了) |
393
+ | `facts` | 待审 / 体检 / 整理动过 三个数 |
394
+ | `flaggedItems` | 这次体检点到的问题**逐条明细**(id / 一句话说明 / 证据 / 问题类型),没有问题时是 `null` |
395
+ | `flaggedDropped` | 明细超过 20 条时被截掉的条数(不是 0 就必须在界面上说出来) |
396
+ | `reasons` | 判定理由(`pending:3` / `flagged:1` / `removed:2`) |
397
+
398
+ `flaggedItems` 只留前 20 条,每条一段话,不含记忆原文全文 —— 明细是为了「知道是哪条、能当场处理」,
399
+ 不是为了把体检结果再存一份。被截掉的条数写进 `flaggedDropped`,界面必须显示「另有 N 条未列出」:
400
+ 静默只显示前 20 条,等于把 23 条问题谎报成 1 条。
401
+
402
+ 设置页那一行有三种写法,且**必须长得不一样**:没有记录 = 「还没跑过,这不是没事,
403
+ 是没跑」;`reported: false` = 「跑过了,判定为无事,没有汇报」;`reported: true` = 「跑过了,报过」。
404
+ 把「没跑」画成「没事」,是这个项目一直在防的那一类错误。
405
+
406
+ 汇报文案本身也带三条硬要求(`patrolNoticeText`):必须列出问题、必须写「这次没报的是什么」、
407
+ 必须给出可自己复算的出口(`GET /api/anima/memory/audit` 或设置页的体检按钮)。
408
+
409
+ ## 回扫式复盘:回头翻旧账
410
+
411
+ 记忆层最糟的失败不是「记错了」,是**「当时压根没记」**——你三天前说过一句话,
412
+ 今天它又犯同一个毛病,你只能再骂一遍。这件事没法靠「每轮盯着屏幕筛查」解决:
413
+ 在这台机器 925 条真实发言、937 个轮次上实测过,最省事的那种常驻闸门(mnemon 的两级
414
+ 漏斗)第一段四条件只命中 4 条(0.4%),第二段打分却让 44.6% 的轮次全要送审 ——
415
+ 要么太严要么太宽,中间没有可用档位。
416
+
417
+ 所以换个方向:**不碰实时对话,回头翻旧账。**
418
+
419
+ `/anima retrospect`(别名 `/anima 复盘`、`POST /api/anima/retrospect`、设置页的
420
+ 「回扫一次」按钮)会扫最近 `memory.retrospectDays` 天(默认 14,可设 1–365)的会话日志,
421
+ 按四类判据挑出「用户当时在纠正 / 抱怨 / 明说要记 / 明说禁令」的句子,交给你裁决。
422
+ 打开这个开关之后,它还会跟着**夜间定时**与**启动补跑**各跑一次(与巡夜各判各自的开关和日期,
423
+ 关掉巡夜不影响回扫):
424
+
425
+ | 类型 | 判据在找什么 | 本机 925 条语料实测 |
426
+ |---|---|---|
427
+ | `complaint` | 「我说过 / 有没有说过 / 早说过」—— 你已经在骂了,说明某件事没被记住 | 2 条(0.2%) |
428
+ | `explicit` | 「记住 / 记下来 / 别忘了」或英文 remember this | 1 条(0.1%) |
429
+ | `correction` | 「不对 / 搞错 / 谁让你 / 我说的是」—— 排除「点错了」这类非纠正 | 6 条(0.6%) |
430
+ | `constraint` | 「不许 / 禁止 / 不要…一律 / 所有 / 永远」—— 否定式长期约束 | 2 条(0.2%) |
431
+
432
+ 最终在 593 条真实发言上产出 **9 条线索,每条都带得出出处**(哪个会话文件、第几个事件),
433
+ 全量扫 53 个会话约 3 秒,最慢的单文件 711ms。
434
+
435
+ 三条硬规矩:
436
+
437
+ 1. **只产线索,不写记忆库。** 复盘不会往 `memories` 表里写任何一个字——
438
+ 那是你没说过的话。要不要记、记成 fact 还是 stance,由你在待审区点。
439
+ 2. **不管有没有线索,都写一条 `meta.retrospect`。** 与巡夜同一条纪律:
440
+ 只报有事的话,「沉默」会同时代表「扫过了没线索」和「根本没扫」。`/api/anima/state`
441
+ 的 `retrospectMeta` 下发它;没扫成的轮次 `files` 是 `null` 而不是 `0`——
442
+ 写成 0 就变成「扫了 0 个会话」,与「扫过没事」长得一模一样。
443
+ 3. **默认关闭。** 它要读磁盘上的会话日志,是新开的一个口子,所以默认 `false`,
444
+ 在设置 → 灵台里打开。关着时接口明确回「已关闭」,不写任何记录——
445
+ 「被你关掉」和「跑了没成」是两种事实,混进同一条记录会让人以后看到「上次回扫失败」以为坏了。
446
+
447
+ ## 凭据闸门
448
+
449
+ 任何命中凭据形态的内容**在进入缓冲区之前就被拒收**(不落盘、不留痕),
450
+ 覆盖私钥块、OpenAI/AWS/GitHub 令牌、通用 api_key / secret / 密码赋值、Bearer、
451
+ URL 内嵌凭据等 11 类模式。
452
+
453
+ 理由:内容一旦落盘缓冲区,就处于「等待进入提示词」的状态,凭据不该走这条路。
454
+ 凭据判定用的是 11 类正则的机械匹配,没有语义理解,会有误判——所以被拦下时界面会
455
+ 把命中规则名一起说出来,用户可以删掉那串字符再粘贴。
456
+
457
+ ## 一键导入外部 AI 的记忆
458
+
459
+ 设置页记忆卡里有一个「导入外部 AI 的记忆」按钮,展开后是两步:
460
+
461
+ 1. **复制提示词**——把一段整理画像的提示词发给你用得最多的那个 AI,让它把你的偏好
462
+ 整理成清单。按钮只在真的写进剪贴板后才显示「已复制」;没有剪贴板 API 时会明说
463
+ 「复制失败,请手动选中后复制」,不假装成功。
464
+ 2. **粘贴结果**——粘贴框接受 AI 的输出,也接受你自己手写的笔记(不要求必须是 AI 生成的)。
465
+
466
+ 解析后逐条预览,每条都能改层级(全局 / 本工作区)或直接丢弃,确认后才写进**待审缓冲区**
467
+ ——和手动添加、模型投递走同一条路,仍然要用户逐条裁决才生效。
468
+
469
+ **切分是机械的,不做语义理解**:按 Markdown 标题、列表项、空行三种边界切;刻意不按句号切,
470
+ 因为中文画像里「偏好:结论先行。」被切成两条会读不通。小节标签(如「我的使用画像」)
471
+ 不进候选——它长得像记忆但没有可采纳内容。
472
+
473
+ **归类是建议不是判决**:`suggestLayer` 只看措辞——提到具体磁盘路径或「本项目 / 这个项目」
474
+ 建议 `workspace`,提到「所有项目 / 一律 / 每次都要」建议 `global`。两条都没有时默认 `global`,
475
+ 理由是把「记忆动不动就不生效」当默认体验,比跨项目污染更糟。每条建议都带一句理由显示在
476
+ 条目下方,用户看到「本工作区」就能立刻知道凭什么。
477
+
478
+ **解析阶段就分流**:一条原文切完后按顺序过五道闸,没通过的会被分别归到不同的桶里,
479
+ 每一桶都在界面上显示出来,**不静默少一条**:
480
+
481
+ | 处置 | 判定 | 界面上 |
482
+ | --- | --- | --- |
483
+ | 过短(`< 4` 信息量) | 切出的碎片太短,通常是编号残留 | 只在汇总里报个数 |
484
+ | 超长(`> 500` 字) | 单条超过记忆上限 | 不截断,明确告知「请拆成几条」 |
485
+ | 凭据 | 命中 11 类正则之一 | 整条拦下并说明命中了哪条规则 |
486
+ | 重复 | 与已有记忆文本相同 | 单独列出 |
487
+ | 超条数 | 超出单次上限(默认 60 条) | 余下部分不进这一批,另开一次导入 |
488
+ | 同义 | 与待审缓冲区里的某条相似度 ≥ 0.6 | 在条目下方提示「已有一条很像」,**不自动合并** |
489
+
490
+ 原文上限 40000 字符,超出直接报错而不是截断(截断等于偷偷丢掉用户画像的后半段)。
491
+ 同义提示刻意只提示不合并:自动合并会生成一段用户从没说过的话。
492
+
493
+ ---
494
+
495
+ ## 安装
496
+
497
+ ### 从 npm 安装(推荐)
498
+
499
+ 已发布到 npm,包名 `dsh-anima`,直接用包名装:
500
+
501
+ ```
502
+ dsh plugin add dsh-anima@2.2.2
503
+ ```
504
+
505
+ 或者手工改 profile 的 `package.json`:
506
+
507
+ ```jsonc
508
+ {
509
+ "dependencies": {
510
+ "dsh-anima": "2.2.2"
511
+ },
512
+ "dsh": { "profile": { "bundles": [ /* ... */ "dsh-anima" ] } }
513
+ }
514
+ ```
515
+
516
+ `@2.2.2` 可以换成别的已发布版本,或整个省略以装最新版。**建议 pin 住一个版本**,
517
+ 否则上游发了新版,下次安装就会直接换掉。
518
+
519
+ 然后 `pnpm install`,**重启 DSH**。宿主侧改动一律需要重启才生效。
520
+
521
+ ### 从 GitHub 安装(跟进未发布改动)
522
+
523
+ 想装还没发到 npm 的版本、或直接跟进 `main`,就从仓库装:
524
+
525
+ ```
526
+ dsh plugin add github:Melody-cx/dsh-anima#v2.2.2
527
+ ```
528
+
529
+ 或者手工改 profile 的 `package.json`:
530
+
531
+ ```jsonc
532
+ {
533
+ "dependencies": {
534
+ "dsh-anima": "github:Melody-cx/dsh-anima#v2.2.2"
535
+ },
536
+ "dsh": { "profile": { "bundles": [ /* ... */ "dsh-anima" ] } }
537
+ }
538
+ ```
539
+
540
+ `#v2.2.2` 是标签(也可以写 commit SHA,或整个省略以跟随 `main`)。**建议 pin 住一个版本**,
541
+ 否则上游的任何改动都会在下次安装时直接进入你的 profile。git 源下载的是**整个仓库 tarball**
542
+ (本仓库很小),首次安装可能比 npm 源慢一些,超时的话重试一次即可。
543
+
544
+ 然后 `pnpm install`,**重启 DSH**。宿主侧改动一律需要重启才生效。
545
+
546
+ ### 从源码安装(开发用)
547
+
548
+ 在 profile 的 `package.json` 里加依赖(也可以用 `/plugins` 界面):
549
+
550
+ ```jsonc
551
+ {
552
+ "dependencies": {
553
+ "dsh-anima": "file:<仓库目录的绝对路径>"
554
+ },
555
+ "dsh": { "profile": { "bundles": [ /* ... */ "dsh-anima" ] } }
556
+ }
557
+ ```
558
+
559
+ 本地 `file:` 依赖是**复制**语义:改完源码要**重新安装**才生效,且重跑安装时若 pnpm 判定
560
+ `changed:false` 会直接跳过 —— 正确姿势是先 `remove_bundle` 再 `install_bundle`
561
+ (或比对 `node_modules` 副本与源码的 SHA256)。
562
+
563
+ **运行前提**(`package.json` 里已声明,装不上时先看这几条):
564
+
565
+ - `engines.node` = `^22.19.0 || >=24.0.0`,`engines.dsh` = `>=0.1.6-0 <0.2.0 || >=0.2.0-0 <0.3.0-0`
566
+ - `peerDependencies` 四包:`@deepseek-ai/cordis`(`^4.0.1 || ^4.0.5-alpha.1`)、
567
+ `@deepseek-ai/dsh-llm`、`@deepseek-ai/dsh-storage-domain`、`@deepseek-ai/dsh-tools`
568
+ —— 它们由宿主提供,插件不打包它们
569
+ - `dependencies.zod`(`^4.4.3`)是唯一真正要装的运行时依赖
570
+ - 存储栈需在 profile 里组合:`@deepseek-ai/dsh-storage` + `@deepseek-ai/dsh-storage-json`(配 `root`)
571
+ + `@deepseek-ai/dsh-storage-domain`(配 `backend: json`)。缺了不会崩,但插件会退化成
572
+ 「存储不可用」,设置页会明说原因(见下「存储」一节)
573
+
574
+ ## 配置
575
+
576
+ 设置页新增一栏「**灵台**」(`settings.section` id = `anima-settings`,order 51)。
577
+
578
+ ### 设置页长什么样
579
+
580
+ 一页装不下,所以分成**四个分区**,顶部一条常驻的导航标签:**人设 / 记忆 / 表现 / 高级**。
581
+
582
+ - **人设**:一键预设、助手身份、关于你、回复风格(预设放最前,因为最先发生的动作通常是「套一套现成的,再微调」)
583
+ - **记忆**:长期记忆(含等你确认的提议、已记住的清单、体检、导入、注入情况)
584
+ - **表现**:回答质检、输入框光轨
585
+ - **高级**:工作区覆盖、AI 实际看到的内容
586
+
587
+ 导航下面还压着一条**常驻的「变更记录」**(不属于任何分区,切到哪一栏都在):
588
+ 收起时给最近 3 条「哪个字段从什么改成了什么、谁改的、什么时候」,
589
+ 点「展开明细」才取全量、才出现清空入口。这一条是用户原话逼出来的 ——
590
+ 「改的咋样我根本不知道改成啥了」;检查入口不该埋进只有知道它存在的人才找得到的地方。
591
+
592
+ 点一个标签只切换**显示哪几张卡**,其余卡片仍然渲染、只是 `display:none`。
593
+ 这条是刻意的:真不渲染会把没保存的表单值和卡片的折叠状态一起丢掉,
594
+ 用户切去看一眼记忆、再切回来,刚打的字就没了。
595
+
596
+ 选中的分区记在 `localStorage` 的 `dsh-anima:active-section`(裸字符串,不是 JSON),
597
+ 下次打开还在那一栏。值不是这五个之一时(改坏了、旧版本残留)一律退回**第一人设栏** ——
598
+ 不认识的字符串不该让整页空掉。
599
+
600
+ **底部一条统一的保存栏**,常驻、不随卡片滚动跑掉:
601
+
602
+ - 左半句说状态:「没有未保存的修改」/「有未保存的修改」(脏的时候变警告色)
603
+ - 右边两个按钮:「恢复默认」「保存」,没有未保存内容时保存是灰的
604
+
605
+ 这一个入口是**全页唯一**的保存与恢复默认。此前保存按钮散在四张卡里、共六处,
606
+ 其中四条调的还是同一个函数 —— 用户得先判断「我在哪张卡」才知道按哪个;
607
+ 而分区之后,那张卡可能根本不在眼前。
608
+ **工作区覆盖有自己的脏状态**(它写的是工作区文件,不是全局配置),
609
+ 但在用户眼里都是「我改了东西还没落盘」,所以同一根栏里一起报、一起存。
610
+
611
+ `workspaceOverridesEnabled` / `workspaceFileDir` 的「保存覆盖」「清除覆盖」按钮保留在卡片里 ——
612
+ 那是工作区专用的另一组动作,文案不同,不算重复。
613
+
614
+ - **助手身份**:`enabled` / `agentName` / `agentRole` / `addressUserAs` / `userOccupation` / `userBio`
615
+ - **回复风格**:`style` / `headingLists` / `emoji` / `tables` / `replyLength` / `language` / `customInstructions`
616
+ - **工作区覆盖**:`workspaceOverridesEnabled` / `workspaceFileDir`
617
+ 覆盖文件默认写在 `<工作区>/.dsh-anima/override.json`(目录名可用 `workspaceFileDir` 改),随目录走、可 git、可拷给别人;
618
+ 该目录会自动写入 `.gitignore`(内容 `*`),避免私人语气设定被推到远程。
619
+ - **输入框光轨**:`trailEnabled` / `trailColor`(`#RRGGBB`,落盘统一大写)/ `trailSpeed`(`slow|normal|fast`)/ `trailWidth`(`thin|normal|thick`)
620
+ - **工具确认**:`requireToolConfirmation` —— 开启后模型调 `set_assistant_persona` 只**提交提议**,不写配置
621
+ - **长期记忆**:`memory.enabled` / `autoCapture` / `nightlyTriage` / `nightlyHour` / `maxPending` /
622
+ `maxInjected` / `maxInjectedChars` / `notifyOnSessionStart` / `defaultLayer`
623
+ - **回答质检**:`quality.enabled` / `quality.ledgerLevel`(`off|three|five`)/ `quality.strict` /
624
+ `quality.preStepCheck` / `quality.maxFindingsInReminder`
625
+
626
+ `quality.*` 五个键全都能用 `/anima set quality.ledgerLevel=five` 改,也全都在设置页的「回答质检」卡片上。
627
+ 加界面不是为了好看:这一层**会主动往你的对话里插消息**,用户必须有一个不走 JSON、不用重启的关闭开关。
628
+ 出误报时的正确动作是关 `strict`(只多判 R08 / R11 / R12 三条),而不是关掉整层 ——
629
+ 但误报多到你想关整层时,**关掉是对的**:一个喊狼来了的检查器比没有检查器更坏。
630
+
631
+ ### 卡片折叠:默认态由「改」还是「查」决定
632
+
633
+ 设置页里的每张卡右上角都有一个「收起 / 展开」按钮。**默认态不是一概展开,也不是一概收起**,
634
+ 而是按这张卡是用来**改东西**还是用来**查东西**分的:
635
+
636
+ - 用来改的,默认展开:助手身份、关于你、回复风格、人设预设、输入框光轨、工作区覆盖、长期记忆、回答质检
637
+ - 用来查的,默认收起:AI 实际看到的内容
638
+
639
+ 判断依据是用户原话:「每个正常人类打开这个设置肯定是想改这个设置相关的东西,比如人设就改人设」。
640
+ 默认收起的卡不该是用户第一步就想动的地方;默认展开的卡也不该只是一份只读清单。
641
+
642
+ 实现上就是一行表:`const DEFAULT_COLLAPSED = { mem: false, preview: true }`。
643
+ 表里没写的卡一律展开 —— 新加的卡默认看得见,比默认藏起来安全。
644
+
645
+ **长期记忆这张卡自己默认展开**,因为开关、条数上限、每晚整理都在这张卡里,属于「要改的」那一类。
646
+ 卡内部还有三个各自独立的小面板,各有各的开合,不受卡片折叠影响:
647
+
648
+ - 「已记住(N 条)」清单:**没有折叠**,直接展开列在卡里
649
+ - 「注入情况」:默认收起(`state.showInjection: false`),点一下才拉数据
650
+ - 「导入外部记忆」:默认收起(`memoryImport.open` 不是 `true` 就不展开)
651
+
652
+ 这不是三层折叠,是「卡展开 → 面板各自开合」两层;再把面板套进折叠卡就会变成三层,
653
+ 点两次才看到内容,反而更难。
654
+
655
+ **手动翻过就听用户的。** 点一下按钮,记住的是**翻转之后**的状态,不是「取消设置」——
656
+ 直接删键等于回到默认,只有默认态恰好相反时才表现为翻转,而用户点按钮的意图永远是「翻一下」。
657
+ 所以翻回默认态同样要**写下来**(值是 `false`,不是删键):
658
+
659
+ - 默认收起的预览卡被手动展开,记 `preview: false`,下次进来还是展开
660
+ - 默认展开的卡片被手动收起,记 `mem: true`,下次进来还是收起
661
+
662
+ 状态记在 `localStorage` 的 `dsh-anima:card-state`,形如 `{"preview":false,"mem":true}`。
663
+ 这个键**只记用户手动改过的卡**;没改过的卡不占位,它们的默认态由代码里的 `DEFAULT_COLLAPSED` 决定,
664
+ 这样以后新增卡片不必写迁移,只要默认态是展开就自然可见。
665
+
666
+ 脏值一律丢弃:不是布尔的值(`0`、`"yes"`、嵌套对象)当作没设置过,回落到卡片默认态——
667
+ 一次手改 `localStorage` 不该让某张卡永远打不开。键名从旧版的 `dsh-anima:collapsed-cards` 换成了
668
+ `dsh-anima:card-state`:旧键记的是「收起的」,在新规则下「收起的」反而是默认态,沿用旧键会让
669
+ 以前手展开过的卡永久收起。
670
+
671
+ **收起只影响这一张卡的内容,不影响卡片里的表单。** 收起时 `children` 不渲染,但表单值住在设置页顶层,
672
+ 收起不会把刚敲的字丢掉;切分区用的是 `display:none` 而不是卸载,两个机制不同但效果一致:
673
+ 用户切去看一眼记忆、再切回来,刚改的东西都还在。
674
+
675
+ 两种「藏」的语义是**相反**的,写在代码注释里免得后人统一:卡片的 `collapsed` 是整段不渲染,
676
+ 分区切换的 `hidden` 是 `display:none`。前者是用户主动收起(要看得见卡头才知道怎么展开),
677
+ 后者是切栏(必须保住树,否则未保存的表单值全丢)。
678
+
679
+ ### 不做任何迁移
680
+
681
+ 本插件**不读任何其它插件或自己旧版本的配置**。它发布过 1.0.0,但从 1.0.0 到 2.2.1 的存储格式完全相同
682
+ (`DOMAIN_VERSION` 都是 1,config / memories / meta / audit 四张表的 spec 逐字一致)——
683
+ 没有「老用户的介质」需要兼容,所以代码里既没有一次性迁移,也没有存储格式回退。
684
+ 这两块曾存在于开发期,都已删除;留着它们的唯一作用是让后人以为有一批需要照顾的存量用户。
685
+
686
+ 将来真要升存储格式:只改 `lib/store.mjs` 的 `DOMAIN_VERSION`,并**先想清楚老用户的介质怎么办** ——
687
+ 那个决定不该在发布之后临时补,因为补的时候介质已经在用户机器上了。范式可参考同生态的
688
+ `某同生态插件`(双 spec 串行回退)。
689
+
690
+
691
+ ### 十二个字段都是自由文本
692
+
693
+ `style` / `headingLists` / `emoji` / `tables` / `replyLength` / `language` 六个字段**不再是下拉框**,
694
+ 存的就是要注入提示词的那句话本身。改之前它们是枚举(`style: 'roast'`),改之后
695
+ `style: '毒舌吐槽;直白指出问题、不留情面,但始终对事不对人。'` —— 你想怎么写就怎么写,
696
+ 只要不超过字段上限(`agentName` / `addressUserAs` / `userOccupation` 各 50 字,`userBio` 500 字,
697
+ `agentRole` / `style` 各 800 字,`headingLists` / `emoji` / `tables` / `replyLength` / `language` 各 300 字,
698
+ `customInstructions` 2000 字 —— 逐字段取值见 `lib/config.mjs` 的 `FIELD_LIMITS`)。
699
+
700
+ 留空是合法状态,**空串在提示词里等于「不提这件事」**:模型按自己判断来,而不是被一句
701
+ 「请按默认方式处理标题」占用注意力。这也是三项特质的 `default` 档对应的实现 ——
702
+ 它不是一个值为 `'default'` 的枚举,而是空串。
703
+
704
+ 空输入框会劝退人,所以每个字段下方都有一排按钮:
705
+
706
+ | 按钮组 | 来源 | 点下去 |
707
+ |---|---|---|
708
+ | **一键填入** | `lib/wording.mjs` 的 `WORDING_TABLES` | 填入一句**定稿文案**(原档位那句话),填完可以继续改 |
709
+ | **示例** | `lib/wording.mjs` 的 `FIELD_EXAMPLES` | 填入一段**明显属于别人的第一人称**,等你改成自己的 |
710
+
711
+ 两组都随 `GET /api/anima/state` 下发给设置页,客户端**不自己抄一份文案表** ——
712
+ 否则改了 host 忘了客户端,界面上就会出现两套说法,而真正注入的是 host 那份。
713
+
714
+ `agentName` 刻意**不给示例**:按钮填一个名字进去用户很可能忘改,结果 AI 一直用别人起的名;
715
+ 这个字段改由 placeholder 提示格式。`default` 档也刻意不生成按钮 —— 空文案做成按钮,
716
+ 会让人以为点一下能得到什么,其实只是清空输入框。
717
+
718
+ **兼容旧配置**:升级不会等于「偷偷把用户的风格改回默认」。`migrateConfig` 会把旧版存的档位键
719
+ (`'professional'` / `'more'` / `'default'` …)自动翻译成对应文案,注入提示词的内容一字不变;
720
+ 用户自己写的句子不命中任何档位键,原样保留。
721
+
722
+ `requireToolConfirmation` 与四个 `trail*` 字段**刻意不参与工作区覆盖**:一个是安全开关,
723
+ 一组是界面偏好(而且必须是封闭集合 —— 它们是给 CSS 用的键),都不该因为「换个工作区」而悄悄换掉。
724
+
725
+ `maxInjectedChars`(默认 4000,单位字符)与 `maxInjected`(默认 40 条)**取先到者**:
726
+ 只有条数约束时,40 条长记忆一样能挤爆上下文。两者的 `0` 含义**不一样**,别记混:
727
+
728
+ | 字段 | `0` 的含义 |
729
+ | --- | --- |
730
+ | `maxInjectedChars` | 不限字符数(只有条数约束生效) |
731
+ | `maxInjected` | **一条都不注入** —— 它同时关闭所有记忆注入;要停记忆请用 `memory.enabled` |
732
+ `defaultLayer` 决定新记忆记在 `global` 还是 `workspace`,默认 `global` 是刻意的 ——
733
+ 把「记忆动不动就少一条」当默认体验,比跨工作区污染更难排查。
734
+
735
+ 其余 12 个字段可被工作区覆盖,且覆盖是**字段级**的 —— 工作区只声明它在意的字段,其余继承全局。
736
+
737
+ 配置落盘:**只用** `storageDomain`(域 `anima` —— 注意是下划线,宿主的域名校验只接受 `^[a-z][a-z0-9_]*$`,连字符会被拒)。
738
+
739
+ **惰性解析**(与 `某同生态插件` 的做法一致):不在 `apply` 阶段解析服务,首次真正使用时才 `ctx.get('storageDomain')`。
740
+ `dsh-storage-domain` 的 `apply` 体是 `ctx.inject(backendServices, …)` + 回调里 `provide('storageDomain', …)`,
741
+ 而 `backendServices` 是 `storage.backend.json`(由 `storage-json` 提供)。本插件若比 `storage-json` 先装载,
742
+ `apply` 阶段那一次 `ctx.get()` 必然拿到 `undefined` —— 这正是 2026-10-10 冷启动时存储后端无声退化的真因:
743
+ 旧版判空后退化到文件后端,还把结果**永久记忆化**,于是用户的配置留在旧文件里读不出来。
744
+
745
+ 现在的契约:服务缺失 ⇒ 抛 `StorageUnavailableError('storage-not-composed')`,**绝不换落盘点**;
746
+ 失败**不**记忆化(服务后来就绪时下一次调用就能成);成功才缓存域句柄。
747
+ `index.mjs` 的 `apply` 首次 `reload()` 撞上它时,挂一个 cordis 原生就绪信号
748
+ `ctx.inject(['storageDomain'], …)` 等它 provide 后重试(依赖未就绪时 fiber 保持 PENDING,服务 `provide`
749
+ 那一刻回调触发,本仓用真 cordis 探针实证过),期间五个 section 返回空串 —— 宁可不注入,也不冒充默认档。
750
+
751
+ **不做旧数据救援**:`storageDomain` 何时可用取决于装载顺序,同一个人可能早期把配置写进过别的落点。
752
+ 而从 1.0.0 到 2.2.1 的存储格式相同(`DOMAIN_VERSION` 都是 1,四张表一致),没有需要救援的旧介质 ——
753
+ 所以打开域时不做任何导入或归档,只打开自己的域。
754
+ 若将来真需要迁移,唯一安全的形状是:**仅在新域 config 记录不存在时**动手,且自己的任何失败都只记 warn,
755
+ 绝不外抛(这段代码在打开域的 try 块里,外抛会被包成新域的 `open-failed`,让一个本该正常的插件停摆)。
756
+
757
+ **不可用原因可查**:`GET /api/anima/state` 的 `source` 段带 `reason`(错误码)与 `message`(原始报错)
758
+ 两个字段,取值 `storage-not-composed` / `vocabulary-unavailable` / `open-failed: <原始报错>`;
759
+ 正常走域时两者均为 `null`,且 `kind === 'storage'`。设置页把它翻成人话显示在「存储位置」下方。
760
+ 存储不可用时该路由返回 **503 + `code`**,而不是 500 空壳 —— 设置页要靠它区分「插件没跑」与
761
+ 「跑了但存储栈没组合」:前者该重装,后者补三行 `storage` / `storage-json` / `storage-domain` 即可。
762
+
763
+ **单一格式戳,无回退**:`storage-json` 的 single 布局在打开时**硬比对**版本戳 ——
764
+ `unit.version !== descriptor.version` 直接抛 `version-mismatch`,没有任何自动迁移。本插件的域版本
765
+ **恒为 1**(`lib/store.mjs` 的 `DOMAIN_VERSION`),只声明一个 spec,不做串行回退:1.0.0 到 2.1.0 的格式戳相同,不存在「老用户的 v2/v3 介质」这种东西需要读。
766
+
767
+ 开发期曾有过兼容回退(按 `[v3, v2, v1]` 串行 `open()`,只对 `version-mismatch` 继续试),随未发布的
768
+ v2/v3 格式一起删除。那段代码的教训保留在这里,因为将来升格式时用得上:**`compatibleVersions` 声明了
769
+ 等于没写** —— 它只被 per-record 布局的 `acceptedStamps()` 读,single 布局的 `parse()` 根本不看。
770
+ 真要做多版本兼容,只能像 `某同生态插件` 那样声明**双 spec**(主 spec + 容错 spec),
771
+ 且兼容 spec 必须声明**全部四张表** —— `DomainImpl.table()` 对未声明表直接抛错,少一张就会在下一次
772
+ `table('audit')` 时炸。
773
+
774
+ **两套编号,别混**:插件自己的版本是 `package.json` 的 semver(`2.1.0`,设置页右上角徽标那个),
775
+ 存储格式戳是个位数(当前是 1),两者互不相干。界面上凡出现格式戳的地方一律写「存储格式 vN」,
776
+ 凡出现插件版本的地方一律带小数点 —— 混着叫「本版 v3」时用户没法判断哪个数字在说他装的插件旧。
777
+ `lib/config.mjs` 里那个零引用的 `CONFIG_VERSION = 3` 已删除,就是为了不再制造第二个「v3」。
778
+
779
+ ## 提示词段落
780
+
781
+ | section 名 | order | 内容 |
782
+ |---|---|---|
783
+ | `anima:identity` | 10 | 助手自身的名字与角色(可被工作区覆盖,装配期实时求值) |
784
+ | `anima:memory` | 20 | 已采纳的长期记忆 |
785
+ | `anima:pending` | 21 | 待审条数提示(**只报数量,不含内容**) |
786
+ | `anima:remember` | 22 | remember 工具的调用时机与字段(不写这条,模型不知道有这个入口) |
787
+ | `anima:quality` | 30 | 十二条判据 + 三档对账规则(`quality.enabled` 关掉即整段消失) |
788
+
789
+ 刻意排在 `PLAN_POLICY`(500) 之前:人设是「我是谁」,应当在行为策略之前确立。
790
+
791
+ ## 命令与工具
792
+
793
+ - `/anima [show|pending|adopt <id>|discard <id>|mark <id> type=… state=…|triage|patrol|retrospect|checkup|audit|name <名字>|enable|disable|set <键>=<值>|confirm|reject|reset]`
794
+ - 工具 `remember(text, note?, scope?, type?, sourceRef?, verifyAt?)` —— 投递一条候选到待审缓冲区
795
+ - 工具 `set_assistant_persona(...)` —— 模型改助手人设;开启「工具确认」时只落提议,
796
+ 需用户 `/anima confirm` 或设置页点「确认」才生效
797
+
798
+ 四个容易混的命令,按「查什么」区分:
799
+
800
+ | 命令 | 查的是什么 |
801
+ |---|---|
802
+ | `/anima show` | 插件此刻的状态(配置、存储、注入、质检计数) |
803
+ | `/anima audit`(别名 `/anima 检查`) | **我上一条回答**有没有违反十二条 |
804
+ | `/anima checkup`(别名 `/anima 体检`) | **记下来的东西**还成不成立(路径 / 复核点) |
805
+ | `/anima patrol`(别名 `/anima 巡夜`) | 跑一次完整的巡夜,并当场说清「这次为什么没说 / 说了什么」 |
806
+ | `/anima retrospect`(别名 `/anima 复盘`) | **回扫旧会话**,找出你当时说过、但没被记下来的话 |
807
+
808
+ 前两个查「我说的话」,第三个查「库里的数据」,第四个决定要不要向你汇报,
809
+ 第五个回头翻旧账、只产线索。前三个都不改任何状态;巡夜会跑整理(可能动缓冲区)、
810
+ 复盘会读磁盘上的会话日志,但都不会替你做任何裁决。
811
+
812
+ `/anima show` 是只读诊断命令:**存储栈没组合时它恰恰最该跑得通**——用户正是靠它才知道
813
+ 「插件在,是存储没接上」,所以它容忍装载失败,把原因和「以上是内置默认值」的提示一起打出来。
814
+
815
+ ## 界面挂载点
816
+
817
+ | 槽位 | id | order | 内容 |
818
+ |---|---|---|---|
819
+ | `settings.section` | `anima-settings` | 51 | 「灵台」设置页 |
820
+ | `conversation.input.left` | `anima-quick-toggle` | 51 | 输入框左侧的「人设已开 / 人设已关」快捷开关 |
821
+ | `conversation.input.overlay` | `anima-trail` | 51 | 输入框光轨(仅助手运行时亮起) |
822
+
823
+ 三个 id 全部与 `某同类人设插件` 的对应项错开:`settings.section` 的文档写明「复用已有 id 即替换那一格」,
824
+ 撞上就会把对方的设置页顶掉。
825
+
826
+ **设置导航图标**用 DOM 替换实现(常量前缀 `data-an-settings-nav` / `data-an-icon-replaced`):
827
+ 壳层的导航图标是 id → 图标的硬编码映射,插件无法通过槽位声明自己的图标。
828
+ 这是锦上添花的能力,所以代码在入口处检查 `document.querySelectorAll` 是否存在,没有就整体不接管
829
+ ——绝不能因为画不出一个图标就让整个客户端装载失败。
830
+
831
+ **光轨为什么带三重自守**:它挂在输入框卡片上,一旦尺寸或定位算错就可能反过来影响宿主布局,
832
+ 所以实现里有三道闸门——内联 `cssText` 写死 12 条关键属性(防第三方样式表覆盖)、
833
+ 尺寸超过 `TRAIL_MAX_SIDE` 直接 `disable()`(防尺寸正反馈)、挂载后自检
834
+ `getComputedStyle(svg).position === 'absolute'`(防样式被覆盖)。
835
+
836
+ ## 对外服务
837
+
838
+ ```js
839
+ const svc = ctx.get('animaConfig')
840
+ await svc.getEffective() // 全局 + 当前工作区合并后的有效人设
841
+ await svc.getConfig()
842
+ await svc.updateConfig(patch)
843
+ await svc.resetConfig() // 与设置页「恢复默认」同一条路径,同样保留自建预设
844
+ await svc.getSystemPrompt(workspace) // 模型此刻实际看到的人设文本(与五个 section 逐条对齐)
845
+ await svc.listPending()
846
+ await svc.runTriage()
847
+ ```
848
+
849
+ `getSystemPrompt(workspacePath)` 的门控与 `anima:identity` / `anima:memory` / `anima:pending` /
850
+ `anima:remember` / `anima:quality` **五个** section 函数逐条对齐——否则这个服务会答出一份**模型实际看不到**的文本,
851
+ 那比不提供它更坏。(这条曾经真的漏过一次:加质检段时只改了 `registerSystemPrompt`,
852
+ 忘了 `getSystemPrompt` 与 `/api/anima/preview` 两处,于是预览里看不到模型实际会收到的质检规则。
853
+ 两处独立犯同一个错,说明这类「同一份文案有多个出口」的地方必须靠测试锁,不能靠记性。)
854
+
855
+ ## 开发
856
+
857
+ 以下脚本在**源码仓库里**跑(`scripts/` 与 `pnpm-lock.yaml` 不进 npm 发布包 —— 发布包共 20 个文件:
858
+ `index.mjs` / `lib`(14 个 .mjs)/ `client/index.mjs` / `assets/icon.svg` / `cordis.patch.yml` / `package.json` /
859
+ `LICENSE` / `README.md` / `README_EN.md`;这个数字是 `npm pack --dry-run` 打出来的,不是手数的):
860
+
861
+ ```bash
862
+ node scripts/verify-core.mjs # 纯函数回归(188 项)
863
+ node scripts/smoke-host.mjs # 宿主侧冒烟(100 项,用假 ctx 真跑 apply)
864
+ node scripts/check-client-i18n.mjs # 客户端字典完整性 + zh/en 对称(zh=en=316 key)
865
+ node scripts/smoke-client.mjs # 客户端渲染冒烟(52 项,假 __ModuleLoader__ + 真钩子运行时)
866
+ node scripts/acceptance.mjs [--url <宿主>] # 验收:全部套件 + 运行态 + 源码/安装副本 SHA256 一致
867
+ node scripts/check-hook.mjs [会话文件] [--since <ISO>] # 质检钩子活性(ALIVE / DEAD / UNDETERMINED)
868
+ node scripts/remind-effect.mjs [会话文件] # 量「质检提醒有没有改变写作行为」(会抖)
869
+ node scripts/audit-response.mjs --text-file <文件> # 拿十二条判据判一段文本(给人用)
870
+ node scripts/trace-sources.mjs [--apply] # 给记忆反查出处(LCS 匹配,不是精确匹配)
871
+ node scripts/backfill-types.mjs [--apply] # 批量回填记忆 type / verifyAt
872
+ node scripts/check-live.mjs # 运行态验收(旧版,已被 acceptance.mjs 覆盖)
873
+ ```
874
+
875
+ 五个判定脚本各有各的诚实边界,别混:
876
+
877
+ - `check-hook.mjs` 的 **UNDETERMINED 不是通过**。没有 `--since` 分界线时,它无法区分
878
+ 「旧代码时期写的回答」与「新代码该报没报」,所以一律不下判词 —— **未知不等于坏**。
879
+ 它只把带 `[dsh-anima 质检]` 前缀的注入消息算作证据;整理通知之类不算
880
+ (早期版本把所有 anima 注入消息都算成提醒,于是一条与质检无关的后台通知也能让判定变 ALIVE)。
881
+ - `remind-effect.mjs` 的比值**会随会话变长而漂**,分母通常只有一二十条,差两条就能翻面。
882
+ 它只能否掉「完全没用」,证不了「有用」;「写了对账块」也不等于「写对了」。
883
+ - `audit-response.mjs` 是给人用的,插件内部**不调用它**。它的退出码 0/1/2 分别表示
884
+ 干净 / 有判词 / 读不到输入,`--json` 给机读。
885
+ - `trace-sources.mjs` 用**最长公共子串**(阈值 10 字)而不是精确匹配 —— 记忆库存的是
886
+ 被改写压缩过的版本,逐字相同永远匹配不上。它的输出分三档:`用户原话` /
887
+ `助手转述(未找到你的原话)` / `不可考`。**「助手转述」不等于假**,只等于「找不到原文可核」。
888
+ - `acceptance.mjs` 结束时必定打印「本次没验什么」。那一段不是免责声明,是给你反查用的清单 ——
889
+ 里面列着的项目,就是它**没有**证明的东西。
890
+
891
+ 前五套都在进程外跑,只能证明「代码是这么写的」。**`acceptance.mjs --url` 与 `check-hook.mjs`
892
+ 证明的是「进程里那份代码确实生效」**——曾经发生过装机副本停留在旧版、新加的路由一条都没注册、
893
+ 而全部单测依然全绿的情况;更早还发生过单测夹具照抄了错误的事件形状,于是钩子永远拿到空串,
894
+ 测试却全绿。**夹具必须照抄现实,不能照抄实现。**
895
+
896
+ 改完插件要**重新安装**才生效:pnpm 的 `file:` 依赖是**复制**语义,重跑 `install_bundle` 时若判定
897
+ `changed:false` 会直接跳过,正确姿势是 `remove_bundle` 再 `install_bundle`(或对比 `node_modules`
898
+ 副本与源码的 SHA256 确认)。宿主侧改动一律需要**完整重启**,热重载不会换掉已缓存的 ESM 模块。
899
+
900
+ `check-live.mjs` 里的断言刻意**不钉具体值**:名字是你的资产,脚本要防的是「数据丢了」,
901
+ 不是「你改了名」。同理「模型可改哪些字段」这类契约变化,断言长度而不是逐条枚举内容;
902
+ 路由数量也一样——它点名核对关键路由是否注册,**不钉总条数**,否则每加一条路由都要回来改这个数字。
903
+
904
+
905
+ `verify-core.mjs` 与 `smoke-host.mjs` 都通过 `scripts/tools-loader.mjs` 把宿主内建包
906
+ (`@deepseek-ai/dsh-tools`、`@deepseek-ai/dsh-storage-domain`)解析到本地替身 —— 它们只存在于宿主
907
+ asar 里,在 DSH 之外解析不到;装进 profile 后走的是真实包,由 `check-live.mjs` 覆盖。
908
+
909
+ ## 路由守卫
910
+
911
+ 本插件的 HTTP 路由会读写人设配置与记忆正文,而宿主 webserver **自身不带鉴权**
912
+ (`@deepseek-ai/dsh-host-webserver` README 原文:*the server carries no TLS,
913
+ authentication, or origin policy of its own*),且默认配置可能绑在 `0.0.0.0`。
914
+ 因此每条路由都自加一道守卫(`index.mjs:467` 起),三档判定,任一不过即 403:
915
+ **非回环来源**一律拒;带了 `Sec-Fetch-Site` 且值不是 `same-origin` 的拒(浏览器发出的跨站请求会带这个头,
916
+ 它比 `Origin` 更早生效,也覆盖了 `no-cors` 之类的场景);带了 `Origin` 的还须协议为 http(s)、
917
+ 主机是回环地址、且 `Origin` 与 `Host` 完全一致。
918
+ **不带 `Origin` 的回环请求是放行的**——本机脚本(curl / PowerShell)属于这一档,
919
+ 这是刻意为之:守卫拦的是**浏览器跨站**,不是本机使用者。做法与生态一致
920
+ (`@michengai/dsh-skills-manager`、`@linxin666/dsh-remote-web-ui` 同类实现)。
921
+
922
+ ## 破坏性动作的确认
923
+
924
+ `POST /api/anima/config/reset` 要求请求体里显式带 `{"confirm": true}`,空 body 一律
925
+ **400 + `code: "confirmation-required"`**,一个字段都不动。设置页的「恢复默认」按钮会先弹一次
926
+ 确认框,通过后才带该字段。语义与 `某同生态插件` 的失败关闭(fail closed)一致:
927
+ 拿不到明确确认就什么都不做,并说明怎么做才有效。
928
+
929
+ 重置**保留用户自建的人设预设**——行为与 `某同类人设插件` 的 重置时保留用户自建人设的函数 对齐,
930
+ 预设是用户一条条攒出来的资产,重置语气设置不该连它一起清掉。
931
+
932
+ > 这条护栏来自一次真实事故:为确认路由是否注册,对写路由逐一 `POST {}` 探活,
933
+ > `/config/reset` 当场 200 并清空了 `agentName` / `agentRole` / `addressUserAs`。
934
+ > 现在有三道防线:路由要求显式确认、`smoke-host.mjs` 断言空 body 不得改动字段、
935
+ > `check-live.mjs` 在运行态探一次并在真被清空时就地恢复。
936
+
937
+ ## 已知边界
938
+
939
+ - 配置写入是**非原子**的(无 tmp+rename),`storageDomain` 路径下由后端保证。
940
+ - `anima:pending` 只提示数量,不暴露候选内容——这是刻意的,避免未裁决内容影响回答。
941
+ - **注入消息缺 `id` 不是「这条丢了」,是「整个会话打不开」**。宿主加载会话时
942
+ `assertMessageEventShape` 要求每条消息 `id` 非空串,抛的是
943
+ `session event at seq N lacks an identified message`(`_tmp_sessionfix/_extract/dsh-session__lib__index.js:1201`)。
944
+ 写入那一刻没有任何校验,坏消息会安静地落盘,直到下次打开会话才炸。
945
+ 已改成自持 `randomUUID()`(`lib/injection.mjs` 的 `createNoticeMessage`),
946
+ 并在 `verify-core` 用三条回归钉住「有 id / 两次不同 / role 与 source 仍合规」。
947
+ 历史坏数据(本机 seq 6104、6301)是**改写存档**补的 id,不是靠插件自愈 ——
948
+ 已经有坏数据的用户升级插件不会自动修好,这一点写在这里免得下次误判。- 工作区覆盖文件的向上查找最多 32 层。
949
+ - 路由守卫假定请求经过宿主 webserver;若将来宿主在前面加了反向代理,`remoteAddress`
950
+ 会变成代理地址,此守卫需要同步调整。
951
+
952
+ **质检层做不到的事**(写在这里,因为「以为它做到了」比它没做到危险得多):
953
+
954
+ - **本轮拦不住**。钩子看到的是已经落盘的回答,只能在下一条消息里提醒。
955
+ - **只判形状,不判内容**。它能发现「有数字却没有出处」,不能发现「这个数字是错的」。
956
+ - **R03 / R05 / R06 / R07 / R10 机器永远判不了**,代码每次都会明说「这五条没查」。
957
+ - **「零提醒」不等于「没问题」**。要看 `qualityStats.checks`:`checks=0` 就是这套东西没在工作。
958
+ - **误报率和漏报率没有同时最小化的解**。当前取舍是宁可漏(`strict` 关掉三条 partial 判据),
959
+ 也不制造假警报 —— 假警报会让用户关掉整层,那时漏报从一个洞变成全部洞。
960
+
961
+ **记忆层做不到的事**:
962
+
963
+ - **不判断哪条记忆被哪条取代**。这是语义判断,自动做会生成用户没说过的话;
964
+ 实测 21 条记忆两两相似度最高 0.094(阈值 0.6),自动判也一次都不会触发。
965
+ - **衰减只影响「怎么显示、排在哪」,不改数据**。`lib/decay.mjs` 按 `type` + 日历
966
+ 算出档位:事实窗口 0(永不因年龄衰减,环境变更由 `verifyAt` 管)、偏好 540 天、
967
+ 立场 365 天、事件 180 天。超窗的条目在注入行尾部加一个标签(「久未更新」/「较早的事」),
968
+ 并在排序里挪到最后(预算紧张时先被挤掉)。**它不会把 `state` 改成 superseded,也不会删除**——
969
+ 「这条还算不算数」只有你能判。
970
+ 那三个天数**没有任何数据支撑**,是按类型语义随手定的量级;等实际用上一段时间觉得不合适,
971
+ 再决定要不要提升成可配置项。
972
+ - **删除是硬删**(`deleteMemory` → 后端 `delete`),不是墓碑。
973
+ `state: 'dropped'` 的记录会被 `selectForInjection` 直接跳过(`lib/memory.mjs:578`),
974
+ 所以**「已删除(墓碑)」这个标签永远不会出现在注入给模型的提示词里**——
975
+ `formatMemoryLine` 里那段分支只是格式化函数的自我完整,注入路径走不到它。
976
+ 这不是缺陷:把已经删掉的内容再塞回上下文,比不显示标签糟得多。
977
+ - **巡夜只报有事,且判据里没有任何语义判断**。它每晚跑一次(`scheduleNightly` → `runPatrol`),
978
+ 做三件事:整理缓冲区、跑一遍记忆体检、然后决定要不要说话。判据全在
979
+ `lib/patrol.mjs`:待审候选的**集合**变了、体检 flagged 的**集合**变了、或本次整理真的动过数据。
980
+ 数量没变就不说 —— 三条候选挂一周,每天报一遍是纯噪音。
981
+ 它判不了「这条记忆还算不算数」,那需要理解语义。
982
+
983
+ **巡夜做不到的事**(写在这里,因为「以为自己会被告知」比不被通知危险得多):
984
+
985
+ - **它不保证「今晚一定跑过」**。机器关机、宿主没开、存储没就绪,都会让它整夜不跑。
986
+ 沉默于是有两种意思:**巡过了没事**,和**根本没巡**。这两种只有 `meta.patrol` 那行能分开 ——
987
+ `at` 是昨晚的时间就是跑过,`at` 还停在前天就是没跑。设置页那行「上次巡夜」就是为此存在。
988
+ 反过来也成立:**如果那行不见了,不是「没事」,是「没跑过」**(客户端对这种情况有专门文案)。
989
+ - **它不看内容的真假**。判据全是集合比较(待审 id 集合、flagged id 集合、整理是否动过数据),
990
+ 没有一处读得懂记忆写的是什么。所以「这条记忆现在是错的」它永远不会主动告诉你。
991
+ - **它不判断哪条取代了哪条**。语义判断,见上一节。
992
+ - **它不会因为你忙就改期**。几点跑由 `nightlyHour` 定。到点那一刻机器睡着、或闹钟被系统拖到别的钟点,
993
+ 它就**跳过这一晚而且不说**(`index.mjs:807` 判 `nowHour !== hour` 时只写一条 warn 日志就 return)。
994
+ 准确地说:**「记 `nightly-late`」这件事从来没有实现过** —— 全仓只有 `lib/patrol.mjs:206` 的注释和
995
+ 本节之前的文字提到它,代码里没有任何一处写入 `nightly-late`。真实存在的是 `reason: 'catch-up'`
996
+ (宿主启动 5 秒后,若当天还没跑过就补一次,`index.mjs:2561`),它只补「当天」,不补「上周三那晚」。
997
+ 它不知道你在赶论文,也不会因此闭嘴或推迟。
998
+ - **它只有一次机会**。一晚只跑一次;那一晚如果什么都没说,就没有第二次。想要确定结论,
999
+ 得自己手动跑一次(设置页按钮或 `/anima patrol`),否则你只能从 `meta.patrol` 反推。
1000
+ 注意手动跑也会**覆盖**那行记录:`at` 会变成你手点的时间,`reason` 变成 `manual`。所以「昨晚到底跑没跑」
1001
+ 只能在你还没手动跑过之前查到 —— 手点一次,这个问题的答案就没了。
1002
+ - **它报的是「集合变了」,不是「这件事重要」**。一条无关紧要的候选新冒出来会报,
1003
+ 一条真要命但上周就已经躺在名单里的不会报。判据里没有权重这个概念。
1004
+ - **它只留最近 20 条明细**。`flaggedItems` 存的是压缩后的前 20 条,被截掉的条数记在
1005
+ `flaggedDropped` 并在界面上写明。要看完整清单只能当场点「体检」重算(`GET /api/anima/memory/audit`),
1006
+ 那份是现算的、不限 20 条 —— 但同样不会告诉你哪条更重要。
1007
+ - **明细里不含记忆原文(一般情况)**。正常只有 id、一句解释、一句证据。唯一的例外是体检记录里缺 `detail` 时
1008
+ 会退回记忆正文(`lib/patrol.mjs:189` 的 `first.detail ?? item?.text`)—— 现有体检代码每条都会给 detail,
1009
+ 所以这条兜底在正常路径上不会触发,但代码里确实留着这条路。
1010
+ - **它的通知只送进「当时活着的会话」**。`injectToAgents` 只对当前活动会话注入;那一刻没开着的会话
1011
+ 收不到,事后也不会补发。所以「我后来打开了没看到」不等于它没报,`delivered` 和 `reported` 才是证据。
1012
+ - **「今晚没报」不等于「你昨天处理的东西已经被看见了」**。整理动过数据只在「上次没动过」时
1013
+ 才说一次(`lib/patrol.mjs:139` 的 `prevTouched === 0` 条件)—— 连续几晚都在自动清理,
1014
+ 只有第一晚会说。之后的清理量仍然记进 `facts.removed`,但不会单独通知你。
1015
+ - **它判不了「这条记忆该不该留」**。整理只会去重与按上限淘汰,从不因为「内容没用」删东西。
1016
+ `facts.removed` 里涨起来的数字全是这两类机械原因。