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/lib/store.mjs ADDED
@@ -0,0 +1,755 @@
1
+ // lib/store.mjs — 持久化层
2
+ //
3
+ // 落盘策略(对应拍板 ⑦「全局存 storage,工作区覆盖存工作区文件」):
4
+ // - 全局人设 + 记忆:@deepseek-ai/dsh-storage-domain(跟人不跟项目);
5
+ // - 工作区覆盖:工作区目录内的 .dsh-anima/override.json(跟项目走、可 git、
6
+ // 可拷给别人),由 lib/workspace.mjs 负责。
7
+ //
8
+ // 为什么没有「文件后端」兜底(2026-10-10 决定,与某同生态插件的取舍一致):
9
+ // dsh-storage-domain 的服务是在它自己的 `ctx.inject(backendServices, …)` 回调里
10
+ // `provide("storageDomain", …)` 的(storage-domain\lib\index.js:441-450),**异步就绪**。
11
+ // 本插件 apply 末尾那次 `reload()` 会立刻触发首次打开;若跑在它前面,一次性
12
+ // `ctx.get()` 只拿到 undefined —— 表现为「重启后存储后端无故退化」。
13
+ //
14
+ // 先前自己造过一版「等就绪 + 超时 + 退化到文件」,2026-10-10 冷启动实测**仍然退化**
15
+ // (连判据用的 `storage` hub 本身也是异步 provide,一并失效)。生态里的成熟做法只有
16
+ // 一条:**惰性解析 + 记忆化** —— 不在 apply 阶段解析服务,改为首次真正使用(路由 /
17
+ // 命令 / 提示词装配)时再解析,那时组合必然已完成。故本文件不再有文件后端:拿不到域
18
+ // 就抛结构化错误(StorageUnavailableError),由调用方决定怎么呈现(路由 503 / 命令
19
+ // 给组合指引),绝不静默改写用户数据的位置。
20
+ //
21
+ // 不再「退化到文件」还有一个更硬的理由:两个后端并存时,同一个人会先后命中两种后端,
22
+ // 早期写进文件后端的配置在域就绪后会被空的新域旁置 —— 表现为「我填的名字/称呼全没了」。
23
+ // 这不是用户的操作问题,是后端可切换本身带来的分叉。
24
+ //
25
+ // 这套取舍在同生态里不是我们首创,完整出处见 docs/peers/ 的调研记录。
26
+ // 两条要点按我们自己的话复述如下(不是原文摘抄):
27
+ //
28
+ // 一、storageDomain 是可选服务。宿主没组存储栈时插件照常挂载,不能把 profile
29
+ // 卡在 pending;需要它的路径各自向上抛,由调用方决定怎么呈现 —— 失败要响,
30
+ // 降级要安静。
31
+ // 二、服务要惰性解析。在 apply 阶段一次性 ctx.get() 会抢在提供方之前拿到
32
+ // undefined,而这层结果一旦被缓存就永不重查,注册表从此永久不可用。改为首次
33
+ // 真正使用时再解析:那时组合必然已完成;真缺失时当场 reject 并说明怎么组合。
34
+
35
+ import { migrateConfig } from './config.mjs'
36
+ import { AUDIT_MAX_RECORDS, buildAuditChanges, makeAuditEntry, trimAudit } from './audit.mjs'
37
+
38
+ // 真实宿主的 storageDomain 会校验域名必须匹配 /^[a-z][a-z0-9_]*$/(连字符非法),
39
+ // 违规时 open() 抛 "domain name 'x' must match /^[a-z][a-z0-9_]*$/"。
40
+ // 假 store 的冒烟测试没有这层校验,曾漏掉此缺陷 —— 故此处固定用下划线形态。
41
+ const DOMAIN_NAME = 'anima'
42
+ // 域版本:只增不减的身份标识。
43
+ //
44
+ // 为什么现在又从 1 起算:v1/v2/v3 这三段沿革全部发生在插件**发布之前**,从外部看
45
+ // 没有「旧版本的用户」这回事。于是这里把格式戳重新从 1 起算,与 `package.json` 的
46
+ // semver(那时是 1.0.0)同步起步,省掉后人面对「插件装的是 1.0.0,介质却写着 v3」时的困惑。
47
+ // 插件版本后来升到 2.0.0,格式戳仍是 1:两套编号各走各的,不跟着一起涨。
48
+ //
49
+ // 前一段沿革(留档,勿再当成需要兼容的历史):
50
+ // v1:首版(config / memories / meta 三张表)。
51
+ // v2:memories 新增 layer 字段;config.memory 新增 maxInjectedChars / defaultLayer。
52
+ // v3:新增 audit 表(人设变更审计日志)。
53
+ //
54
+ // 相应地被删掉的是**兼容回退**:旧版按「主 spec → 兼容 spec」串行试开,因为
55
+ // @deepseek-ai/dsh-storage-json 的 single 布局在 parse() 里硬比对版本戳
56
+ // (storage-json\lib\index.js:102),且 `compatibleVersions` 只在 per-record 布局被读。
57
+ // 那条路径现在的唯一价值是「读别人机器上更早的介质」,而未发布意味着不存在这种介质。
58
+ //
59
+ // 若将来真要升格式:只改这个数字,并且**先想清楚老用户的介质怎么办** —— 那个决定
60
+ // 不该在发布之后临时补,因为补的时候介质已经在用户机器上了。范式可参考同生态的
61
+ // 某同生态插件(双 spec 串行回退)。
62
+ /** 当前域版本(介质格式戳)。只在模块内使用:外部看到的是 `/state` 下发的 `source.domainVersion`。 */
63
+ const DOMAIN_VERSION = 1
64
+ const CONFIG_KEY = 'main'
65
+
66
+ /**
67
+ * 存储域不可用。区别于普通异常:带机器可读的 `code` 与 `statusCode = 503`,
68
+ * 让路由层能直接把它翻成 HTTP 状态与 `source.reason`。
69
+ *
70
+ * 三种 code:
71
+ * - `storage-not-composed` 这份 profile 没组合存储栈(配置问题,不是故障);
72
+ * - `vocabulary-unavailable` 域词汇表(dsh-storage-domain / zod)解析不了(安装问题);
73
+ * - `open-failed` 域词汇表在,但 open() 被拒(介质损坏 / 版本不匹配 / 域名非法)。
74
+ */
75
+ export class StorageUnavailableError extends Error {
76
+ constructor(code, message, options = {}) {
77
+ super(message)
78
+ this.name = 'StorageUnavailableError'
79
+ this.code = code
80
+ this.statusCode = 503
81
+ if (options.cause !== undefined) this.cause = options.cause
82
+ }
83
+ }
84
+
85
+ // ==================== storageDomain 后端 ====================
86
+
87
+ /**
88
+ * 惰性打开存储域。返回 {kind:'storage', table(name), close()},或抛
89
+ * StorageUnavailableError。
90
+ *
91
+ * **只在真正要读写时才调用**(StateStore._open 的职责)。apply 阶段调用它,
92
+ * 就是在赌 storage-domain 已经 provide —— 2026-10-10 那次退化赌输了。
93
+ *
94
+ * @param {any} ctx 插件 ctx
95
+ * @param {{warn?: Function, info?: Function}} [logger]
96
+ * @returns {Promise<{kind:string, table:(name:string)=>any, close:()=>Promise<void>}>}
97
+ */
98
+ async function openStorageBackend(ctx, logger) {
99
+ let storageDomain
100
+ try {
101
+ storageDomain = ctx.get?.('storageDomain')
102
+ } catch {
103
+ storageDomain = undefined
104
+ }
105
+ if (!storageDomain || typeof storageDomain.open !== 'function') {
106
+ throw new StorageUnavailableError(
107
+ 'storage-not-composed',
108
+ '这份 profile 没有组合存储栈,anima 无法读写人设与记忆。请加入三行:'
109
+ + '@deepseek-ai/dsh-storage、@deepseek-ai/dsh-storage-json(config.root)、'
110
+ + '@deepseek-ai/dsh-storage-domain(config.backend: json)。'
111
+ )
112
+ }
113
+
114
+ let defineDomain, domainTable, z
115
+ try {
116
+ ({ defineDomain, domainTable } = await import('@deepseek-ai/dsh-storage-domain'))
117
+ ;({ z } = await import('zod'))
118
+ } catch (err) {
119
+ throw new StorageUnavailableError(
120
+ 'vocabulary-unavailable',
121
+ `存储域词汇表不可用(@deepseek-ai/dsh-storage-domain / zod 解析失败):${String(err?.message || err)}`,
122
+ { cause: err }
123
+ )
124
+ }
125
+
126
+ // 注意:storageDomain 会逐条校验存量记录,schema 少一个字段就会让整条 config 记录
127
+ // 被判 invalid-record 而拒绝打开整个域。故新增配置字段时必须同步这里。
128
+ const configSchema = z.object({
129
+ enabled: z.boolean().optional(),
130
+ agentName: z.string().optional(),
131
+ agentRole: z.string().optional(),
132
+ addressUserAs: z.string().optional(),
133
+ userOccupation: z.string().optional(),
134
+ userBio: z.string().optional(),
135
+ style: z.string().optional(),
136
+ headingLists: z.string().optional(),
137
+ emoji: z.string().optional(),
138
+ tables: z.string().optional(),
139
+ replyLength: z.string().optional(),
140
+ language: z.string().optional(),
141
+ customInstructions: z.string().optional(),
142
+ // 工具确认门槛 + 光轨四项(对照某同类人设插件)。schema 必须与 DEFAULT_CONFIG 同步,
143
+ // 少一个字段 = 存量记录被判 invalid-record = 整个域打不开(见上方注释)。
144
+ requireToolConfirmation: z.boolean().optional(),
145
+ trailEnabled: z.boolean().optional(),
146
+ trailColor: z.string().optional(),
147
+ trailSpeed: z.string().optional(),
148
+ trailWidth: z.string().optional(),
149
+ presets: z.array(z.object({
150
+ id: z.string(),
151
+ name: z.string(),
152
+ description: z.string().optional(),
153
+ custom: z.boolean().optional(),
154
+ createdAt: z.string().optional(),
155
+ fields: z.record(z.string(), z.string())
156
+ })).optional(),
157
+ workspaceOverridesEnabled: z.boolean().optional(),
158
+ workspaceFileDir: z.string().optional(),
159
+ memory: z.object({
160
+ enabled: z.boolean().optional(),
161
+ autoCapture: z.boolean().optional(),
162
+ nightlyTriage: z.boolean().optional(),
163
+ nightlyHour: z.number().optional(),
164
+ maxPending: z.number().optional(),
165
+ maxInjected: z.number().optional(),
166
+ maxInjectedChars: z.number().optional(),
167
+ notifyOnSessionStart: z.boolean().optional(),
168
+ defaultLayer: z.enum(['global', 'workspace']).optional(),
169
+ // 回扫式复盘(2026-11-08)。少写这两个字段的后果不是报错,是
170
+ // 「设置页保存成功、刷新后又是关的」—— zod 读回时把它们静默剥掉,
171
+ // 而写入路径照落盘,于是 POST 返回 200 但 GET 读不到。同类 bug 的
172
+ // 上一次形态见下方 metaSchema 的注释(2026-10-24)。
173
+ retrospectEnabled: z.boolean().optional(),
174
+ retrospectDays: z.number().optional()
175
+ }).optional(),
176
+ // 质检层。与 memory 同理:schema 少了这一段,整条 config 会被判 invalid-record
177
+ // 而拒绝打开域;多了这一段而 migrateConfig 不搬,则会静默回默认值。
178
+ // 三处必须同步:本 schema、DEFAULT_CONFIG、migrateConfig。
179
+ quality: z.object({
180
+ enabled: z.boolean().optional(),
181
+ ledgerLevel: z.enum(['off', 'three', 'five']).optional(),
182
+ strict: z.boolean().optional(),
183
+ preStepCheck: z.boolean().optional(),
184
+ maxFindingsInReminder: z.number().optional()
185
+ }).optional()
186
+ })
187
+
188
+ const memorySchema = z.object({
189
+ id: z.string(),
190
+ text: z.string(),
191
+ digest: z.string().optional(),
192
+ status: z.enum(['pending', 'adopted', 'discarded']),
193
+ // 层级:global 处处生效,workspace 只在 cwd 匹配时注入。
194
+ layer: z.enum(['global', 'workspace']).optional(),
195
+ scope: z.string().optional(),
196
+ source: z.string().optional(),
197
+ note: z.string().optional(),
198
+ capturedAt: z.string(),
199
+ decidedAt: z.string().optional(),
200
+ sessionId: z.string().optional(),
201
+ cwd: z.string().optional(),
202
+
203
+ // ============ 质检层新增(2026-10 十二条第 5/6/7/10 条的数据基础) ============
204
+ //
205
+ // 为什么这四项必须写在 schema 里:zod 默认**剥离未声明字段**,storageDomain
206
+ // 又逐条校验存量记录。少写一个字段不会报错,只会让写进去的值读出来时凭空消失 ——
207
+ // 那比报错更难查。任何新增字段都必须同时出现在这里。
208
+ //
209
+ // 类型:决定时间衰减的窗口(见 lib/decay.mjs 的 DECAY_WINDOWS_DAYS),
210
+ // 以及被人读到时该被怎么理解 —— 不是分类学爱好。
211
+ // 四种的「什么时候该失效」完全不同:事实改了才覆盖(窗口 0 = 不因年龄衰减,
212
+ // 它要防的是「环境变了」,那由 verifyAt 管);偏好半衰期很长;立场在「我改主意了」
213
+ // 时归档为「曾如此」;事件永不失效,但越远越少被用到。
214
+ //
215
+ // 注意衰减**只影响注入行的标签与排序**,不改这里的任何字段:
216
+ // state 永远是用户的所有权,代码不会因为「这条旧了」就把它标成不成立。
217
+ type: z.enum(['fact', 'preference', 'stance', 'event']).optional(),
218
+ // 生命周期状态(区别于 status 的裁决状态):
219
+ // current 现行
220
+ // superseded 用户改主意了,旧条目归档为「曾如此」
221
+ // contradicted 与后来的事实冲突,但我们还没裁决谁对
222
+ // dropped 用户亲手删的(保留墓碑,不真删 —— 见降级三步)
223
+ state: z.enum(['current', 'superseded', 'contradicted', 'dropped']).optional(),
224
+ // 复核点:到了这个日期,注入时标注「可能已过时」,由用户决定要不要更新。
225
+ // 只由日期触发,不由模型判断触发 —— 模型判断「这条过时了」正是要防的东西。
226
+ verifyAt: z.string().optional(),
227
+ // 来源引用:这条是从哪来的。允许的形态是可反查的(m00001 这类消息编号、
228
+ // 文件:行号、URL),不接受「对话里说过」这种不可核对的表述。
229
+ sourceRef: z.string().optional(),
230
+ // 被哪条取代(state=superseded 时指向新条目 id)。留空表示「不知道被什么取代」。
231
+ supersededBy: z.string().optional(),
232
+ // 上次被检索到的时间。
233
+ //
234
+ // ⚠️ 如实记录:这个字段目前是**预留**的 —— 全库 grep 只有这一行提到它,
235
+ // 没有任何代码写入、也没有任何代码读取。21 条存量记忆里它 100% 为空。
236
+ //
237
+ // 为什么留着而不是删掉:它是 optional,删与留都不影响存量记录能否读出;
238
+ // 留着是为了将来若要改成「按最近使用时间衰减」时不必动 schema。
239
+ //
240
+ // 为什么不该继续按原来的写法注释:原文写「用于『用就提升』」和「给用户一个
241
+ // 可查的痕迹:这条到底有没有被用过」—— 那两句描述的是不存在的行为。
242
+ // 而「这条有没有被用上」现在另有答案:selectForInjection 每次渲染都产出
243
+ // decisions[],每条带 included 与 reason,设置页的注入面板直接展示它。
244
+ // 那才是实时答案;这个字段即使将来真写上了,也只是一份会过期的副本。
245
+ lastUsedAt: z.string().optional()
246
+ })
247
+
248
+ // meta 表存的**不是**一个固定形状的对象,而是若干条异构记录:
249
+ // key='triage' → 整理记录(lastTriage* 那组)
250
+ // key='patrol' → 巡夜记录(at/signature/reported… 那组)
251
+ // 而 domainTable(valueSchema) 的 schema 是**逐条记录**校验的,所以这个 schema 必须
252
+ // 同时容纳两类记录的形状 —— 字段全部 optional,具体是哪一类由**键**区分。
253
+ //
254
+ // 真实缺陷(2026-10-24 发现;从巡夜上线起一直没生效,只是没人看得出):
255
+ // 原来 metaSchema 只声明了 lastTriage* 三个字段,于是读回 patrol 记录时
256
+ // zod 把整条剥成 {}。写入是原样落盘的(anima.json 里数据完整),只有读回才被剥 ——
257
+ // 这就是「文件里有、接口里没有」的来源。两处后果:
258
+ // ① /api/anima/state 的 patrolMeta 恒为空 → 验收项 live-patrol 报 FAIL;
259
+ // ② 更要命:runPatrol 自己读 previous.signature 也是空的 ⇒ 每次巡夜都当成
260
+ // 「头一回」,判据退化成「每次都算新事」,每次重启后必多报一次。
261
+ // 一度试过把 patrol 写成 metaSchema 的子键 `patrol: z.object({...})` ——
262
+ // 那是错的:schema 校验的是记录值本身,不是「值里某个叫 patrol 的字段」。
263
+ // 加字段只放宽不收紧,且全部 optional ⇒ 旧介质照读,不必升 DOMAIN_VERSION。
264
+ const metaSchema = z.object({
265
+ // ---- 整理记录(key = 'triage')----
266
+ lastTriageDay: z.string().optional(),
267
+ lastTriageAt: z.string().optional(),
268
+ lastTriageSummary: z.object({
269
+ ranAt: z.string().optional(),
270
+ pendingBefore: z.number().optional(),
271
+ pendingAfter: z.number().optional(),
272
+ duplicatesRemoved: z.number().optional(),
273
+ overflowRemoved: z.number().optional(),
274
+ discardedTotal: z.number().optional(),
275
+ adoptedTotal: z.number().optional()
276
+ }).optional(),
277
+ // ---- 巡夜记录(key = 'patrol')----
278
+ at: z.string().optional(),
279
+ day: z.string().optional(),
280
+ reason: z.string().optional(),
281
+ signature: z.string().optional(),
282
+ reported: z.boolean().optional(),
283
+ wouldReport: z.boolean().optional(),
284
+ delivered: z.number().optional(),
285
+ reasons: z.array(z.string()).optional(),
286
+ facts: z.object({
287
+ pending: z.number().optional(),
288
+ flagged: z.number().optional(),
289
+ removed: z.number().optional()
290
+ }).optional(),
291
+ // added / cleared 是**计数**(lib/patrol.mjs 里取的是 .length),不是 id 数组。
292
+ // 上一版曾误写成 z.array(z.string()),那样真实数据一读就会 invalid-record。
293
+ added: z.object({
294
+ pending: z.number().optional(),
295
+ flagged: z.number().optional()
296
+ }).nullable().optional(),
297
+ cleared: z.object({
298
+ pending: z.number().optional(),
299
+ flagged: z.number().optional()
300
+ }).nullable().optional(),
301
+ checked: z.number().optional(),
302
+ // 体检明细(2026-10-25 加)。为什么必须在这里一起声明,而不是先写到文件里再补:
303
+ // 上一次的 fetchTables 教训就是「写入照落盘、读回被 zod 剥空」,于是界面上
304
+ // 明明有数据却一个字都不显示。加字段时**schema 和写入必须同一轮提交**。
305
+ flaggedItems: z.array(z.object({
306
+ id: z.string(),
307
+ detail: z.string().optional(),
308
+ evidence: z.string().optional(),
309
+ kinds: z.array(z.string()).optional()
310
+ })).nullable().optional(),
311
+ // 超过上限没落盘的条数。**这个数不能丢**:不然「只有 20 条问题」和
312
+ // 「有 33 条问题但只显示了 20 条」在界面上长得一模一样。
313
+ flaggedDropped: z.number().optional(),
314
+ // ---- 质检运行计数(key = 'qualityStats')----
315
+ //
316
+ // 为什么它必须落盘:这几个计数是「质检层到底活着没有」的唯一证据。
317
+ // 只放内存的后果是**每次重启都归零**,而用户重装系统、重启编辑器、
318
+ // 甚至只是关一下窗口都会触发重启 —— 于是「已查 0 次」这个数字既可能
319
+ // 是「真的没查过」,也可能是「查了几百次,刚重启」。两种完全相反的事实
320
+ // 长得一模一样,这正是这套计数器本来要消灭的那种沉默。
321
+ //
322
+ // 写入策略刻意不是「每步都写」:pre-step 每一步都跑,每步落盘等于
323
+ // 把磁盘当计数器用。改为脏标记 + 节流 flush(见 index.mjs 的
324
+ // scheduleQualityStatsFlush),插件卸载与查询时强制 flush 一次。
325
+ checks: z.number().optional(),
326
+ reminders: z.number().optional(),
327
+ suppressed: z.number().optional(),
328
+ skipped: z.record(z.string(), z.number()).optional(),
329
+ startedAt: z.string().optional(),
330
+ flushedAt: z.string().optional(),
331
+ // ---- 回扫式复盘记录(key = 'retrospect')----
332
+ //
333
+ // 与 patrol 同一纪律:**无论说不说都写一条**。只报有事的话,
334
+ // 「没有复盘记录」既可能是「扫过没事」也可能是「压根没扫」,两种相反的
335
+ // 事实长得一模一样 —— 而消除这种沉默正是这套记录存在的理由。
336
+ //
337
+ // leads 必须是 `compactLeads` 产出的三字段形状(key / kind / text)。
338
+ // 直接塞全量线索对象会被 zod 剥成 {},这正是 2026-10-24 那个 bug 的形状:
339
+ // 写入照落盘、读回被剥空,界面上有数据却一个字都不显示。
340
+ scannedAt: z.string().optional(),
341
+ scannedDays: z.number().optional(),
342
+ // files / messages / found 三个数必须允许 null:扫不成的那些轮次
343
+ // 它们的值**未知**,写成 0 就变成了「扫了 0 个会话、0 条发言、0 条线索」,
344
+ // 和「扫过没事」长得一模一样。null 才说得清「这次没扫成」。
345
+ files: z.number().nullable().optional(),
346
+ messages: z.number().nullable().optional(),
347
+ found: z.number().nullable().optional(),
348
+ leadKeys: z.array(z.string()).optional(),
349
+ leads: z.array(z.object({
350
+ key: z.string(),
351
+ kind: z.string(),
352
+ text: z.string()
353
+ })).nullable().optional(),
354
+ // leadsAt 是「这批明细是什么时候找到的」,与 scannedAt(上次扫描时刻)
355
+ // 必须分开:没有新线索的轮次会保留旧明细,界面要能说清它有多旧。
356
+ leadsAt: z.string().nullable().optional(),
357
+ // leadsDropped 同理要允许 null:失败轮次里「丢了几条没列出」也无从得知,
358
+ // 写成 0 会让人以为「这轮确实一条都没多」。
359
+ leadsDropped: z.number().nullable().optional(),
360
+ delivered: z.number().optional(),
361
+ error: z.string().optional()
362
+ })
363
+
364
+ // 人设变更审计日志。独立成表而不是塞进 config:
365
+ // config 是每次启动都要整条读入内存的记录,审计日志会随时间增长到几百条,
366
+ // 混在一起等于让启动开销随使用时长上升。
367
+ const auditSchema = z.object({
368
+ id: z.string(),
369
+ at: z.string(),
370
+ actor: z.enum(['user', 'agent', 'system']),
371
+ source: z.string(),
372
+ note: z.string().optional(),
373
+ sessionId: z.string().optional(),
374
+ changes: z.array(z.object({
375
+ key: z.string(),
376
+ from: z.union([z.string(), z.number(), z.boolean(), z.null()]).optional(),
377
+ to: z.union([z.string(), z.number(), z.boolean(), z.null()]).optional()
378
+ }))
379
+ })
380
+
381
+ try {
382
+ // spec 的构造也要放进 try:defineDomain 自身会做域名/表名校验,
383
+ // 放在 try 外会让一次词汇表拒绝直接冒泡成 500 而不是带 code 的 503。
384
+ const tableSpecs = {
385
+ config: domainTable(configSchema),
386
+ memories: domainTable(memorySchema),
387
+ meta: domainTable(metaSchema),
388
+ audit: domainTable(auditSchema)
389
+ }
390
+
391
+ // 单一 spec 打开。曾经这里有一段「主 spec → 兼容 spec」的串行回退,用来读旧版
392
+ // 介质;未发布 ⇒ 不存在那种介质,回退已删(理由见 DOMAIN_VERSION 处注释)。
393
+ const domain = await storageDomain.open(defineDomain({
394
+ name: DOMAIN_NAME,
395
+ version: DOMAIN_VERSION,
396
+ tables: tableSpecs
397
+ }))
398
+
399
+ ctx.effect(() => () => { void domain.close() }, 'dsh-anima.domain.close')
400
+ const backend = {
401
+ kind: 'storage',
402
+ // 域打开时的版本戳。它恒等于 DOMAIN_VERSION(已无兼容回退),保留这个字段是
403
+ // 为了让 /state 下发给设置页与 check-live —— 没有它,界面上就只剩一个「存储位置」,
404
+ // 用户无法判断插件到底往哪份介质里读写。
405
+ domainVersion: DOMAIN_VERSION,
406
+ table: (name) => domain.table(name),
407
+ close: () => domain.close()
408
+ }
409
+ // 到这里域已打开、四张表就绪。曾经这里接着跑三条一次性迁移(改名前的旧域搬迁、
410
+ // 更早的文件后端导入、从另一个插件继承「工具改人设需确认」);插件未发布 ⇒
411
+ // 不存在需要兼容的旧介质或旧文件,三条连同它们的常量一并删除。
412
+ // 若将来真要迁移:只在新域 config 记录不存在时动手,且自己的任何失败都只能记
413
+ // warn —— 这段代码在下面的 try 块里,外抛会被包成新域的 open-failed,
414
+ // 让一个本该正常的插件停摆。
415
+ return backend
416
+ } catch (err) {
417
+ if (err instanceof StorageUnavailableError) throw err
418
+ throw new StorageUnavailableError(
419
+ 'open-failed',
420
+ `opening the ${DOMAIN_NAME} storage domain failed: ${String(err?.message || err)}`,
421
+ { cause: err }
422
+ )
423
+ }
424
+ }
425
+
426
+ // ==================== 统一门面 ====================
427
+
428
+ /**
429
+ * StateStore:全局配置与记忆的唯一入口。
430
+ *
431
+ * 后端在**首次真正调用**时才打开(惰性),失败**不记忆化** —— storageDomain
432
+ * 可能在本插件之后才 provide,一次失败就永久固守等于让插件再无恢复机会
433
+ * (这正是 2026-10-10 冷启动退化的固化机制,必须去掉)。
434
+ */
435
+ export class StateStore {
436
+ constructor(ctx, logger) {
437
+ this.ctx = ctx
438
+ this.logger = logger
439
+ this._backendPromise = null
440
+ this._backend = null
441
+ this._error = null
442
+ this._listeners = new Set()
443
+ }
444
+
445
+ get srcKind() {
446
+ return this._backend?.kind ?? null
447
+ }
448
+
449
+ get srcPath() {
450
+ return this._backend?.path ?? null
451
+ }
452
+
453
+ /**
454
+ * 域打开时使用的版本戳(可用时为 null)。
455
+ *
456
+ * 已无兼容模式:它现在恒等于 DOMAIN_VERSION。保留这个字段是因为设置页与
457
+ * check-live 都靠它确认「插件真的把域打开了」——介质版本高于当前版本时
458
+ * (用户装回旧插件、介质是新插件写的)open 会失败,此时这里是 null。
459
+ */
460
+ get srcDomainVersion() {
461
+ return this._backend?.domainVersion ?? null
462
+ }
463
+
464
+ /**
465
+ * 当前不可用的机器可读原因(可用时为 null)。
466
+ * 暴露给 /state 与 check-live —— 2026-10-10 那次无声退化,就是因为配置里
467
+ * 只有一个 kind:'file' 可看,没有任何线索指向「哪一步没就绪」。
468
+ */
469
+ get srcReason() {
470
+ return this._error?.code ?? null
471
+ }
472
+
473
+ /** 不可用时的完整说明(给人看;可用时为 null)。 */
474
+ get srcMessage() {
475
+ return this._error?.message ?? null
476
+ }
477
+
478
+ async _open() {
479
+ if (this._backend) return this._backend
480
+ let promise = this._backendPromise
481
+ if (!promise) {
482
+ promise = (async () => {
483
+ const backend = await openStorageBackend(this.ctx, this.logger)
484
+ this._backend = backend
485
+ this._error = null
486
+ return backend
487
+ })()
488
+ this._backendPromise = promise
489
+ // 失败不固化:清掉缓存,下一次调用重新尝试解析 storageDomain。
490
+ // 这个 catch 同时消费掉 rejection,避免 Node 报 unhandled rejection。
491
+ promise.catch(() => {
492
+ if (this._backendPromise === promise) this._backendPromise = null
493
+ })
494
+ }
495
+ try {
496
+ return await promise
497
+ } catch (err) {
498
+ const wrapped = err instanceof StorageUnavailableError
499
+ ? err
500
+ : new StorageUnavailableError('open-failed', String(err?.message || err), { cause: err })
501
+ this._error = wrapped
502
+ throw wrapped
503
+ }
504
+ }
505
+
506
+ /** 存储是否已就绪(供 index.mjs 决定提示词注入与否)。 */
507
+ get ready() {
508
+ return this._backend !== null
509
+ }
510
+
511
+ /** 订阅变更(写成功后被调用,用于触发提示词刷新)。 */
512
+ onChanged(listener) {
513
+ this._listeners.add(listener)
514
+ return () => this._listeners.delete(listener)
515
+ }
516
+
517
+ /**
518
+ * 取得裸表(meta 等边角数据用)。
519
+ * 注意:这是异步的 —— 首次调用会真正打开后端,可能抛 StorageUnavailableError。
520
+ */
521
+ async table(name) {
522
+ const backend = await this._open()
523
+ return backend.table(name)
524
+ }
525
+
526
+ _emit() {
527
+ for (const listener of this._listeners) {
528
+ try { listener() } catch { /* 监听者异常不影响写入方 */ }
529
+ }
530
+ }
531
+
532
+ // ---- 配置 ----
533
+
534
+ /** 读取全局配置(已迁移归一)。存储不可用时抛 StorageUnavailableError。 */
535
+ async getConfig() {
536
+ const backend = await this._open()
537
+ const table = backend.table('config')
538
+ const raw = await table.get(CONFIG_KEY)
539
+ return migrateConfig(raw)
540
+ }
541
+
542
+ /**
543
+ * 合并写入全局配置。
544
+ *
545
+ * @param {object} patch 已通过 sanitizeConfig 的补丁
546
+ * @param {object} [attribution] {actor, source, note, sessionId} —— 谁改的、从哪改的。
547
+ * 审计日志挂在**这一个**收口上,是因为所有写入路径(设置页 / 命令 / 模型工具 /
548
+ * 预设 / 提议确认)最终都走这里;在各自的调用点写日志,迟早会漏掉一条路径,
549
+ * 而漏掉的那条恰恰可能是模型的静默改写。
550
+ * @returns {{config: object, changed: string[]}}
551
+ */
552
+ async updateConfig(patch, attribution = {}) {
553
+ const backend = await this._open()
554
+ const table = backend.table('config')
555
+ const current = migrateConfig(await table.get(CONFIG_KEY))
556
+ const next = migrateConfig({
557
+ ...current,
558
+ ...patch,
559
+ // 子对象必须逐个深合并:patch 里只带 `quality.enabled` 时,若直接展开,
560
+ // 用户先前设的 ledgerLevel / maxFindingsInReminder 会在 migrateConfig
561
+ // 里被默认值填回 —— 表现是「改一个开关,另三个设置被悄悄重置」。
562
+ // memory 一直是这么处理的;新增子对象时容易只加 DEFAULT_CONFIG 忘了这里,
563
+ // 故此处显式列出,并在 lib/config.mjs 的子对象段落了同一条注释。
564
+ memory: { ...current.memory, ...(patch.memory ?? {}) },
565
+ quality: { ...current.quality, ...(patch.quality ?? {}) }
566
+ })
567
+ const changed = diffConfig(current, next)
568
+ if (changed.length === 0) return { config: current, changed }
569
+ await table.put(CONFIG_KEY, next)
570
+ await this._appendAudit(current, next, changed, attribution)
571
+ this._emit()
572
+ return { config: next, changed }
573
+ }
574
+
575
+ /**
576
+ * 写一条审计记录(内部用)。
577
+ * 审计失败**不得**影响配置写入本身:配置已经落盘了,此时抛错会让调用方
578
+ * 以为「没改成」而重试,反而制造混乱。失败只记一条 warn。
579
+ */
580
+ async _appendAudit(before, after, changedKeys, attribution = {}) {
581
+ try {
582
+ const entry = makeAuditEntry({
583
+ changes: buildAuditChanges(before, after, changedKeys),
584
+ actor: attribution.actor,
585
+ source: attribution.source,
586
+ note: attribution.note,
587
+ sessionId: attribution.sessionId
588
+ })
589
+ if (!entry) return null
590
+ await this.appendAudit(entry)
591
+ return entry
592
+ } catch (err) {
593
+ this.logger?.warn?.(`[anima] 审计日志写入失败(配置已保存):${String(err?.message || err)}`)
594
+ return null
595
+ }
596
+ }
597
+
598
+ // ---- 审计日志 ----
599
+
600
+ /** 读取全部审计记录,按时间升序。 */
601
+ async listAudit() {
602
+ const backend = await this._open()
603
+ const table = backend.table('audit')
604
+ const out = []
605
+ for (const [, value] of table.entries()) out.push(value)
606
+ return out.sort((a, b) => String(a?.at ?? '').localeCompare(String(b?.at ?? '')))
607
+ }
608
+
609
+ /** 追加一条审计记录,并在超限时环形淘汰最早的记录。 */
610
+ async appendAudit(entry) {
611
+ const backend = await this._open()
612
+ const table = backend.table('audit')
613
+ await table.put(entry.id, entry)
614
+ // 环形淘汰:审计日志无界增长最终会拖垮存储与 /state 响应。
615
+ // 淘汰放在写入之后而不是每次先查总数 —— 多一次遍历换一个简单的不变量。
616
+ const all = []
617
+ for (const [key] of table.entries()) all.push(key)
618
+ if (all.length > AUDIT_MAX_RECORDS) {
619
+ const records = await this.listAudit()
620
+ const { kept, dropped } = trimAudit(records, AUDIT_MAX_RECORDS)
621
+ if (dropped > 0) {
622
+ const keepIds = new Set(kept.map((r) => r.id))
623
+ for (const key of all) {
624
+ if (!keepIds.has(key)) await table.delete(key)
625
+ }
626
+ }
627
+ }
628
+ return entry
629
+ }
630
+
631
+ /** 清空审计日志;返回删除条数。 */
632
+ async clearAudit() {
633
+ const backend = await this._open()
634
+ const table = backend.table('audit')
635
+ const keys = []
636
+ for (const [key] of table.entries()) keys.push(key)
637
+ let removed = 0
638
+ for (const key of keys) {
639
+ if (await table.delete(key)) removed += 1
640
+ }
641
+ return removed
642
+ }
643
+
644
+ /**
645
+ * 重置为默认配置,但保留用户自建的人设预设。
646
+ *
647
+ * 同类插件在重置时也保留用户自建的人设预设,我们与之对齐:
648
+ * 预设是用户一条条攒出来的资产,重置语气设置不该连它一起清掉。
649
+ * (本插件最初漏了这一步,2026-10-10 的事故才暴露出来。)
650
+ */
651
+ async resetConfig(attribution = {}) {
652
+ const backend = await this._open()
653
+ const table = backend.table('config')
654
+ const current = migrateConfig(await table.get(CONFIG_KEY))
655
+ const next = migrateConfig({ presets: current.presets ?? [] })
656
+ await table.put(CONFIG_KEY, next)
657
+ // 重置也要留痕:否则「昨天还好好的,今天全空了」在日志里查无此事,
658
+ // 而这恰恰是最需要回头确认的一次变更。
659
+ const changed = diffConfig(current, next)
660
+ await this._appendAudit(current, next, changed, { source: 'reset', ...attribution })
661
+ this._emit()
662
+ // 把变更清单一起交出去:调用方要靠它决定「要不要向活动会话声明旧设定作废」,
663
+ // 重置又是最该声明的一次(三个字段一起没了,对话历史里却还留着)。
664
+ return { config: next, changed }
665
+ }
666
+
667
+ // ---- 人设预设 ----
668
+
669
+ /**
670
+ * 读取用户自建预设(内置预设不落盘,由 lib/presets.mjs 提供)。
671
+ * @returns {Promise<Array<object>>}
672
+ */
673
+ async listCustomPresets() {
674
+ const config = await this.getConfig()
675
+ return Array.isArray(config.presets) ? config.presets : []
676
+ }
677
+
678
+ /**
679
+ * 整表替换自建预设。
680
+ * 用「整表替换」而非逐条增删:预设之间只有几十字节,整表写入天然原子,
681
+ * 不必为并发删除/改名维护一套索引一致性。
682
+ */
683
+ async putCustomPresets(presets) {
684
+ return this.updateConfig({ presets: Array.isArray(presets) ? presets : [] })
685
+ }
686
+
687
+ // ---- 记忆 ----
688
+
689
+ /** 读取全部记忆记录(含 pending/adopted/discarded)。 */
690
+ async listMemories() {
691
+ const backend = await this._open()
692
+ const table = backend.table('memories')
693
+ const out = []
694
+ for (const [, value] of table.entries()) out.push(value)
695
+ return out
696
+ }
697
+
698
+ async getMemory(id) {
699
+ const backend = await this._open()
700
+ return backend.table('memories').get(id)
701
+ }
702
+
703
+ async putMemory(record) {
704
+ const backend = await this._open()
705
+ await backend.table('memories').put(record.id, record)
706
+ this._emit()
707
+ return record
708
+ }
709
+
710
+ async deleteMemory(id) {
711
+ const backend = await this._open()
712
+ const ok = await backend.table('memories').delete(id)
713
+ if (ok) this._emit()
714
+ return ok
715
+ }
716
+
717
+ /** 批量删除(用于缓冲区淘汰)。 */
718
+ async deleteMemories(ids) {
719
+ let removed = 0
720
+ for (const id of ids) {
721
+ if (await this.deleteMemory(id)) removed += 1
722
+ }
723
+ return removed
724
+ }
725
+ }
726
+
727
+ /** 逐字段比较,返回发生变化的字段名(memory 记为 memory.<key>)。 */
728
+ export function diffConfig(a, b) {
729
+ const changed = []
730
+ for (const key of [
731
+ 'enabled', 'agentName', 'agentRole', 'addressUserAs',
732
+ 'userOccupation', 'userBio',
733
+ 'style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language',
734
+ 'customInstructions',
735
+ // 工具确认门槛与光轨四项:不参与提示词,但必须计入 diff ——
736
+ // 否则设置页改了光轨颜色、宿主这边判「无变化」不落盘,用户会以为开关失灵。
737
+ 'requireToolConfirmation', 'trailEnabled', 'trailColor', 'trailSpeed', 'trailWidth',
738
+ 'workspaceOverridesEnabled', 'workspaceFileDir'
739
+ ]) {
740
+ if (a?.[key] !== b?.[key]) changed.push(key)
741
+ }
742
+ // 预设是数组,逐条比较代价高且无必要:只要首尾/长度变了就当变更,
743
+ // 提示词重注册是幂等的,多刷一次没有副作用。
744
+ if (JSON.stringify(a?.presets ?? []) !== JSON.stringify(b?.presets ?? [])) changed.push('presets')
745
+ for (const key of Object.keys(b?.memory ?? {})) {
746
+ if (a?.memory?.[key] !== b?.memory?.[key]) changed.push(`memory.${key}`)
747
+ }
748
+ // 子对象必须逐个登记。漏掉一个的后果不是「报错」而是**静默不写盘**:
749
+ // updateConfig 见 changed 为空就直接 return,用户改了设置页却什么都没发生。
750
+ // 2026-10-19 的 quality 就是这么漏的(只写了 memory 那一段)。
751
+ for (const key of Object.keys(b?.quality ?? {})) {
752
+ if (a?.quality?.[key] !== b?.quality?.[key]) changed.push(`quality.${key}`)
753
+ }
754
+ return changed
755
+ }