cs-bots 0.0.0-stage → 0.1.1

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/src/memory.js ADDED
@@ -0,0 +1,1905 @@
1
+ /**
2
+ * cs-bots MemoryService —— 分层记忆。
3
+ *
4
+ * 设计依据:`multiagent/10-初步架构设计方案-v1.md` §3.4(六层设计)。
5
+ *
6
+ * ── 为什么要自建(硬事实)──
7
+ * 官方**没有**记忆系统;第三方 `dsh-memory-evolve` **不能依赖**(零依赖纪律,见 key 记忆:
8
+ * 禁依赖 6 个插件)。⇒ 平台必须自建。本文件即该自建实现。
9
+ *
10
+ * ── 六层(§3.4 原文)──
11
+ * | 层 | 名称 | 写入者 | 常驻注入 |
12
+ * | L0 | 人格 persona | **模型不可写**,仅人改配置 | ✅ |
13
+ * | L1 | 用户偏好 | 自动 review + 人 | ✅ |
14
+ * | L2 | 团队全局 | 主 Bot / 房间管理员 | ✅ |
15
+ * | L3 | 项目 key(按 git 分支过滤) | 任一成员(限本项目) | ✅ |
16
+ * | L4 | 任务与会话笔记 | 成员本人 | ❌ 按需 recall |
17
+ * | L5 | 日札 | 后台 consolidate | ❌ 按需 recall |
18
+ *
19
+ * ── 四条规定(治「review 与显式写入打架」,逐条机械化,**不靠自觉**)──
20
+ * 1. **条目身份证**:每条带 8 位 `[id:xxxxxxxx]` + `[rev:N]`;一切修改**按 id 定位**
21
+ * (`update`/`remove`/`archive`/`restore` 都要求 id,不存在「按编号改第 3 条」这种易错面)。
22
+ * 2. **权限分级**:`explicit:true` 条目(人显式说的)**review 只能建议不能改** ——
23
+ * `review()` 命中 explicit 条目时**不写盘**,只在返回值里给 `{ action:'suggest', ... }`;
24
+ * `update()` 不带 `explicit:true` 改 explicit 条目 → 抛 `MEMORY_EXPLICIT_PROTECTED`。
25
+ * 3. **冲突不静默**:矛盾两条**都留**,两条都标 `[conflict]` + 互相 `conflictWith`,
26
+ * `contextFor()` 渲染时显式输出 `[conflict]` 标记。
27
+ * 4. **写入优先级**:显式工具 > 自动 review > 后台 consolidate。落点:`source` 三值
28
+ * (`explicit`/`review`/`consolidate`),低优先级写者不得改写高优先级条目(见 `assertWriteAllowed`)。
29
+ *
30
+ * ── 上限取值:600,**不用** ds-bot 的 300 ──
31
+ * 依据(§3.4):实测本机 `~/.dsh/memories/MEMORY.md` 最长单行 **826 字符** ——
32
+ * ds-bot 的 `entry:300` 会把这类真实条目**全拒收**。⇒ `entryMax = 600`,
33
+ * 且 `add()` 对超长内容**自动切分**为多条(**无损**:切分只按码点切,不插入任何标记,
34
+ * 各片段拼回 === 原文),并在返回条目上显式给 `split:true` + `parts:[...]`
35
+ * ——「超上限」这件事**看得见**,不是静默截断。`update()` 无法切分(它是单条 CAS),
36
+ * 故超长直接抛 `MEMORY_ENTRY_TOO_LONG`。
37
+ *
38
+ * ── 存储 ──
39
+ * `memory.jsonl` 事件流(**真相源**,append-only)
40
+ * `memory.snap.json` 原子快照(加速启动;事件流为空时才作为回落)
41
+ * `memory-fts.db` FTS5 检索索引(**派生缓存**,可随时重建;坏了不影响真相)
42
+ *
43
+ * ── 检索的两条路径(都必须可用)──
44
+ * a) `node:sqlite` FTS5 + **trigram** 分词器。为什么必须 trigram:实测 unicode61/porter
45
+ * 对中文整段不分词,`MATCH '分层记忆'` 返回 0;trigram 可用(2026-11-01 本机实测:
46
+ * `分支策略`/`主干开发`/`discipline` 均命中)。查询一律**加引号短语 + 双引号转义**,
47
+ * 杜绝 FTS5 运算符注入(`NEAR`/`*`/`:` 等实测均被安全降级为字面短语)。
48
+ * b) 退化路径 `String.includes`:sqlite 不可用、或词表无法建立、或查询短于 3 个码点
49
+ * (trigram 的物理下限)时启用。走哪条由 `diagnostics().searchBackend` **可见地**记录。
50
+ *
51
+ * ── 为什么 kernel.js 是动态导入(照 board.js,勿改成静态)──
52
+ * 本仓库是**裸检出**(无 node_modules),静态 import 会让本文件连带 kernel.js 加载失败。
53
+ * 拿不到 kernel 且调用方也没给 `config.stateDir` → **抛可读错误**(不静默兜底)。
54
+ *
55
+ * 依赖面:node 内置(`node:crypto` / `node:sqlite`)+ `./store.js` + `./kernel.js`。
56
+ * **不 import 任何 `@deepseek-ai/*`**(`test/ci-zero-dep.sh` 检查 1 机械强制)。
57
+ */
58
+
59
+ import { randomBytes } from 'node:crypto'
60
+
61
+ import {
62
+ appendJsonl,
63
+ ensureDir,
64
+ readJson,
65
+ readJsonl,
66
+ statePath,
67
+ writeJsonAtomic,
68
+ } from './store.js'
69
+
70
+ // ── kernel 惰性桥(见文件头「为什么是动态导入」)──────────────────────────────
71
+
72
+ /** @type {object | null} */
73
+ let kernel = null
74
+ /** @type {unknown} */
75
+ let kernelError = null
76
+ try {
77
+ kernel = await import('./kernel.js')
78
+ } catch (error) {
79
+ kernelError = error
80
+ }
81
+
82
+ // ── node:sqlite 惰性桥(可用则用 FTS5,不可用则退化 includes)──────────────────
83
+
84
+ /** @type {object | null} */
85
+ let sqliteModule = null
86
+ /** @type {string | null} */
87
+ let sqliteError = null
88
+ try {
89
+ // 显式 catch:Node < 22.5 无此模块,宿主机差异不该让整个记忆服务装配失败。
90
+ sqliteModule = await import('node:sqlite')
91
+ } catch (error) {
92
+ sqliteError = error?.message ?? String(error)
93
+ }
94
+
95
+ // ── 常量 ────────────────────────────────────────────────────────────────────
96
+
97
+ /** 六层(顺序即注入顺序)。 */
98
+ export const LAYERS = ['L0', 'L1', 'L2', 'L3', 'L4', 'L5']
99
+ /** 常驻注入的层(§3.4「常驻注入 ✅」= L0–L3);L4/L5 按需 recall。 */
100
+ export const INJECTED_LAYERS = ['L0', 'L1', 'L2', 'L3']
101
+ /** 层的中文名(渲染用)。 */
102
+ const LAYER_NAMES = {
103
+ L0: '人格 persona(人改,模型不可写)',
104
+ L1: '用户偏好',
105
+ L2: '团队全局',
106
+ L3: '项目 key(按 git 分支过滤)',
107
+ L4: '任务与会话笔记(按需 recall)',
108
+ L5: '日札(按需 recall)',
109
+ }
110
+ /** 唯一天然不可写的层。 */
111
+ const PERSONA_LAYER = 'L0'
112
+ /** 单条内容上限(**不是** ds-bot 的 300,见文件头「上限取值」)。 */
113
+ const DEFAULT_ENTRY_MAX = 600
114
+ /** id 长度(8 位 hex,渲染为 `[id:xxxxxxxx]`)。 */
115
+ const ID_LEN = 8
116
+ /** 切分时的软边界回溯窗口占 max 的比例(在窗口内找换行/空格,找不到就硬切)。 */
117
+ const SOFT_BOUNDARY_RATIO = 0.5
118
+ /** 事件动作集合。 */
119
+ const ACTIONS = ['add', 'update', 'remove', 'archive', 'restore', 'review', 'consolidate']
120
+ /** 否定极性标记(用于「冲突 vs 重复」判定,见 `polarityOf`)。 */
121
+ const NEG_MARKERS = ['不', '禁止', '不要', '别', 'never', 'must not', 'mustn\'t', '避免', '不可', '无需', '不应']
122
+ /** 肯定极性标记(存在即视为「有极性主张」,用于与否定对照)。 */
123
+ const POS_MARKERS = ['必须', '要', '应该', 'always', 'must', 'should', '推荐', '优先', '使用']
124
+
125
+ // ── 错误 ────────────────────────────────────────────────────────────────────
126
+
127
+ /** 记忆服务结构化错误。所有失败都走它,**绝不静默**。 */
128
+ export class MemoryError extends Error {
129
+ /**
130
+ * @param {string} message 人类可读、含定位信息(层 / id / rev / 字段名)
131
+ * @param {string} [code]
132
+ * @param {object} [details]
133
+ */
134
+ constructor(message, code = 'MEMORY_ERROR', details) {
135
+ super(message)
136
+ this.name = 'MemoryError'
137
+ this.code = code
138
+ if (details !== undefined) this.details = details
139
+ }
140
+ }
141
+
142
+ // ── 小工具 ──────────────────────────────────────────────────────────────────
143
+
144
+ /**
145
+ * 深拷贝 + **JSON 归一**(去掉值为 undefined 的键)。
146
+ * 必须走 JSON 而不是 structuredClone:否则内存态会比磁盘态多出 `{archived: undefined}`
147
+ * 这类键,导致 `replay()` 重建后的 `list()` 与原值不等(照 board.js 的教训)。
148
+ */
149
+ function clone(value) {
150
+ if (value === undefined || value === null) return value
151
+ return JSON.parse(JSON.stringify(value))
152
+ }
153
+
154
+ /** 码点长度(切分与上限判定都用码点数,避免代理对被切开)。 */
155
+ function cpLength(text) {
156
+ return Array.from(String(text ?? '')).length
157
+ }
158
+
159
+ /** 非空字符串字段校验。 */
160
+ function requireString(value, field, where) {
161
+ if (typeof value !== 'string' || value.length === 0) {
162
+ throw new MemoryError(
163
+ `memory.${where}:字段 ${field} 缺失或类型错误(要求非空字符串,收到 ${JSON.stringify(value)})`,
164
+ 'MEMORY_MISSING_FIELD',
165
+ { field, where },
166
+ )
167
+ }
168
+ return value
169
+ }
170
+
171
+ /** 可选字符串字段校验(给了就必须是非空字符串)。 */
172
+ function optionalString(value, field, where) {
173
+ if (value === undefined || value === null) return undefined
174
+ return requireString(value, field, where)
175
+ }
176
+
177
+ /** 字符串数组归一:接受 `string | string[]`,去空、去重、保序。 */
178
+ function stringList(value, field, where) {
179
+ if (value === undefined || value === null) return []
180
+ const raw = Array.isArray(value) ? value : [value]
181
+ const out = []
182
+ for (const item of raw) {
183
+ if (typeof item !== 'string' || item.trim() === '') {
184
+ throw new MemoryError(
185
+ `memory.${where}:字段 ${field} 的元素必须是非空字符串,收到 ${JSON.stringify(item)}`,
186
+ 'MEMORY_BAD_FIELD',
187
+ { field, where },
188
+ )
189
+ }
190
+ const v = item.trim()
191
+ if (!out.includes(v)) out.push(v)
192
+ }
193
+ return out
194
+ }
195
+
196
+ /** 层名校验。 */
197
+ function requireLayer(value, where) {
198
+ const layer = requireString(value, 'layer', where)
199
+ if (!LAYERS.includes(layer)) {
200
+ throw new MemoryError(
201
+ `memory.${where}:非法 layer=${JSON.stringify(layer)}(合法值:${LAYERS.join(' | ')})`,
202
+ 'MEMORY_BAD_LAYER',
203
+ { layer, valid: LAYERS },
204
+ )
205
+ }
206
+ return layer
207
+ }
208
+
209
+ /** 正整数校验(limit 用)。 */
210
+ function optionalLimit(value, where) {
211
+ if (value === undefined || value === null) return undefined
212
+ if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
213
+ throw new MemoryError(
214
+ `memory.${where}:limit 必须是正整数,收到 ${JSON.stringify(value)}`,
215
+ 'MEMORY_BAD_FIELD',
216
+ { field: 'limit', where },
217
+ )
218
+ }
219
+ return value
220
+ }
221
+
222
+ /** 时间参数归一:缺省 now;支持 ms 数字与 Date。 */
223
+ function normalizeNow(value, where) {
224
+ if (value === undefined || value === null) return Date.now()
225
+ if (value instanceof Date) return value.getTime()
226
+ if (typeof value === 'number' && Number.isFinite(value)) return value
227
+ throw new MemoryError(
228
+ `memory.${where}:now 必须是毫秒时间戳或 Date,收到 ${JSON.stringify(value)}`,
229
+ 'MEMORY_BAD_NOW',
230
+ { where },
231
+ )
232
+ }
233
+
234
+ // ── 极性 / 签名(规定 3 的判定基础)────────────────────────────────────────────
235
+
236
+ /**
237
+ * 极性探测:条目是否含「否定」标记(`neg`)或「肯定主张」标记(`pos`)。
238
+ * @param {string} text
239
+ * @returns {{ neg: boolean, pos: boolean }}
240
+ */
241
+ function polarityOf(text) {
242
+ const lower = String(text ?? '').toLowerCase()
243
+ const hit = (markers) => markers.some((m) => lower.includes(m.toLowerCase()))
244
+ return { neg: hit(NEG_MARKERS), pos: hit(POS_MARKERS) }
245
+ }
246
+
247
+ /**
248
+ * 条目签名:**剥掉否定标记**后做归一(小写 + 去标点 + 折叠空白)。
249
+ *
250
+ * 用途:判断两条是否在讨论「同一件事」——例如
251
+ * `本项目使用 pnpm` 与 `本项目不使用 pnpm`
252
+ * 归一后签名相同、极性相反 ⇒ **矛盾**(规定 3:两条都留并标 conflict),
253
+ * 而不是被当成重复合并掉。**冲突判定先于去重判定**,顺序不可颠倒。
254
+ *
255
+ * @param {string} text
256
+ * @returns {string}
257
+ */
258
+ function signatureOf(text) {
259
+ let s = String(text ?? '').toLowerCase()
260
+ for (const m of NEG_MARKERS) s = s.split(m.toLowerCase()).join('')
261
+ return s
262
+ .replace(/[\s\u3000]+/g, '')
263
+ .replace(/[,。;、!?:,.;!?:'"“”‘’()()[\]【】{}<>《》~`|/\\_\-+*=#*&^%$@!]/g, '')
264
+ }
265
+
266
+ /**
267
+ * 重复判定用的归一:小写 + 折叠空白 + 去首尾标点(**不**剥否定标记)。
268
+ * @param {string} text
269
+ * @returns {string}
270
+ */
271
+ function dupKeyOf(text) {
272
+ return String(text ?? '')
273
+ .toLowerCase()
274
+ .replace(/[\s\u3000]+/g, ' ')
275
+ .replace(/^[\s,。;、!?:,.;!?:'"“”()()[\]【】]+|[\s,。;、!?:,.;!?:'"“”()()[\]【】]+$/g, '')
276
+ }
277
+
278
+ // ── 内容切分(无损)──────────────────────────────────────────────────────────
279
+
280
+ /**
281
+ * 把超长内容切成每段 ≤ `max` 码点的片段。**无损**:`parts.join('') === content`。
282
+ * 软边界优先(回溯窗口内找最后的换行,其次空格),找不到就硬切 —— 但**绝不丢字符**、
283
+ * 也绝不插入 `[1/2]` 之类的标记(那会污染记忆正文)。
284
+ *
285
+ * @param {string} content
286
+ * @param {number} max
287
+ * @returns {string[]} 至少一段;内容为空时返回 `['']`(调用方另行校验非空)
288
+ */
289
+ function partitionContent(content, max) {
290
+ const cps = Array.from(String(content ?? ''))
291
+ if (cps.length <= max) return [String(content ?? '')]
292
+ const softFloor = Math.max(1, Math.floor(max * SOFT_BOUNDARY_RATIO))
293
+ const parts = []
294
+ let i = 0
295
+ while (i < cps.length) {
296
+ let end = Math.min(i + max, cps.length)
297
+ if (end < cps.length) {
298
+ let cut = -1
299
+ for (let j = end - 1; j >= i + softFloor; j -= 1) {
300
+ if (cps[j] === '\n') { cut = j + 1; break }
301
+ }
302
+ if (cut < 0) {
303
+ for (let j = end - 1; j >= i + softFloor; j -= 1) {
304
+ if (cps[j] === ' ') { cut = j + 1; break }
305
+ }
306
+ }
307
+ if (cut > i) end = cut
308
+ }
309
+ parts.push(cps.slice(i, end).join(''))
310
+ i = end
311
+ }
312
+ return parts
313
+ }
314
+
315
+ // ── 写入优先级(规定 4)───────────────────────────────────────────────────────
316
+
317
+ /** 写者优先级:数值越大越优先(规定 4:显式工具 > review > consolidate)。 */
318
+ const WRITE_RANK = { explicit: 3, review: 2, consolidate: 1 }
319
+
320
+ /**
321
+ * 条目的「被保护等级」——**由 `explicit` 布尔与来源共同决定**。
322
+ *
323
+ * ⚠️ 这里有一个必须讲清的取舍(2026-11-01 实测踩到,不是想当然):
324
+ * 若按字面把「规定 4 的阶梯」直接映射成 `protection = rank(source)`,那么
325
+ * `add()` 默认 `source='explicit'`(**经显式工具写入**)→ 保护级 3 → `consolidate`(1)
326
+ * 永远无权改动**任何一条正常写入的记忆** ⇒ 合并/冲突标记功能当场死掉,
327
+ * 而这恰恰是 §3.4 给 consolidate 的职责。
328
+ *
329
+ * 故正确模型是分开两件事:
330
+ * - **`explicit:true`(人显式说的)** = 硬保护(保护级 3):review 只能建议、consolidate 不得改写(规定 2)。
331
+ * - **`source`** = 来源留痕 + 在**自动写者之间**排先后(规定 4):review 写的条目保护级 2,
332
+ * consolidate 写的保护级 0 —— 于是「review > consolidate」仍然可观测,而工具直写的条目
333
+ * 保护级 0,任何自动写者都能处理。
334
+ *
335
+ * @param {object} entry
336
+ * @returns {number} 0/2/3
337
+ */
338
+ function protectionOf(entry) {
339
+ if (entry?.explicit === true) return 3 // 规定 2:人显式说的,只有显式写入才能动
340
+ if (entry?.source === 'review') return 2 // 规定 4:自动 review 的产出,consolidate 不得改写
341
+ return 0 // 工具直写(source='explicit' 但非人显式)或 consolidate 自身产出 → 可被自动写者处理
342
+ }
343
+
344
+ /**
345
+ * 规定 2 / 4 的机械化闸门:低优先级写者不得改写高优先级条目。
346
+ *
347
+ * @param {object} entry 目标条目(已存在)
348
+ * @param {'explicit'|'review'|'consolidate'} writer 本次写者身份
349
+ * @param {string} where 调用点(错误信息定位用)
350
+ * @throws {MemoryError} code = `MEMORY_WRITE_PRIORITY_DENIED`
351
+ */
352
+ function assertWriteAllowed(entry, writer, where) {
353
+ if (!WRITE_RANK[writer]) {
354
+ throw new MemoryError(
355
+ `memory.${where}:未知写者身份 source=${JSON.stringify(writer)}(合法值:${Object.keys(WRITE_RANK).join(' | ')})`,
356
+ 'MEMORY_BAD_FIELD',
357
+ { field: 'source', where },
358
+ )
359
+ }
360
+ if (entry?.explicit === true && writer !== 'explicit') {
361
+ throw new MemoryError(
362
+ `memory.${where}:条目 [id:${entry.id}] 是 explicit(人显式说的),${writer} 写者无权改写 —— `
363
+ + 'review 请改用 review()(只返回建议、不落盘);consolidate 请把它报为建议(规定 2)',
364
+ 'MEMORY_WRITE_PRIORITY_DENIED',
365
+ { id: entry.id, layer: entry.layer, writer, entrySource: entry.source, explicit: true },
366
+ )
367
+ }
368
+ const guard = protectionOf(entry)
369
+ if (WRITE_RANK[writer] < guard) {
370
+ throw new MemoryError(
371
+ `memory.${where}:写入优先级不足 —— ${writer}(${WRITE_RANK[writer]}) 不能改写 ${entry.source}(保护级 ${guard}) `
372
+ + `的条目 [id:${entry.id}](规定 4)`,
373
+ 'MEMORY_WRITE_PRIORITY_DENIED',
374
+ { id: entry.id, layer: entry.layer, writer, entrySource: entry.source, protection: guard },
375
+ )
376
+ }
377
+ }
378
+
379
+ // ── 状态目录 ────────────────────────────────────────────────────────────────
380
+
381
+ /**
382
+ * 解析状态目录:`config.stateDir` > `kernel.stateDir()` > 抛错。
383
+ * **不做静默兜底**:两条路都不通时必须让调用方看见。
384
+ * @param {{ stateDir?: string }} config
385
+ * @returns {string}
386
+ */
387
+ function resolveStateDir(config) {
388
+ if (typeof config?.stateDir === 'string' && config.stateDir.trim() !== '') {
389
+ return config.stateDir.trim()
390
+ }
391
+ if (kernel && typeof kernel.stateDir === 'function') {
392
+ return kernel.stateDir()
393
+ }
394
+ const reason = kernelError?.message ?? (kernel ? 'kernel.stateDir 不存在' : 'kernel 未加载')
395
+ throw new MemoryError(
396
+ `memory.createMemory:既未提供 config.stateDir,也无法从 kernel.js 取得 stateDir(${reason})。`
397
+ + '裸检出(无宿主包)下必须显式传 config.stateDir。',
398
+ 'MEMORY_NO_STATE_DIR',
399
+ )
400
+ }
401
+
402
+ // ── 主体 ────────────────────────────────────────────────────────────────────
403
+
404
+ /**
405
+ * 创建一个分层记忆实例。
406
+ *
407
+ * @param {{ logger?: { info?: Function, warn?: Function } }} [ctx] 宿主上下文(只用于日志,缺失即降级 no-op)
408
+ * @param {{ stateDir?: string, entryMax?: number, persona?: string|string[],
409
+ * searchBackend?: 'auto'|'fts5'|'includes', splitOnOverflow?: boolean,
410
+ * name?: string }} [config]
411
+ * @returns {object} memory
412
+ */
413
+ export function createMemory(ctx, config = {}) {
414
+ const stateDir = resolveStateDir(config)
415
+ const entryMax = Number.isInteger(config.entryMax) && config.entryMax > 0
416
+ ? config.entryMax
417
+ : DEFAULT_ENTRY_MAX
418
+ const splitOnOverflow = config.splitOnOverflow !== false
419
+ /** `searchBackend: 'includes'` 用于**强制**走退化路径(CI 要覆盖两条路)。 */
420
+ const forcedBackend = config.searchBackend ?? 'auto'
421
+ if (!['auto', 'fts5', 'includes'].includes(forcedBackend)) {
422
+ throw new MemoryError(
423
+ `memory.createMemory:config.searchBackend 必须是 auto | fts5 | includes,收到 ${JSON.stringify(forcedBackend)}`,
424
+ 'MEMORY_BAD_FIELD',
425
+ { field: 'searchBackend' },
426
+ )
427
+ }
428
+
429
+ const jsonlPath = statePath(stateDir, 'memory.jsonl')
430
+ const snapPath = statePath(stateDir, 'memory.snap.json')
431
+ const ftsPath = statePath(stateDir, 'memory-fts.db')
432
+
433
+ const info = (...args) => { try { ctx?.logger?.info?.(...args) } catch { /* 日志失败不影响业务 */ } }
434
+ const warn = (...args) => { try { ctx?.logger?.warn?.(...args) } catch { /* 同上 */ } }
435
+
436
+ /** L0 人格:**只来自人改的配置**,永不进事件流(模型不可写的机械保证)。 */
437
+ const persona = stringList(config.persona, 'persona', 'createMemory')
438
+
439
+ /** @type {Map<string, object>} id → Entry */
440
+ const entries = new Map()
441
+ /** @type {object[]} 全部 MemoryEvent(审计流,按落盘顺序) */
442
+ const events = []
443
+
444
+ const diagnostics = {
445
+ stateDir,
446
+ jsonlPath,
447
+ snapPath,
448
+ ftsPath,
449
+ kernel: kernel ? 'loaded' : `load-failed:${kernelError?.name ?? 'unknown'}`,
450
+ hydratedFrom: /** @type {null|'jsonl'|'snapshot'|'empty'} */ (null),
451
+ replayedEvents: 0,
452
+ badLines: 0,
453
+ snapshotWriteFailures: 0,
454
+ searchBackend: /** @type {'fts5'|'includes'} */ ('includes'),
455
+ searchBackendReason: /** @type {string} */ ('未初始化'),
456
+ ftsTokenize: /** @type {string|null} */ (null),
457
+ lastSearchAt: /** @type {number|undefined} */ (undefined),
458
+ lastReplayAt: /** @type {number|undefined} */ (undefined),
459
+ }
460
+
461
+ // ── FTS5 索引(派生缓存;坏了不影响真相源)──────────────────────────────────
462
+
463
+ /** @type {object | null} */ let db = null
464
+
465
+ /**
466
+ * 建立 FTS5 索引。分词器**必须**是 trigram —— 实测 unicode61/porter 对中文整段不分词
467
+ * (`MATCH '分层记忆'` 返回 0),会让中文检索整体失效。
468
+ * 建不起来就退化 `String.includes`,并把原因记进 diagnostics(**可见的降级**)。
469
+ */
470
+ function openIndex() {
471
+ if (forcedBackend === 'includes') {
472
+ diagnostics.searchBackend = 'includes'
473
+ diagnostics.searchBackendReason = 'config.searchBackend=includes(显式强制退化路径)'
474
+ return
475
+ }
476
+ if (!sqliteModule?.DatabaseSync) {
477
+ diagnostics.searchBackend = 'includes'
478
+ diagnostics.searchBackendReason = `node:sqlite 不可用(${sqliteError ?? '无 DatabaseSync 导出'})`
479
+ return
480
+ }
481
+ try {
482
+ ensureDir(stateDir)
483
+ db = new sqliteModule.DatabaseSync(ftsPath)
484
+ db.exec(
485
+ "CREATE VIRTUAL TABLE IF NOT EXISTS mem_fts USING fts5("
486
+ + "id UNINDEXED, layer UNINDEXED, rev UNINDEXED, body, tokenize='trigram')",
487
+ )
488
+ diagnostics.ftsTokenize = 'trigram'
489
+ diagnostics.searchBackend = 'fts5'
490
+ diagnostics.searchBackendReason = 'node:sqlite FTS5 + trigram'
491
+ } catch (error) {
492
+ try { db?.close?.() } catch { /* 关不掉不影响降级 */ }
493
+ db = null
494
+ diagnostics.searchBackend = 'includes'
495
+ diagnostics.searchBackendReason = `FTS5 建表失败,已退化:${error?.message ?? error}`
496
+ warn(`[cs-bots/memory] ${diagnostics.searchBackendReason}`)
497
+ }
498
+ }
499
+
500
+ /** 索引里各 id 的 rev(用于增量判断)。 */
501
+ const indexedRev = new Map()
502
+
503
+ /** 清空并全量重建索引。 */
504
+ function indexRebuild() {
505
+ indexedRev.clear()
506
+ if (!db) return
507
+ try {
508
+ db.exec('DELETE FROM mem_fts')
509
+ const ins = db.prepare('INSERT INTO mem_fts(id, layer, rev, body) VALUES (?, ?, ?, ?)')
510
+ for (const e of entries.values()) {
511
+ ins.run(e.id, e.layer, e.rev, e.content)
512
+ indexedRev.set(e.id, e.rev)
513
+ }
514
+ } catch (error) {
515
+ // 索引是派生缓存:失败不阻断业务,但**必须可见**(否则检索静默漏结果)
516
+ diagnostics.searchBackend = 'includes'
517
+ diagnostics.searchBackendReason = `索引重建失败,本次已退化:${error?.message ?? error}`
518
+ warn(`[cs-bots/memory] ${diagnostics.searchBackendReason}`)
519
+ try { db?.close?.() } catch { /* ignore */ }
520
+ db = null
521
+ }
522
+ }
523
+
524
+ /** 单条增量同步(commit 后调用)。 */
525
+ function indexUpsert(entry) {
526
+ if (!db) return
527
+ try {
528
+ db.prepare('DELETE FROM mem_fts WHERE id = ?').run(entry.id)
529
+ db.prepare('INSERT INTO mem_fts(id, layer, rev, body) VALUES (?, ?, ?, ?)')
530
+ .run(entry.id, entry.layer, entry.rev, entry.content)
531
+ indexedRev.set(entry.id, entry.rev)
532
+ } catch (error) {
533
+ // 增量失败 → 标脏,交给下一次检索前的自愈(见 ensureIndexFresh)
534
+ indexedRev.delete(entry.id)
535
+ warn(`[cs-bots/memory] 索引增量同步失败(id=${entry.id}):${error?.message ?? error}`)
536
+ }
537
+ }
538
+
539
+ /**
540
+ * 检索前自愈:内存态里任一活跃条目的 id/rev 与索引不一致 → 全量重建。
541
+ * 索引是缓存,宁可重建也不能让检索**静默漏条目**。
542
+ */
543
+ function ensureIndexFresh() {
544
+ if (!db) return
545
+ if (indexedRev.size !== entries.size) { indexRebuild(); return }
546
+ for (const e of entries.values()) {
547
+ if (indexedRev.get(e.id) !== e.rev) { indexRebuild(); return }
548
+ }
549
+ }
550
+
551
+ // ── 持久化 ────────────────────────────────────────────────────────────────
552
+
553
+ /** 快照内容(落盘形态)。 */
554
+ const snapshot = () => ({
555
+ version: 1,
556
+ savedAt: Date.now(),
557
+ entries: [...entries.values()],
558
+ })
559
+
560
+ /**
561
+ * 提交一次变更:**先落事件,再改内存,最后写快照**。
562
+ *
563
+ * 顺序不可颠倒:append 失败时内存必须原封不动(否则内存与磁盘分叉,
564
+ * 重启回放后状态凭空回退,属于最恶劣的静默失败)。
565
+ *
566
+ * @param {string} id
567
+ * @param {string} action
568
+ * @param {string|null} byId
569
+ * @param {number} at
570
+ * @param {object|null} before 变更前 Entry(add 为 null)
571
+ * @param {object} after 变更后 Entry
572
+ * @returns {object} 克隆后的 after
573
+ */
574
+ function commit(id, action, byId, at, before, after) {
575
+ const event = {
576
+ id,
577
+ layer: after.layer,
578
+ rev: after.rev,
579
+ action,
580
+ byId: byId ?? null,
581
+ at,
582
+ before: before ? clone(before) : null,
583
+ after: clone(after),
584
+ }
585
+ if (!appendJsonl(jsonlPath, event)) {
586
+ throw new MemoryError(
587
+ `memory.${action}:事件追加失败(${jsonlPath})——拒绝「只改内存不落盘」`,
588
+ 'MEMORY_APPEND_FAILED',
589
+ { id, action },
590
+ )
591
+ }
592
+ entries.set(id, clone(after))
593
+ events.push(event)
594
+ indexUpsert(after)
595
+ if (!writeJsonAtomic(snapPath, snapshot())) {
596
+ diagnostics.snapshotWriteFailures += 1
597
+ warn(`[cs-bots/memory] 快照写入失败(${snapPath})——事件流已落盘,replay() 可完整恢复`)
598
+ }
599
+ return clone(after)
600
+ }
601
+
602
+ // ── 回放 ──────────────────────────────────────────────────────────────────
603
+
604
+ /**
605
+ * 从 `memory.jsonl`(真相源)重建内存态;jsonl 为空才回落到 `memory.snap.json`。
606
+ * 坏行**不静默丢弃**:计入 diagnostics 并 warn。
607
+ */
608
+ function hydrate() {
609
+ entries.clear()
610
+ events.length = 0
611
+
612
+ const { rows, bad } = readJsonl(jsonlPath)
613
+ diagnostics.badLines = bad
614
+ diagnostics.replayedEvents = rows.length
615
+ if (bad > 0) {
616
+ warn(`[cs-bots/memory] ${jsonlPath} 有 ${bad} 行无法解析(撕裂写或文件损坏)——已跳过,请核对`)
617
+ }
618
+
619
+ if (rows.length === 0) {
620
+ const snap = readJson(snapPath, null)
621
+ const snapEntries = Array.isArray(snap?.entries) ? snap.entries : null
622
+ if (snapEntries) {
623
+ for (const raw of snapEntries) {
624
+ if (!raw || typeof raw.id !== 'string' || typeof raw.layer !== 'string') continue
625
+ entries.set(raw.id, clone(raw))
626
+ }
627
+ diagnostics.hydratedFrom = 'snapshot'
628
+ indexRebuild()
629
+ info(`[cs-bots/memory] 回放完成:无事件流 → 由快照恢复 ${entries.size} 条`)
630
+ return
631
+ }
632
+ diagnostics.hydratedFrom = 'empty'
633
+ indexRebuild()
634
+ info('[cs-bots/memory] 回放完成:空记忆(无事件流、无快照)')
635
+ return
636
+ }
637
+
638
+ // 事件流是真相源:只接受 rev 递增的事件(同 rev 重放幂等,容忍重复投递)
639
+ let skipped = 0
640
+ for (const ev of rows) {
641
+ if (!ev || typeof ev.id !== 'string' || !ev.after || typeof ev.after.id !== 'string') {
642
+ skipped += 1
643
+ continue
644
+ }
645
+ const before = entries.get(ev.id)
646
+ if (before && Number.isFinite(ev.rev) && ev.rev <= before.rev) {
647
+ skipped += 1
648
+ continue
649
+ }
650
+ entries.set(ev.id, clone(ev.after))
651
+ events.push(clone(ev))
652
+ }
653
+ if (skipped > 0) {
654
+ warn(`[cs-bots/memory] 回放跳过 ${skipped} 条非递增/残缺事件(已保留在文件中,未被静默丢弃)`)
655
+ }
656
+ diagnostics.hydratedFrom = 'jsonl'
657
+ indexRebuild()
658
+ info(`[cs-bots/memory] 回放完成:${rows.length} 条事件 → ${entries.size} 条记忆`)
659
+ }
660
+
661
+ // ── 内部读取 ──────────────────────────────────────────────────────────────
662
+
663
+ /** 生成一个未占用的 8 位 hex id。 */
664
+ function newId() {
665
+ for (let i = 0; i < 1000; i += 1) {
666
+ const id = randomBytes(Math.ceil(ID_LEN / 2)).toString('hex').slice(0, ID_LEN)
667
+ if (!entries.has(id)) return id
668
+ }
669
+ throw new MemoryError('memory:连续 1000 次生成的 id 均已存在(疑似熵源异常)', 'MEMORY_ID_EXHAUSTED')
670
+ }
671
+
672
+ /**
673
+ * 按 id 取条目(**按 id 定位是规定 1 的机械落点**)。
674
+ * @param {string} id
675
+ * @param {string|undefined} layer 给了就校验层归属(跨层拿 id 是调用方 bug,必须可见)
676
+ * @param {string} where
677
+ * @returns {object}
678
+ */
679
+ function requireEntry(id, layer, where) {
680
+ const entryId = requireString(id, 'id', where)
681
+ const entry = entries.get(entryId)
682
+ if (!entry) {
683
+ throw new MemoryError(
684
+ `memory.${where}:未知 id=${entryId}(记忆按 id 定位,请先用 list()/search() 取 id)`,
685
+ 'MEMORY_ENTRY_NOT_FOUND',
686
+ { id: entryId, layer },
687
+ )
688
+ }
689
+ if (layer !== undefined && layer !== null && entry.layer !== layer) {
690
+ throw new MemoryError(
691
+ `memory.${where}:id=${entryId} 属于 ${entry.layer},不是 ${layer} —— 拒绝跨层误改`,
692
+ 'MEMORY_LAYER_MISMATCH',
693
+ { id: entryId, layer, actualLayer: entry.layer },
694
+ )
695
+ }
696
+ return entry
697
+ }
698
+
699
+ /** L0 防线:任何写路径都先过这里。 */
700
+ function assertNotPersonaLayer(layer, where) {
701
+ if (layer === PERSONA_LAYER) {
702
+ throw new MemoryError(
703
+ `memory.${where}:${PERSONA_LAYER} 是人格层,**模型不可写**,只能由人改配置(config.persona)—— 拒绝写入`,
704
+ 'MEMORY_PERSONA_IMMUTABLE',
705
+ { layer: PERSONA_LAYER, where },
706
+ )
707
+ }
708
+ }
709
+
710
+ /**
711
+ * Bot 可见性:条目不带 `botId` = 全局可见;带了 = 只对该 Bot 可见。
712
+ * @param {object} entry
713
+ * @param {string | undefined} forBot
714
+ */
715
+ function visibleToBot(entry, forBot) {
716
+ if (!entry?.botId) return true
717
+ return entry.botId === forBot
718
+ }
719
+
720
+ /** 分支可见性:`branches` 为空 = 全分支可见;否则要求包含目标分支。 */
721
+ function branchVisible(entry, branch) {
722
+ if (branch === undefined || branch === null) return true
723
+ const branches = Array.isArray(entry.branches) ? entry.branches : []
724
+ return branches.length === 0 || branches.includes(branch)
725
+ }
726
+
727
+ // ── API:写 ───────────────────────────────────────────────────────────────
728
+
729
+ /**
730
+ * 新增记忆条目(规定 1:返回带 `id` + `rev` 的 Entry)。
731
+ *
732
+ * 超长(> `entryMax` 码点)时**自动无损切分**为多条,返回**首条**并带
733
+ * `split:true` + `parts:[其余 id]`;`splitOnOverflow:false` 时改为抛
734
+ * `MEMORY_ENTRY_TOO_LONG`(两条路都**可见**,绝不静默截断)。
735
+ *
736
+ * @param {{ layer: string, content: string, by: string, explicit?: boolean,
737
+ * source?: 'explicit'|'review'|'consolidate', branch?: string|string[],
738
+ * tags?: string[], now?: number }} input
739
+ * @returns {object} Entry(含 id + rev)
740
+ */
741
+ function add(input = {}) {
742
+ const { layer, content, by, explicit, source, branch, tags, botId, now } = input ?? {}
743
+ const targetLayer = requireLayer(layer, 'add')
744
+ assertNotPersonaLayer(targetLayer, 'add')
745
+ const author = requireString(by, 'by', 'add')
746
+ const text = requireString(content, 'content', 'add')
747
+ const writer = source === undefined || source === null
748
+ ? 'explicit'
749
+ : requireString(source, 'source', 'add')
750
+ if (!WRITE_RANK[writer]) {
751
+ throw new MemoryError(
752
+ `memory.add:非法 source=${JSON.stringify(source)}(合法值:${Object.keys(WRITE_RANK).join(' | ')})`,
753
+ 'MEMORY_BAD_FIELD',
754
+ { field: 'source' },
755
+ )
756
+ }
757
+ const branches = stringList(branch, 'branch', 'add')
758
+ // Bot 维度:不传 = 全局记忆;传了 = 只在那个 Bot 的 contextFor 里出现
759
+ const ownerBot = optionalString(botId, 'botId', 'add')
760
+ const tagList = stringList(tags, 'tags', 'add')
761
+ const at = normalizeNow(now, 'add')
762
+
763
+ const parts = cpLength(text) > entryMax ? partitionContent(text, entryMax) : [text]
764
+ if (parts.length > 1 && !splitOnOverflow) {
765
+ throw new MemoryError(
766
+ `memory.add:内容超长(${cpLength(text)} > ${entryMax} 码点上限),且 config.splitOnOverflow=false —— 拒绝写入`,
767
+ 'MEMORY_ENTRY_TOO_LONG',
768
+ { length: cpLength(text), max: entryMax, layer: targetLayer },
769
+ )
770
+ }
771
+
772
+ /** @type {object[]} */
773
+ const created = []
774
+ for (const part of parts) {
775
+ const id = newId()
776
+ /** @type {Record<string, unknown>} */
777
+ const entry = {
778
+ id,
779
+ rev: 1,
780
+ layer: targetLayer,
781
+ content: part,
782
+ source: writer,
783
+ explicit: explicit === true,
784
+ branches,
785
+ ...(ownerBot !== undefined ? { botId: ownerBot } : {}),
786
+ tags: tagList.slice(),
787
+ createdAt: at,
788
+ createdBy: author,
789
+ updatedAt: at,
790
+ updatedBy: author,
791
+ }
792
+ if (parts.length > 1) {
793
+ entry.splitIndex = created.length
794
+ entry.splitTotal = parts.length
795
+ }
796
+ try {
797
+ created.push(commit(id, 'add', author, at, null, entry))
798
+ } catch (error) {
799
+ // 绝不静默:已落盘的部分必须被调用方看见
800
+ throw new MemoryError(
801
+ `memory.add:切分写入第 ${created.length + 1}/${parts.length} 段失败 —— `
802
+ + `已落盘 ${created.length} 段(id: ${created.map((e) => e.id).join(', ') || '无'}):${error?.message ?? error}`,
803
+ 'MEMORY_PARTIAL_SPLIT',
804
+ { layer: targetLayer, created: created.map((e) => e.id), failedPart: created.length + 1, total: parts.length },
805
+ )
806
+ }
807
+ }
808
+
809
+ const first = created[0]
810
+ if (created.length > 1) {
811
+ first.split = true
812
+ first.parts = created.slice(1).map((e) => e.id)
813
+ info(`[cs-bots/memory] add:内容 ${cpLength(text)} 码点 > ${entryMax} 上限 → 无损切分为 ${created.length} 条(首条 ${first.id})`)
814
+ }
815
+ return first
816
+ }
817
+
818
+ /**
819
+ * 按 id 做 CAS 变更。**rev 不匹配即抛错,绝不静默覆盖**。
820
+ *
821
+ * 规定 2 的硬闸门:目标条目 `explicit:true` 而本次调用未带 `explicit:true` →
822
+ * 抛 `MEMORY_EXPLICIT_PROTECTED`(并指向 `review()`)。
823
+ *
824
+ * @param {{ layer: string, id: string, expectedRev: number, content?: string, tags?: string[],
825
+ * explicit?: boolean, by: string, now?: number }} input
826
+ * @returns {object} 变更后的 Entry
827
+ */
828
+ function update(input = {}) {
829
+ const { layer, id, expectedRev, content, tags, explicit, by, now } = input ?? {}
830
+ const targetLayer = requireLayer(layer, 'update')
831
+ assertNotPersonaLayer(targetLayer, 'update')
832
+ const author = requireString(by, 'by', 'update')
833
+ const current = requireEntry(id, targetLayer, 'update')
834
+
835
+ if (typeof expectedRev !== 'number' || !Number.isInteger(expectedRev)) {
836
+ throw new MemoryError(
837
+ `memory.update:expectedRev 必须是整数(CAS 需要显式期望版本;条目 ${current.id} 当前 rev=${current.rev},`
838
+ + `收到 ${JSON.stringify(expectedRev)})`,
839
+ 'MEMORY_BAD_FIELD',
840
+ { field: 'expectedRev', id: current.id, rev: current.rev },
841
+ )
842
+ }
843
+ if (current.rev !== expectedRev) {
844
+ throw new MemoryError(
845
+ `memory.update CAS 冲突:条目 [id:${current.id}] 期望 rev=${expectedRev},实际 rev=${current.rev} —— `
846
+ + '拒绝静默覆盖,请重新 get() 后按最新 rev 重试',
847
+ 'MEMORY_CAS_CONFLICT',
848
+ { id: current.id, layer: targetLayer, expectedRev, rev: current.rev },
849
+ )
850
+ }
851
+ if (current.archived) {
852
+ throw new MemoryError(
853
+ `memory.update:条目 [id:${current.id}] 已归档(reason=${current.archived.reason ?? 'n/a'}),需先 restore() 才能改`,
854
+ 'MEMORY_ENTRY_ARCHIVED',
855
+ { id: current.id, layer: targetLayer },
856
+ )
857
+ }
858
+ if (current.explicit === true && explicit !== true) {
859
+ throw new MemoryError(
860
+ `memory.update:条目 [id:${current.id}] 是 explicit(人显式说的),本次调用未带 explicit:true —— `
861
+ + '规定 2:review 只能建议不能改,请改用 review()(它会返回建议而不落盘)',
862
+ 'MEMORY_EXPLICIT_PROTECTED',
863
+ { id: current.id, layer: targetLayer, hint: 'use review()' },
864
+ )
865
+ }
866
+ // 到这里已过 explicit 闸门;本调用是**显式工具直写**(规定 4 的最高优先级)
867
+ assertWriteAllowed(current, 'explicit', 'update')
868
+
869
+ const at = normalizeNow(now, 'update')
870
+ const before = clone(current)
871
+ const next = clone(current)
872
+ next.rev = current.rev + 1
873
+ next.updatedAt = at
874
+ next.updatedBy = author
875
+ if (explicit === true) next.explicit = true
876
+ if (content !== undefined && content !== null) {
877
+ const text = requireString(content, 'content', 'update')
878
+ if (cpLength(text) > entryMax) {
879
+ throw new MemoryError(
880
+ `memory.update:内容超长(${cpLength(text)} > ${entryMax} 码点上限)—— update 是单条 CAS 无法切分,`
881
+ + '请用 add()(它会自动无损切分)或先自行拆分',
882
+ 'MEMORY_ENTRY_TOO_LONG',
883
+ { length: cpLength(text), max: entryMax, id: current.id },
884
+ )
885
+ }
886
+ next.content = text
887
+ }
888
+ if (tags !== undefined) next.tags = stringList(tags, 'tags', 'update')
889
+ return commit(current.id, 'update', author, at, before, next)
890
+ }
891
+
892
+ /**
893
+ * 删除 = **归档**(规定:不物理删,可恢复)。
894
+ * @param {{ layer: string, id: string, expectedRev: number, by: string, reason?: string, now?: number }} input
895
+ * @returns {object} 归档后的 Entry
896
+ */
897
+ function remove(input = {}) {
898
+ const { layer, id, expectedRev, by, reason, now } = input ?? {}
899
+ return doArchive({ layer, id, expectedRev, by, reason: reason ?? 'removed', now, action: 'remove' })
900
+ }
901
+
902
+ /**
903
+ * 归档(同 `remove`,语义上更强调「不是删除」)。
904
+ * @param {{ layer: string, id: string, by: string, reason?: string, expectedRev?: number, now?: number }} input
905
+ * @returns {object} 归档后的 Entry
906
+ */
907
+ function archive(input = {}) {
908
+ const { layer, id, by, reason, expectedRev, now } = input ?? {}
909
+ return doArchive({ layer, id, expectedRev, by, reason: reason ?? 'manual-archive', now, action: 'archive' })
910
+ }
911
+
912
+ /**
913
+ * 归档实现。`expectedRev` 给了就强制 CAS;没给也**必须**在已归档时抛错(不静默重复归档)。
914
+ * @param {{ layer: string, id: string, expectedRev?: number, by: string, reason: string, now?: number, action: string }} input
915
+ * @returns {object}
916
+ */
917
+ function doArchive(input) {
918
+ const { layer, id, expectedRev, by, reason, now, action } = input
919
+ const targetLayer = requireLayer(layer, action)
920
+ assertNotPersonaLayer(targetLayer, action)
921
+ const author = requireString(by, 'by', action)
922
+ const current = requireEntry(id, targetLayer, action)
923
+ const why = requireString(reason, 'reason', action)
924
+
925
+ if (expectedRev !== undefined && expectedRev !== null) {
926
+ if (typeof expectedRev !== 'number' || !Number.isInteger(expectedRev)) {
927
+ throw new MemoryError(
928
+ `memory.${action}:expectedRev 必须是整数(收到 ${JSON.stringify(expectedRev)})`,
929
+ 'MEMORY_BAD_FIELD',
930
+ { field: 'expectedRev', id: current.id },
931
+ )
932
+ }
933
+ if (current.rev !== expectedRev) {
934
+ throw new MemoryError(
935
+ `memory.${action} CAS 冲突:条目 [id:${current.id}] 期望 rev=${expectedRev},实际 rev=${current.rev} —— 拒绝静默覆盖`,
936
+ 'MEMORY_CAS_CONFLICT',
937
+ { id: current.id, expectedRev, rev: current.rev },
938
+ )
939
+ }
940
+ }
941
+ if (current.archived) {
942
+ throw new MemoryError(
943
+ `memory.${action}:条目 [id:${current.id}] 已于 ${new Date(current.archived.at).toISOString()} 归档`
944
+ + `(reason=${current.archived.reason ?? 'n/a'})—— 拒绝静默重复归档`,
945
+ 'MEMORY_ALREADY_ARCHIVED',
946
+ { id: current.id, archived: current.archived },
947
+ )
948
+ }
949
+ assertWriteAllowed(current, 'explicit', action)
950
+
951
+ const at = normalizeNow(now, action)
952
+ const before = clone(current)
953
+ const next = clone(current)
954
+ next.rev = current.rev + 1
955
+ next.updatedAt = at
956
+ next.updatedBy = author
957
+ next.archived = { by: author, at, reason: why }
958
+ return commit(current.id, action, author, at, before, next)
959
+ }
960
+
961
+ /**
962
+ * 从归档恢复(与 `remove`/`archive` 构成往返一致)。归档不是删除,恢复后原文照旧。
963
+ * @param {{ layer: string, id: string, by?: string, now?: number }} input
964
+ * @returns {object} 恢复后的 Entry
965
+ */
966
+ function restore(input = {}) {
967
+ const { layer, id, by, now } = input ?? {}
968
+ const targetLayer = requireLayer(layer, 'restore')
969
+ assertNotPersonaLayer(targetLayer, 'restore')
970
+ const current = requireEntry(id, targetLayer, 'restore')
971
+ if (!current.archived) {
972
+ throw new MemoryError(
973
+ `memory.restore:条目 [id:${current.id}] 未处于归档状态,无需恢复 —— 拒绝静默空操作`,
974
+ 'MEMORY_NOT_ARCHIVED',
975
+ { id: current.id, layer: targetLayer },
976
+ )
977
+ }
978
+ const author = by === undefined || by === null ? 'human' : requireString(by, 'by', 'restore')
979
+ const at = normalizeNow(now, 'restore')
980
+ const before = clone(current)
981
+ const next = clone(current)
982
+ next.rev = current.rev + 1
983
+ next.updatedAt = at
984
+ next.updatedBy = author
985
+ delete next.archived
986
+ delete next.mergedInto
987
+ return commit(current.id, 'restore', author, at, before, next)
988
+ }
989
+
990
+ // ── API:review(规定 2 的主战场)────────────────────────────────────────────
991
+
992
+ /**
993
+ * 自动 review:**只对非 explicit 条目生效**;碰到 explicit 条目**只返回建议、不改写**(规定 2)。
994
+ *
995
+ * `proposed` 每项:
996
+ * - 带 `id`:定位该条目;explicit → 建议;否则 CAS 改内容(source='review')。
997
+ * - 不带 `id`:按签名匹配既有条目(explicit 优先给建议),匹配不到则新增(source='review')。
998
+ *
999
+ * @param {{ layer: string, proposed: object|object[], by: string, now?: number }} input
1000
+ * @returns {{ layer: string, applied: object[], suggested: object[], rejected: object[], results: object[] }}
1001
+ */
1002
+ function review(input = {}) {
1003
+ const { layer, proposed, by, now } = input ?? {}
1004
+ const targetLayer = requireLayer(layer, 'review')
1005
+ assertNotPersonaLayer(targetLayer, 'review')
1006
+ const author = requireString(by, 'by', 'review')
1007
+ const items = Array.isArray(proposed) ? proposed : (proposed === undefined ? [] : [proposed])
1008
+ if (items.length === 0) {
1009
+ throw new MemoryError(
1010
+ 'memory.review:proposed 为空 —— 无内容可 review(拒绝静默空转)',
1011
+ 'MEMORY_MISSING_FIELD',
1012
+ { field: 'proposed', layer: targetLayer },
1013
+ )
1014
+ }
1015
+
1016
+ const results = []
1017
+ const applied = []
1018
+ const suggested = []
1019
+ const rejected = []
1020
+
1021
+ for (const raw of items) {
1022
+ const item = raw ?? {}
1023
+ const content = requireString(item.content, 'content', 'review')
1024
+ const at = normalizeNow(item.now ?? now, 'review')
1025
+
1026
+ /** 定位目标:显式 id 优先,否则按签名匹配 */
1027
+ /** @type {object|undefined} */
1028
+ let target
1029
+ if (item.id !== undefined && item.id !== null) {
1030
+ target = requireEntry(item.id, targetLayer, 'review')
1031
+ } else {
1032
+ const sig = signatureOf(content)
1033
+ target = [...entries.values()].find(
1034
+ (e) => e.layer === targetLayer && !e.archived && signatureOf(e.content) === sig,
1035
+ )
1036
+ }
1037
+
1038
+ if (!target) {
1039
+ const created = add({ layer: targetLayer, content, by: author, source: 'review', tags: item.tags, now: at })
1040
+ const row = { action: 'added', id: created.id, rev: created.rev }
1041
+ results.push(row)
1042
+ applied.push(row)
1043
+ continue
1044
+ }
1045
+
1046
+ if (target.archived) {
1047
+ const row = {
1048
+ action: 'rejected',
1049
+ id: target.id,
1050
+ code: 'MEMORY_ENTRY_ARCHIVED',
1051
+ reason: '目标条目已归档,review 不改归档内容;如需恢复请 restore()',
1052
+ current: target.content,
1053
+ }
1054
+ results.push(row)
1055
+ rejected.push(row)
1056
+ continue
1057
+ }
1058
+
1059
+ // ★ 规定 2:explicit 条目只给建议,**不写盘**
1060
+ if (target.explicit === true) {
1061
+ const row = {
1062
+ action: 'suggest',
1063
+ id: target.id,
1064
+ layer: targetLayer,
1065
+ rev: target.rev,
1066
+ current: target.content,
1067
+ suggested: content,
1068
+ reason: 'explicit 条目是人显式写的,review 只能建议不能改(规定 2)',
1069
+ }
1070
+ results.push(row)
1071
+ suggested.push(row)
1072
+ info(`[cs-bots/memory] review:命中 explicit 条目 ${target.id} → 仅返回建议,未改写`)
1073
+ continue
1074
+ }
1075
+
1076
+ /**
1077
+ * 单条失败的处置:**整批不中断,但绝不静默**。
1078
+ * review 是批量 pass,一条撞上优先级/归档等闸门不该让其余条目全不处理;
1079
+ * 失败以 `action:'rejected'` + 原始 code 出现在返回里(可见 = 合规)。
1080
+ */
1081
+ try {
1082
+ assertWriteAllowed(target, 'review', 'review')
1083
+ const updated = update({
1084
+ layer: targetLayer,
1085
+ id: target.id,
1086
+ expectedRev: target.rev,
1087
+ content,
1088
+ by: author,
1089
+ tags: item.tags,
1090
+ now: at,
1091
+ })
1092
+ const row = { action: 'updated', id: updated.id, rev: updated.rev, from: target.content, to: updated.content }
1093
+ results.push(row)
1094
+ applied.push(row)
1095
+ } catch (error) {
1096
+ if (!(error instanceof MemoryError)) throw error
1097
+ const row = {
1098
+ action: 'rejected',
1099
+ id: target.id,
1100
+ rev: target.rev,
1101
+ code: error.code,
1102
+ reason: error.message,
1103
+ current: target.content,
1104
+ suggested: content,
1105
+ }
1106
+ results.push(row)
1107
+ rejected.push(row)
1108
+ warn(`[cs-bots/memory] review 跳过 ${target.id}:${error.code} —— ${error.message}`)
1109
+ }
1110
+ }
1111
+
1112
+ if (applied.length > 0 || suggested.length > 0) {
1113
+ info(`[cs-bots/memory] review(${targetLayer}):改 ${applied.length} 条 / 建议 ${suggested.length} 条 / 拒 ${rejected.length} 条`)
1114
+ }
1115
+ return { layer: targetLayer, by: author, applied, suggested, rejected, results }
1116
+ }
1117
+
1118
+ // ── API:consolidate(规定 3 与 4)─────────────────────────────────────────────
1119
+
1120
+ /**
1121
+ * 后台合并:**重复**合并(被并方归档、保留 `mergedInto` 证据);
1122
+ * **矛盾**两条都留并标 `[conflict]`(规定 3);**explicit 条目一律不动**,只给建议(规定 2/4)。
1123
+ *
1124
+ * 判定顺序**不可颠倒**:先判冲突(极性相反),再判重复(归一后全等)。
1125
+ * 否则 `使用 pnpm` 与 `不使用 pnpm` 会被当成重复合并掉,那正是规定 3 要禁止的静默冲突消除。
1126
+ *
1127
+ * @param {{ layer: string, by: string, conflicts?: Array<[string,string]>, daily?: boolean, now?: number }} input
1128
+ * @returns {{ layer: string, scanned: number, merged: object[], conflicts: object[], suggested: object[], unchanged: number, dailyId: string|null }}
1129
+ */
1130
+ function consolidate(input = {}) {
1131
+ const { layer, by, conflicts, daily, now } = input ?? {}
1132
+ const targetLayer = requireLayer(layer, 'consolidate')
1133
+ assertNotPersonaLayer(targetLayer, 'consolidate')
1134
+ const author = requireString(by, 'by', 'consolidate')
1135
+ const at = normalizeNow(now, 'consolidate')
1136
+
1137
+ const live = [...entries.values()].filter((e) => e.layer === targetLayer && !e.archived)
1138
+ const merged = []
1139
+ const conflictRows = []
1140
+ const suggested = []
1141
+ const rejectedConflicts = []
1142
+ /** @type {Set<string>} 已被本次处理的 id(避免同一条被合并两次) */
1143
+ const handled = new Set()
1144
+
1145
+ /**
1146
+ * 冲突集**传递闭包**:矛盾关系是「同题反主张」,它天然成团。
1147
+ * 例如 `用 pnpm` / `不用 pnpm` / `用 pnpm`(重复)三条里,
1148
+ * 前两条矛盾、后两条只差重复 —— 团内每条都该与**其余冲突成员**互标,
1149
+ * 否则 `不用 pnpm` 那条看起来「只跟一份副本冲突」,另一份副本仍是干净态,
1150
+ * 注入时读者会以为它没争议。故对团取闭包,一次把团内两两全部登记。
1151
+ * @param {Array<[string, string]>} edges 已确认的矛盾边(id 对)
1152
+ * @returns {Array<[string, string]>} 闭包后的边(去重、无自环)
1153
+ */
1154
+ function closeConflictEdges(edges) {
1155
+ const adj = new Map()
1156
+ const add = (x, y) => {
1157
+ if (!adj.has(x)) adj.set(x, new Set())
1158
+ adj.get(x).add(y)
1159
+ }
1160
+ for (const [x, y] of edges) {
1161
+ if (x === y) continue
1162
+ add(x, y)
1163
+ add(y, x)
1164
+ }
1165
+ const out = []
1166
+ const seen = new Set()
1167
+ const nodes = [...adj.keys()]
1168
+ for (const start of nodes) {
1169
+ // 该连通分量(BFS)
1170
+ const comp = new Set([start])
1171
+ const queue = [start]
1172
+ while (queue.length > 0) {
1173
+ const cur = queue.pop()
1174
+ for (const nxt of adj.get(cur) ?? []) {
1175
+ if (!comp.has(nxt)) { comp.add(nxt); queue.push(nxt) }
1176
+ }
1177
+ }
1178
+ const members = [...comp].sort()
1179
+ for (let i = 0; i < members.length; i += 1) {
1180
+ for (let j = i + 1; j < members.length; j += 1) {
1181
+ const key = `${members[i]}|${members[j]}`
1182
+ if (seen.has(key)) continue
1183
+ seen.add(key)
1184
+ out.push([members[i], members[j]])
1185
+ }
1186
+ }
1187
+ }
1188
+ return out
1189
+ }
1190
+
1191
+ /**
1192
+ * 把最终态 = **每一个连通分量内全量两两标记**。
1193
+ * 逐边 apply 会退化成「最后一次调用胜出」的半截态(实测踩到),
1194
+ * 故这里按分量算**最终 marked 集合**,再逐条按当前 rev 落盘。
1195
+ *
1196
+ * @param {Array<[object, object, string]>} pairList 已确认的矛盾对
1197
+ * @returns {object[]} conflictRows
1198
+ */
1199
+ function applyConflictClosure(pairList) {
1200
+ const rows = []
1201
+ const edges = pairList.map(([a, b]) => [a.id, b.id])
1202
+ const closedEdges = closeConflictEdges(edges)
1203
+
1204
+ /** 分量成员 id → 该成员应登记的 conflictWith 全集合 */
1205
+ const want = new Map()
1206
+ for (const [x, y] of closedEdges) {
1207
+ if (!want.has(x)) want.set(x, new Set())
1208
+ if (!want.has(y)) want.set(y, new Set())
1209
+ want.get(x).add(y)
1210
+ want.get(y).add(x)
1211
+ }
1212
+ /** 原因:取该成员参与的第一条边的判定来源(declared 优先,便于人回溯) */
1213
+ const reasonOf = new Map()
1214
+ for (const [a, b, how] of pairList) {
1215
+ const label = how === 'declared' ? 'declared-conflict' : 'polarity-conflict'
1216
+ for (const id of [a.id, b.id]) {
1217
+ if (!reasonOf.has(id) || label === 'declared-conflict') reasonOf.set(id, label)
1218
+ }
1219
+ }
1220
+
1221
+ for (const [id, withSet] of want) {
1222
+ const cur = entries.get(id)
1223
+ if (!cur) {
1224
+ rows.push({
1225
+ action: 'rejected', id, code: 'MEMORY_ENTRY_NOT_FOUND',
1226
+ reason: '冲突闭包的成员在处理过程中已不存在 → 跳过并回报(不写半截)',
1227
+ })
1228
+ continue
1229
+ }
1230
+ const missing = [...withSet].filter((x) => !(Array.isArray(cur.conflictWith) && cur.conflictWith.includes(x)))
1231
+ if (missing.length === 0 && cur.conflict === true) continue // 已是最新,幂等
1232
+ const bad = checkWritable(cur, 'consolidate')
1233
+ if (bad) {
1234
+ rows.push({ action: 'rejected', id, code: bad.code, reason: `冲突闭包成员不可改写 → 跳过:${bad.message}` })
1235
+ warn(`[cs-bots/memory] consolidate:冲突闭包跳过 ${id}:${bad.message}`)
1236
+ continue
1237
+ }
1238
+ try {
1239
+ const next = commit(
1240
+ id,
1241
+ 'consolidate',
1242
+ author,
1243
+ at,
1244
+ clone(cur),
1245
+ conflictMarkNext(cur, [...withSet], author, at, reasonOf.get(id) ?? 'conflict-closure'),
1246
+ )
1247
+ rows.push({ a: id, conflictWith: next.conflictWith, rev: next.rev, kind: 'marked' })
1248
+ } catch (error) {
1249
+ if (!(error instanceof MemoryError)) throw error
1250
+ rows.push({ action: 'rejected', id, code: error.code, reason: error.message })
1251
+ warn(`[cs-bots/memory] consolidate:冲突闭包写 ${id} 失败:${error.message}`)
1252
+ }
1253
+ }
1254
+ return rows
1255
+ }
1256
+
1257
+ // ── 第 1 步:冲突判定(先于去重)─────────────────────────────────────────
1258
+ /** 外部显式声明的矛盾对(检测器判不出时由 review/人补) */
1259
+ const declared = Array.isArray(conflicts) ? conflicts : []
1260
+ /** @type {Array<[object, object, string]>} */
1261
+ const pairs = []
1262
+ for (const pair of declared) {
1263
+ if (!Array.isArray(pair) || pair.length !== 2) {
1264
+ throw new MemoryError(
1265
+ `memory.consolidate:conflicts 每项必须是 [idA, idB],收到 ${JSON.stringify(pair)}`,
1266
+ 'MEMORY_BAD_FIELD',
1267
+ { field: 'conflicts' },
1268
+ )
1269
+ }
1270
+ pairs.push([
1271
+ requireEntry(pair[0], targetLayer, 'consolidate'),
1272
+ requireEntry(pair[1], targetLayer, 'consolidate'),
1273
+ 'declared',
1274
+ ])
1275
+ }
1276
+ const bySignature = new Map()
1277
+ for (const e of live) {
1278
+ const sig = signatureOf(e.content)
1279
+ if (!bySignature.has(sig)) bySignature.set(sig, [])
1280
+ bySignature.get(sig).push(e)
1281
+ }
1282
+ for (const [, group] of bySignature) {
1283
+ if (group.length < 2) continue
1284
+ for (let i = 0; i < group.length; i += 1) {
1285
+ for (let j = i + 1; j < group.length; j += 1) {
1286
+ const a = group[i]
1287
+ const b = group[j]
1288
+ const pa = polarityOf(a.content)
1289
+ const pb = polarityOf(b.content)
1290
+ if (pa.neg !== pb.neg) pairs.push([a, b, 'polarity'])
1291
+ }
1292
+ }
1293
+ }
1294
+
1295
+ for (const [a, b, how] of pairs) {
1296
+ if (a.id === b.id) continue
1297
+ if (isConflicted(a, b)) {
1298
+ conflictRows.push({ a: a.id, b: b.id, how, already: true })
1299
+ continue
1300
+ }
1301
+ if (a.explicit === true || b.explicit === true) {
1302
+ // 规定 2/4:explicit 条目不动,只建议
1303
+ const row = {
1304
+ action: 'suggest',
1305
+ conflict: [a.id, b.id],
1306
+ reason: '其中含 explicit 条目,consolidate 不得改写,只能建议(规定 2/4)',
1307
+ a: { id: a.id, content: a.content },
1308
+ b: { id: b.id, content: b.content },
1309
+ }
1310
+ suggested.push(row)
1311
+ continue
1312
+ }
1313
+
1314
+ /**
1315
+ * **先查可写性再落盘**:宁可整对跳过,也不留「只标了一边」的半截冲突态。
1316
+ * (另一条路是标完再回滚,但回滚本身要写两个事件,净效果更乱、更难审计。)
1317
+ * 跳过原因以 `action:'rejected'` + code 出现在返回里 —— 可见即合规,绝不静默。
1318
+ */
1319
+ const blocked = [a, b].map((e) => checkWritable(e, 'consolidate')).find((r) => r !== null)
1320
+ if (blocked) {
1321
+ const row = {
1322
+ action: 'rejected',
1323
+ a: a.id,
1324
+ b: b.id,
1325
+ code: blocked.code,
1326
+ reason: `冲突对含不可由 consolidate 改写的条目(${blocked.id})→ 整对跳过并回报,不标半截:${blocked.message}`,
1327
+ }
1328
+ conflictRows.push(row)
1329
+ rejectedConflicts.push(row)
1330
+ warn(`[cs-bots/memory] consolidate:${row.reason}`)
1331
+ continue
1332
+ }
1333
+
1334
+ const pa = markConflict(a, [b.id], author, at, how === 'declared' ? 'declared-conflict' : 'polarity-conflict')
1335
+ const pb = markConflict(b, [a.id], author, at, how === 'declared' ? 'declared-conflict' : 'polarity-conflict')
1336
+ conflictRows.push({ a: pa.id, b: pb.id, aRev: pa.rev, bRev: pb.rev, how })
1337
+ handled.add(a.id)
1338
+ handled.add(b.id)
1339
+ info(`[cs-bots/memory] consolidate:检测到矛盾 ${a.id} ⇄ ${b.id}(${how})→ 两条都留并标 [conflict](规定 3)`)
1340
+ }
1341
+
1342
+ // ── 第 2 步:重复合并(归一后全等;已被判为冲突的 id 不再参与)────────────
1343
+ const byDup = new Map()
1344
+ for (const e of live) {
1345
+ if (handled.has(e.id)) continue
1346
+ const key = dupKeyOf(e.content)
1347
+ if (!byDup.has(key)) byDup.set(key, [])
1348
+ byDup.get(key).push(e)
1349
+ }
1350
+ for (const [, group] of byDup) {
1351
+ if (group.length < 2) continue
1352
+ // 保留规则:等级高者 → 更新时间新者 → id 小者(确定性,不靠遍历顺序)
1353
+ const sorted = group.slice().sort((x, y) => {
1354
+ const r = protectionOf(y) - protectionOf(x)
1355
+ if (r !== 0) return r
1356
+ const t = (y.updatedAt ?? 0) - (x.updatedAt ?? 0)
1357
+ if (t !== 0) return t
1358
+ return x.id < y.id ? -1 : 1
1359
+ })
1360
+ const keeper = sorted[0]
1361
+ if (keeper.explicit === true || group.some((e) => e.explicit === true)) {
1362
+ const row = {
1363
+ action: 'suggest',
1364
+ duplicates: group.map((e) => e.id),
1365
+ reason: '重复组中含 explicit 条目,consolidate 不得合并,只能建议(规定 2/4)',
1366
+ keepSuggestion: keeper.id,
1367
+ }
1368
+ suggested.push(row)
1369
+ continue
1370
+ }
1371
+ /** 与冲突分支同款纪律:**先查可写性**,保证 keeper 一定留得住、被并方一定过得去。 */
1372
+ const keepBlocked = checkWritable(keeper, 'consolidate')
1373
+ const losers = sorted.slice(1).filter((e) => checkWritable(e, 'consolidate') === null)
1374
+ const losersBlocked = sorted.slice(1).filter((e) => !losers.includes(e))
1375
+ if (keepBlocked || losers.length === 0) {
1376
+ const row = {
1377
+ action: 'rejected',
1378
+ duplicates: group.map((e) => e.id),
1379
+ code: keepBlocked?.code ?? 'MEMORY_WRITE_PRIORITY_DENIED',
1380
+ reason: '重复组中保留者不可由 consolidate 改写 → 整组不合并,只回报(绝不静默消失)',
1381
+ }
1382
+ suggested.push(row)
1383
+ warn(`[cs-bots/memory] consolidate:${row.reason}(组:${row.duplicates.join(', ')})`)
1384
+ continue
1385
+ }
1386
+ if (losersBlocked.length > 0) {
1387
+ const row = {
1388
+ action: 'suggest',
1389
+ duplicates: group.map((e) => e.id),
1390
+ keepSuggestion: keeper.id,
1391
+ unmergeable: losersBlocked.map((e) => e.id),
1392
+ reason: '部分重复条目不可由 consolidate 改写 → 只合并可动的那几条,其余留原样',
1393
+ }
1394
+ suggested.push(row)
1395
+ }
1396
+ for (const loser of losers) {
1397
+ const archived = mergeAway(loser, keeper.id, author, at)
1398
+ merged.push({ kept: keeper.id, archived: archived.id, rev: archived.rev, content: loser.content })
1399
+ handled.add(loser.id)
1400
+ info(`[cs-bots/memory] consolidate:重复合并 ${loser.id} → ${keeper.id}(被并方已归档,保留 mergedInto 证据)`)
1401
+ }
1402
+ }
1403
+
1404
+ // ── 第 3 步:可选写 L5 日札(§3.4:L5 的写入者是后台 consolidate)──────────
1405
+ let dailyId = null
1406
+ if (daily === true) {
1407
+ const digest = `consolidate ${targetLayer}:扫描 ${live.length} 条;合并 ${merged.length} 条;`
1408
+ + `矛盾 ${conflictRows.length} 对(均已保留并标 [conflict]);建议 ${suggested.length} 条;`
1409
+ + `拒 ${rejectedConflicts.length} 对;未变动 ${live.length - handled.size}`
1410
+ // ⚠️ L5 的 source 必须是 consolidate —— 规定 4 靠 source 机械排序,
1411
+ // 若这里误用默认的 explicit,日札会被当成「人显式说的」而不可再动。
1412
+ const created = add({ layer: 'L5', content: digest, by: author, source: 'consolidate', now: at })
1413
+ dailyId = created.id
1414
+ }
1415
+
1416
+ return {
1417
+ layer: targetLayer,
1418
+ scanned: live.length,
1419
+ merged,
1420
+ conflicts: conflictRows,
1421
+ suggested,
1422
+ rejectedConflicts,
1423
+ unchanged: live.length - handled.size,
1424
+ dailyId,
1425
+ }
1426
+ }
1427
+
1428
+ /** 撤销一次冲突标记(只回滚本次刚加的那一边,**不碰**此前的 conflictWith 集合)。 */
1429
+ function unmarkConflict(entry, dropIds, author, at, reason) {
1430
+ const before = clone(entry)
1431
+ const next = clone(entry)
1432
+ next.rev = entry.rev + 1
1433
+ next.updatedAt = at
1434
+ next.updatedBy = author
1435
+ next.conflictWith = (Array.isArray(entry.conflictWith) ? entry.conflictWith : []).filter((x) => !dropIds.includes(x))
1436
+ if (next.conflictWith.length === 0) {
1437
+ delete next.conflict
1438
+ delete next.conflictReason
1439
+ next.tags = (Array.isArray(entry.tags) ? entry.tags : []).filter((t) => t !== 'conflict')
1440
+ }
1441
+ next.rollbackReason = reason
1442
+ return commit(entry.id, 'consolidate', author, at, before, next)
1443
+ }
1444
+
1445
+ /** two-step 重试(保证「成对」或「都不标」)。 */
1446
+ const SETTLE_MAX_RETRY = 3
1447
+ const SETTLE_RETRY_WAIT_MS = 1
1448
+
1449
+ /**
1450
+ * **成对**完成一次双条目变更(规定 3 的机械保证)。
1451
+ *
1452
+ * 为什么不能边算边写:做「两条都留并标 [conflict]」时,第二条可能临场失败
1453
+ * (并发下 rev 变了、或者某条是 explicit 的硬保护条目)。若第一条已落盘,
1454
+ * 内存里就留下**只标了一边**的半截冲突态 —— 那正是规定 3「冲突不静默」要防的东西。
1455
+ *
1456
+ * 本函数按**两遍**走:第一遍 plan(只读、不落盘、不产生副作用),第二遍 exec。
1457
+ * exec 里若**第一条**就失败 ⇒ 什么都没写,抛错即可;若**第二条**失败 ⇒ 重跑 plan
1458
+ * 后再 exec(把第一条的新 rev 兜进来),直到两条都成或重试耗尽。
1459
+ * 重试耗尽仍不成 ⇒ 抛**合成**错误(声称配对失败),重放后状态是一致可解释的。
1460
+ *
1461
+ * @param {() => Array<{ check: () => ({ ok: true } | { ok: false, reason: string }), run: () => object }>} plan
1462
+ * @param {string} what 失败信息里的动作名
1463
+ * @returns {object[]} 两条变更后的条目
1464
+ */
1465
+ function settlePair(plan, what) {
1466
+ let lastReason = 'unknown'
1467
+ for (let attempt = 1; attempt <= SETTLE_MAX_RETRY; attempt += 1) {
1468
+ const ops = plan()
1469
+ if (ops.length !== 2) {
1470
+ throw new MemoryError(
1471
+ `memory:${what} 的 settlePair 必须恰好得到 2 项操作,收到 ${ops.length}`,
1472
+ 'MEMORY_INTERNAL_INVARIANT',
1473
+ { what },
1474
+ )
1475
+ }
1476
+ const blocked = ops.map((op) => op.check()).find((r) => r && r.ok === false)
1477
+ if (blocked) {
1478
+ throw new MemoryError(
1479
+ `memory:${what} 不可执行 —— ${blocked.reason}`,
1480
+ 'MEMORY_WRITE_PRIORITY_DENIED',
1481
+ { what, reason: blocked.reason },
1482
+ )
1483
+ }
1484
+ try {
1485
+ return [ops[0].run(), ops[1].run()]
1486
+ } catch (error) {
1487
+ if (!(error instanceof MemoryError)) throw error
1488
+ lastReason = `${error.code}: ${error.message}`
1489
+ warn(`[cs-bots/memory] ${what} 第 ${attempt}/${SETTLE_MAX_RETRY} 次尝试第二条失败,将重试:${lastReason}`)
1490
+ if (attempt < SETTLE_MAX_RETRY) sleepSync(SETTLE_RETRY_WAIT_MS)
1491
+ }
1492
+ }
1493
+ throw new MemoryError(
1494
+ `memory:${what} 配对失败(已重试 ${SETTLE_MAX_RETRY} 次)—— 最后原因:${lastReason}。`
1495
+ + '本次未留下半截状态;请重新 list() 后重试',
1496
+ 'MEMORY_PAIR_SETTLE_FAILED',
1497
+ { what, attempts: SETTLE_MAX_RETRY },
1498
+ )
1499
+ }
1500
+
1501
+ /** 极短同步等待(仅用于配对重试的退避;不依赖任何第三方库)。 */
1502
+ function sleepSync(ms) {
1503
+ const end = Date.now() + ms
1504
+ // eslint-disable-next-line no-empty
1505
+ while (Date.now() < end) { /* busy-wait:毫秒级,且只在罕见的重试路径上发生 */ }
1506
+ }
1507
+
1508
+ /** 两条是否已互相标记为冲突(幂等,避免重复写事件)。 */
1509
+ function isConflicted(a, b) {
1510
+ return a.conflict === true && b.conflict === true
1511
+ && Array.isArray(a.conflictWith) && a.conflictWith.includes(b.id)
1512
+ && Array.isArray(b.conflictWith) && b.conflictWith.includes(a.id)
1513
+ }
1514
+
1515
+ /**
1516
+ * 非抛错的「可写性预检」:批量操作(consolidate)先用它整对判断,
1517
+ * 避免「标了一半才发现另一边不可写」。单条 API 仍用 `assertWriteAllowed` 直接抛。
1518
+ * @param {object} entry
1519
+ * @param {'explicit'|'review'|'consolidate'} writer
1520
+ * @returns {{ code: string, id: string, message: string } | null} null = 可写
1521
+ */
1522
+ function checkWritable(entry, writer) {
1523
+ try {
1524
+ assertWriteAllowed(entry, writer, 'consolidate')
1525
+ return null
1526
+ } catch (error) {
1527
+ if (!(error instanceof MemoryError)) throw error
1528
+ return { code: error.code, id: entry.id, message: error.message }
1529
+ }
1530
+ }
1531
+
1532
+ /**
1533
+ * 计算「标 `[conflict]`」的下一步形态(**纯函数,不落盘**)。
1534
+ *
1535
+ * 为什么要拆出纯函数:`settlePair` 需要先 plan(只读)后 exec(落盘)两遍,
1536
+ * 而 plan 必须**用最新 rev** —— 直接读内存态即可,不产生任何副作用。
1537
+ * @param {object} entry
1538
+ * @param {string[]} withIds
1539
+ * @param {string} author
1540
+ * @param {number} at
1541
+ * @param {string} reason
1542
+ * @returns {object} next(未落盘的下一版)
1543
+ */
1544
+ function conflictMarkNext(entry, withIds, author, at, reason) {
1545
+ const next = clone(entry)
1546
+ next.rev = entry.rev + 1
1547
+ next.updatedAt = at
1548
+ next.updatedBy = author
1549
+ next.conflict = true
1550
+ const set = new Set([...(Array.isArray(entry.conflictWith) ? entry.conflictWith : []), ...withIds])
1551
+ next.conflictWith = [...set]
1552
+ next.conflictReason = reason
1553
+ const tags = new Set([...(Array.isArray(entry.tags) ? entry.tags : []), 'conflict'])
1554
+ next.tags = [...tags]
1555
+ return next
1556
+ }
1557
+
1558
+ /** 标 `[conflict]`(不删内容)—— **单条**落盘;成对场景走 `settleConflictPair`。 */
1559
+ function markConflict(entry, withIds, author, at, reason) {
1560
+ assertWriteAllowed(entry, 'consolidate', 'consolidate')
1561
+ return commit(entry.id, 'consolidate', author, at, clone(entry), conflictMarkNext(entry, withIds, author, at, reason))
1562
+ }
1563
+
1564
+ /**
1565
+ * **规定 3 的核心动作**:把两条矛盾记忆都标 `[conflict]` 并互相登记 `conflictWith`,
1566
+ * 内容一条都不删。走 `settlePair` 保证「成对成功」——不留半截冲突态。
1567
+ *
1568
+ * @param {string} idA
1569
+ * @param {string} idB
1570
+ * @param {string} author
1571
+ * @param {number} at
1572
+ * @param {string} reason
1573
+ * @returns {object[]} [a', b']
1574
+ */
1575
+ function settleConflictPair(idA, idB, author, at, reason) {
1576
+ return settlePair(() => {
1577
+ const a = entries.get(idA)
1578
+ const b = entries.get(idB)
1579
+ if (!a || !b) {
1580
+ throw new MemoryError(
1581
+ `memory.consolidate:冲突对 ${idA} ⇄ ${idB} 的成员已不存在 —— 拒绝写半截`,
1582
+ 'MEMORY_ENTRY_NOT_FOUND',
1583
+ { idA, idB },
1584
+ )
1585
+ }
1586
+ return [
1587
+ {
1588
+ check: () => {
1589
+ const bad = checkWritable(a, 'consolidate')
1590
+ return bad ? { ok: false, reason: `冲突方 ${a.id} 不可改写:${bad.message}` } : { ok: true }
1591
+ },
1592
+ run: () => commit(a.id, 'consolidate', author, at, clone(a), conflictMarkNext(a, [b.id], author, at, reason)),
1593
+ },
1594
+ {
1595
+ check: () => {
1596
+ const bad = checkWritable(entries.get(idB) ?? b, 'consolidate')
1597
+ return bad ? { ok: false, reason: `冲突方 ${b.id} 不可改写:${bad.message}` } : { ok: true }
1598
+ },
1599
+ run: () => {
1600
+ // ⚠️ 必须在 run 时重新取内存态:第一条已经 +1 rev(CAS 依据由此保持正确)
1601
+ const cur = entries.get(idB)
1602
+ const bad = checkWritable(cur, 'consolidate')
1603
+ if (bad) {
1604
+ throw new MemoryError(bad.message, bad.code, { id: cur.id, layer: cur.layer })
1605
+ }
1606
+ return commit(cur.id, 'consolidate', author, at, clone(cur), conflictMarkNext(cur, [idA], author, at, reason))
1607
+ },
1608
+ },
1609
+ ]
1610
+ }, `consolidate 冲突配对 ${idA} ⇄ ${idB}`)
1611
+ }
1612
+
1613
+ /** 重复合并:被并方**归档**(不物理删),带 `mergedInto` 证据。 */
1614
+ function mergeAway(loser, keeperId, author, at) {
1615
+ assertWriteAllowed(loser, 'consolidate', 'consolidate')
1616
+ const before = clone(loser)
1617
+ const next = clone(loser)
1618
+ next.rev = loser.rev + 1
1619
+ next.updatedAt = at
1620
+ next.updatedBy = author
1621
+ next.mergedInto = keeperId
1622
+ next.archived = { by: author, at, reason: 'merged-duplicate' }
1623
+ return commit(loser.id, 'consolidate', author, at, before, next)
1624
+ }
1625
+
1626
+ // ── API:读 ───────────────────────────────────────────────────────────────
1627
+
1628
+ /**
1629
+ * 按 id 取单条(规定 1:**按 id 定位**)。
1630
+ * @param {{ layer: string, id: string }} input
1631
+ * @returns {object} Entry
1632
+ */
1633
+ function get(input = {}) {
1634
+ const { layer, id } = input ?? {}
1635
+ const targetLayer = requireLayer(layer, 'get')
1636
+ return clone(requireEntry(id, targetLayer, 'get'))
1637
+ }
1638
+
1639
+ /**
1640
+ * 列出条目。
1641
+ * @param {{ layer?: string, branch?: string, limit?: number, query?: string,
1642
+ * includeArchived?: boolean, onlyArchived?: boolean }} [options]
1643
+ * @returns {object[]}
1644
+ */
1645
+ function list(options = {}) {
1646
+ const { layer, branch, limit, query, includeArchived, onlyArchived } = options ?? {}
1647
+ const targetLayer = layer === undefined || layer === null ? undefined : requireLayer(layer, 'list')
1648
+ const lim = optionalLimit(limit, 'list')
1649
+ const q = optionalString(query, 'query', 'list')
1650
+
1651
+ let rows = [...entries.values()]
1652
+ if (targetLayer) rows = rows.filter((e) => e.layer === targetLayer)
1653
+ if (onlyArchived === true) rows = rows.filter((e) => e.archived)
1654
+ else if (includeArchived !== true) rows = rows.filter((e) => !e.archived)
1655
+ if (branch !== undefined && branch !== null) {
1656
+ const b = requireString(branch, 'branch', 'list')
1657
+ rows = rows.filter((e) => branchVisible(e, b))
1658
+ }
1659
+ if (q !== undefined) {
1660
+ const needle = q.toLowerCase()
1661
+ rows = rows.filter((e) => String(e.content).toLowerCase().includes(needle)
1662
+ || (Array.isArray(e.tags) && e.tags.some((t) => String(t).toLowerCase().includes(needle))))
1663
+ }
1664
+ if (lim !== undefined) rows = rows.slice(0, lim)
1665
+ return clone(rows)
1666
+ }
1667
+
1668
+ /** 审计流查询。@param {string} [id] 省略则返回全部事件 */
1669
+ function history(id) {
1670
+ const rows = id ? events.filter((e) => e.id === id) : events
1671
+ return clone(rows)
1672
+ }
1673
+
1674
+ /**
1675
+ * 检索(规定:**用 node:sqlite FTS5**,不可用则退化 `String.includes`)。
1676
+ *
1677
+ * @param {{ query: string, layers?: string[], branch?: string, limit?: number,
1678
+ * includeArchived?: boolean }} input
1679
+ * @returns {object[]} 命中条目(按 FTS5 rank / 稳定顺序)
1680
+ */
1681
+ function search(input = {}) {
1682
+ const { query, layers, branch, limit, includeArchived } = input ?? {}
1683
+ const q = requireString(query, 'query', 'search')
1684
+ const layerFilter = layers === undefined || layers === null ? undefined : stringList(layers, 'layers', 'search')
1685
+ if (layerFilter) {
1686
+ for (const l of layerFilter) {
1687
+ if (!LAYERS.includes(l)) {
1688
+ throw new MemoryError(
1689
+ `memory.search:非法 layer=${JSON.stringify(l)}(合法值:${LAYERS.join(' | ')})`,
1690
+ 'MEMORY_BAD_LAYER',
1691
+ { layer: l, valid: LAYERS },
1692
+ )
1693
+ }
1694
+ }
1695
+ }
1696
+ const lim = optionalLimit(limit, 'search')
1697
+ const b = branch === undefined || branch === null ? undefined : requireString(branch, 'branch', 'search')
1698
+
1699
+ /** 命中判定:FTS5 需要 ≥3 码点(trigram 物理下限),短查询直接走 includes(可见地记录) */
1700
+ const cps = cpLength(q)
1701
+ const useFts = db && cps >= 3
1702
+ diagnostics.lastSearchAt = Date.now()
1703
+
1704
+ /** @type {string[] | null} */
1705
+ let ids = null
1706
+ if (useFts) {
1707
+ ensureIndexFresh()
1708
+ if (db) {
1709
+ try {
1710
+ const phrase = `"${q.replace(/"/g, '""')}"` // 加引号短语 + 转义:杜绝 FTS 运算符注入
1711
+ const rows = db.prepare('SELECT id FROM mem_fts WHERE mem_fts MATCH ? ORDER BY rank').all(phrase)
1712
+ ids = rows.map((r) => r.id)
1713
+ diagnostics.searchBackend = 'fts5'
1714
+ diagnostics.searchBackendReason = 'node:sqlite FTS5 + trigram'
1715
+ } catch (error) {
1716
+ // 词法错误等一律退化,**不静默**:把原因记下来
1717
+ ids = null
1718
+ diagnostics.searchBackend = 'includes'
1719
+ diagnostics.searchBackendReason = `FTS5 查询失败,本次退化:${error?.message ?? error}`
1720
+ warn(`[cs-bots/memory] ${diagnostics.searchBackendReason}`)
1721
+ }
1722
+ }
1723
+ }
1724
+ if (ids === null) {
1725
+ const lower = q.toLowerCase()
1726
+ ids = [...entries.values()]
1727
+ .filter((e) => String(e.content).toLowerCase().includes(lower))
1728
+ .map((e) => e.id)
1729
+ // 降级原因**必须**能区分三种情形,否则排障时看不出是「配置强制」还是「环境缺失」
1730
+ const reason = diagnostics.searchBackend === 'fts5' || diagnostics.searchBackendReason.startsWith('FTS5 查询失败')
1731
+ ? diagnostics.searchBackendReason // 上一轮/本轮 FTS5 失败留下的原因,保留
1732
+ : forcedBackend === 'includes'
1733
+ ? 'config.searchBackend=includes(显式强制退化路径)'
1734
+ : cps < 3
1735
+ ? `查询短于 3 码点(trigram 物理下限)→ 退化 includes`
1736
+ : `FTS5 不可用(${sqliteError ?? '无 DatabaseSync 导出'})→ 退化 includes`
1737
+ diagnostics.searchBackend = 'includes'
1738
+ diagnostics.searchBackendReason = reason
1739
+ }
1740
+
1741
+ /** @type {object[]} */
1742
+ let hits = ids
1743
+ .map((id) => entries.get(id))
1744
+ .filter((e) => Boolean(e))
1745
+ .filter((e) => (includeArchived === true ? true : !e.archived))
1746
+ .filter((e) => (layerFilter ? layerFilter.includes(e.layer) : true))
1747
+ .filter((e) => branchVisible(e, b))
1748
+ if (lim !== undefined) hits = hits.slice(0, lim)
1749
+ return clone(hits)
1750
+ }
1751
+
1752
+ // ── API:常驻注入 ──────────────────────────────────────────────────────────
1753
+
1754
+ /** 渲染一行:`[id:xxxxxxxx] [rev:N] [conflict] 内容`。 */
1755
+ function renderLine(entry) {
1756
+ const bits = [`[id:${entry.id}]`, `[rev:${entry.rev}]`]
1757
+ if (entry.conflict === true) bits.push('[conflict]')
1758
+ if (entry.explicit === true) bits.push('[explicit]')
1759
+ if (Array.isArray(entry.branches) && entry.branches.length > 0) bits.push(`[branch:${entry.branches.join(',')}]`)
1760
+ return `${bits.join(' ')} ${entry.content}`
1761
+ }
1762
+
1763
+ /**
1764
+ * 常驻注入文本(**L0–L3 冻结快照**):值一旦返回即不受后续变更影响。
1765
+ * L0 来自 `config.persona`(人改),其余来自条目;L4/L5 永不进入常驻注入。
1766
+ *
1767
+ * @param {{ layers?: string[], branch?: string, title?: string }} [options]
1768
+ * @returns {string}
1769
+ */
1770
+ function contextFor(options = {}) {
1771
+ const { layers, branch, title, botId: forBot } = options ?? {}
1772
+ const want = layers === undefined || layers === null
1773
+ ? INJECTED_LAYERS.slice()
1774
+ : stringList(layers, 'layers', 'contextFor')
1775
+ for (const l of want) {
1776
+ if (!LAYERS.includes(l)) {
1777
+ throw new MemoryError(
1778
+ `memory.contextFor:非法 layer=${JSON.stringify(l)}(合法值:${LAYERS.join(' | ')})`,
1779
+ 'MEMORY_BAD_LAYER',
1780
+ { layer: l, valid: LAYERS },
1781
+ )
1782
+ }
1783
+ }
1784
+ const b = branch === undefined || branch === null ? undefined : requireString(branch, 'branch', 'contextFor')
1785
+ if (b !== undefined && !INJECTED_LAYERS.includes('L3')) {
1786
+ throw new MemoryError('memory.contextFor:branch 过滤只对 L3 有意义', 'MEMORY_BAD_FIELD', { field: 'branch' })
1787
+ }
1788
+
1789
+ const out = []
1790
+ out.push(`# cs-bots 分层记忆${title ? ` · ${title}` : ''}`)
1791
+ out.push(`# branch=${b ?? '(all)'} · layers=${want.join(',')} · entries=${entries.size}`
1792
+ + ` · search=${diagnostics.searchBackend}${forBot ? ` · bot=${forBot}` : ''}`)
1793
+
1794
+ for (const layer of want) {
1795
+ out.push('')
1796
+ out.push(`## ${layer} ${LAYER_NAMES[layer] ?? ''}`.trim())
1797
+ if (layer === PERSONA_LAYER) {
1798
+ if (persona.length === 0) out.push('(未配置——人格层只能由人改配置,模型不可写)')
1799
+ else for (const line of persona) out.push(`- ${line}`)
1800
+ continue
1801
+ }
1802
+ const rows = [...entries.values()]
1803
+ .filter((e) => e.layer === layer && !e.archived && branchVisible(e, b) && visibleToBot(e, forBot))
1804
+ .sort((x, y) => (x.id < y.id ? -1 : 1))
1805
+ if (rows.length === 0) out.push('(空)')
1806
+ else for (const e of rows) out.push(renderLine(e))
1807
+ }
1808
+ return out.join('\n')
1809
+ }
1810
+
1811
+ // ── API:回放与统计 ────────────────────────────────────────────────────────
1812
+
1813
+ /**
1814
+ * 从 `memory.jsonl` 重建内存态(重启回放一致性)。
1815
+ * @returns {{ from: string, entries: number, events: number, bad: number, search: string }}
1816
+ */
1817
+ function replay() {
1818
+ hydrate()
1819
+ diagnostics.lastReplayAt = Date.now()
1820
+ return {
1821
+ from: diagnostics.hydratedFrom ?? 'empty',
1822
+ entries: entries.size,
1823
+ events: events.length,
1824
+ bad: diagnostics.badLines,
1825
+ search: diagnostics.searchBackend,
1826
+ }
1827
+ }
1828
+
1829
+ /** 统计(给记忆中心页面与诊断用)。 */
1830
+ function stats() {
1831
+ const byLayer = {}
1832
+ for (const layer of LAYERS) byLayer[layer] = { active: 0, archived: 0, conflict: 0 }
1833
+ let archived = 0
1834
+ let conflict = 0
1835
+ const bySource = {}
1836
+ for (const e of entries.values()) {
1837
+ const slot = byLayer[e.layer] ?? (byLayer[e.layer] = { active: 0, archived: 0, conflict: 0 })
1838
+ if (e.archived) { slot.archived += 1; archived += 1 } else slot.active += 1
1839
+ if (e.conflict === true) { slot.conflict += 1; conflict += 1 }
1840
+ const src = e.source ?? 'unknown'
1841
+ bySource[src] = (bySource[src] ?? 0) + 1
1842
+ }
1843
+ return {
1844
+ stateDir,
1845
+ paths: { jsonl: jsonlPath, snapshot: snapPath, fts: ftsPath },
1846
+ layers: LAYERS,
1847
+ injectedLayers: INJECTED_LAYERS,
1848
+ entryMax,
1849
+ persona: persona.length,
1850
+ entries: entries.size,
1851
+ active: entries.size - archived,
1852
+ archived,
1853
+ conflicts: conflict,
1854
+ byLayer,
1855
+ bySource,
1856
+ events: events.length,
1857
+ search: { backend: diagnostics.searchBackend, reason: diagnostics.searchBackendReason, tokenize: diagnostics.ftsTokenize },
1858
+ hydration: {
1859
+ from: diagnostics.hydratedFrom,
1860
+ replayedEvents: diagnostics.replayedEvents,
1861
+ badLines: diagnostics.badLines,
1862
+ snapshotWriteFailures: diagnostics.snapshotWriteFailures,
1863
+ },
1864
+ }
1865
+ }
1866
+
1867
+ /** 关闭 sqlite 句柄(测试与卸载用;不关也不影响正确性)。 */
1868
+ function dispose() {
1869
+ try { db?.close?.() } catch { /* ignore */ }
1870
+ db = null
1871
+ }
1872
+
1873
+ // ── 启动 ──────────────────────────────────────────────────────────────────
1874
+
1875
+ openIndex()
1876
+ /** 首次构造即回放,保证 `list()` 在重启后马上可用。 */
1877
+ hydrate()
1878
+
1879
+ const memory = {
1880
+ name: config.name ?? 'memory',
1881
+ paths: Object.freeze({ stateDir, jsonl: jsonlPath, snapshot: snapPath, fts: ftsPath }),
1882
+ layers: LAYERS,
1883
+ injectedLayers: INJECTED_LAYERS,
1884
+ entryMax,
1885
+ add,
1886
+ get,
1887
+ list,
1888
+ update,
1889
+ remove,
1890
+ archive,
1891
+ restore,
1892
+ review,
1893
+ consolidate,
1894
+ search,
1895
+ contextFor,
1896
+ history,
1897
+ replay,
1898
+ stats,
1899
+ dispose,
1900
+ diagnostics: () => clone(diagnostics),
1901
+ }
1902
+ return memory
1903
+ }
1904
+
1905
+ export default createMemory