dsh-soul 0.7.2 → 0.8.0

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.
@@ -0,0 +1,593 @@
1
+ // lib/history.mjs — dsh-soul 变更审计(append-only JSONL + 回滚规划,无 cordis 依赖)
2
+ //
3
+ // 职责:
4
+ // - 把一次配置变更写成一个条目(时间戳 / 来源 / 动作 / 字段级 before→after / 预设库事件)
5
+ // - 读取记录文件,并把「不可解析的行」「超长行」「过大的文件」「截断的尾行」如实报告出来
6
+ // - 规划回滚:从当前配置逐条反向还原到目标条目**之后**的状态
7
+ //
8
+ // 四条设计前提,改本模块前请先读:
9
+ //
10
+ // 1. 记录文件是**不可信输入**。用户可以手改、别的工具可以写坏、进程可以在写入中途被杀。
11
+ // 所以读侧的每一行都要重新校验,非法行丢弃并计数 —— 绝不让一条坏行把整个列表打不开。
12
+ // 2. 条目只存**发生变化的字段**,不是完整快照。一次 style 改动不该在磁盘上留下 4.8 KB,
13
+ // 而且 `professional → humorous` 比两份快照更适合人读。代价是回滚必须**逐条反向还原**
14
+ // (见 planRollback),因此每条都带一份 configHash 作为链式指纹。
15
+ // 3. 指纹只覆盖 AUDIT_FIELDS 这一冻结投影。配了很多字段却不算变更(见 AUDIT_FIELDS 注释)
16
+ // 的原因必须记在这里:哈希一旦把「无关字段」算进去,回滚会因为一次外观调整而拒绝工作。
17
+ // 4. 本模块只负责如实报告,不替调用方决定要不要回退 —— 与 lib/store.mjs 同一条约定,
18
+ // 可被 scripts/verify-history.mjs 独立加载并针对真实文件系统测试。
19
+
20
+ import { appendFile, mkdir, open, rename, rm, stat, writeFile } from 'node:fs/promises'
21
+ import { createHash, randomBytes } from 'node:crypto'
22
+ import { dirname } from 'node:path'
23
+ import { DEFAULT_CONFIG, PERSONA_FIELDS, PERSONA_META_KEYS, PERSONA_NAME_MAX, sanitizeConfig } from './config.mjs'
24
+
25
+ // ==================== 契约常量 ====================
26
+
27
+ // 条目格式版本。读到 `v !== HISTORY_VERSION` 的条目一律丢弃(而不是猜测字段语义):
28
+ // 升级后旧条目的含义无法保证,宁可少一条记录,也不能按错的方式还原配置。
29
+ export const HISTORY_VERSION = 1
30
+
31
+ // 来源(UI 上的「谁」徽标)与动作(UI 上的「做了什么」)。两者都是**枚举白名单**而不是
32
+ // 正则:写错一个来源标签应当在断言里当场变红,而不是安静地存进文件、等 UI 显示空白。
33
+ export const HISTORY_SOURCES = ['web', 'command', 'tool', 'service', 'persona', 'rollback', 'restorePersona']
34
+ export const HISTORY_ACTIONS = [
35
+ 'save',
36
+ 'reset',
37
+ 'use',
38
+ 'savePreset',
39
+ 'deletePreset',
40
+ 'restorePreset',
41
+ 'rollback',
42
+ 'nickname',
43
+ 'enable',
44
+ 'disable',
45
+ 'set',
46
+ 'confirm',
47
+ 'setPersona',
48
+ 'update'
49
+ ]
50
+
51
+ // 保留条目数上限(默认值)。压实只丢**最老的**,于是保留下来的永远是完整日志的
52
+ // 一个后缀 —— 这是 planRollback 能安全工作的前提(见那里的注释)。
53
+ // 实际上限由配置字段 historyMaxEntries 决定(clampHistoryMaxEntries 夹在 50–500),
54
+ // 这里只作调用方未传时的兜底;数值必须与 lib/config.mjs 的
55
+ // HISTORY_MAX_ENTRIES_DEFAULT 一致,由 verify-config 钉住。
56
+ export const MAX_ENTRIES = 200
57
+
58
+ // 触发压缩的字节阈值。幸福路径只有一次 stat,压缩是低频动作。
59
+ export const MAX_BYTES = 512 * 1024
60
+
61
+ // 单行上限。超长行直接丢弃(否则一份手写的畸形文件可以把内存吃满)。
62
+ export const MAX_LINE_BYTES = 64 * 1024
63
+
64
+ // 单次读取上限:文件大于它时只读**尾部**这么多字节,再丢掉被截断的首行。
65
+ // 不能直接 readFile —— 用户手改出一个 200 MB 的文件就能把宿主进程拖死。
66
+ export const MAX_READ_BYTES = 4 * 1024 * 1024
67
+
68
+ // 单次解析行数上限(兜底,防「很多条短行」这种绕过字节上限的形状)。
69
+ // 注意是保留**尾部**行:日志的价值集中在最近,从头截断会把最该看到的几条丢掉。
70
+ export const MAX_READ_LINES = 1000
71
+
72
+ // ★ AUDIT_FIELDS:参与指纹与差分的字段全集,从 DEFAULT_CONFIG 推导且**只算一次**。
73
+ //
74
+ // 为什么必须是一个冻结的投影而不是「配置对象里除 personas 外的所有键」:
75
+ // commitConfig 的 mutate 收到的 `current` 是 loadConfig 的产物,可能夹带内存态字段
76
+ // (缓存、派生值……)。若把这些也算进 configHash,一次「不影响任何持久字段」的内部
77
+ // 改动就会改动哈希 —— 表现为回滚突然报 diverged,而用户什么都没做。
78
+ // 反过来,从 DEFAULT_CONFIG 推导而不是另抄一份,是为了让将来加字段时两边不会漂移
79
+ // (漂移的表现是「新字段的变更静默丢失」,极难发现)。
80
+ //
81
+ // personas **刻意不在其中**:预设库的变化单独记为事件(见 diffPersonas),
82
+ // 且不参与指纹。否则「先在预设库里存一个,再撤销一次 style 改动」会因哈希不匹配
83
+ // 而拒绝回滚 —— 两件毫不相干的事互相干扰。
84
+ const AUDIT_FIELDS = Object.freeze(Object.keys(DEFAULT_CONFIG).sort())
85
+
86
+ // 原型污染键:出现在 before/after/personas 里一律丢弃。
87
+ // 注意与 lib/config.mjs 的 FORBIDDEN_PERSONA_NAMES 是**两处**独立防线 ——
88
+ // 那边管预设名,这边管配置字段与预设事件名,谁也不该依赖对方。
89
+ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
90
+
91
+ const AUDIT_FIELD_SET = new Set(AUDIT_FIELDS)
92
+ const ID_PATTERN = /^[0-9a-z]{1,12}-[0-9a-f]{6}$/
93
+ const HASH_PATTERN = /^[0-9a-f]{64}$/
94
+
95
+ function isPlainObject(value) {
96
+ return !!value && typeof value === 'object' && !Array.isArray(value)
97
+ }
98
+
99
+ export function auditFields() {
100
+ return AUDIT_FIELDS.slice()
101
+ }
102
+
103
+ // ==================== 路径与 id ====================
104
+
105
+ // 记录文件是配置文件的**兄弟文件**:$DSH_HOME/soul-config.json → $DSH_HOME/soul-config.history.jsonl
106
+ export function historyPathFor(configPath) {
107
+ const path = String(configPath || '')
108
+ if (path.endsWith('.json')) return `${path.slice(0, -'.json'.length)}.history.jsonl`
109
+ return `${path}.history.jsonl`
110
+ }
111
+
112
+ // 6 位随机十六进制。毫秒时间戳不足以保证唯一(写队列虽然串行,但同一毫秒内完成两次
113
+ // 写入完全正常),所以随机段是必需的,不是装饰。
114
+ export function randomSuffix() {
115
+ return randomBytes(3).toString('hex')
116
+ }
117
+
118
+ export function createEntryId(nowMs, suffix) {
119
+ return `${Number(nowMs).toString(36)}-${suffix}`
120
+ }
121
+
122
+ // ==================== 指纹 ====================
123
+
124
+ // 深度按键名排序,供哈希前做规范序列化。
125
+ //
126
+ // 为什么必须排序:JSON.stringify 的键序取决于对象属性的插入顺序,而配置对象在
127
+ // 「写入时构造」与「从磁盘读回后重建」两条路径上的键序不保证一致。不排序的话,
128
+ // 同一份配置会算出两个指纹,链式校验就会把完全正常的状态判成「不一致」。
129
+ function canonicalize(value) {
130
+ if (Array.isArray(value)) return value.map(canonicalize)
131
+ if (isPlainObject(value)) {
132
+ const out = {}
133
+ for (const key of Object.keys(value).sort()) out[key] = canonicalize(value[key])
134
+ return out
135
+ }
136
+ return value
137
+ }
138
+
139
+ // 配置指纹:链式校验的判据(见 planRollback)。只覆盖 AUDIT_FIELDS 这一投影,
140
+ // 因此「与审计无关的键」无论怎么变都不会动摇链条。
141
+ export function hashConfig(config) {
142
+ const source = isPlainObject(config) ? config : {}
143
+ const projection = {}
144
+ for (const key of AUDIT_FIELDS) {
145
+ if (Object.prototype.hasOwnProperty.call(source, key)) projection[key] = source[key]
146
+ }
147
+ return createHash('sha256').update(JSON.stringify(canonicalize(projection))).digest('hex')
148
+ }
149
+
150
+ // ==================== 差分 ====================
151
+
152
+ // 活动配置的字段级差分(跳过 personas —— 它由 diffPersonas 单独处理)。
153
+ // 与 index.mjs 的 diffKeys 分工不同:那个只回答「有没有变、要不要刷新提示词」,
154
+ // 这个要留下「从什么变成什么」。
155
+ export function diffFields(previous, next) {
156
+ const before = {}
157
+ const after = {}
158
+ const from = isPlainObject(previous) ? previous : {}
159
+ const to = isPlainObject(next) ? next : {}
160
+ for (const key of AUDIT_FIELDS) {
161
+ if (from[key] === to[key]) continue
162
+ // 「切到 undefined」无法表达(JSON 里会凭空消失),记下来只会在回滚时误导,
163
+ // 所以两边都缺席时直接不记。
164
+ if (from[key] === undefined && to[key] === undefined) continue
165
+ if (from[key] !== undefined) before[key] = from[key]
166
+ if (to[key] !== undefined) after[key] = to[key]
167
+ }
168
+ return { before, after }
169
+ }
170
+
171
+ // 预设库白名单过滤:只留 PERSONA_FIELDS 内的人设字段与 PERSONA_META_KEYS 里的元数据。
172
+ // 复用 lib/config.mjs 的白名单 —— 记录里的预设内容最终会被写回磁盘,
173
+ // 过滤规则必须与磁盘归一化完全一致,否则「恢复此预设」会恢复出一个
174
+ // normalizePersonas 下次读盘时会悄悄削掉的东西。
175
+ function pickPersonaFields(raw) {
176
+ const out = {}
177
+ if (!isPlainObject(raw)) return out
178
+ for (const key of Object.keys(raw)) {
179
+ if (FORBIDDEN_KEYS.has(key)) continue
180
+ if (PERSONA_FIELDS.includes(key) || PERSONA_META_KEYS.has(key)) out[key] = raw[key]
181
+ }
182
+ return out
183
+ }
184
+
185
+ // 预设「内容」的指纹:只比 PERSONA_FIELDS,**刻意不比 updatedAt**。
186
+ //
187
+ // updatedAt 每次保存都会变。把它算进来,用户「原样再存一遍」就会留下一条
188
+ // 「覆盖了预设 X」的记录 —— 而覆盖之所以要留底,是因为它会丢掉原内容;
189
+ // 内容没丢就不该报。假记录比漏记更糟:它会让时间线失去可信度。
190
+ function personaSignature(raw) {
191
+ const content = {}
192
+ if (isPlainObject(raw)) {
193
+ for (const key of PERSONA_FIELDS) {
194
+ if (Object.prototype.hasOwnProperty.call(raw, key)) content[key] = raw[key]
195
+ }
196
+ }
197
+ return JSON.stringify(canonicalize(content))
198
+ }
199
+
200
+ // 预设库事件:新增 / 覆盖 / 删除。
201
+ //
202
+ // 为什么不能省:index.mjs 的 diffKeys 里有 `if (key === 'personas') continue`(那是刻意的
203
+ // —— 预设库不是活动配置、不该触发提示词刷新)。于是预设的增删改**不会**出现在 changed 里,
204
+ // 审计必须显式记录,否则「新增了一个预设」会静默漏记。
205
+ //
206
+ // 为什么覆盖(updated)也要带原内容:删除是明显的不可逆操作,覆盖则容易被人忽略 ——
207
+ // 「存为预设」用一个已存在的名字,原条目就没了。二者都只有这里留底。
208
+ export function diffPersonas(previous, next) {
209
+ const prev = isPlainObject(previous) ? previous : {}
210
+ const nxt = isPlainObject(next) ? next : {}
211
+ const added = []
212
+ const updated = []
213
+ const removed = []
214
+ for (const name of Object.keys(nxt)) {
215
+ if (FORBIDDEN_KEYS.has(name)) continue
216
+ if (!Object.prototype.hasOwnProperty.call(prev, name)) added.push(name)
217
+ else if (personaSignature(prev[name]) !== personaSignature(nxt[name])) {
218
+ updated.push({ name, entry: pickPersonaFields(prev[name]) })
219
+ }
220
+ }
221
+ for (const name of Object.keys(prev)) {
222
+ if (FORBIDDEN_KEYS.has(name)) continue
223
+ if (!Object.prototype.hasOwnProperty.call(nxt, name)) {
224
+ removed.push({ name, entry: pickPersonaFields(prev[name]) })
225
+ }
226
+ }
227
+ return { added, updated, removed }
228
+ }
229
+
230
+ export function hasPersonaEvents(events) {
231
+ return !!events && (events.added.length > 0 || events.updated.length > 0 || events.removed.length > 0)
232
+ }
233
+
234
+ // ==================== 条目构造与校验 ====================
235
+
236
+ // 组装一条记录。任一必填项不合法时返回 null(调用方据此跳过审计,而不是写半条进去)。
237
+ //
238
+ // label 是可选的人类可读上下文(如「应用了哪个预设」「哪个斜杠命令」):
239
+ // 它不参与指纹、不参与回滚,只是给时间线看的 —— 所以归一化时超长就丢弃而不是整条拒收。
240
+ export function buildHistoryEntry({ id, at, source, action, configHash, before, after, personas, label }) {
241
+ return normalizeHistoryEntry({
242
+ v: HISTORY_VERSION,
243
+ id,
244
+ at,
245
+ source,
246
+ action,
247
+ configHash,
248
+ before: pickConfigFields(before),
249
+ after: pickConfigFields(after),
250
+ personas: hasPersonaEvents(personas) ? personas : { added: [], updated: [], removed: [] },
251
+ label
252
+ })
253
+ }
254
+
255
+ // 校验并归一化一条记录(也用于读侧)。任何一处不对就整条丢弃。
256
+ //
257
+ // 严格是刻意的:回滚会把条目里的值写回配置,一条形状可疑的记录宁可丢掉。
258
+ // 丢掉的代价是「少一条历史」,接受的代价是「写出一个谁都没见过的配置」。
259
+ export function normalizeHistoryEntry(raw) {
260
+ if (!isPlainObject(raw)) return null
261
+ if (raw.v !== HISTORY_VERSION) return null
262
+ if (typeof raw.id !== 'string' || raw.id.length > 64 || !ID_PATTERN.test(raw.id)) return null
263
+ if (typeof raw.at !== 'string' || raw.at.length > 40 || !Number.isFinite(Date.parse(raw.at))) return null
264
+ if (!HISTORY_SOURCES.includes(raw.source)) return null
265
+ if (!HISTORY_ACTIONS.includes(raw.action)) return null
266
+ if (typeof raw.configHash !== 'string' || !HASH_PATTERN.test(raw.configHash)) return null
267
+
268
+ const before = pickConfigFields(raw.before)
269
+ const after = pickConfigFields(raw.after)
270
+ const personas = normalizePersonaEvents(raw.personas)
271
+ // 空条目(什么都没变)不入账:每次点「保存」都刷一条噪声,列表很快就没法用了。
272
+ if (Object.keys(before).length === 0 && Object.keys(after).length === 0 && !hasPersonaEvents(personas)) return null
273
+
274
+ // label 是展示用的可选字段:旧记录没有它照常解析(向后兼容),超长或非字符串就丢弃 ——
275
+ // 它不影响指纹与回滚,犯不着为一句文案拒绝整条记录。
276
+ const label = typeof raw.label === 'string' && raw.label.trim() !== '' && raw.label.length <= LABEL_MAX
277
+ ? raw.label
278
+ : undefined
279
+ const entry = { v: HISTORY_VERSION, id: raw.id, at: raw.at, source: raw.source, action: raw.action, configHash: raw.configHash, before, after, personas }
280
+ if (label !== undefined) entry.label = label
281
+ return entry
282
+ }
283
+
284
+ // label 上限:预设名上限(PERSONA_NAME_MAX)之外再留些余量给组合文案;JSONL 单行上限
285
+ // (MAX_LINE_BYTES = 64 KiB)远远够不到,这个值是「显示层不跑马」的约束,不是安全边界。
286
+ const LABEL_MAX = 120
287
+
288
+ function pickConfigFields(raw) {
289
+ const out = {}
290
+ if (!isPlainObject(raw)) return out
291
+ for (const key of Object.keys(raw)) {
292
+ if (FORBIDDEN_KEYS.has(key)) continue
293
+ if (AUDIT_FIELD_SET.has(key)) out[key] = raw[key]
294
+ }
295
+ return out
296
+ }
297
+
298
+ function normalizePersonaEvents(raw) {
299
+ const added = []
300
+ const updated = []
301
+ const removed = []
302
+ if (isPlainObject(raw)) {
303
+ if (Array.isArray(raw.added)) {
304
+ for (const name of raw.added) {
305
+ if (typeof name !== 'string' || !name || name.length > PERSONA_NAME_MAX) continue
306
+ if (FORBIDDEN_KEYS.has(name)) continue
307
+ added.push(name)
308
+ }
309
+ }
310
+ for (const [field, target] of [
311
+ ['updated', updated],
312
+ ['removed', removed]
313
+ ]) {
314
+ if (!Array.isArray(raw[field])) continue
315
+ for (const item of raw[field]) {
316
+ if (!isPlainObject(item)) continue
317
+ const name = item.name
318
+ if (typeof name !== 'string' || !name || name.length > PERSONA_NAME_MAX) continue
319
+ if (FORBIDDEN_KEYS.has(name)) continue
320
+ target.push({ name, entry: pickPersonaFields(item.entry) })
321
+ }
322
+ }
323
+ }
324
+ return { added, updated, removed }
325
+ }
326
+
327
+ export function serializeEntry(entry) {
328
+ return `${JSON.stringify(entry)}\n`
329
+ }
330
+
331
+ // ==================== JSONL 解析 ====================
332
+
333
+ // 解析记录文件全文。返回 { entries, dropped, trimmed, truncated }:
334
+ // - entries :按文件物理顺序(最老在前)的合法条目
335
+ // - dropped :被丢弃的行数(坏 JSON / 形状不对 / 超长 / 末尾半行)
336
+ // - trimmed :因为超出条数上限而没被读到的**合法**行数(与 dropped 分开,见下)
337
+ // - truncated:文件末尾没有换行 ⇒ 上一次追加被打断,最后一条记录不完整
338
+ //
339
+ // truncated 不只是显示用的:它意味着「有一条变更没被记下来」,
340
+ // planRollback 的指纹校验会因此失败并拒绝回滚(这正是期望的行为)。
341
+ export function parseHistoryText(text) {
342
+ const source = typeof text === 'string' ? text : ''
343
+ const truncated = source.length > 0 && !source.endsWith('\n')
344
+ const lines = source.split('\n')
345
+ // 末尾的换行会在 split 结果里多出一个空元素 —— 它不是一行。
346
+ // 不剥掉的话它会占掉条数上限里的一个名额,让「最近 1000 条」实际只拿到 999 条。
347
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
348
+ // 末尾不完整的半行不参与解析(但仍计入 dropped,如实报告)
349
+ if (truncated) lines.pop()
350
+
351
+ let dropped = truncated ? 1 : 0
352
+ // 超出条数上限时丢**最老的**(见 MAX_READ_LINES 注释)。
353
+ // 与 dropped 分开计数:这些行完全合法,只是没被读到 —— 混进 dropped 会让 UI 显示
354
+ // 「有 2512 行无法解析」,而文件里其实一个坏行都没有。
355
+ let trimmed = 0
356
+ let window = lines
357
+ if (lines.length > MAX_READ_LINES) {
358
+ trimmed = lines.length - MAX_READ_LINES
359
+ window = lines.slice(lines.length - MAX_READ_LINES)
360
+ }
361
+
362
+ const entries = []
363
+ for (const line of window) {
364
+ const row = line.trim()
365
+ if (!row) continue
366
+ if (Buffer.byteLength(row, 'utf8') > MAX_LINE_BYTES) {
367
+ dropped += 1
368
+ continue
369
+ }
370
+ let raw
371
+ try {
372
+ raw = JSON.parse(row)
373
+ } catch {
374
+ dropped += 1
375
+ continue
376
+ }
377
+ const entry = normalizeHistoryEntry(raw)
378
+ if (entry) entries.push(entry)
379
+ else dropped += 1
380
+ }
381
+ return { entries, dropped, trimmed, truncated }
382
+ }
383
+
384
+ // 只保留最新的 max 条(缺省 MAX_ENTRIES)。
385
+ //
386
+ // 「丢最老的」不是随手定的:保留了后缀,就意味着任何仍然存在的条目,
387
+ // 它之后的每一条也都还在 —— planRollback 从最新往回走时不会踩空。反过来若从中间挖洞,
388
+ // 回滚会安静地算出错误结果。
389
+ export function pruneEntries(entries, max = MAX_ENTRIES) {
390
+ const list = Array.isArray(entries) ? entries : []
391
+ const limit = Number.isFinite(max) && max > 0 ? Math.floor(max) : MAX_ENTRIES
392
+ if (list.length <= limit) return list
393
+ // 取**尾部** limit 条。注意这里不能顺手 sort:条目顺序由文件物理顺序决定,
394
+ // 而时间戳只用于显示(§8「时间戳不参与任何逻辑判定」)—— 按时间排会在同毫秒
395
+ // 或时钟回拨时把顺序搅乱,而顺序正是 planRollback 的判据。
396
+ return list.slice(list.length - limit)
397
+ }
398
+
399
+ export function findEntryIndex(entries, id) {
400
+ const list = Array.isArray(entries) ? entries : []
401
+ for (let i = 0; i < list.length; i++) {
402
+ if (list[i].id === id) return i
403
+ }
404
+ return -1
405
+ }
406
+
407
+ // ==================== 文件 IO ====================
408
+
409
+ async function readRange(handle, start, length) {
410
+ const buffer = Buffer.alloc(length)
411
+ let filled = 0
412
+ // read() 允许短读(返回的字节数可以小于请求量)。本地文件上通常一次就读满,
413
+ // 但把「短读」当成读完会让解析器看到一段凭空截断的文本 —— 那是难查的偶发 bug。
414
+ while (filled < length) {
415
+ const { bytesRead } = await handle.read(buffer, filled, length - filled, start + filled)
416
+ if (bytesRead <= 0) break
417
+ filled += bytesRead
418
+ }
419
+ return filled === length ? buffer : buffer.subarray(0, filled)
420
+ }
421
+
422
+ export async function readHistoryFile(filePath) {
423
+ let text
424
+ let trimmedHead = false
425
+ try {
426
+ const handle = await open(filePath, 'r')
427
+ try {
428
+ const info = await handle.stat()
429
+ // 目录能被打开发,且 size 为 0 —— 不显式判断的话,一个叫 soul-config.history.jsonl
430
+ // 的目录会安静地读成「还没有任何变更记录」,而文件其实根本读不了。
431
+ // 这正是本模块要避免的那类安静失败:UI 上永远显示空列表,用户无从知道哪里不对。
432
+ if (info.isDirectory()) {
433
+ return { entries: [], dropped: 0, trimmed: 0, truncated: false, trimmedHead: false, error: `变更记录路径是一个目录,无法读取:${filePath}` }
434
+ }
435
+ const start = Math.max(0, info.size - MAX_READ_BYTES)
436
+ if (start > 0) {
437
+ // 只读了尾部 ⇒ 无论首行保不保留,都要如实上报「没有读全」
438
+ // (否则用户看到的是一个静默缩水的列表)。
439
+ trimmedHead = true
440
+ // 再决定首行能不能用:看被切掉的那一个字节是不是换行符。
441
+ // - 是 → 读取起点正好落在行首,首行是完整的,必须保留;
442
+ // - 不是 → 起点切在某一行中间,首行是半行,丢掉它。
443
+ // 不判断就一律丢首行,会在「文件大小正好是 MAX_READ_BYTES 的整数倍」这类
444
+ // 巧合下静默少一条记录 —— 而少的那条会影响 planRollback 的指纹校验。
445
+ const probe = await readRange(handle, start - 1, 1)
446
+ const atLineStart = probe.length === 1 && probe[0] === 0x0a
447
+ const tail = await readRange(handle, start, info.size - start)
448
+ text = tail.toString('utf8')
449
+ if (!atLineStart) {
450
+ const cut = text.indexOf('\n')
451
+ text = cut >= 0 ? text.slice(cut + 1) : ''
452
+ }
453
+ } else {
454
+ text = (await readRange(handle, 0, info.size)).toString('utf8')
455
+ }
456
+ } finally {
457
+ await handle.close()
458
+ }
459
+ } catch (err) {
460
+ if (err && err.code === 'ENOENT') {
461
+ return { entries: [], dropped: 0, trimmed: 0, truncated: false, trimmedHead: false, error: null }
462
+ }
463
+ return { entries: [], dropped: 0, trimmed: 0, truncated: false, trimmedHead: false, error: `变更记录读取失败(${(err && err.code) || 'unknown'}):${(err && err.message) || err}` }
464
+ }
465
+
466
+ return { ...parseHistoryText(text), trimmedHead, error: null }
467
+ }
468
+
469
+ // 追加一条记录。调用方负责把 entry 构造好(buildHistoryEntry 返回非 null)。
470
+ export async function appendHistoryEntry(filePath, entry) {
471
+ await mkdir(dirname(filePath), { recursive: true })
472
+ await appendFile(filePath, serializeEntry(entry), 'utf8')
473
+ }
474
+
475
+ // 原子重写整份记录(临时文件 + rename,与 lib/store.mjs 的 writeConfigFile 同一手法)。
476
+ export async function rewriteHistoryFile(filePath, entries) {
477
+ await mkdir(dirname(filePath), { recursive: true })
478
+ const tmpPath = `${filePath}.tmp`
479
+ await writeFile(tmpPath, entries.map(serializeEntry).join(''), 'utf8')
480
+ await rename(tmpPath, filePath)
481
+ }
482
+
483
+ async function historySize(filePath) {
484
+ try {
485
+ return (await stat(filePath)).size
486
+ } catch {
487
+ return 0
488
+ }
489
+ }
490
+
491
+ // 记录一条变更:正常情况追加一行;文件超过 MAX_BYTES 时读回全文、压实、原子重写。
492
+ //
493
+ // 注意压实会**重新序列化合法条目**:那些被丢弃的坏行随之消失。这是有意为之(自愈),
494
+ // 代价是坏行不再出现 —— 所以 dropped 计数必须在读取时就被报告出去,
495
+ // 不能等到压缩时才发现「有几行不见了」。
496
+ export async function recordHistoryEntry(filePath, entry, maxEntries) {
497
+ const size = await historySize(filePath)
498
+ if (size > MAX_BYTES) {
499
+ const { entries, dropped, error } = await readHistoryFile(filePath)
500
+ if (error) throw new Error(error)
501
+ const kept = pruneEntries(entries.concat(entry), maxEntries)
502
+ await rewriteHistoryFile(filePath, kept)
503
+ return { compacted: true, kept: kept.length, dropped }
504
+ }
505
+ await appendHistoryEntry(filePath, entry)
506
+ return { compacted: false, kept: 0, dropped: 0 }
507
+ }
508
+
509
+ // 清空记录:先读一遍拿到条数(供 UI 报「已清空 N 条」),再删文件。
510
+ // 留着空文件只会让「有没有记录」多出第三种状态。
511
+ export async function clearHistoryFile(filePath) {
512
+ const { entries } = await readHistoryFile(filePath)
513
+ await rm(filePath, { force: true })
514
+ return entries.length
515
+ }
516
+
517
+ // ==================== 回滚规划 ====================
518
+
519
+ // 规划一次回滚:**撤销**目标条目以及它之后的所有改动。
520
+ //
521
+ // 语义是「撤销这一条」,不是「恢复到这一条刚做完的样子」。区别很重要:
522
+ // 按后者,点最新那条永远是空操作(你已经在那个状态),而最新那条恰恰是用户最想撤的。
523
+ // 更糟的是它的结果与行内容相反 —— 行上写着「style 变成 humorous」,撤销却把
524
+ // 「之后那次 emoji 改动」退回去了,style 反而留着。撤销必须把点的那条真正撤掉。
525
+ //
526
+ // 为什么必须连带撤销它之后的:条目只记变化字段,后一条的 before 是它自己那次改动的
527
+ // 前提。跳过中间某条去单独还原更早的字段,会算出一份从未存在过的混合状态。
528
+ // 所以结果状态 = 「目标条目发生**之前**」的状态,被撤销的条目 id 一并回报(供 UI 预览)。
529
+ //
530
+ // 为什么不无脑撤销就好:撤销链假设「记录与配置同步」。一旦不同步(用户手改了
531
+ // soul-config.json、或上次进程死在「配置已写、审计未写」之间),撤销出来的就是那份
532
+ // 混合状态 —— 比拒绝更糟。所以每一步都先验指纹:当前状态的哈希必须等于该条目的
533
+ // configHash,对不上就返回 reason='diverged',由调用方明确报错。
534
+ //
535
+ // 返回 { ok, patch, skipped, reversed, reason }:
536
+ // - patch :相对当前配置的字段级补丁(已过 sanitizeConfig 白名单)
537
+ // - skipped :记录里有、但当前 schema 校验不通过的字段名(如实回报,不静默丢弃)
538
+ // - reversed :被撤销的条目 id(从新到旧,含目标条目)
539
+ export function planRollback(entries, current, targetId) {
540
+ const list = Array.isArray(entries) ? entries : []
541
+ const index = findEntryIndex(list, targetId)
542
+ if (index < 0) return { ok: false, reason: 'missing', patch: {}, skipped: [], reversed: [] }
543
+
544
+ let state = isPlainObject(current) ? current : {}
545
+ const newest = list[list.length - 1]
546
+ if (!newest || hashConfig(state) !== newest.configHash) {
547
+ return { ok: false, reason: 'diverged', patch: {}, skipped: [], reversed: [] }
548
+ }
549
+
550
+ const reversed = []
551
+ for (let i = list.length - 1; i >= index; i--) {
552
+ const entry = list[i]
553
+ if (hashConfig(state) !== entry.configHash) {
554
+ return { ok: false, reason: 'diverged', patch: {}, skipped: [], reversed: [] }
555
+ }
556
+ state = { ...state, ...entry.before }
557
+ reversed.push(entry.id)
558
+ }
559
+
560
+ const rawPatch = {}
561
+ for (const key of AUDIT_FIELDS) {
562
+ if (state[key] === current[key]) continue
563
+ // 缺席与「值为 undefined」在 JSON 里无法区分,喂给 sanitizeConfig 只会换来一条
564
+ // 「必须为字符串」的误报(并被算进 skipped)—— 直接跳过,让它如实缺席。
565
+ if (state[key] === undefined) continue
566
+ rawPatch[key] = state[key]
567
+ }
568
+ const { patch, errors } = sanitizeConfig(rawPatch)
569
+ return { ok: true, reason: null, patch, skipped: Object.keys(errors), reversed }
570
+ }
571
+
572
+ // ==================== 读取结果的对外形状 ====================
573
+
574
+ // 把读结果整理成 HTTP 响应要用的形状:条目**最新在前**,并附上可展示的警告。
575
+ //
576
+ // 倒序在服务端做一次:客户端再排一次的话,两边规则迟早漂移,
577
+ // 而「列表顺序和实际写入顺序不一致」这种 bug 很难被当成 bug 报出来。
578
+ export function toHistoryResponse(read, writeFailure, maxEntries) {
579
+ const warnings = []
580
+ if (read.error) warnings.push(read.error)
581
+ if (typeof writeFailure === 'string' && writeFailure) warnings.push(`变更记录写入失败:${writeFailure}`)
582
+ if (read.truncated) warnings.push('记录文件末尾不完整,最后一次变更可能没有留下记录。')
583
+ if (read.trimmedHead) warnings.push('记录文件较大,只读取了最近的部分。')
584
+ if (read.trimmed > 0) warnings.push(`记录条目较多,只显示了最近的 ${MAX_READ_LINES} 条。`)
585
+ if (read.dropped > 0) warnings.push(`有 ${read.dropped} 行无法解析,已忽略。`)
586
+ return {
587
+ entries: read.entries.slice().reverse(),
588
+ // 上限是**配置里的承诺**,不是模块常量:客户端把「最多保留 N 条」这句话当真,
589
+ // 服务端就必须说自己配置里真正承诺的那个数。
590
+ max: Number.isFinite(maxEntries) && maxEntries > 0 ? Math.floor(maxEntries) : MAX_ENTRIES,
591
+ warning: warnings.length > 0 ? warnings.join(' ') : null
592
+ }
593
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-soul",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "为 DeepSeek Harness 添加个性化设置。",
5
5
  "icon": "./assets/icon.svg",
6
6
  "keywords": [
@@ -18,9 +18,12 @@
18
18
  "type": "module",
19
19
  "main": "index.mjs",
20
20
  "scripts": {
21
- "verify": "node scripts/verify-config.mjs && node scripts/verify-store.mjs && node scripts/verify-client.mjs && node scripts/verify-e2e-prompt.mjs && node scripts/verify-live-prompt.mjs && node scripts/verify-compat.mjs && node scripts/verify-trail.mjs && node scripts/verify-nav-icon.mjs",
22
- "verify:strict": "node scripts/verify-config.mjs --strict && node scripts/verify-store.mjs --strict && node scripts/verify-client.mjs --strict && node scripts/verify-e2e-prompt.mjs --strict && node scripts/verify-live-prompt.mjs --strict && node scripts/verify-compat.mjs --strict && node scripts/verify-trail.mjs --strict && node scripts/verify-nav-icon.mjs --strict",
21
+ "verify": "node scripts/verify-config.mjs && node scripts/verify-store.mjs && node scripts/verify-history.mjs && node scripts/verify-bindings.mjs && node scripts/verify-client.mjs && node scripts/verify-e2e-prompt.mjs && node scripts/verify-live-prompt.mjs && node scripts/verify-compat.mjs && node scripts/verify-trail.mjs && node scripts/verify-nav-icon.mjs",
22
+ "verify:strict": "node scripts/verify-config.mjs --strict && node scripts/verify-store.mjs --strict && node scripts/verify-history.mjs --strict && node scripts/verify-bindings.mjs --strict && node scripts/verify-client.mjs --strict && node scripts/verify-e2e-prompt.mjs --strict && node scripts/verify-live-prompt.mjs --strict && node scripts/verify-compat.mjs --strict && node scripts/verify-trail.mjs --strict && node scripts/verify-nav-icon.mjs --strict",
23
+ "verify:config": "node scripts/verify-config.mjs",
23
24
  "verify:store": "node scripts/verify-store.mjs",
25
+ "verify:history": "node scripts/verify-history.mjs",
26
+ "verify:bindings": "node scripts/verify-bindings.mjs",
24
27
  "verify:client": "node scripts/verify-client.mjs",
25
28
  "verify:e2e": "node scripts/verify-e2e-prompt.mjs",
26
29
  "verify:host": "node scripts/verify-live-prompt.mjs",