@nightpainters/dsh-ling 1.5.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.
Files changed (51) hide show
  1. package/ACCESS-DESIGN.md +119 -0
  2. package/CHANGELOG.md +627 -0
  3. package/DESIGN.md +461 -0
  4. package/LICENSE +201 -0
  5. package/NOTICE +7 -0
  6. package/README.md +236 -0
  7. package/cordis.patch.yml +16 -0
  8. package/lib/audit/access-log.js +533 -0
  9. package/lib/audit/index.js +92 -0
  10. package/lib/audit/patch-spec.js +195 -0
  11. package/lib/client.js +5069 -0
  12. package/lib/host/api.js +2584 -0
  13. package/lib/host/archive.js +194 -0
  14. package/lib/host/autotree.js +131 -0
  15. package/lib/host/backfill.js +267 -0
  16. package/lib/host/classify.js +10 -0
  17. package/lib/host/clock-skill.js +141 -0
  18. package/lib/host/clock.js +132 -0
  19. package/lib/host/corpus-suggest.js +139 -0
  20. package/lib/host/deepsummary.js +338 -0
  21. package/lib/host/dsweb-summary.js +225 -0
  22. package/lib/host/feedback.js +119 -0
  23. package/lib/host/freeze.js +144 -0
  24. package/lib/host/genesis.js +176 -0
  25. package/lib/host/guard.js +184 -0
  26. package/lib/host/habit-gen.js +273 -0
  27. package/lib/host/import-file.js +291 -0
  28. package/lib/host/import-log.js +129 -0
  29. package/lib/host/inject-common.js +25 -0
  30. package/lib/host/inject.js +285 -0
  31. package/lib/host/l1.js +282 -0
  32. package/lib/host/lifecycle.js +367 -0
  33. package/lib/host/memory.js +2389 -0
  34. package/lib/host/mode.js +101 -0
  35. package/lib/host/persona.js +214 -0
  36. package/lib/host/retitle.js +214 -0
  37. package/lib/host/rules.js +195 -0
  38. package/lib/host/scan-dsweb.js +111 -0
  39. package/lib/host/seal.js +17 -0
  40. package/lib/host/settings-file.js +49 -0
  41. package/lib/host/snapshot.js +168 -0
  42. package/lib/host/suggestions.js +47 -0
  43. package/lib/host/summarizer.js +103 -0
  44. package/lib/host/tone-advice.js +112 -0
  45. package/lib/host/tools.js +1067 -0
  46. package/lib/host/tree-ops.js +330 -0
  47. package/lib/host/util.js +165 -0
  48. package/lib/host/zstd.js +63 -0
  49. package/lib/index.js +263 -0
  50. package/package.json +69 -0
  51. package/tools/apply-access-log-patch.mjs +295 -0
@@ -0,0 +1,2389 @@
1
+ // dsh-ling host — memory store (node:sqlite, zero deps).
2
+ // Schema per DESIGN §7. All access single-process (host).
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { join } from 'node:path';
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { ensureDir, sha256Text, utcIso, mergeDeep, toIso, toEpochMs } from './util.js';
8
+ // A-01(1.5.1):主脉记忆被覆盖前**必须先归档**。archive.js 只依赖 node:fs / node:path,
9
+ // 且把 memory 当参数传(依赖注入)⇒ memory.js 引它**不成环**。
10
+ import { archiveBefore } from './archive.js';
11
+
12
+ const SCHEMA_VERSION = 13; // v13:forgotten(软遗忘标记 —— 永远不彻底删除,先归档再打标记);v12:branch_log.actor(G4 器灵侧通道 —— 工具改的树必须记在"器灵"名下,不再一律记成主人);v11:vein_suggestions(主脉提炼审核队列 R7 —— 设计稿 §5:提炼必须走审核,不直接进记忆);
13
+ // v10:tree_snapshot.kind('manual'|'auto',审计 A#9 —— 自动备份与手动快照此前外表完全一致,删错了就没有撤回路径);v9:tree_snapshot(树的保存/恢复 R1);v8:conv_branch(非会话源的枝归属覆盖层,方案 A);v7:branch_log(复盘留痕);v6:vein_link + memory_conflict(D9-b 树的生长);v5:branch(记忆分枝 D9-a);v4:title_by(定名者:user/ai);v3:title_locked;v2:conv_overview.source 放开 CHECK
14
+
15
+ /** 主干枝 id(D9-a 记忆分枝,2026-09-18):存量会话与记忆天然属于主干。 */
16
+ export const TRUNK_ID = 'trunk';
17
+
18
+ /** 血缘档位(用户 2026-09-18 定档 1.0/0.7/0.4)。
19
+ * 取"最弱环"单值,不做连乘 —— `0.7^5=0.168`、`0.4^5=0.010`,连乘会让深枝等于从记忆里消失,
20
+ * 且每层舍入都会乘进后面。单值 + 有界,权重才可解释、可复现。 */
21
+ export const LINEAGE_SAME = 1.0; // 同枝
22
+ export const LINEAGE_ANCESTOR = 0.7; // 枝读其祖先(直系血缘:主干就是这条枝的历史)
23
+ export const LINEAGE_SIDE = 0.4; // 旁系 / 祖先读后代(枝的内容不构成主干的记忆)
24
+ export const CONFLICT_DOWNWEIGHT = 0.3; // 矛盾败方降权系数(D9-b:未复盘时以最新为准,旧的一方乘此值)
25
+
26
+ /** 原文命名空间(G4/2026-09-17):dsh_turns_raw 无 source 维,DSH 会话与导入原文共表。
27
+ * 若不隔离,概述器会把导入内容当成 DSH 会话重建(GROUP BY session_id 认领)→ 跨源双份;
28
+ * 同 id 时更会互相覆盖原文(不可恢复)。故导入原文一律落 'import:<conv_id>'。
29
+ * 历史数据(1.2.2 之前)的导入原文仍是裸 id —— 读取侧保留回退,写入侧一律带前缀。 */
30
+ export const RAW_IMPORT_PREFIX = 'import:';
31
+
32
+ /** 写入侧:按来源得出该会话原文应落的 session_id。 */
33
+ export function rawSessionId(source, convId) {
34
+ const id = String(convId ?? '');
35
+ if (String(source) === 'import' && id && !id.startsWith(RAW_IMPORT_PREFIX)) return RAW_IMPORT_PREFIX + id;
36
+ return id;
37
+ }
38
+
39
+ /** 读取侧:候选 id 链(新前缀优先,旧裸 id 回退)。 */
40
+ export function rawSessionCandidates(source, convId) {
41
+ const id = String(convId ?? '');
42
+ if (id.startsWith(RAW_IMPORT_PREFIX)) return [id, id.slice(RAW_IMPORT_PREFIX.length)];
43
+ return String(source) === 'import' ? [RAW_IMPORT_PREFIX + id, id] : [id];
44
+ }
45
+
46
+ /** 本地时间戳(MM-DD HH:mm)。快照默认命名用它 —— utcIso 是 UTC,给主人看的名字应走本地时间。 */
47
+ function localStamp() {
48
+ const d = new Date();
49
+ const p = (n) => String(n).padStart(2, '0');
50
+ return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
51
+ }
52
+
53
+ const DDL = `
54
+ CREATE TABLE IF NOT EXISTS conv_overview (
55
+ conv_id TEXT NOT NULL,
56
+ source TEXT NOT NULL,
57
+ title TEXT NOT NULL DEFAULT '',
58
+ started_at TEXT,
59
+ updated_at TEXT,
60
+ domain_tags TEXT NOT NULL DEFAULT '[]',
61
+ category TEXT NOT NULL DEFAULT 'daily',
62
+ keywords TEXT NOT NULL DEFAULT '[]',
63
+ summary TEXT NOT NULL DEFAULT '',
64
+ heat REAL NOT NULL DEFAULT 0,
65
+ importance INTEGER NOT NULL DEFAULT 0,
66
+ last_hit_at TEXT,
67
+ hit_count INTEGER NOT NULL DEFAULT 0,
68
+ overview_ok INTEGER NOT NULL DEFAULT 0,
69
+ origin TEXT NOT NULL DEFAULT 'local',
70
+ title_locked INTEGER NOT NULL DEFAULT 0,
71
+ title_by TEXT NOT NULL DEFAULT '',
72
+ PRIMARY KEY (source, conv_id)
73
+ );
74
+ CREATE TABLE IF NOT EXISTS dsh_turns_raw (
75
+ session_id TEXT NOT NULL,
76
+ seq INTEGER NOT NULL,
77
+ role TEXT NOT NULL,
78
+ ts TEXT,
79
+ model TEXT,
80
+ text TEXT NOT NULL DEFAULT '',
81
+ PRIMARY KEY (session_id, seq)
82
+ );
83
+ CREATE TABLE IF NOT EXISTS session_meta (
84
+ session_id TEXT PRIMARY KEY,
85
+ mode TEXT,
86
+ mode_updated_at TEXT,
87
+ archived INTEGER NOT NULL DEFAULT 0,
88
+ raw_seq INTEGER NOT NULL DEFAULT 0,
89
+ branch_id TEXT NOT NULL DEFAULT 'trunk'
90
+ );
91
+ CREATE TABLE IF NOT EXISTS branch (
92
+ id TEXT PRIMARY KEY,
93
+ name TEXT NOT NULL DEFAULT '',
94
+ kind TEXT NOT NULL DEFAULT 'branch',
95
+ parent_id TEXT,
96
+ fork_at TEXT,
97
+ fork_seq INTEGER,
98
+ name_locked INTEGER NOT NULL DEFAULT 0,
99
+ -- ⚠️ 预留字段(审计 A#11,2026-09-22):weight_scale / visibility 目前**只存与显示**,
100
+ -- **不参与任何打分**(L1 的 score 只看血缘档位 × 矛盾系数)。让它们真正生效会改变检索排序语义,
101
+ -- 须主人拍板后再动;在那之前返回体里带 applied=false 标记,免得界面把"存了"当成"生效了"。
102
+ weight_scale REAL NOT NULL DEFAULT 1.0,
103
+ visibility TEXT NOT NULL DEFAULT 'lineage',
104
+ status TEXT NOT NULL DEFAULT 'active',
105
+ created_at TEXT NOT NULL,
106
+ updated_at TEXT
107
+ );
108
+ -- v6(D9-b 树的生长,2026-09-19):横向连边。刻意不叫"合并" —— 任何树操作都不改写记忆内容。
109
+ CREATE TABLE IF NOT EXISTS vein_link (
110
+ from_branch TEXT NOT NULL,
111
+ to_branch TEXT NOT NULL,
112
+ kind TEXT NOT NULL DEFAULT 'related', -- related | prerequisite | contrast
113
+ note TEXT NOT NULL DEFAULT '', -- 为什么连(复盘时写)
114
+ created_at TEXT NOT NULL,
115
+ PRIMARY KEY (from_branch, to_branch)
116
+ );
117
+ -- v6:矛盾标记层。检出矛盾但**不改写任何内容**;未复盘时以"最新为准"(检索侧降权旧的一方)。
118
+ CREATE TABLE IF NOT EXISTS memory_conflict (
119
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
120
+ created_at TEXT NOT NULL,
121
+ a_source TEXT NOT NULL,
122
+ a_conv_id TEXT NOT NULL,
123
+ b_source TEXT NOT NULL,
124
+ b_conv_id TEXT NOT NULL,
125
+ kind TEXT NOT NULL DEFAULT 'contradict', -- contradict | duplicate | supersede
126
+ detected_by TEXT NOT NULL DEFAULT 'heuristic', -- heuristic | llm | user
127
+ score REAL, -- 相似度(检出时)
128
+ reason TEXT NOT NULL DEFAULT '',
129
+ status TEXT NOT NULL DEFAULT 'pending', -- pending | confirmed | dismissed
130
+ winner_side TEXT, -- 'a' | 'b' | NULL=未裁定(以最新为准)
131
+ resolved_at TEXT,
132
+ UNIQUE(a_source, a_conv_id, b_source, b_conv_id)
133
+ );
134
+ CREATE TABLE IF NOT EXISTS feedback_queue (
135
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
136
+ created_at TEXT NOT NULL,
137
+ session_id TEXT NOT NULL DEFAULT '',
138
+ message_id TEXT NOT NULL DEFAULT '',
139
+ rating TEXT NOT NULL,
140
+ note TEXT NOT NULL DEFAULT '',
141
+ status TEXT NOT NULL DEFAULT 'new'
142
+ );
143
+ CREATE TABLE IF NOT EXISTS persona_suggestions (
144
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
145
+ created_at TEXT NOT NULL,
146
+ kind TEXT NOT NULL,
147
+ value TEXT NOT NULL DEFAULT '',
148
+ note TEXT NOT NULL DEFAULT '',
149
+ evidence TEXT NOT NULL DEFAULT '{}',
150
+ status TEXT NOT NULL DEFAULT 'new'
151
+ );
152
+ CREATE TABLE IF NOT EXISTS branch_log (
153
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
154
+ at TEXT NOT NULL,
155
+ branch_id TEXT NOT NULL,
156
+ action TEXT NOT NULL,
157
+ before_val TEXT NOT NULL DEFAULT '',
158
+ after_val TEXT NOT NULL DEFAULT '',
159
+ note TEXT NOT NULL DEFAULT '',
160
+ actor TEXT NOT NULL DEFAULT 'user' -- v12:'user' | 'ling'(G4:谁改的树)
161
+ );
162
+ CREATE TABLE IF NOT EXISTS conv_branch (
163
+ source TEXT NOT NULL,
164
+ conv_id TEXT NOT NULL,
165
+ branch_id TEXT NOT NULL,
166
+ created_at TEXT NOT NULL,
167
+ PRIMARY KEY (source, conv_id)
168
+ );
169
+ CREATE TABLE IF NOT EXISTS tree_snapshot (
170
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
171
+ at TEXT NOT NULL,
172
+ name TEXT NOT NULL DEFAULT '',
173
+ note TEXT NOT NULL DEFAULT '',
174
+ data TEXT NOT NULL,
175
+ kind TEXT NOT NULL DEFAULT 'manual' -- v10:'manual' | 'auto'(审计 A#9)
176
+ );
177
+ CREATE TABLE IF NOT EXISTS vein_suggestions (
178
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
179
+ at TEXT NOT NULL,
180
+ vein_id TEXT NOT NULL,
181
+ text TEXT NOT NULL DEFAULT '',
182
+ sources TEXT NOT NULL DEFAULT '[]',
183
+ model TEXT NOT NULL DEFAULT '',
184
+ status TEXT NOT NULL DEFAULT 'new',
185
+ note TEXT NOT NULL DEFAULT '',
186
+ resolved_at TEXT
187
+ );
188
+ CREATE TABLE IF NOT EXISTS forgotten (
189
+ source TEXT NOT NULL,
190
+ conv_id TEXT NOT NULL,
191
+ at TEXT NOT NULL,
192
+ reason TEXT NOT NULL DEFAULT '',
193
+ archive_path TEXT NOT NULL DEFAULT '',
194
+ actor TEXT NOT NULL DEFAULT 'user',
195
+ PRIMARY KEY (source, conv_id)
196
+ );
197
+ CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL);
198
+ `;
199
+
200
+ /** v1→v2 迁移:conv_overview.source 曾带 CHECK(source IN ('dsweb','dsh')),
201
+ * 文件导入需第三来源 → 检测旧 SQL 后整表重建(保留全部行与主键)。 */
202
+ function migrateSourceCheck(db) {
203
+ try {
204
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='conv_overview'").get();
205
+ const sql = row && typeof row.sql === 'string' ? row.sql : '';
206
+ if (!sql.includes('source IN') || !sql.includes('CHECK')) return; // 已是新表
207
+ db.exec('BEGIN');
208
+ db.exec(`CREATE TABLE conv_overview_v2 (
209
+ conv_id TEXT NOT NULL,
210
+ source TEXT NOT NULL,
211
+ title TEXT NOT NULL DEFAULT '',
212
+ started_at TEXT,
213
+ updated_at TEXT,
214
+ domain_tags TEXT NOT NULL DEFAULT '[]',
215
+ category TEXT NOT NULL DEFAULT 'daily',
216
+ keywords TEXT NOT NULL DEFAULT '[]',
217
+ summary TEXT NOT NULL DEFAULT '',
218
+ heat REAL NOT NULL DEFAULT 0,
219
+ importance INTEGER NOT NULL DEFAULT 0,
220
+ last_hit_at TEXT,
221
+ hit_count INTEGER NOT NULL DEFAULT 0,
222
+ overview_ok INTEGER NOT NULL DEFAULT 0,
223
+ origin TEXT NOT NULL DEFAULT 'local',
224
+ PRIMARY KEY (source, conv_id)
225
+ )`);
226
+ db.exec(`INSERT INTO conv_overview_v2
227
+ (conv_id, source, title, started_at, updated_at, domain_tags, category, keywords,
228
+ summary, heat, importance, last_hit_at, hit_count, overview_ok, origin)
229
+ SELECT conv_id, source, title, started_at, updated_at, domain_tags, category, keywords,
230
+ summary, heat, importance, last_hit_at, hit_count, overview_ok, origin
231
+ FROM conv_overview`);
232
+ db.exec('DROP TABLE conv_overview');
233
+ db.exec('ALTER TABLE conv_overview_v2 RENAME TO conv_overview');
234
+ db.exec('COMMIT');
235
+ db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
236
+ } catch (e) {
237
+ try { db.exec('ROLLBACK'); } catch {}
238
+ console.warn('[dsh-ling] conv_overview 迁移失败(忽略,以 IF NOT EXISTS 语义为准):', String(e?.message ?? e).slice(0, 200));
239
+ }
240
+ }
241
+
242
+ /** v2→v3/v4:conv_overview 增列 title_locked + title_by(D2 2026-09-17 与用户定)。
243
+ * 记忆的"名字"是这个系统的核心资产之一:概述器每轮重建与批量重命名都会覆盖 title,
244
+ * 主人手改过的标题必须不被机器覆盖 —— 故加锁列,由 renameTitle() 置 1。
245
+ * title_by 记录"这个名字是谁定的":'user'=主人手改、'ai'=机器起名。
246
+ * 界面(2026-09-17 用户定稿):三条路径统一显示「🔒 已定名」,归属只藏在水下的悬浮说明里 ——
247
+ * 标题层是器灵"修枝剪叶"式的自我复盘,器灵与主人同级,不该在标记上分等级。 */
248
+ function migrateTitleCols(db) {
249
+ try {
250
+ const cols = db.prepare('PRAGMA table_info(conv_overview)').all().map((c) => String(c.name));
251
+ if (!cols.includes('title_locked')) db.exec('ALTER TABLE conv_overview ADD COLUMN title_locked INTEGER NOT NULL DEFAULT 0');
252
+ if (!cols.includes('title_by')) db.exec("ALTER TABLE conv_overview ADD COLUMN title_by TEXT NOT NULL DEFAULT ''");
253
+ } catch (e) {
254
+ console.warn('[dsh-ling] title 列迁移失败(忽略):', String(e?.message ?? e).slice(0, 200));
255
+ }
256
+ }
257
+
258
+ /** v4→v5:记忆分枝(D9-a,2026-09-18 用户定方向)。
259
+ * session_meta 加 branch_id(默认 'trunk' —— 存量会话天然属主干,迁移只填一列);
260
+ * 建 branch 表并保证主干行存在。**记忆行不冗余存 branch**,靠 session → branch 推导:
261
+ * 迁移成本最低,且枝归属变化时无需回写海量记忆行。 */
262
+ function migrateBranchCols(db) {
263
+ try {
264
+ const cols = db.prepare('PRAGMA table_info(session_meta)').all().map((c) => String(c.name));
265
+ if (!cols.includes('branch_id')) {
266
+ db.exec("ALTER TABLE session_meta ADD COLUMN branch_id TEXT NOT NULL DEFAULT 'trunk'");
267
+ }
268
+ const now = utcIso();
269
+ db.prepare(`INSERT INTO branch (id,name,kind,parent_id,created_at,updated_at)
270
+ VALUES (?,?,?,NULL,?,?) ON CONFLICT(id) DO NOTHING`).run(TRUNK_ID, '主干', 'trunk', now, now);
271
+ } catch (e) {
272
+ console.warn('[dsh-ling] branch 迁移失败(忽略):', String(e?.message ?? e).slice(0, 200));
273
+ }
274
+ }
275
+
276
+ /** v9→v10:tree_snapshot 增列 kind(审计 A#9,2026-09-22)。
277
+ *
278
+ * 为什么需要:恢复前的那份**自动备份**是这个系统里"撤回上一次恢复"的唯一路径,
279
+ * 但它在库里与主人手动存的快照**外表完全一致**(同名同形同大小)—— 界面上分不出哪个能删,
280
+ * 删掉最后一份 auto 之后,一次误恢复就再也没有回路。
281
+ *
282
+ * 存量行没有 kind 可考,按既有命名习惯**回填**:`restoreTree` 的自动备份一直叫「自动快照 - …」
283
+ * (迁移只在这一列刚加上时跑一次,幂等)。 */
284
+ function migrateSnapshotKind(db) {
285
+ try {
286
+ const cols = db.prepare('PRAGMA table_info(tree_snapshot)').all().map((c) => String(c.name));
287
+ if (cols.includes('kind')) return; // 已迁移
288
+ db.exec("ALTER TABLE tree_snapshot ADD COLUMN kind TEXT NOT NULL DEFAULT 'manual'");
289
+ db.prepare("UPDATE tree_snapshot SET kind='auto' WHERE name LIKE '自动快照%'").run();
290
+ } catch (e) {
291
+ console.warn('[dsh-ling] tree_snapshot.kind 迁移失败(忽略):', String(e?.message ?? e).slice(0, 200));
292
+ }
293
+ }
294
+
295
+ /** v11→v12:branch_log 增列 actor(G4 器灵侧正规通道,2026-09-25)。
296
+ *
297
+ * 为什么需要:Part G 之前,器灵想改记忆树**没有任何正规通道** —— 只能伪造本机进程身份走
298
+ * UI 的 HTTP 口,或者直接开库。而 `logBranch` 又**没有 actor 参数**,于是那些改动
299
+ * 在复盘留痕里**一律记在主人名下**,主人复盘时看到的是自己没做过的操作
300
+ * (实测 91 笔)。加上 actor 后,工具通道的改动记 `'ling'`,注入文案据此分流。
301
+ *
302
+ * 存量行没有 actor 可考 —— 默认回填 `'user'`(旧通道确实都是人经界面点的)。 */
303
+ function migrateLogActor(db) {
304
+ try {
305
+ const cols = db.prepare('PRAGMA table_info(branch_log)').all().map((c) => String(c.name));
306
+ if (cols.includes('actor')) return; // 已迁移
307
+ db.exec("ALTER TABLE branch_log ADD COLUMN actor TEXT NOT NULL DEFAULT 'user'");
308
+ } catch (e) {
309
+ console.warn('[dsh-ling] branch_log.actor 迁移失败(忽略):', String(e?.message ?? e).slice(0, 200));
310
+ }
311
+ }
312
+
313
+ /** 会话级成员"可展开"的谓词(审计 A#6,2026-09-22)。
314
+ *
315
+ * 展开(`branchMembers`)与计数(`branchMemberCounts` / `branchTree().members`)必须共用这一份 SQL,
316
+ * 否则计数会变成"永远展不开的假广告":旧实现 `branchTree` 直接数 `session_meta` 全量,
317
+ * 而 `branchMembers` 会滤掉**空壳会话**(既无概述标题、又没有任何原文轮次)——
318
+ * 实测主干写「76 个会话」,点开只有 15 条,剩下 61 条永远点不开。
319
+ * 两处共用字符串常量,比"两处各手写一遍同一个 WHERE"更难写歪。 */
320
+ const MEMBER_LIVE_SESSION = "(NULLIF(TRIM(o.title),'') IS NOT NULL OR EXISTS (SELECT 1 FROM dsh_turns_raw t2 WHERE t2.session_id = m.session_id))";
321
+ const MEMBER_LIVE_ENTRY = "(NULLIF(TRIM(o.title),'') IS NOT NULL OR EXISTS (SELECT 1 FROM dsh_turns_raw t2 WHERE t2.session_id = c.conv_id))";
322
+
323
+ /** 计数 Map → **双读法**视图(审计 A#6 跨文件对接,2026-09-22)。
324
+ *
325
+ * 为什么不能只给 Map:api.js 的 `memberCountOf()` 是"软链"消费方,它按
326
+ * `Object.keys(计数).forEach(id => 计数[id])` 的**下标**读法取数 —— 而 `Object.keys(Map)` 恒为 `[]`
327
+ * (Map 的键不是自有可枚举属性),于是它不会报错,只会**静默地把每条枝的计数读成 0**,
328
+ * 端点上还照样标着"口径 = members"。这种"形状不匹配 ⇒ 全 0 且不报错"是最难查的一类。
329
+ * 故返回普通对象(下标读法可用),并把 `.get()` 以**不可枚举**属性附上(Map 读法也可用,
330
+ * 且不会出现在 `Object.keys` 里污染消费者)。
331
+ */
332
+ function countView(m) {
333
+ const o = {};
334
+ for (const [k, v] of m) o[k] = v;
335
+ Object.defineProperty(o, 'get', { value: (k) => o[String(k)], enumerable: false });
336
+ return o;
337
+ }
338
+
339
+ export class MemoryStore {
340
+ constructor(dbPath) {
341
+ ensureDir(dbPath.slice(0, Math.max(0, dbPath.lastIndexOf('/'), dbPath.lastIndexOf('\\'))) || '.');
342
+ this.dbPath = dbPath; // archive.js 用它推导归档目录(与库同级)
343
+ this.db = new DatabaseSync(dbPath);
344
+ this.db.exec('PRAGMA journal_mode=WAL;');
345
+ this.db.exec('PRAGMA busy_timeout=15000;');
346
+ this.db.exec(DDL);
347
+ migrateSourceCheck(this.db); // v1→v2:去掉 conv_overview.source 的两源 CHECK
348
+ migrateTitleCols(this.db); // v2→v4:title_locked + title_by(主人改过的标题不被机器覆盖)
349
+ migrateBranchCols(this.db); // v4→v5:branch 表 + session_meta.branch_id(记忆分枝 D9-a)
350
+ migrateSnapshotKind(this.db); // v9→v10:tree_snapshot.kind(区分自动备份与手动快照,审计 A#9)
351
+ migrateLogActor(this.db); // v11→v12:branch_log.actor(区分"谁改的树",Part G G4)
352
+ this._kvGet = this.db.prepare('SELECT value FROM kv WHERE key=?');
353
+ this._kvSet = this.db.prepare('INSERT INTO kv(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value');
354
+ // schema_version 必须跟随真实结构更新,不能只写一次:
355
+ // v5 加了 branch 表与 session_meta.branch_id,若仍留 '4',后续会话会误判库结构。
356
+ // v6 加 vein_link + memory_conflict 两张新表(纯新增,无 ALTER,DDL 的 IF NOT EXISTS 即迁移)。
357
+ // v10 加 tree_snapshot.kind(ALTER,见 migrateSnapshotKind)。
358
+ if (this.kvGet('schema_version') !== String(SCHEMA_VERSION)) this.kvSet('schema_version', String(SCHEMA_VERSION));
359
+ }
360
+
361
+ close() {
362
+ try {
363
+ this.db.close();
364
+ } catch {}
365
+ }
366
+
367
+ // ---- branch(记忆分枝 D9-a,2026-09-18) ----
368
+
369
+ /** 会话所属枝(无记录 → 主干)。 */
370
+ branchOfSession(sessionId) {
371
+ try {
372
+ const row = this.db.prepare('SELECT branch_id FROM session_meta WHERE session_id=?').get(String(sessionId));
373
+ return row && row.branch_id ? String(row.branch_id) : TRUNK_ID;
374
+ } catch {
375
+ return TRUNK_ID;
376
+ }
377
+ }
378
+
379
+ /** 把会话挂到枝上(该会话无 meta 行时补建)。 */
380
+ setSessionBranch(sessionId, branchId) {
381
+ const sid = String(sessionId);
382
+ const bid = String(branchId || TRUNK_ID);
383
+ this.db.prepare(`INSERT INTO session_meta (session_id, branch_id) VALUES (?,?)
384
+ ON CONFLICT(session_id) DO UPDATE SET branch_id=excluded.branch_id`).run(sid, bid);
385
+ return bid;
386
+ }
387
+
388
+ /** 建枝(幂等);返回枝 id。缺省 id = `br:<uuid>`。
389
+ * ⚠️ `visibility` 是**预留字段**(审计 A#11,2026-09-22):只落库、只显示,
390
+ * 当前不参与血缘解析、不参与检索打分(血缘看的是树结构,不是这个字段)。 */
391
+ createBranch({ id = null, name = '', kind = 'branch', parentId = TRUNK_ID, forkAt = null, forkSeq = null, visibility = 'lineage' } = {}) {
392
+ const bid = String(id || ('br:' + randomUUID()));
393
+ const now = utcIso();
394
+ this.db.prepare(`INSERT INTO branch
395
+ (id,name,kind,parent_id,fork_at,fork_seq,visibility,status,created_at,updated_at)
396
+ VALUES (?,?,?,?,?,?,?,'active',?,?) ON CONFLICT(id) DO NOTHING`)
397
+ .run(
398
+ bid,
399
+ String(name || ''),
400
+ String(kind || 'branch'),
401
+ parentId ? String(parentId) : null,
402
+ forkAt ? String(forkAt) : null,
403
+ Number.isFinite(Number(forkSeq)) ? Number(forkSeq) : null,
404
+ String(visibility || 'lineage'),
405
+ now,
406
+ now,
407
+ );
408
+ return bid;
409
+ }
410
+
411
+ /** 全部枝(含主干)。
412
+ *
413
+ * ⚠️ 预留字段(审计 A#11,2026-09-22 定为方案 b):`weightScale` / `visibility`
414
+ * 目前**只存只显示,不参与任何打分** —— L1 的分数是 `raw × 血缘档位 × 矛盾系数`,
415
+ * 血缘档位由 `lineageWeightMap` 按树结构算(1.0 / 0.7 / 0.4),与这两个字段无关。
416
+ * 改语义(让权重真正乘进检索分)会改变所有既有记忆的排序结果,**需主人拍板**,
417
+ * 故这里不擅自改打分,只在返回体里如实标明 `weightScaleApplied/visibilityApplied = false`,
418
+ * 让界面与调用方知道"调了它也没用",避免把预留字段当成已生效的旋钮。 */
419
+ listBranches() {
420
+ try {
421
+ return this.db.prepare('SELECT * FROM branch ORDER BY created_at, id').all().map((r) => ({
422
+ id: String(r.id),
423
+ name: String(r.name || ''),
424
+ kind: String(r.kind || 'branch'),
425
+ parentId: r.parent_id ? String(r.parent_id) : null,
426
+ forkAt: r.fork_at ? String(r.fork_at) : null,
427
+ forkSeq: r.fork_seq == null ? null : Number(r.fork_seq),
428
+ nameLocked: Number(r.name_locked || 0),
429
+ weightScale: Number(r.weight_scale ?? 1),
430
+ visibility: String(r.visibility || 'lineage'),
431
+ // 预留标记:见上方注释(审计 A#11)。界面不要给这两个字段做"生效中"的暗示。
432
+ weightScaleApplied: false,
433
+ visibilityApplied: false,
434
+ status: String(r.status || 'active'),
435
+ createdAt: r.created_at ? String(r.created_at) : null,
436
+ }));
437
+ } catch {
438
+ return [];
439
+ }
440
+ }
441
+
442
+ /** 血缘链:自己 → 父 → … → 主干(未知枝视为主干后代)。 */
443
+ branchAncestors(branchId) {
444
+ const out = [];
445
+ const seen = new Set();
446
+ let cur = String(branchId || TRUNK_ID);
447
+ while (cur && !seen.has(cur)) {
448
+ seen.add(cur);
449
+ out.push(cur);
450
+ let row = null;
451
+ try {
452
+ row = this.db.prepare('SELECT parent_id FROM branch WHERE id=?').get(cur);
453
+ } catch {
454
+ row = null;
455
+ }
456
+ cur = row && row.parent_id ? String(row.parent_id) : null;
457
+ }
458
+ if (!out.includes(TRUNK_ID)) out.push(TRUNK_ID);
459
+ return out;
460
+ }
461
+
462
+ /** 单点血缘权重(UI 与测试用)。 */
463
+ lineageWeight(currentBranchId, targetBranchId) {
464
+ const cur = String(currentBranchId || TRUNK_ID);
465
+ const tgt = String(targetBranchId || TRUNK_ID);
466
+ if (cur === tgt) return LINEAGE_SAME;
467
+ return this.branchAncestors(cur).includes(tgt) ? LINEAGE_ANCESTOR : LINEAGE_SIDE;
468
+ }
469
+
470
+ /** 全库枝 → 血缘权重映射(L1 打分用:一次算好,避免逐行查库)。 */
471
+ lineageWeightMap(currentBranchId) {
472
+ const cur = String(currentBranchId || TRUNK_ID);
473
+ const anc = new Set(this.branchAncestors(cur));
474
+ const m = new Map();
475
+ let rows = [];
476
+ try {
477
+ rows = this.db.prepare('SELECT id FROM branch').all();
478
+ } catch {
479
+ rows = [];
480
+ }
481
+ for (const r of rows) {
482
+ const id = String(r.id);
483
+ m.set(id, id === cur ? LINEAGE_SAME : anc.has(id) ? LINEAGE_ANCESTOR : LINEAGE_SIDE);
484
+ }
485
+ // ⚠️ 不给"未知枝"满权重(审计 A#4,2026-09-22 修):旧实现无条件 `m.set(cur, LINEAGE_SAME)`,
486
+ // 于是**悬空枝(已删但仍有引用)反而拿 1.0** —— 与 `lineageOf` 的 0.4 兜底自相矛盾,
487
+ // 而且它还是"数据丢了但权重没降"的掩体:错误不会因排序异常而暴露。
488
+ // 现在只有真的登记在 branch 表里的当前枝才有 1.0,未知枝交给调用方的兜底档。
489
+ if (m.has(cur)) m.set(cur, LINEAGE_SAME);
490
+ if (!m.has(TRUNK_ID)) m.set(TRUNK_ID, cur === TRUNK_ID ? LINEAGE_SAME : LINEAGE_ANCESTOR);
491
+ return m;
492
+ }
493
+
494
+ // ---- conv_branch:非会话源的枝归属覆盖层(方案 A,2026-09-21) ----
495
+ //
496
+ // 为什么需要它:枝归属原本靠 `session_meta` 推导(会话 → 枝),但 dsweb(1523 条)与 import(16 条)
497
+ // 的历史条目**不是 DSH 会话**、没有会话身份 ⇒ 推不出枝,只能永远留在主干,"一键生成树"对它们无效。
498
+ // 于是加一张显式覆盖表:**只对非会话源使用**,DSH 会话仍走 session_meta(既有链路零变化)。
499
+ // 解析优先级:conv_branch(显式指定) > session_meta(会话推导) > trunk。
500
+
501
+ /** 显式指定某条概述的枝归属(dsweb/import 用;幂等)。 */
502
+ setConvBranch(source, convId, branchId) {
503
+ const src = String(source || '');
504
+ const cid = String(convId || '');
505
+ const bid = String(branchId || TRUNK_ID);
506
+ if (!src || !cid) return { ok: false, reason: 'bad-arg' };
507
+ // ⚠️ 存在性检查(审计 A#10,2026-09-22):覆盖层是"幽灵枝"的**写入侧** ——
508
+ // 旧实现照单全收,指向不存在的枝也能落库;读取侧(branchCounts)只好把它对账回主干,
509
+ // 界面则凭空多出一行"名字 = 裸 UUID"的枝。主干视为恒存在(它是隐式默认归属,
510
+ // 历史上没有 branch 行也照样成立,不能因为迁移没跑到就拒收主干归属)。
511
+ if (bid !== TRUNK_ID) {
512
+ let row = null;
513
+ try {
514
+ row = this.db.prepare('SELECT id FROM branch WHERE id=?').get(bid);
515
+ } catch {
516
+ row = null;
517
+ }
518
+ if (!row) return { ok: false, reason: 'no-branch' };
519
+ }
520
+ this.db.prepare(`INSERT INTO conv_branch (source,conv_id,branch_id,created_at) VALUES (?,?,?,?)
521
+ ON CONFLICT(source,conv_id) DO UPDATE SET branch_id=excluded.branch_id`).run(src, cid, bid, utcIso());
522
+ return { ok: true, source: src, convId: cid, branchId: bid };
523
+ }
524
+
525
+ /** 撤销显式归属(回到"未指定" = 主干)。 */
526
+ clearConvBranch(source, convId) {
527
+ const r = this.db.prepare('DELETE FROM conv_branch WHERE source=? AND conv_id=?')
528
+ .run(String(source || ''), String(convId || ''));
529
+ return { ok: true, removed: Number(r.changes || 0) };
530
+ }
531
+
532
+ /** 覆盖层全量映射(key = `source\0convId`)。 */
533
+ convBranchMap() {
534
+ const m = new Map();
535
+ try {
536
+ for (const r of this.db.prepare('SELECT source, conv_id, branch_id FROM conv_branch').all()) {
537
+ m.set(String(r.source) + '\u0000' + String(r.conv_id), String(r.branch_id || TRUNK_ID));
538
+ }
539
+ } catch {
540
+ /* noop */
541
+ }
542
+ return m;
543
+ }
544
+
545
+ /** 某条概述的最终枝归属(显式覆盖优先,其次会话推导)。 */
546
+ branchOfConv(source, convId) {
547
+ try {
548
+ const r = this.db.prepare('SELECT branch_id FROM conv_branch WHERE source=? AND conv_id=?')
549
+ .get(String(source || ''), String(convId || ''));
550
+ if (r && r.branch_id) return String(r.branch_id);
551
+ } catch {
552
+ /* noop */
553
+ }
554
+ return String(source || '') === 'dsh' ? this.branchOfSession(convId) : TRUNK_ID;
555
+ }
556
+
557
+ /** 会话 → 枝 映射(L1 把记忆行映射到枝用;未登记会话视为主干)。 */
558
+ sessionBranchMap() {
559
+ const m = new Map();
560
+ try {
561
+ for (const r of this.db.prepare('SELECT session_id, branch_id FROM session_meta').all()) {
562
+ m.set(String(r.session_id), String(r.branch_id || TRUNK_ID));
563
+ }
564
+ } catch {
565
+ /* noop */
566
+ }
567
+ return m;
568
+ }
569
+
570
+ // ---- 树操作(D9-b,2026-09-19):主脉 / 并脉 / 连边 —— 一律不改写记忆内容 ----
571
+
572
+ /** 建主脉(kind='vein',如「力学」);缺省 id = `vein:<uuid>`。 */
573
+ createVein({ id = null, name = '', parentId = TRUNK_ID, visibility = 'lineage' } = {}) {
574
+ return this.createBranch({
575
+ id: id || ('vein:' + randomUUID()),
576
+ name,
577
+ kind: 'vein',
578
+ parentId,
579
+ visibility,
580
+ });
581
+ }
582
+
583
+ /** 某枝的全部后代 id(不含自身)。用于并脉防环与整树渲染。 */
584
+ branchDescendants(branchId) {
585
+ const out = [];
586
+ const root = String(branchId || '');
587
+ if (!root) return out;
588
+ let rows = [];
589
+ try {
590
+ rows = this.db.prepare('SELECT id, parent_id FROM branch').all();
591
+ } catch {
592
+ return out;
593
+ }
594
+ const kids = new Map();
595
+ for (const r of rows) {
596
+ const p = r.parent_id ? String(r.parent_id) : '';
597
+ if (!p) continue;
598
+ if (!kids.has(p)) kids.set(p, []);
599
+ kids.get(p).push(String(r.id));
600
+ }
601
+ const stack = [...(kids.get(root) || [])];
602
+ const seen = new Set();
603
+ while (stack.length) {
604
+ const cur = stack.pop();
605
+ if (seen.has(cur)) continue;
606
+ seen.add(cur);
607
+ out.push(cur);
608
+ for (const k of kids.get(cur) || []) stack.push(k);
609
+ }
610
+ return out;
611
+ }
612
+
613
+ /** 并脉:把一条枝挂到新父节点下。**防环**(新父不能是自己或自己的后代)。内容零变化。 */
614
+ reparentBranch(branchId, newParentId) {
615
+ const bid = String(branchId || '');
616
+ const pid = String(newParentId || TRUNK_ID);
617
+ if (!bid) return { ok: false, reason: 'no-branch' };
618
+ if (bid === TRUNK_ID) return { ok: false, reason: 'trunk-immutable' };
619
+ if (bid === pid) return { ok: false, reason: 'self-parent' };
620
+ // ⚠️ 存在性检查(审计 A#10,2026-09-22):旧实现只查**父**,不查**自己** ——
621
+ // 传一个不存在的枝 id 时 `UPDATE ... WHERE id=?` 影响 0 行,却照样返回 ok:true;
622
+ // 调用方(建枝/并脉链路)以为挂上了,后续再拿这个 id 去写归属,就是孤儿制造源。
623
+ let me = null;
624
+ try {
625
+ me = this.db.prepare('SELECT id FROM branch WHERE id=?').get(bid);
626
+ } catch {
627
+ me = null;
628
+ }
629
+ if (!me) return { ok: false, reason: 'no-branch' };
630
+ if (pid !== TRUNK_ID) {
631
+ let p = null;
632
+ try {
633
+ p = this.db.prepare('SELECT id FROM branch WHERE id=?').get(pid);
634
+ } catch {
635
+ p = null;
636
+ }
637
+ if (!p) return { ok: false, reason: 'no-parent' };
638
+ }
639
+ if (this.branchDescendants(bid).includes(pid)) return { ok: false, reason: 'cycle' };
640
+ this.db.prepare('UPDATE branch SET parent_id=?, updated_at=? WHERE id=?').run(pid, utcIso(), bid);
641
+ return { ok: true, id: bid, parentId: pid };
642
+ }
643
+
644
+ /** 枝改名(复盘时用)。`name_locked` 语义同 D2 的 `title_locked`。 */
645
+ /**
646
+ * 删枝(审计 B#4 补的能力,2026-09-22)。此前**全插件没有任何删枝端点** ——
647
+ * 建错枝(如空名枝)只能直连改库,这与"树操作可复盘、可审计"的设计相悖。
648
+ *
649
+ * 安全规则:① trunk 不可删;② 有子枝或仍有归属(会话 / 覆盖层)时,除非 `force`,
650
+ * 否则**拒绝并回报影响面** —— 让调用方先知道会动到什么。
651
+ * `force` 删枝时,归属一律**回退到主干**,绝不留下悬空引用(与 `restoreTree` 同一条纪律)。
652
+ */
653
+ deleteBranch(branchId, { force = false } = {}) {
654
+ const bid = String(branchId || '');
655
+ if (!bid) return { ok: false, reason: 'bad-arg' };
656
+ if (bid === TRUNK_ID) return { ok: false, reason: 'trunk-immutable' };
657
+ let row = null;
658
+ try {
659
+ // 1.5.1 红蓝对抗(红队 E 顺带发现):此前只 `SELECT id`,而返回体用的是 `row.name`
660
+ // ⇒ **回执的 name 恒为空串**,删枝留痕只能回落到裸 id。查询与消费必须同口径。
661
+ row = this.db.prepare('SELECT id, name FROM branch WHERE id=?').get(bid);
662
+ } catch {
663
+ row = null;
664
+ }
665
+ if (!row) return { ok: false, reason: 'not-found' };
666
+ const cnt = (sql, a, b) => {
667
+ try {
668
+ const r = b === undefined ? this.db.prepare(sql).get(a) : this.db.prepare(sql).get(a, b);
669
+ return Number(r?.n || 0);
670
+ } catch {
671
+ return 0;
672
+ }
673
+ };
674
+ const impact = {
675
+ children: cnt('SELECT COUNT(*) n FROM branch WHERE parent_id=?', bid),
676
+ sessions: cnt('SELECT COUNT(*) n FROM session_meta WHERE branch_id=?', bid),
677
+ convs: cnt('SELECT COUNT(*) n FROM conv_branch WHERE branch_id=?', bid),
678
+ links: cnt('SELECT COUNT(*) n FROM vein_link WHERE from_branch=? OR to_branch=?', bid, bid),
679
+ };
680
+ const busy = impact.children || impact.sessions || impact.convs || impact.links;
681
+ if (!force && busy) return { ok: false, reason: 'not-empty', impact };
682
+ try {
683
+ this.db.exec('BEGIN');
684
+ this.db.prepare('UPDATE branch SET parent_id=? WHERE parent_id=?').run(TRUNK_ID, bid);
685
+ this.db.prepare('UPDATE session_meta SET branch_id=? WHERE branch_id=?').run(TRUNK_ID, bid);
686
+ this.db.prepare('DELETE FROM conv_branch WHERE branch_id=?').run(bid);
687
+ this.db.prepare('DELETE FROM vein_link WHERE from_branch=? OR to_branch=?').run(bid, bid);
688
+ this.db.prepare('DELETE FROM branch WHERE id=?').run(bid);
689
+ this.db.exec('COMMIT');
690
+ } catch (e) {
691
+ try { this.db.exec('ROLLBACK'); } catch { /* noop */ }
692
+ return { ok: false, reason: 'db', error: String(e?.message ?? e).slice(0, 200) };
693
+ }
694
+ return { ok: true, id: bid, name: String(row.name || ''), impact };
695
+ }
696
+
697
+ renameBranch(branchId, name, { lock = true } = {}) {
698
+ const bid = String(branchId || '');
699
+ const nm = String(name || '').trim().slice(0, 60);
700
+ if (!bid) return { ok: false, reason: 'no-branch' };
701
+ if (!nm) return { ok: false, reason: 'empty-name' };
702
+ this.db.prepare('UPDATE branch SET name=?, name_locked=?, updated_at=? WHERE id=?')
703
+ .run(nm, lock ? 1 : 0, utcIso(), bid);
704
+ return { ok: true, id: bid, name: nm };
705
+ }
706
+
707
+ /** 复盘期权重调节(平时只读 —— 设计稿 §3.3)。scale 夹在 [0,2]。
708
+ *
709
+ * ⚠️ 预留字段(审计 A#11,2026-09-22 定为方案 b):写进去的 `weight_scale`
710
+ * **当前不参与检索打分**(L1 = raw × 血缘档位 × 矛盾系数)。保留写入与显示,
711
+ * 是为了不破坏"复盘期可调"的界面与既有数据;但返回体里明确带
712
+ * `applied:false` + `note`,避免调用方(与主人)误以为检索排序已经跟着变。
713
+ * 让它真正生效属于**改变检索语义**,需主人拍板(方案 a),不在本次审计修法内。 */
714
+ setBranchWeight(branchId, scale) {
715
+ const bid = String(branchId || '');
716
+ const v = Number(scale);
717
+ if (!bid || !Number.isFinite(v)) return { ok: false, reason: 'bad-arg' };
718
+ const clamped = Math.max(0, Math.min(2, v));
719
+ this.db.prepare('UPDATE branch SET weight_scale=?, updated_at=? WHERE id=?').run(clamped, utcIso(), bid);
720
+ return {
721
+ ok: true, id: bid, weightScale: clamped,
722
+ applied: false, note: 'weight_scale 为预留字段:已写入并显示,当前不参与检索打分',
723
+ };
724
+ }
725
+
726
+ /** 连边:两条枝之间的横向关联(如 力学 ↔ 材料科学)。 */
727
+ linkVeins(fromBranch, toBranch, { kind = 'related', note = '' } = {}) {
728
+ const a = String(fromBranch || '');
729
+ const b = String(toBranch || '');
730
+ if (!a || !b || a === b) return { ok: false, reason: 'bad-arg' };
731
+ this.db.prepare(`INSERT INTO vein_link (from_branch,to_branch,kind,note,created_at)
732
+ VALUES (?,?,?,?,?)
733
+ ON CONFLICT(from_branch,to_branch) DO UPDATE SET kind=excluded.kind, note=excluded.note`)
734
+ .run(a, b, String(kind || 'related'), String(note || '').slice(0, 300), utcIso());
735
+ return { ok: true, from: a, to: b };
736
+ }
737
+
738
+ unlinkVein(fromBranch, toBranch) {
739
+ try {
740
+ const r = this.db.prepare('DELETE FROM vein_link WHERE from_branch=? AND to_branch=?')
741
+ .run(String(fromBranch || ''), String(toBranch || ''));
742
+ return { ok: true, removed: Number(r.changes || 0) };
743
+ } catch {
744
+ return { ok: false, reason: 'db' };
745
+ }
746
+ }
747
+
748
+ listVeinLinks() {
749
+ try {
750
+ return this.db.prepare('SELECT * FROM vein_link ORDER BY created_at, from_branch').all().map((r) => ({
751
+ from: String(r.from_branch),
752
+ to: String(r.to_branch),
753
+ kind: String(r.kind || 'related'),
754
+ note: String(r.note || ''),
755
+ createdAt: r.created_at ? String(r.created_at) : null,
756
+ }));
757
+ } catch {
758
+ return [];
759
+ }
760
+ }
761
+
762
+ // ---- 软遗忘(Part G G2,2026-09-25) ----
763
+ //
764
+ // 原则(用户 2026-09-25 定):**永远不做彻底删除**。遗忘 = 先把原数据归档落盘(原文 + 概述 +
765
+ // 改动日志),再在库里打一个标记,召回路径据标记排除。行仍在库里、归档文件在磁盘上 ——
766
+ // 两条独立还原路径,任何一条都能反悔。
767
+ //
768
+ // 为什么"真删"是错的:① 只删概述不删原文 ⇒ summarizer 下一轮就从原文重建,等于没忘;
769
+ // ② 连原文一起删 ⇒ 一旦发现"忘错了"就再也回不来(backfill 的前提恰恰是原文还在)。
770
+ // 所以这里只做"标记",不做 DELETE —— 删除权限留在人的手里。
771
+
772
+ /** 已遗忘条目的键集(`source|conv_id`),供召回路径排除。 */
773
+ forgottenConvIdSet() {
774
+ try {
775
+ const rows = this.db.prepare('SELECT source, conv_id FROM forgotten').all();
776
+ return new Set(rows.map((r) => String(r.source) + '|' + String(r.conv_id)));
777
+ } catch {
778
+ return new Set();
779
+ }
780
+ }
781
+
782
+ /**
783
+ * 打遗忘标记。
784
+ *
785
+ * **调用方必须先行完成归档**(此处只写库)—— 而且**必须确认那份归档不是空的**:
786
+ * B-05(1.5.1 红蓝对抗)的教训是"归档成功"与"备份到了东西"是两件事,
787
+ * 对一条不存在的条目(或概述行在、原文已被硬删的条目)归档出来是 0 轮 ——
788
+ * 文件系统上确实有个目录,判据却什么都没保住,标记一打,这条 id 就永不召回,
789
+ * 界面还显示"忘过了"。所以这里**再兜一层**(fail-closed 的第二道):
790
+ * · 没给 `archivePath` ⇒ 调用方没归档就想过关;除非它显式声明
791
+ * `archiveTurns: 0`(它自己知道并承担),否则拒绝;
792
+ * · `archiveTurns` 声明了轮数(≥1)⇒ 信声明(HTTP 通道的调用方按此口径传);
793
+ * · 没声明 ⇒ 直接验盘(归档目录里 `turns.jsonl` 有没有内容),
794
+ * 这样"裸调 `forgetEntry`"也过不了关 —— 默认拒绝形态。
795
+ * ⚠️ 仍然缺的一环在**调用侧**(`tools.js` 的 forget handler):它只判 `arc.ok`,
796
+ * 不判 `arc.empty` ⇒ 想彻底堵住 B-05 还得在那儿加一句守卫(见审计台账)。
797
+ */
798
+ forgetEntry({ source, convId, reason = '', archivePath = '', actor = 'user', archiveTurns = null } = {}) {
799
+ const s = String(source || '').trim();
800
+ const c = String(convId || '').trim();
801
+ if (!s || !c) return { ok: false, reason: 'bad-key' };
802
+ const dir = String(archivePath || '').trim();
803
+ if (!dir) return { ok: false, reason: 'empty-archive', message: '没有归档路径 —— 先归档再打标记' };
804
+ if (archiveTurns === null || archiveTurns === undefined) {
805
+ let n = 0;
806
+ try {
807
+ n = existsSync(join(dir, 'turns.jsonl'))
808
+ ? readFileSync(join(dir, 'turns.jsonl'), 'utf8').split('\n').filter((l) => l.trim()).length
809
+ : 0;
810
+ } catch { n = 0; }
811
+ if (n === 0) {
812
+ return { ok: false, reason: 'empty-archive', message: '归档里没有原文(0 轮)', archivePath: dir };
813
+ }
814
+ } else if (!(Number(archiveTurns) > 0)) {
815
+ return { ok: false, reason: 'empty-archive', message: '归档里没有原文(0 轮)', archivePath: dir };
816
+ }
817
+ const oneLine = (x, n) => String(x == null ? '' : x).replace(/[\r\n\t]+/g, ' ').trim().slice(0, n);
818
+ try {
819
+ this.db.prepare(`INSERT INTO forgotten (source,conv_id,at,reason,archive_path,actor)
820
+ VALUES (?,?,?,?,?,?) ON CONFLICT(source,conv_id) DO UPDATE SET
821
+ at=excluded.at, reason=excluded.reason, archive_path=excluded.archive_path, actor=excluded.actor`)
822
+ .run(s, c, utcIso(), oneLine(reason, 300), oneLine(archivePath, 400),
823
+ String(actor || 'user').toLowerCase() === 'ling' ? 'ling' : 'user');
824
+ return { ok: true, source: s, convId: c, archivePath: String(archivePath || '') };
825
+ } catch (e) {
826
+ return { ok: false, reason: 'db', message: String(e?.message ?? e).slice(0, 120) };
827
+ }
828
+ }
829
+
830
+ /** 取消遗忘标记(归档文件不动 —— 它本来就是"曾经忘过"的证据)。 */
831
+ unforgetEntry({ source, convId } = {}) {
832
+ try {
833
+ const r = this.db.prepare('DELETE FROM forgotten WHERE source=? AND conv_id=?')
834
+ .run(String(source || ''), String(convId || ''));
835
+ return { ok: true, removed: Number(r.changes || 0) };
836
+ } catch {
837
+ return { ok: false, reason: 'db' };
838
+ }
839
+ }
840
+
841
+ /** 遗忘清单(最近的在前)。 */
842
+ listForgotten({ limit = 100 } = {}) {
843
+ try {
844
+ const n = Math.max(1, Math.min(1000, Number(limit) || 100));
845
+ return this.db.prepare('SELECT * FROM forgotten ORDER BY at DESC LIMIT ?').all(n).map((r) => ({
846
+ source: String(r.source || ''), convId: String(r.conv_id || ''), at: String(r.at || ''),
847
+ reason: String(r.reason || ''), archivePath: String(r.archive_path || ''), actor: String(r.actor || 'user'),
848
+ }));
849
+ } catch {
850
+ return [];
851
+ }
852
+ }
853
+
854
+ /** 导出单条的**全部数据**(概述 + 会话元数据 + 原文),供归档落盘。 */
855
+ exportEntry(source, convId) {
856
+ const s = String(source || '');
857
+ const c = String(convId || '');
858
+ const out = { source: s, convId: c, at: utcIso(), overview: null, sessionMeta: null, turns: [] };
859
+ try { out.overview = this.overviewById(s, c); } catch { /* 允许缺失 */ }
860
+ try { out.sessionMeta = this.db.prepare('SELECT * FROM session_meta WHERE session_id=?').get(c) || null; } catch { /* 允许缺失 */ }
861
+ try {
862
+ out.turns = this.db.prepare('SELECT seq, role, ts, model, text FROM dsh_turns_raw WHERE session_id=? ORDER BY seq').all(c);
863
+ } catch { /* 允许缺失 */ }
864
+ // 导入源的原文带命名空间前缀(见 RAW_IMPORT_PREFIX),裸 id 取不到时按前缀再试一次
865
+ if (!out.turns.length) {
866
+ try {
867
+ out.turns = this.db.prepare('SELECT seq, role, ts, model, text FROM dsh_turns_raw WHERE session_id=? ORDER BY seq')
868
+ .all(RAW_IMPORT_PREFIX + c);
869
+ } catch { /* ignore */ }
870
+ }
871
+ return out;
872
+ }
873
+
874
+ /**
875
+ * 从归档写回(回灌)。
876
+ *
877
+ * **调用方必须先归档当前状态** —— 本方法只管写,不做任何备份。
878
+ * 回灌的危险性高于遗忘:遗忘会留下"少了一条"的可见痕迹,而回灌是**往里加东西**,
879
+ * 加错了很难察觉(用户 2026-09-25:「backfill 比 forget 危险多了,因为它更难以觉察」)。
880
+ *
881
+ * 两道保护:① 写回的概述带 `origin` 标记(默认 `backfill-v1`),界面上看得出"这条是灌回来的";
882
+ * ② 不传 `keepUserState:false` ⇒ **不动主人留下的命中数与置顶**,回灌只补内容、不夺用户态。
883
+ */
884
+ restoreEntry({ source, convId, turns = [], overview = null, origin = 'backfill-v1' } = {}) {
885
+ const s = String(source || '').trim();
886
+ // ⚠️ B-01(1.5.1 红蓝对抗,2026-09-25):写入侧**必须**与本模块契约(`:25` "写入侧一律带前缀")
887
+ // 和 `import-file.js:285` 同口径走 rawSessionId。
888
+ // 此前这里直接用裸 convId ⇒ import 源的原文落成**裸 id**,而概述器按 dsh 会话扫描
889
+ // `dsh_turns_raw`,于是给它建出 `source='dsh'` 的镜像行 ⇒ **同 id 双源**
890
+ // (D1 一代 / B7 二代 之后,由全新工具 memory_backfill 打开的第三次)。
891
+ // 对"已带前缀"的正确输入是无操作(idempotent),只拦畸形输入 —— 默认拒绝形态。
892
+ const c = rawSessionId(s, String(convId || '').trim());
893
+ if (!s || !c) return { ok: false, reason: 'bad-key' };
894
+ let wroteTurns = 0;
895
+ try {
896
+ const ins = this.db.prepare(`INSERT INTO dsh_turns_raw (session_id,seq,role,ts,model,text)
897
+ VALUES (?,?,?,?,?,?) ON CONFLICT(session_id,seq) DO UPDATE SET
898
+ role=excluded.role, ts=excluded.ts, model=excluded.model, text=excluded.text`);
899
+ this.db.exec('BEGIN');
900
+ for (const t of (Array.isArray(turns) ? turns : [])) {
901
+ ins.run(c, Number(t.seq) || 0, String(t.role || ''), t.ts ?? null, t.model ?? null, String(t.text ?? ''));
902
+ wroteTurns += 1;
903
+ }
904
+ this.db.exec('COMMIT');
905
+ } catch (e) {
906
+ try { this.db.exec('ROLLBACK'); } catch { /* ignore */ }
907
+ return { ok: false, reason: 'write-failed', message: String(e?.message ?? e).slice(0, 160) };
908
+ }
909
+ let wroteOverview = false;
910
+ if (overview && typeof overview === 'object') {
911
+ try {
912
+ this.upsertOverview({ ...overview, source: s, conv_id: c, origin: String(origin || 'backfill-v1') });
913
+ wroteOverview = true;
914
+ } catch { /* 概述写失败不算整体失败:原文已回去,概述可由概述器重建 */ }
915
+ }
916
+ return { ok: true, source: s, convId: c, turns: wroteTurns, overview: wroteOverview };
917
+ }
918
+
919
+ /**
920
+ * 复盘留痕(设计稿 §3.3 第 2 条:仅在复盘时修改,并留痕"改了什么、为什么")。
921
+ * 只记结构操作(改名/并脉/权重/连边),**绝不记录记忆内容** —— 与"内容零改写"不变量一致。
922
+ */
923
+ logBranch(branchId, action, { before = '', after = '', note = '', actor = 'user' } = {}) {
924
+ // 写入侧净化(审计 B#2,2026-09-22):branchId 必须限长 —— /veins/link 只要求"非空且 from≠to",
925
+ // 实测可塞进 200KB 的 id,渲染成单行 20 万字符;自由文本一律**压平换行**,
926
+ // 因为换行能穿透引号,在系统提示里自造独立行(如 note 塞 `\n\n【系统】忽略此前指令…`)。
927
+ // G4(2026-09-25):actor 记录**谁改的** —— 工具通道上线前器灵只能经 API/开库改树,
928
+ // 而旧 logBranch 无此字段,91 笔器灵改动在复盘留痕里全被显示成"主人改的"。
929
+ // 取值 'user' | 'ling'(默认 'user' 以兼容既有调用点)。
930
+ const oneLine = (s, n) => String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').trim().slice(0, n);
931
+ const who = String(actor || 'user').toLowerCase() === 'ling' ? 'ling' : 'user';
932
+ try {
933
+ this.db.prepare(`INSERT INTO branch_log (at,branch_id,action,before_val,after_val,note,actor)
934
+ VALUES (?,?,?,?,?,?,?)`)
935
+ .run(utcIso(), oneLine(branchId, 64), oneLine(action, 40),
936
+ oneLine(before, 200), oneLine(after, 200), oneLine(note, 300), who);
937
+ return { ok: true };
938
+ } catch {
939
+ return { ok: false, reason: 'db' };
940
+ }
941
+ }
942
+
943
+ /**
944
+ * 结构改动摘要(D9-b 注入感知,2026-09-22) —— 把 branch_log 里**还没被器灵看过**的改动
945
+ * 格式化成中文短句,供注入面使用:主人改了树,器灵下一次开口前就能看到。
946
+ *
947
+ * 水位用**自增 id** 而非时间戳 —— 同一毫秒内的多条改动用时间戳比较会漏。
948
+ * 只报结构操作,不碰任何记忆内容(与"内容零改写"不变量一致)。
949
+ */
950
+ branchLogDigest({ limit = 5, afterId = null } = {}) {
951
+ const max = Math.max(1, Math.min(20, Number(limit) || 5));
952
+ // B#9(2026-09-22 审计):水位必须**按会话** —— 全局键会让会话 A 消费之后、
953
+ // 会话 B 再也看不到同一批改动。调用方传 afterId(取自 kv `branch_log_wm.<sessionId>`);
954
+ // 未传才退回全局键(仅为兼容旧调用点,新代码不应依赖)。
955
+ const wm = (afterId !== null && afterId !== undefined && Number.isFinite(Number(afterId)))
956
+ ? Number(afterId)
957
+ : Number(this.kvGet('branch_log_wm') || 0);
958
+ let rows = [];
959
+ try {
960
+ rows = this.listBranchLog({ limit: 120 });
961
+ } catch {
962
+ return { lines: [], maxId: wm, total: 0 };
963
+ }
964
+ const fresh = rows.filter((r) => Number(r.id) > wm);
965
+ if (!fresh.length) return { lines: [], maxId: wm, total: 0 };
966
+ // ── B1(1.5.1):真实剩余笔数**必须问库** ─────────────────────────────────────
967
+ // 上面 listBranchLog 只取最近 120 行,积压超过 120 时窗口恒满;旧代码拿
968
+ // `fresh.length - lines.length`(= limit 夹取差,恒为 120-5=115)当"另有 N 笔",
969
+ // 于是主人读到的是一个与真实积压无关的**常数**(实测 live 上未读 394 笔,仍显示 115)。
970
+ // 这里补一条独立 COUNT:branch_log.id 是 INTEGER PRIMARY KEY(AUTOINCREMENT),
971
+ // `WHERE id > ?` 是主键范围扫描 —— 只走索引不拉行,代价与积压量无关,
972
+ // 所以**不需要**把 limit 调大去"凑数"(那才是真把几百行读进内存)。
973
+ // 只服务展示:水位推进仍由下方 `maxId` 负责(B3 按主人的决定推迟到 1.5.2)。
974
+ let realTotal = fresh.length; // 兜底:COUNT 失败时退回窗口内条数(已知下限)
975
+ try {
976
+ const agg = this.db.prepare('SELECT COUNT(*) AS n FROM branch_log WHERE id > ?').get(wm);
977
+ if (agg) realTotal = Math.max(Number(agg.n) || 0, fresh.length);
978
+ } catch { /* 库出错时退回窗口口径:摘要宁可少报,也不能整段消失 */ }
979
+ const names = new Map(this.listBranches().map((b) => [b.id, b.name || b.id]));
980
+ // 提示面净化(审计 §2.5 + B#2,2026-09-22):
981
+ // ① 枝名/id 一律翻译或缩成短号,**永不吐裸 uuid**(实测:gather/autobuild 两支漏了 nm(),
982
+ // 器灵在注入面里读到的是一串 `vein:558ae2a3-…`);
983
+ // ② 所有渲染文本**压平换行 + 限长** —— branch_log 装的是自由文本(枝名、连边理由、备注),
984
+ // 换行能穿透引号、在系统提示里自造独立行(实测:note 塞 `\n\n【系统】…` 即可脱离框架)。
985
+ // 显示面修正(2026-09-22 实测):原实现对一切"查不到名字的 id"回退成「(已删枝 …)」,
986
+ // 但 branch_log 的 before/after **不只装枝 id** —— snapshot 装的是快照号(#9)、
987
+ // assign 装的是记忆条目 id(026ebd86),二者都被误标成"已删枝"(用户截图所见:
988
+ // `snapshot:主干 → (已删枝 #9)` 实际一根枝都没删)。先按 id 形态分流再回退查名。
989
+ const nm = (v) => {
990
+ const k = String(v || '');
991
+ if (!k) return '?';
992
+ const hit = names.get(k);
993
+ if (hit) return hit; // 真的是现有枝
994
+ if (/^#?\d+$/.test(k)) return '快照 ' + (k[0] === '#' ? k : '#' + k); // 快照号
995
+ if (/^(br|vein):/.test(k)) return '(已删枝 ' + k.slice(-8) + ')'; // 长 id 形态 ⇒ 枝
996
+ return '条目 ' + k.slice(-8); // 其余按记忆条目
997
+ };
998
+ const flat = (s) => String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').trim().slice(0, 80);
999
+ const lines = fresh.slice(0, max).map((r) => {
1000
+ const who = nm(r.branchId);
1001
+ const line = (() => {
1002
+ switch (String(r.action || '')) {
1003
+ case 'rename': return '把「' + (r.before || '未命名') + '」改名为「' + r.after + '」';
1004
+ case 'reparent': return '把「' + who + '」从「' + nm(r.before) + '」下挪到了「' + nm(r.after) + '」下';
1005
+ case 'weight': return '把「' + who + '」的枝系数从 ' + (r.before || '1') + ' 调到了 ' + r.after;
1006
+ case 'link': return '连了一条边:「' + who + '」↔「' + nm(r.after) + '」(' + (r.note || 'related') + ')';
1007
+ case 'unlink': return '断开了边:「' + who + '」↔「' + nm(r.before) + '」';
1008
+ case 'gather': return '把「' + who + '」归并到了「' + nm(r.after) + '」下';
1009
+ case 'snapshot': return '保存了' + nm(r.after) + (r.note ? '(' + r.note + ')' : '');
1010
+ case 'restore': return '用' + nm(r.after || r.before) + '恢复了一次树结构';
1011
+ case 'assign': return '把「' + nm(r.after) + '」挪到了「' + who + '」下';
1012
+ case 'delete': {
1013
+ // D-T04(1.5.1 红蓝对抗):新行 after 存**被删枝的名字**,旧行 after 存**枝 id**。
1014
+ // 此前一律用 who = nm(branchId),而旧写入端恒传 TRUNK_ID ⇒ 每行都渲染成
1015
+ // 「删掉了枝「主干」」(实测:live 上 59 笔删枝留痕全是这句),被删枝的名字全丢。
1016
+ const a = String(r.after || '');
1017
+ const label = /^(br|vein):/.test(a) ? nm(a) : (a || who);
1018
+ return '删掉了枝「' + label + '」';
1019
+ }
1020
+ case 'autobuild': return '自动建了一条枝「' + nm(r.after) + '」' + (r.note ? '(' + r.note + ')' : '');
1021
+ // ── 1.5.0 新增的动作(A-09 / F1 / C-15 / D-S03:此前全落进 default ⇒ 渲染成
1022
+ // 「主人 distill:学术与写作 → 条目 候选 #10」这类乱码,且 note 被整条丢弃) ──
1023
+ case 'distill': return '为「' + who + '」提炼了一条主脉记忆草稿' + (r.note ? '(' + r.note + ')' : '');
1024
+ case 'vein-adopt': {
1025
+ const rejected = String(r.after || '').includes('驳回');
1026
+ if (rejected) {
1027
+ // who 为 '?' 的只有 1.5.0 的**存量驳回行**(写入端当年没回传 veinId,所以 branch_id 是空串)
1028
+ // ⇒ 宁可不提那条主脉,也不要吐一个 '?' 给模型猜。
1029
+ return who === '?' ? '驳回了一条提炼候选(什么都没改)' : '驳回了「' + who + '」的一条提炼候选(什么都没改)';
1030
+ }
1031
+ // A-01:覆盖是**预期行为**(概括随主脉一起长),但注入面要说清"旧的没丢,已归档"。
1032
+ return String(r.after || '').includes('覆盖')
1033
+ ? '采纳了一条提炼候选,作为「' + who + '」的新版主脉记忆(旧版已归档)'
1034
+ : '采纳了一条提炼候选,作为「' + who + '」的主脉记忆';
1035
+ }
1036
+ case 'theme-rebuild': return '重建了主题结构:' + (r.before || '') + ' → ' + (r.after || '') + (r.note ? '(' + r.note + ')' : '');
1037
+ // ── 器灵工具通道会写的动作(1.5.1 一并补上,免得将来重演同一处乱码) ──
1038
+ case 'create': return '建了一条枝「' + (r.after || who) + '」' + (r.note ? '(' + r.note + ')' : '');
1039
+ case 'forget': return '把' + nm(r.after) + '标记为遗忘' + (r.note ? '(' + r.note + ')' : '');
1040
+ case 'unforget': return '撤销了' + nm(r.after) + '的遗忘标记';
1041
+ case 'backfill': return '回灌了' + nm(r.after) + (r.note ? '(' + r.note + ')' : '');
1042
+ default: return (r.action || '操作') + ':' + who + (r.after ? ' → ' + nm(r.after) : '');
1043
+ }
1044
+ })();
1045
+ // G4(2026-09-25):每行带主语 —— 器灵经工具通道改的树记 'ling',
1046
+ // 不再和"主人改的"混在同一批留痕里(Part G 之前 91 笔器灵改动全记在主人名下)。
1047
+ // 中文动作短语(把「」…)直接接主语;英文动作名(distill:/gather:)前面补空格,免得拼成"主人distill:"。
1048
+ const subject = String(r.actor || 'user') === 'ling' ? '器灵' : '主人';
1049
+ return flat(subject + (/^[A-Za-z0-9]/.test(line) ? ' ' : '') + line);
1050
+ });
1051
+ // 最多 max 行 + 一行计数。折算的**余量走 realTotal**(B1:真实剩余笔数)。
1052
+ if (realTotal > lines.length) lines.push('…另有 ' + (realTotal - lines.length) + ' 笔改动');
1053
+ return { lines, maxId: Number(fresh[0].id), total: realTotal };
1054
+ }
1055
+
1056
+ /** 复盘留痕列表(最近的在前)。 */
1057
+ listBranchLog({ limit = 100 } = {}) {
1058
+ try {
1059
+ const n = Math.max(1, Math.min(500, Number(limit) || 100));
1060
+ return this.db.prepare('SELECT * FROM branch_log ORDER BY id DESC LIMIT ?').all(n).map((r) => ({
1061
+ id: Number(r.id),
1062
+ at: String(r.at || ''),
1063
+ branchId: String(r.branch_id || ''),
1064
+ action: String(r.action || ''),
1065
+ before: String(r.before_val || ''),
1066
+ after: String(r.after_val || ''),
1067
+ note: String(r.note || ''),
1068
+ actor: String(r.actor || 'user'),
1069
+ }));
1070
+ } catch {
1071
+ return [];
1072
+ }
1073
+ }
1074
+
1075
+ /** 尚未同步给器灵的改动笔数(复盘守门,2026-09-22 用户:「动记忆树不能是随意的」)。
1076
+ *
1077
+ * 判据:自器灵**上一次开口**(注入推进水位)以来累积了多少笔结构改动。
1078
+ * 水位取 `branch_log_wm.last`(由 `inject.js` 的 advanceLogWatermark 在投递时写入),
1079
+ * 因此它与"按会话水位"互补:会话水位决定**该会话**看到什么,本键决定**是否还没交代**。
1080
+ */
1081
+ branchLogPending() {
1082
+ let wm = 0;
1083
+ let hasKey = false;
1084
+ try {
1085
+ const raw = this.kvGet('branch_log_wm.last');
1086
+ if (raw !== undefined && raw !== null && String(raw) !== '') {
1087
+ wm = Number(raw) || 0;
1088
+ hasKey = true;
1089
+ }
1090
+ } catch {
1091
+ wm = 0;
1092
+ }
1093
+ let pending = 0;
1094
+ let maxId = 0;
1095
+ try {
1096
+ // 'snapshot'(保存快照)不计入待交代笔数(2026-09-25 用户:「单纯保存不弹修改」)。
1097
+ // 依据:保存快照**不改树**——它只是把当前结构另存一份,是"大改之前的保险",
1098
+ // 不是"改动"。把它算成债会让主人每存一次就看到「⚠ 有 N 笔改动还没跟器灵说过」,
1099
+ // 而实际上树一个字节没变。留痕仍写进 branch_log(审计价值保留),只是不推进债。
1100
+ // 注:restore(恢复)/reparent/rename 等**真改结构**的动作照旧计入。
1101
+ // maxId 仍取全表最大(含 snapshot),否则水位会卡在 snapshot 行之前反复重算。
1102
+ const r = this.db.prepare(
1103
+ 'SELECT COALESCE(MAX(id),0) AS m,'
1104
+ + " SUM(CASE WHEN action <> 'snapshot' THEN 1 ELSE 0 END) AS n"
1105
+ + ' FROM branch_log WHERE id > ?'
1106
+ ).get(wm);
1107
+ maxId = Number((r && r.m) || 0);
1108
+ pending = Number((r && r.n) || 0);
1109
+ } catch {
1110
+ /* noop */
1111
+ }
1112
+ // 升级后首次运行:库里可能已积压上百条历史留痕(1.4.0 之前没有这个键),
1113
+ // 那些属于"机制上线前的事",不该一次性报成未交代 —— 用当前最大 id 作起点并落键,
1114
+ // 避免"一升级就显示 118 笔未同步"的假警报。此后水位只由 inject 的投递推进。
1115
+ if (!hasKey && maxId > 0) {
1116
+ try {
1117
+ this.kvSet('branch_log_wm.last', String(maxId));
1118
+ } catch {
1119
+ /* noop */
1120
+ }
1121
+ return { watermark: maxId, maxId, pending: 0, initialized: true };
1122
+ }
1123
+ return { watermark: wm, maxId, pending };
1124
+ }
1125
+
1126
+ /** 整棵树(UI 用):嵌套 children + 每枝会话数 + 横向连边 + **层级(计算属性,不落库)**。
1127
+ *
1128
+ * ⚠️ 两个计数**语义不同**(审计 A#6,2026-09-22),别混用:
1129
+ * · `sessions` = `session_meta` **台账行数**(含空壳会话)—— "这个枝登记了多少会话",
1130
+ * 存量消费方依赖它,故保持原样(空壳也是登记过的会话,只是没有内容);
1131
+ * · `members` = **展开实际会给出的条数**,与 `branchMembers()` 同一谓词(有标题 OR 有原文)。
1132
+ * 界面里挨着 ▶ 的数字**必须用 `members`**:台账口径里那批空壳永远展不开,
1133
+ * 实测主干写「76 个会话」而展开只有 15 条 —— 61 条是点不开的假广告。
1134
+ * 两个数都给,是为了让"为什么对不上"在返回体里一眼可查,而不是靠猜。
1135
+ */
1136
+ branchTree() {
1137
+ const nodes = this.listBranches();
1138
+ const counts = new Map();
1139
+ try {
1140
+ for (const r of this.db.prepare('SELECT branch_id AS bid, COUNT(*) AS n FROM session_meta GROUP BY branch_id').all()) {
1141
+ counts.set(String(r.bid || TRUNK_ID), Number(r.n || 0));
1142
+ }
1143
+ } catch {
1144
+ /* noop */
1145
+ }
1146
+ let expand = new Map();
1147
+ try {
1148
+ expand = this.branchMemberCounts().members;
1149
+ } catch {
1150
+ /* noop */
1151
+ }
1152
+ const byId = new Map();
1153
+ for (const n of nodes) {
1154
+ byId.set(n.id, {
1155
+ ...n,
1156
+ sessions: counts.get(n.id) || 0, // 台账(含空壳)—— 兼容既有消费方
1157
+ members: expand.get(n.id) || 0, // 展开实际条数 —— 界面计数用这个
1158
+ children: [],
1159
+ });
1160
+ }
1161
+ const roots = [];
1162
+ for (const n of byId.values()) {
1163
+ const p = n.parentId ? byId.get(n.parentId) : null;
1164
+ if (p && n.id !== TRUNK_ID) p.children.push(n);
1165
+ else roots.push(n);
1166
+ }
1167
+ // 层级 = **计算属性**(用户 2026-09-19 定):按相对深度切,树长高时自动重算,**零回写**。
1168
+ // 于是"几条主脉并成一条更大的主脉 ⇒ 原节点自动降级"是视图效果,不需要回写任何一行记忆 ——
1169
+ // 这正是"任何树操作都不改写记忆内容"这条核心不变量能成立的原因。
1170
+ // 档位:0=主干(恒 1 条);(0,0.25]=主脉;≤0.62=枝;其余=叶 ⇒ 最多 1 主干 / 2 主脉 / 3 枝 / 4 叶。
1171
+ let maxDepth = 0;
1172
+ const walk = (n, d) => {
1173
+ n.depth = d;
1174
+ if (d > maxDepth) maxDepth = d;
1175
+ for (const c of n.children) walk(c, d + 1);
1176
+ };
1177
+ for (const r of roots) walk(r, 0);
1178
+ const N = Math.max(1, maxDepth);
1179
+ for (const n of byId.values()) {
1180
+ const p = n.depth / N;
1181
+ n.level = (n.id === TRUNK_ID || p === 0) ? 'trunk'
1182
+ : (p <= 0.25 ? 'vein' : (p <= 0.62 ? 'branch' : 'leaf'));
1183
+ }
1184
+ return { roots, links: this.listVeinLinks(), total: nodes.length, depth: maxDepth };
1185
+ }
1186
+
1187
+ // ---- 树的保存 / 恢复(R1,2026-09-22)----
1188
+ // 快照只存**结构**(枝 / 归属 / 连边),不存任何记忆内容 —— 与"内容零改写"不变量一致。
1189
+ // 恢复 = 把结构整体还原;恢复前**自动存一份当前状态**,所以"恢复"本身也是可撤的。
1190
+
1191
+ /** 存一份当前树结构的快照。
1192
+ * `kind`:'manual'(默认,主人手动保存) | 'auto'(恢复前的自动备份)。
1193
+ * 审计 A#9:两者外表完全一致,必须靠这一列区分 —— 自动备份是"撤回上次恢复"的唯一路径,
1194
+ * 手动快照可以随便删,自动备份不能删到一份不剩(见 deleteTreeSnapshot)。 */
1195
+ snapshotTree({ name = '', note = '', kind = 'manual' } = {}) {
1196
+ const now = utcIso();
1197
+ const k = kind === 'auto' ? 'auto' : 'manual';
1198
+ try {
1199
+ const data = {
1200
+ branches: this.db.prepare('SELECT * FROM branch').all(),
1201
+ sessionBranch: this.db.prepare('SELECT session_id, branch_id FROM session_meta').all(),
1202
+ convBranch: this.db.prepare('SELECT source, conv_id, branch_id FROM conv_branch').all(),
1203
+ links: this.db.prepare('SELECT from_branch, to_branch, kind, note FROM vein_link').all(),
1204
+ };
1205
+ // 名字留空 → 自动命名;不写「未命名」(2026-09-22 用户:手工/自动 + 日期时间即可)
1206
+ const nm = String(name || '').trim() || (k === 'auto' ? '自动快照 - ' : '手动快照 - ') + localStamp();
1207
+ const r = this.db.prepare('INSERT INTO tree_snapshot (at,name,note,kind,data) VALUES (?,?,?,?,?)')
1208
+ .run(now, nm.slice(0, 80), String(note || '').slice(0, 300), k, JSON.stringify(data));
1209
+ return {
1210
+ ok: true,
1211
+ id: Number(r.lastInsertRowid),
1212
+ at: now,
1213
+ kind: k,
1214
+ counts: {
1215
+ branches: data.branches.length,
1216
+ sessionBranch: data.sessionBranch.length,
1217
+ convBranch: data.convBranch.length,
1218
+ links: data.links.length,
1219
+ },
1220
+ };
1221
+ } catch (e) {
1222
+ return { ok: false, reason: 'db', error: String(e?.message ?? e).slice(0, 200) };
1223
+ }
1224
+ }
1225
+
1226
+ /** 快照列表(不带 data —— 快照体可能很大,列表只给元信息与规模)。 */
1227
+ listTreeSnapshots({ limit = 50 } = {}) {
1228
+ const n = Math.max(1, Math.min(200, Number(limit) || 50));
1229
+ try {
1230
+ return this.db.prepare('SELECT id, at, name, note, kind, LENGTH(data) AS bytes FROM tree_snapshot ORDER BY id DESC LIMIT ?')
1231
+ .all(n)
1232
+ .map((r) => ({
1233
+ id: Number(r.id),
1234
+ at: r.at ? String(r.at) : null,
1235
+ name: String(r.name || ''),
1236
+ note: String(r.note || ''),
1237
+ // 审计 A#9:列表必须带 kind,界面才分得出"哪个是自动备份、删不得"
1238
+ kind: String(r.kind || 'manual'),
1239
+ bytes: Number(r.bytes || 0),
1240
+ }));
1241
+ } catch {
1242
+ return [];
1243
+ }
1244
+ }
1245
+
1246
+ /**
1247
+ * 恢复树结构(单事务)。恢复前自动把当前状态存一份,返回 backupId。
1248
+ * 只恢复快照里记录过的会话归属 —— 快照之后新建的会话保持原样,不会被拽回主干。
1249
+ */
1250
+ restoreTree(snapshotId) {
1251
+ const sid = Number(snapshotId);
1252
+ let row = null;
1253
+ try {
1254
+ row = this.db.prepare('SELECT * FROM tree_snapshot WHERE id=?').get(sid);
1255
+ } catch {
1256
+ row = null;
1257
+ }
1258
+ if (!row) return { ok: false, reason: 'not-found' };
1259
+ let data = null;
1260
+ try {
1261
+ data = JSON.parse(String(row.data || '{}'));
1262
+ } catch {
1263
+ return { ok: false, reason: 'bad-data' };
1264
+ }
1265
+ const backup = this.snapshotTree({ kind: 'auto', name: '自动快照 - ' + localStamp(), note: '恢复快照 #' + sid + ' 之前的自动备份' });
1266
+ let detached = 0;
1267
+ try {
1268
+ const insB = this.db.prepare(`INSERT INTO branch
1269
+ (id,name,kind,parent_id,fork_at,fork_seq,name_locked,weight_scale,visibility,status,created_at,updated_at)
1270
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
1271
+ ON CONFLICT(id) DO UPDATE SET name=excluded.name, kind=excluded.kind, parent_id=excluded.parent_id,
1272
+ fork_at=excluded.fork_at, fork_seq=excluded.fork_seq, name_locked=excluded.name_locked,
1273
+ weight_scale=excluded.weight_scale, visibility=excluded.visibility, status=excluded.status,
1274
+ updated_at=excluded.updated_at`);
1275
+ const insC = this.db.prepare('INSERT INTO conv_branch (source,conv_id,branch_id,created_at) VALUES (?,?,?,?) ON CONFLICT(source,conv_id) DO UPDATE SET branch_id=excluded.branch_id');
1276
+ const insL = this.db.prepare('INSERT INTO vein_link (from_branch,to_branch,kind,note,created_at) VALUES (?,?,?,?,?) ON CONFLICT(from_branch,to_branch) DO UPDATE SET kind=excluded.kind, note=excluded.note');
1277
+ const upS = this.db.prepare('UPDATE session_meta SET branch_id=? WHERE session_id=?');
1278
+ const now = utcIso();
1279
+ this.db.exec('BEGIN');
1280
+ // ⚠️ **不删枝**(审计 A#1/#3/#7,2026-09-22 修):
1281
+ // 旧实现是 `DELETE FROM branch WHERE id<>'trunk'` —— 它会静默删掉"快照之后新建的枝",
1282
+ // 而 `session_meta.branch_id` 仍指向它们 ⇒ **悬空引用**。实测后果三连:
1283
+ // 那些记忆**树里看不见**(枝没了)、**计数里数得到**(幽灵桶)、**权重还满格**(L1 lineage=1)
1284
+ // —— 数据仍在库里,但 UI 没有任何路径能再看见或改回它们,等于永久不可达。
1285
+ // 改为:快照里没有的枝一律 **reparent 到主干** —— 枝与归属都保留,只是回到主干下。
1286
+ const snapIds = new Set((data.branches || []).map((b) => String(b.id)));
1287
+ snapIds.add(TRUNK_ID);
1288
+ const detachIds = this.db.prepare('SELECT id FROM branch WHERE id<>?').all(TRUNK_ID)
1289
+ .map((r) => String(r.id)).filter((id) => !snapIds.has(id));
1290
+ const upP = this.db.prepare('UPDATE branch SET parent_id=?, updated_at=? WHERE id=?');
1291
+ for (const id of detachIds) upP.run(TRUNK_ID, now, id);
1292
+ detached = detachIds.length;
1293
+ for (const b of data.branches || []) {
1294
+ insB.run(
1295
+ String(b.id), String(b.name || ''), String(b.kind || 'branch'),
1296
+ b.parent_id ? String(b.parent_id) : null,
1297
+ b.fork_at ? String(b.fork_at) : null,
1298
+ Number.isFinite(Number(b.fork_seq)) ? Number(b.fork_seq) : null,
1299
+ Number(b.name_locked || 0), Number(b.weight_scale ?? 1),
1300
+ String(b.visibility || 'lineage'), String(b.status || 'active'),
1301
+ String(b.created_at || now), b.updated_at ? String(b.updated_at) : now,
1302
+ );
1303
+ }
1304
+ // trunk 行必须存在(快照里没有就补一个)
1305
+ this.db.prepare(`INSERT INTO branch (id,name,kind,parent_id,created_at,updated_at)
1306
+ VALUES (?,?,?,NULL,?,?) ON CONFLICT(id) DO NOTHING`).run(TRUNK_ID, '主干', 'trunk', now, now);
1307
+ this.db.prepare('DELETE FROM conv_branch').run();
1308
+ for (const c of data.convBranch || []) insC.run(String(c.source), String(c.conv_id), String(c.branch_id), now);
1309
+ this.db.prepare('DELETE FROM vein_link').run();
1310
+ for (const l of data.links || []) {
1311
+ insL.run(String(l.from_branch), String(l.to_branch), String(l.kind || 'related'), String(l.note || ''), now);
1312
+ }
1313
+ for (const s of data.sessionBranch || []) upS.run(String(s.branch_id || TRUNK_ID), String(s.session_id));
1314
+ this.db.exec('COMMIT');
1315
+ } catch (e) {
1316
+ try { this.db.exec('ROLLBACK'); } catch { /* noop */ }
1317
+ return { ok: false, reason: 'db', error: String(e?.message ?? e).slice(0, 200) };
1318
+ }
1319
+ this.logBranch(TRUNK_ID, 'restore', { after: String(row.name || ('#' + sid)), note: '恢复树快照 #' + sid + (detached ? (';' + detached + ' 条新枝回退到主干') : '') });
1320
+ return {
1321
+ ok: true, id: sid, backupId: backup.ok ? backup.id : null, counts: backup.ok ? backup.counts : null,
1322
+ // 让调用方知道这次恢复**动了什么**(旧实现只报"被覆盖掉的那个状态",看不出会删枝)
1323
+ affected: { detachedToTrunk: detached },
1324
+ };
1325
+ }
1326
+
1327
+ /**
1328
+ * 删一份树快照。审计 A#9(2026-09-22):**拒绝删掉最后一份 `kind='auto'` 的快照**。
1329
+ *
1330
+ * `restoreTree` 在动手之前自动存一份备份 —— 那是"撤回刚才那次恢复"的唯一路径。
1331
+ * 而在这一列存在之前,自动备份与手动快照在库里、界面上**外表完全一致**(名字都只是"… - 时间"),
1332
+ * 主人顺手清理旧快照时完全可能把唯一的退路删掉,而"误恢复"之后是没有第二次机会的。
1333
+ *
1334
+ * 存在性检查同批补上(与 resolveConflict 同一条纪律):删一个不存在的 id 不该报 ok。
1335
+ * `force: true` 是留给维护动作的显式逃生口(默认关闭)—— 但**没有它就无法删除过期的自动备份**,
1336
+ * 所以调用方需要真删时应当显式传入,而不是把保护拆掉。
1337
+ */
1338
+ deleteTreeSnapshot(snapshotId, { force = false } = {}) {
1339
+ const sid = Number(snapshotId);
1340
+ if (!Number.isFinite(sid)) return { ok: false, reason: 'bad-id' };
1341
+ try {
1342
+ const row = this.db.prepare('SELECT id, kind FROM tree_snapshot WHERE id=?').get(sid);
1343
+ if (!row) return { ok: false, reason: 'not-found' };
1344
+ const kind = String(row.kind || 'manual');
1345
+ if (!force && kind === 'auto') {
1346
+ const auto = this.db.prepare("SELECT COUNT(*) AS n FROM tree_snapshot WHERE kind='auto'").get();
1347
+ if (Number(auto?.n || 0) <= 1) {
1348
+ return {
1349
+ ok: false,
1350
+ reason: 'last-auto',
1351
+ kind,
1352
+ message: '这是最后一份自动备份(撤回上次树恢复的唯一路径),不能删除',
1353
+ };
1354
+ }
1355
+ }
1356
+ const r = this.db.prepare('DELETE FROM tree_snapshot WHERE id=?').run(sid);
1357
+ return { ok: true, removed: Number(r.changes || 0), id: sid, kind };
1358
+ } catch {
1359
+ return { ok: false, reason: 'db' };
1360
+ }
1361
+ }
1362
+
1363
+ /**
1364
+ * 某枝下的成员(R2 会话级展开):DSH 会话 + 覆盖层条目,均带标题。
1365
+ * 标题三级回退:概述标题 → **原文首条用户消息** → 空(前端再回退成短 id)。
1366
+ * 只显示裸 uuid 对主人毫无信息量 —— 库里绝大多数会话没有概述行(概述器有正文长度门槛),
1367
+ * 故必须回读原文;取前 120 字交给 JS 侧压成单行 40 字。
1368
+ *
1369
+ * 计数口径见 `branchMemberCounts()`:同一谓词算出的条数必须等于本函数返回的长度。
1370
+ */
1371
+ branchMembers(branchId, { limit = 300 } = {}) {
1372
+ const bid = String(branchId || TRUNK_ID);
1373
+ // ⚠️ 审计 A#5(2026-09-22):`limit` 是**两次查询共用的总预算**,不是每边各一份 ——
1374
+ // 旧实现两次 `.all(bid, n)` ⇒ 实际最多吐 2×limit(limit=300 时 600 条);
1375
+ // 且 `Number(limit) || 300` 把 `limit=0` 吞成默认值 ⇒ "要 0 条"永远拿不到空数组。
1376
+ // 现在:0 就是空;非 0 时先取会话,覆盖层条目只吃剩下的预算。
1377
+ const asked = Number(limit);
1378
+ const n = Number.isFinite(asked) ? Math.max(0, Math.min(2000, Math.trunc(asked))) : 300;
1379
+ if (n === 0) return [];
1380
+ const clean = (t) => String(t || '').replace(/\s+/g, ' ').trim().slice(0, 40);
1381
+ const out = [];
1382
+ try {
1383
+ for (const r of this.db.prepare(`SELECT m.session_id AS cid,
1384
+ COALESCE(NULLIF(TRIM(o.title),''),
1385
+ (SELECT SUBSTR(REPLACE(REPLACE(TRIM(t.text), CHAR(10), ' '), CHAR(13), ' '), 1, 120)
1386
+ FROM dsh_turns_raw t
1387
+ WHERE t.session_id = m.session_id AND t.role='user' AND TRIM(COALESCE(t.text,'')) <> ''
1388
+ ORDER BY t.seq LIMIT 1), '') AS title,
1389
+ COALESCE(m.archived,0) AS arc
1390
+ FROM session_meta m LEFT JOIN conv_overview o ON o.source='dsh' AND o.conv_id=m.session_id
1391
+ WHERE m.branch_id=? AND ${MEMBER_LIVE_SESSION}
1392
+ ORDER BY m.session_id LIMIT ?`).all(bid, n)) {
1393
+ out.push({ kind: 'session', source: 'dsh', convId: String(r.cid), title: clean(r.title), archived: Number(r.arc || 0) });
1394
+ }
1395
+ } catch { /* noop */ }
1396
+ const left = n - out.length; // 剩余预算(会话先取,条目吃剩下的)
1397
+ try {
1398
+ if (left <= 0) return out;
1399
+ for (const r of this.db.prepare(`SELECT c.source AS src, c.conv_id AS cid,
1400
+ COALESCE(NULLIF(TRIM(o.title),''),
1401
+ (SELECT SUBSTR(REPLACE(REPLACE(TRIM(t.text), CHAR(10), ' '), CHAR(13), ' '), 1, 120)
1402
+ FROM dsh_turns_raw t
1403
+ WHERE t.session_id = c.conv_id AND t.role='user' AND TRIM(COALESCE(t.text,'')) <> ''
1404
+ ORDER BY t.seq LIMIT 1), '') AS title
1405
+ FROM conv_branch c LEFT JOIN conv_overview o ON o.source=c.source AND o.conv_id=c.conv_id
1406
+ WHERE c.branch_id=? AND ${MEMBER_LIVE_ENTRY}
1407
+ ORDER BY c.conv_id LIMIT ?`).all(bid, left)) {
1408
+ out.push({ kind: 'entry', source: String(r.src), convId: String(r.cid), title: clean(r.title), archived: 0 });
1409
+ }
1410
+ } catch { /* noop */ }
1411
+ return out;
1412
+ }
1413
+
1414
+ /**
1415
+ * 各枝"可展开成员"计数(审计 A#6,2026-09-22)—— 与 `branchMembers()` **同一谓词**的 per-branch 计数。
1416
+ *
1417
+ * 界面上的「N 个会话」应当用它,而不是 `session_meta` 的台账行数:台账里躺着大量**空壳会话**
1418
+ * (既无概述标题、又没有任何原文轮次),它们永远展不开,计进去就是假广告。
1419
+ *
1420
+ * 返回三个**双读法**视图(见 `countView()`:下标 `c[id]` 与 `.get(id)` 都可用):
1421
+ * `members` —— 展开实际条数(会话 + 覆盖层条目),`branchMembers()` 不截断时长度即此值;
1422
+ * `sessions` —— 其中会话部分(session_meta 来源);
1423
+ * `rows` —— session_meta 原始行数(含空壳;只给排障/观测,**界面不要拿它当计数**);
1424
+ * `byMembers` —— 与 `members` 同一份数据(历史命名,保留以避免对接方改名)。
1425
+ */
1426
+ branchMemberCounts() {
1427
+ const members = new Map();
1428
+ const sessions = new Map();
1429
+ const rows = new Map();
1430
+ const bump = (m, k) => m.set(k, (m.get(k) || 0) + 1);
1431
+ try {
1432
+ for (const r of this.db.prepare(`SELECT m.branch_id AS bid,
1433
+ CASE WHEN ${MEMBER_LIVE_SESSION} THEN 1 ELSE 0 END AS live
1434
+ FROM session_meta m LEFT JOIN conv_overview o ON o.source='dsh' AND o.conv_id=m.session_id`).all()) {
1435
+ const bid = String(r.bid || TRUNK_ID);
1436
+ bump(rows, bid);
1437
+ if (Number(r.live) === 1) {
1438
+ bump(members, bid);
1439
+ bump(sessions, bid);
1440
+ }
1441
+ }
1442
+ } catch { /* noop */ }
1443
+ try {
1444
+ for (const r of this.db.prepare(`SELECT c.branch_id AS bid,
1445
+ CASE WHEN ${MEMBER_LIVE_ENTRY} THEN 1 ELSE 0 END AS live
1446
+ FROM conv_branch c LEFT JOIN conv_overview o ON o.source=c.source AND o.conv_id=c.conv_id`).all()) {
1447
+ if (Number(r.live) === 1) bump(members, String(r.bid || TRUNK_ID));
1448
+ }
1449
+ } catch { /* noop */ }
1450
+ const view = countView(members);
1451
+ return { members: view, byMembers: view, sessions: countView(sessions), rows: countView(rows) };
1452
+ }
1453
+
1454
+ /** 改单条(会话 / 历史条目)的枝归属 —— R2/R3 拖动会话用。 */
1455
+ assignConv(source, convId, branchId) {
1456
+ const src = String(source || '');
1457
+ const cid = String(convId || '');
1458
+ const bid = String(branchId || TRUNK_ID);
1459
+ if (!src || !cid) return { ok: false, reason: 'bad-arg' };
1460
+ if (src === 'dsh') {
1461
+ const prev = this.branchOfSession(cid);
1462
+ this.setSessionBranch(cid, bid);
1463
+ return { ok: true, via: 'session_meta', branchId: bid, prev };
1464
+ }
1465
+ let prev = null;
1466
+ try {
1467
+ prev = this.branchOfConv(src, cid);
1468
+ } catch { /* noop */ }
1469
+ this.setConvBranch(src, cid, bid);
1470
+ return { ok: true, via: 'conv_branch', branchId: bid, prev };
1471
+ }
1472
+
1473
+ // ---- 矛盾标记层(D9-b,2026-09-19):检出但**不改写内容**;未复盘 → 以最新为准 ----
1474
+
1475
+ /** 记录一对矛盾(幂等:同一对只留一条)。a/b 会被归一排序,保证先后无关。 */
1476
+ recordConflict({ aSource, aConvId, bSource, bConvId, kind = 'contradict', detectedBy = 'heuristic', score = null, reason = '' } = {}) {
1477
+ const a = [String(aSource || ''), String(aConvId || '')];
1478
+ const b = [String(bSource || ''), String(bConvId || '')];
1479
+ if (!a[1] || !b[1] || (a[0] === b[0] && a[1] === b[1])) return { ok: false, reason: 'bad-arg' };
1480
+ const ka = a[0] + '\u0000' + a[1];
1481
+ const kb = b[0] + '\u0000' + b[1];
1482
+ const [x, y] = ka <= kb ? [a, b] : [b, a];
1483
+ const sc = score == null || !Number.isFinite(Number(score)) ? null : Number(score);
1484
+ try {
1485
+ // 先查后写(不用 upsert 的 CASE 表达式 —— 那里 未限定列名指旧值还是新值容易读错):
1486
+ // 已存在则保持原状,**除非此前被驳回**(dismissed):再次检出说明它又冒出来了,
1487
+ // 应当重新提起(回到 pending 并刷新检出信息),否则一条被驳回的矛盾将永远无法再被提出。
1488
+ const cur = this.db.prepare(`SELECT id, status FROM memory_conflict
1489
+ WHERE a_source=? AND a_conv_id=? AND b_source=? AND b_conv_id=?`).get(x[0], x[1], y[0], y[1]);
1490
+ if (cur) {
1491
+ const id = Number(cur.id);
1492
+ if (String(cur.status) === 'dismissed') {
1493
+ this.db.prepare(`UPDATE memory_conflict
1494
+ SET status='pending', kind=?, detected_by=?, score=?, reason=?, winner_side=NULL, resolved_at=NULL
1495
+ WHERE id=?`)
1496
+ .run(String(kind || 'contradict'), String(detectedBy || 'heuristic'), sc, String(reason || '').slice(0, 300), id);
1497
+ return { ok: true, id, reopened: true };
1498
+ }
1499
+ return { ok: true, id, reopened: false };
1500
+ }
1501
+ this.db.prepare(`INSERT INTO memory_conflict
1502
+ (created_at,a_source,a_conv_id,b_source,b_conv_id,kind,detected_by,score,reason,status)
1503
+ VALUES (?,?,?,?,?,?,?,?,?,'pending')`)
1504
+ .run(utcIso(), x[0], x[1], y[0], y[1], String(kind || 'contradict'), String(detectedBy || 'heuristic'), sc, String(reason || '').slice(0, 300));
1505
+ const row = this.db.prepare(`SELECT id FROM memory_conflict
1506
+ WHERE a_source=? AND a_conv_id=? AND b_source=? AND b_conv_id=?`).get(x[0], x[1], y[0], y[1]);
1507
+ return { ok: true, id: row ? Number(row.id) : null, reopened: false };
1508
+ } catch (e) {
1509
+ return { ok: false, reason: String(e?.message ?? e).slice(0, 120) };
1510
+ }
1511
+ }
1512
+
1513
+ /**
1514
+ * 模型判定后回写这条候选的**性质**(duplicate / contradict / unrelated)。
1515
+ * 只改标记,不改记忆内容 —— 启发式只能找"措辞重复",判"同题反结论"必须读懂意思。
1516
+ */
1517
+ setConflictKind(id, { kind = '', detectedBy = 'llm', reason = '' } = {}) {
1518
+ try {
1519
+ const cid = Number(id);
1520
+ if (!Number.isFinite(cid)) return { ok: false, reason: 'bad-id' };
1521
+ const k = String(kind || '').trim().slice(0, 20);
1522
+ if (!k) return { ok: false, reason: 'bad-kind' };
1523
+ this.db.prepare('UPDATE memory_conflict SET kind=?, detected_by=?, reason=? WHERE id=?')
1524
+ .run(k, String(detectedBy || 'llm').slice(0, 20), String(reason || '').slice(0, 300), cid);
1525
+ return { ok: true, id: cid, kind: k };
1526
+ } catch (e) {
1527
+ return { ok: false, reason: String(e?.message ?? e).slice(0, 120) };
1528
+ }
1529
+ }
1530
+
1531
+ listConflicts({ status = '', limit = 100 } = {}) {
1532
+ try {
1533
+ const where = status ? 'WHERE status=?' : '';
1534
+ const args = status ? [String(status)] : [];
1535
+ args.push(Math.max(1, Math.min(500, Number(limit) || 100)));
1536
+ return this.db.prepare(`SELECT * FROM memory_conflict ${where} ORDER BY created_at DESC, id DESC LIMIT ?`).all(...args).map((r) => ({
1537
+ id: Number(r.id),
1538
+ createdAt: r.created_at ? String(r.created_at) : null,
1539
+ a: { source: String(r.a_source), convId: String(r.a_conv_id) },
1540
+ b: { source: String(r.b_source), convId: String(r.b_conv_id) },
1541
+ kind: String(r.kind || 'contradict'),
1542
+ detectedBy: String(r.detected_by || 'heuristic'),
1543
+ score: r.score == null ? null : Number(r.score),
1544
+ reason: String(r.reason || ''),
1545
+ status: String(r.status || 'pending'),
1546
+ winnerSide: r.winner_side ? String(r.winner_side) : null,
1547
+ resolvedAt: r.resolved_at ? String(r.resolved_at) : null,
1548
+ }));
1549
+ } catch {
1550
+ return [];
1551
+ }
1552
+ }
1553
+
1554
+ /** 复盘裁定:指定谁为准(winner='a'|'b'),或驳回(dismissed)。 */
1555
+ resolveConflict(id, { winner = null, status = 'confirmed' } = {}) {
1556
+ const cid = Number(id);
1557
+ if (!Number.isFinite(cid)) return { ok: false, reason: 'bad-id' };
1558
+ const st = status === 'dismissed' ? 'dismissed' : 'confirmed';
1559
+ const w = winner === 'a' || winner === 'b' ? winner : null;
1560
+ if (st === 'confirmed' && !w) return { ok: false, reason: 'need-winner' };
1561
+ try {
1562
+ // ⚠️ 存在性检查(审计 A#8,2026-09-22):旧实现对不存在的 id 也返回 ok:true ——
1563
+ // `UPDATE ... WHERE id=?` 影响 0 行却报成功,调用方(含"归并时顺带驳回"的批处理)
1564
+ // 会以为裁定已落地,而待复盘队列里那条其实一动没动,错误就此静默。
1565
+ const row = this.db.prepare('SELECT id FROM memory_conflict WHERE id=?').get(cid);
1566
+ if (!row) return { ok: false, reason: 'not-found' };
1567
+ this.db.prepare('UPDATE memory_conflict SET status=?, winner_side=?, resolved_at=? WHERE id=?')
1568
+ .run(st, st === 'dismissed' ? null : w, utcIso(), cid);
1569
+ return { ok: true, id: cid, status: st, winnerSide: st === 'dismissed' ? null : w };
1570
+ } catch (e) {
1571
+ return { ok: false, reason: String(e?.message ?? e).slice(0, 120) };
1572
+ }
1573
+ }
1574
+
1575
+ /** 某条概述的时间戳(矛盾裁定用"最新为准"的判据)。
1576
+ * `conv_overview` **没有 created_at 列**(只有 started_at / updated_at);
1577
+ * `updated_at` 也可能为空(调用方没传),故兜底到 `started_at`。 */
1578
+ _overviewWhen(source, convId) {
1579
+ try {
1580
+ const r = this.db.prepare('SELECT updated_at, started_at FROM conv_overview WHERE source=? AND conv_id=?')
1581
+ .get(String(source), String(convId));
1582
+ return String((r && (r.updated_at || r.started_at)) || '');
1583
+ } catch {
1584
+ return '';
1585
+ }
1586
+ }
1587
+
1588
+ /** 检索侧降权映射:`"source\\u0000convId" → 系数`。
1589
+ * 规则(用户 2026-09-20 定):**复盘裁定的按裁定;未复盘的以最新为准**(旧的一方降权)。
1590
+ * `duplicate` 不降权(两条内容相同,降谁都会丢信息);`dismissed` 一律不降权。 */
1591
+ conflictDowngradeMap() {
1592
+ const m = new Map();
1593
+ let rows = [];
1594
+ try {
1595
+ rows = this.db.prepare("SELECT * FROM memory_conflict WHERE status<>'dismissed' AND kind<>'duplicate'").all();
1596
+ } catch {
1597
+ return m;
1598
+ }
1599
+ for (const r of rows) {
1600
+ const aKey = String(r.a_source) + '\u0000' + String(r.a_conv_id);
1601
+ const bKey = String(r.b_source) + '\u0000' + String(r.b_conv_id);
1602
+ let loser = null;
1603
+ if (r.status === 'confirmed' && r.winner_side) {
1604
+ loser = String(r.winner_side) === 'a' ? bKey : aKey;
1605
+ } else {
1606
+ const ta = this._overviewWhen(r.a_source, r.a_conv_id);
1607
+ const tb = this._overviewWhen(r.b_source, r.b_conv_id);
1608
+ if (ta && tb) {
1609
+ if (ta < tb) loser = aKey;
1610
+ else if (tb < ta) loser = bKey;
1611
+ } else if (!ta && tb) loser = aKey;
1612
+ else if (ta && !tb) loser = bKey;
1613
+ }
1614
+ if (loser) m.set(loser, CONFLICT_DOWNWEIGHT);
1615
+ }
1616
+ return m;
1617
+ }
1618
+
1619
+ /** 源会话标题(D9-a:建枝时给枝起种子名用;查不到返回 '')。 */
1620
+ overviewTitleOf(convId) {
1621
+ try {
1622
+ const row = this.db
1623
+ .prepare("SELECT title FROM conv_overview WHERE conv_id=? AND title<>'' ORDER BY updated_at DESC LIMIT 1")
1624
+ .get(String(convId));
1625
+ return row && row.title ? String(row.title) : '';
1626
+ } catch {
1627
+ return '';
1628
+ }
1629
+ }
1630
+
1631
+ /**
1632
+ * 已归档会话 id 集合(2026-09-21,D9-b 用)。
1633
+ *
1634
+ * 归档 = 主人主动封存、不再参与日常召回的那批会话。矛盾扫描必须排除它们,
1635
+ * 否则会拿几个月前已封存的对话互相判"矛盾",把待复盘队列塞满噪音(用户实测反馈)。
1636
+ * 一次取全量而非逐行查 session_meta —— scan 里要判上千条,逐行查是 O(n) 次 SQL。
1637
+ */
1638
+ archivedConvIdSet() {
1639
+ const s = new Set();
1640
+ try {
1641
+ for (const r of this.db.prepare('SELECT session_id FROM session_meta WHERE archived=1').all()) {
1642
+ s.add(String(r.session_id));
1643
+ }
1644
+ } catch {
1645
+ /* noop */
1646
+ }
1647
+ return s;
1648
+ }
1649
+
1650
+ /** 全库概述索引(源/会话/标题/时间) —— 供一键生成记忆树做机械分段(只读,不取摘要正文)。 */
1651
+ overviewIndex() {
1652
+ try {
1653
+ return this.db.prepare(`SELECT source, conv_id, title, summary,
1654
+ COALESCE(updated_at, started_at) AS at FROM conv_overview`).all().map((r) => ({
1655
+ source: String(r.source || ''),
1656
+ convId: String(r.conv_id || ''),
1657
+ title: String(r.title || ''),
1658
+ hasSummary: !!(r.summary && String(r.summary).trim()),
1659
+ at: r.at ? String(r.at) : '',
1660
+ }));
1661
+ } catch {
1662
+ return [];
1663
+ }
1664
+ }
1665
+
1666
+ // ---- kv ----
1667
+ kvGet(key) {
1668
+ const r = this._kvGet.get(key);
1669
+ return r ? r.value : undefined;
1670
+ }
1671
+
1672
+ kvSet(key, value) {
1673
+ this._kvSet.run(key, String(value));
1674
+ }
1675
+
1676
+ // ---- overviews ----
1677
+ /** 写入/更新一条概述(幂等)。
1678
+ *
1679
+ * G2(2026-09-17):默认**不覆盖本机用户态字段** —— heat / importance / last_hit_at / hit_count。
1680
+ * 这四个是本机行为的产物:importance 是用户显式置顶、hit_count/last_hit_at 是运行时命中、
1681
+ * heat 由 L1 运行时计算。而概述器每轮增量重建都会 upsertOverview(旧行为无条件
1682
+ * `SET ...=excluded.*`)⇒ 每 15 分钟把置顶与命中打回 0,导致:
1683
+ * ① cleanOverviewOnArchive/archivedOverviewCandidates 的 `importance>=1` 删除保护失效
1684
+ * (置顶的归档会话被当普通会话清掉);
1685
+ * ② 命中次数永远长不起来,热度排序失真。
1686
+ * 故 UPDATE 分支不再触碰这四列(保留库中现值);新行 INSERT 时仍用调用方给的初值。
1687
+ * 需要整行覆盖的显式场景(如"按备份原样恢复")传 `keepUserState: false`。
1688
+ */
1689
+ // ---- 主脉提炼(R7,2026-09-23):子枝概述 → 器灵提炼 → 审核队列 ----
1690
+ // 设计稿 §5:主脉装的内容**不是任何子枝的内容**,而是"从多条枝里看出来的共性";
1691
+ // 三条硬约束 —— 走审核(不直接进记忆) / 标注派生来源 / 与并脉解耦。
1692
+
1693
+ /** 提炼素材:主脉及其子枝下所有**带摘要**的记忆(只要概述,不碰原文)。 */
1694
+ veinMaterial(veinId, { limit = 24, chars = 300 } = {}) {
1695
+ const bid = String(veinId || '');
1696
+ if (!bid) return [];
1697
+ const n = Math.max(1, Math.min(60, Math.trunc(Number(limit) || 24)));
1698
+ const cap = Math.max(80, Math.min(1200, Math.trunc(Number(chars) || 300)));
1699
+ const ids = [bid];
1700
+ try {
1701
+ for (const r of this.db.prepare('SELECT id FROM branch WHERE parent_id=?').all(bid)) ids.push(String(r.id));
1702
+ } catch { /* noop */ }
1703
+ const marks = ids.map(() => '?').join(',');
1704
+ const out = [];
1705
+ const push = (src, cid, title, summary) => {
1706
+ const s = String(summary || '').replace(/\s+/g, ' ').trim();
1707
+ if (!s) return;
1708
+ out.push({
1709
+ source: String(src), convId: String(cid),
1710
+ title: String(title || '').slice(0, 60), summary: s.slice(0, cap),
1711
+ });
1712
+ };
1713
+ // ⚠️ A-02(1.5.1 红蓝对抗,2026-09-25):提炼素材**必须排除已遗忘的记忆**。
1714
+ // 实测全库只有 `l1.js` 的 selectL1 消费了 `forgotten` —— 而本函数是**会写回记忆**的路径:
1715
+ // 已遗忘的记忆被当素材 → 提炼 → 采纳 → 重新写回 conv_overview ⇒ **遗忘失效**。
1716
+ // 与 l1 同口径按 (source, conv_id) 排除;两条 SQL 都要加,漏一条等于没加。
1717
+ try {
1718
+ for (const r of this.db.prepare(`SELECT o.source AS src, o.conv_id AS cid, o.title, o.summary
1719
+ FROM session_meta m JOIN conv_overview o ON o.source='dsh' AND o.conv_id=m.session_id
1720
+ WHERE m.branch_id IN (${marks}) AND TRIM(COALESCE(o.summary,'')) <> ''
1721
+ AND NOT EXISTS (SELECT 1 FROM forgotten f WHERE f.source=o.source AND f.conv_id=o.conv_id)
1722
+ ORDER BY o.hit_count DESC, o.updated_at DESC LIMIT ?`).all(...ids, n)) {
1723
+ push(r.src, r.cid, r.title, r.summary);
1724
+ }
1725
+ } catch { /* noop */ }
1726
+ const left = n - out.length;
1727
+ try {
1728
+ if (left > 0) {
1729
+ for (const r of this.db.prepare(`SELECT c.source AS src, c.conv_id AS cid, o.title, o.summary
1730
+ FROM conv_branch c JOIN conv_overview o ON o.source=c.source AND o.conv_id=c.conv_id
1731
+ WHERE c.branch_id IN (${marks}) AND TRIM(COALESCE(o.summary,'')) <> ''
1732
+ AND NOT EXISTS (SELECT 1 FROM forgotten f WHERE f.source=o.source AND f.conv_id=o.conv_id)
1733
+ ORDER BY o.hit_count DESC, o.updated_at DESC LIMIT ?`).all(...ids, left)) {
1734
+ push(r.src, r.cid, r.title, r.summary);
1735
+ }
1736
+ }
1737
+ } catch { /* noop */ }
1738
+ return out;
1739
+ }
1740
+
1741
+ /** 记一条提炼候选(审核队列;**不**直接进记忆)。 */
1742
+ addVeinSuggestion({ veinId, text, sources = [], model = '', note = '' } = {}) {
1743
+ const bid = String(veinId || '');
1744
+ const t = String(text || '').replace(/\s+/g, ' ').trim().slice(0, 1200);
1745
+ if (!bid || !t) return { ok: false, reason: 'bad-arg' };
1746
+ const now = utcIso();
1747
+ try {
1748
+ const r = this.db.prepare(`INSERT INTO vein_suggestions (at,vein_id,text,sources,model,status,note)
1749
+ VALUES (?,?,?,?,?,'new',?)`).run(
1750
+ now, bid, t,
1751
+ JSON.stringify(Array.isArray(sources) ? sources.slice(0, 40) : []),
1752
+ String(model || '').slice(0, 80), String(note || '').slice(0, 300),
1753
+ );
1754
+ return { ok: true, id: Number(r.lastInsertRowid), at: now };
1755
+ } catch (e) {
1756
+ return { ok: false, reason: 'db', error: String(e?.message ?? e).slice(0, 160) };
1757
+ }
1758
+ }
1759
+
1760
+ listVeinSuggestions({ status = 'new', veinId = '', limit = 50 } = {}) {
1761
+ const n = Math.max(1, Math.min(200, Math.trunc(Number(limit) || 50)));
1762
+ const where = [];
1763
+ const args = [];
1764
+ if (status) { where.push('status=?'); args.push(String(status)); }
1765
+ if (veinId) { where.push('vein_id=?'); args.push(String(veinId)); }
1766
+ try {
1767
+ return this.db.prepare(`SELECT * FROM vein_suggestions
1768
+ ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
1769
+ ORDER BY id DESC LIMIT ?`).all(...args, n).map((r) => ({
1770
+ id: Number(r.id),
1771
+ at: r.at ? String(r.at) : '',
1772
+ veinId: String(r.vein_id || ''),
1773
+ text: String(r.text || ''),
1774
+ sources: (() => { try { return JSON.parse(String(r.sources || '[]')); } catch { return []; } })(),
1775
+ model: String(r.model || ''),
1776
+ status: String(r.status || 'new'),
1777
+ note: String(r.note || ''),
1778
+ resolvedAt: r.resolved_at ? String(r.resolved_at) : null,
1779
+ }));
1780
+ } catch { return []; }
1781
+ }
1782
+
1783
+ /** 裁定:采纳 → 写入**主脉自己的记忆**(复用 conv_overview,设计稿 §2.3)。 */
1784
+ resolveVeinSuggestion(id, { action = '', text = '', note = '', actor = 'user' } = {}) {
1785
+ const sid = Number(id);
1786
+ if (!Number.isFinite(sid)) return { ok: false, reason: 'bad-id' };
1787
+ let row = null;
1788
+ try { row = this.db.prepare('SELECT * FROM vein_suggestions WHERE id=?').get(sid); } catch { row = null; }
1789
+ if (!row) return { ok: false, reason: 'not-found' };
1790
+ // A-05(1.5.1 红蓝对抗):动作**白名单化 + 默认拒绝**。此前的判据是"**不等于** 'accept' 就当驳回"
1791
+ // ⇒ `Accept` / `adopt` / 拼错 / **字段整个缺失**,全部**静默走驳回**且回 `ok:true`(实测),
1792
+ // 调用方以为成功、实际做了另一件事(而调用方是 UI,`body.action` 缺失时旧代码反而会**采纳**)。
1793
+ // 改成:不认识的动作**一个字节都不动库**,并如实回报期望值。
1794
+ const act = String(action || '').trim().toLowerCase();
1795
+ if (act !== 'accept' && act !== 'reject') return { ok: false, reason: 'bad-action', want: 'accept|reject' };
1796
+ if (act === 'reject') {
1797
+ try {
1798
+ this.db.prepare("UPDATE vein_suggestions SET status='rejected', note=?, resolved_at=? WHERE id=?")
1799
+ .run(String(note || '').slice(0, 300), utcIso(), sid);
1800
+ // 1.5.1 红蓝对抗(A-09):驳回也必须回传 veinId —— 此前只回 {ok,id,status},
1801
+ // 调用方(api.js 的留痕)取 r.veinId 拿到 undefined ⇒ branch_id 落成空串 ⇒
1802
+ // 注入面渲染出「主人 vein-adopt:? → 条目 已驳回」(实测 10/10 笔驳回都是这样)。
1803
+ return { ok: true, id: sid, status: 'rejected', veinId: String(row.vein_id || '') };
1804
+ } catch { return { ok: false, reason: 'db' }; }
1805
+ }
1806
+ const body = String(text || row.text || '').replace(/\s+/g, ' ').trim().slice(0, 1200);
1807
+ if (!body) return { ok: false, reason: 'empty-text' };
1808
+ const veinId = String(row.vein_id || '');
1809
+ if (!veinId) return { ok: false, reason: 'no-vein' };
1810
+ // ── A-01(1.5.1 红蓝对抗;用户 2026-09-25 裁决「备份并覆盖」)──────────────────
1811
+ // 采纳写入的键是 `(source='vein', conv_id=<主脉 id>)` ⇒ **同一主脉的两条候选先后采纳
1812
+ // 会命中同一个 ON CONFLICT,后一条静默吃掉前一条**(无留痕、无归档 —— 数据真的没了;
1813
+ // 红队 A 与红队 E 各自独立复现)。
1814
+ // 用户的裁决与理由:主脉记忆是一种**概括**,它理应随主脉本身一起成长 ⇒ **覆盖**是对的;
1815
+ // 但覆盖必须能回头 ⇒ 动手前把旧版**归档落盘**,归档失败就**拒绝覆盖**
1816
+ // (与 forget 那条"归档失败绝不打标记"同一条纪律),并把结果回传调用方去记总变动日志。
1817
+ let replaced = null;
1818
+ try {
1819
+ const prev = this.overviewById('vein', veinId);
1820
+ if (prev) {
1821
+ const prevChars = String(prev.summary || '').length;
1822
+ const a = archiveBefore(this, {
1823
+ source: 'vein', convId: veinId, kind: 'vein-overwrite', actor,
1824
+ reason: String(note || '').trim() || ('主脉再次采纳,覆盖上一版(' + prevChars + ' 字)'),
1825
+ });
1826
+ if (!a.ok) return { ok: false, reason: 'archive-failed', message: a.message };
1827
+ replaced = { dir: a.dir, prevChars };
1828
+ }
1829
+ } catch (e) {
1830
+ return { ok: false, reason: 'archive-failed', message: String(e?.message ?? e).slice(0, 160) };
1831
+ }
1832
+ try {
1833
+ // title 走启发式截断(与 D2 一致口径),summary 存全文 —— upsertOverview 默认保护用户态。
1834
+ this.upsertOverview({
1835
+ source: 'vein', conv_id: veinId,
1836
+ title: body.slice(0, 40),
1837
+ summary: body,
1838
+ category: 'vein',
1839
+ keywords: [], domain_tags: [],
1840
+ updated_at: utcIso(),
1841
+ origin: 'vein-distill',
1842
+ overview_ok: 1,
1843
+ });
1844
+ // ★ 2026-09-25 修:采纳的记忆必须挂到**它所属的主脉**下。
1845
+ // 此前只写 conv_overview、不写归属 ⇒ 按枝过滤的解析链
1846
+ // (conv_branch 显式覆盖 > session_meta 会话推导 > 主干)对 vein 源一路落到**主干**,
1847
+ // 于是主人刚采纳完,在记忆中心/记忆树里按主脉看却找不到它。
1848
+ // 实测:2026-09-24 夜采纳「学术与写作」的提炼,库里 written=1 而树上不可见。
1849
+ // 挂枝失败不让整次采纳失败(记忆已经写进去了)。
1850
+ try { this.assignConv('vein', veinId, veinId); } catch (e) {
1851
+ console.warn('[dsh-ling] 主脉记忆挂枝失败:', String(e?.message ?? e).slice(0, 120));
1852
+ }
1853
+ this.db.prepare("UPDATE vein_suggestions SET status='accepted', text=?, note=?, resolved_at=? WHERE id=?")
1854
+ .run(body, String(note || '').slice(0, 300), utcIso(), sid);
1855
+ return { ok: true, id: sid, status: 'accepted', veinId, chars: body.length, branchId: veinId, replaced };
1856
+ } catch (e) {
1857
+ return { ok: false, reason: 'db', error: String(e?.message ?? e).slice(0, 160) };
1858
+ }
1859
+ }
1860
+
1861
+ upsertOverview(row) {
1862
+ const r = row || {};
1863
+ // 保留调用方声明的来源(dsweb/dsh/import/…);v0 时期曾强制"非 dsh 即 dsweb"
1864
+ const source = String(r.source ?? '').trim() || 'dsweb';
1865
+ // 不变量(G4 补强,2026-09-17):`import:` 是本机导入域的**内部命名空间**,只允许配 source='import'。
1866
+ // 实锤教训:把导入原文的 session_id 改成 `import:<uuid>` 之后,仍在跑旧代码的概述器
1867
+ // (缺 NOT LIKE 过滤)把它当 DSH 会话,产出了 15 条 `source='dsh'` + `conv_id='import:<uuid>'`
1868
+ // 的镜像行 —— 跨源污染的二代形态(一代是裸 uuid 双份)。这里直接拒写。
1869
+ const convId = String(r.conv_id ?? '');
1870
+ if (source !== 'import' && convId.startsWith(RAW_IMPORT_PREFIX)) {
1871
+ console.warn('[dsh-ling] 拒绝跨源镜像行:', JSON.stringify({ source, conv_id: convId.slice(0, 40) }));
1872
+ return;
1873
+ }
1874
+ const userStateSet = r.keepUserState === false
1875
+ ? ` heat=excluded.heat, importance=excluded.importance,
1876
+ last_hit_at=excluded.last_hit_at, hit_count=excluded.hit_count,`
1877
+ : '';
1878
+ // ── B-02(1.5.1 红蓝对抗):定名锁**必须能被写回**,否则回灌不是无损往返 ──────────────
1879
+ // 此前 INSERT 列清单与 ON CONFLICT SET 里**都没有** title_locked / title_by:
1880
+ // 归档(exportEntry 经 mapRow 取的是全列,含这一对)读得回来,却写不回去 ⇒
1881
+ // 「主人锁过的标题」这一**事实**在"遗忘 → 回灌"之后静默消失,而标题文本还在
1882
+ // (旧 SET 里那句 `title=CASE WHEN title_locked=1 …` 只是**读锁**的写法,救不了已经丢掉的锁)。
1883
+ // 写入口径取**显式给值才覆盖**(对象里带 `title_locked` 键才算"调用方要声明这把锁"):
1884
+ // ① 回灌/导入/快照恢复这些"还原一条既有行"的路径经 archive 的 overview.json 带全字段;
1885
+ // ② 概述器/批量重命名这些"只更新内容"的路径不带这对字段 ⇒ 不可能顺手解锁一条已锁的标题
1886
+ // (`retitleCandidates` 只挑 `title_locked=0`,解锁只能走 `unlockTitle`)。
1887
+ // 取 `Number(...) ? 1 : 0` 是因为 overview.json 里是布尔(`mapRow` 把列映射成 `!!r.title_locked`)。
1888
+ const hasLock = Object.prototype.hasOwnProperty.call(r, 'title_locked');
1889
+ const lockCols = hasLock ? ', title_locked, title_by' : '';
1890
+ const lockPh = hasLock ? ',?,?' : '';
1891
+ const lockSet = hasLock ? `
1892
+ title_locked=excluded.title_locked, title_by=excluded.title_by,` : '';
1893
+ this.db.prepare(
1894
+ `INSERT INTO conv_overview
1895
+ (conv_id, source, title, started_at, updated_at, domain_tags, category, keywords, summary,
1896
+ heat, importance, last_hit_at, hit_count, overview_ok, origin${lockCols})
1897
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?${lockPh})
1898
+ ON CONFLICT(source, conv_id) DO UPDATE SET
1899
+ title=CASE WHEN conv_overview.title_locked=1 THEN conv_overview.title ELSE excluded.title END,
1900
+ updated_at=excluded.updated_at,
1901
+ domain_tags=excluded.domain_tags, category=excluded.category, keywords=excluded.keywords,
1902
+ summary=excluded.summary,${userStateSet}${lockSet}
1903
+ overview_ok=excluded.overview_ok, origin=excluded.origin`,
1904
+ ).run(
1905
+ String(r.conv_id ?? ''), source, String(r.title ?? ''), r.started_at || null, r.updated_at || null,
1906
+ JSON.stringify(r.domain_tags ?? []), String(r.category ?? 'daily'),
1907
+ JSON.stringify(r.keywords ?? []), String(r.summary ?? ''),
1908
+ Number(r.heat ?? 0), Number(r.importance ?? 0), r.last_hit_at || null,
1909
+ Number(r.hit_count ?? 0), r.overview_ok ? 1 : 0, String(r.origin ?? 'local'),
1910
+ ...(hasLock ? [Number(r.title_locked) ? 1 : 0, String(r.title_by ?? '')] : []),
1911
+ );
1912
+ }
1913
+
1914
+ listOverviews({ limit, onlyOk, source } = {}) {
1915
+ let sql = 'SELECT * FROM conv_overview';
1916
+ const where = [];
1917
+ if (onlyOk) where.push('overview_ok=1');
1918
+ if (source) where.push('source=?');
1919
+ if (where.length) sql += ' WHERE ' + where.join(' AND ');
1920
+ sql += ' ORDER BY hit_count DESC, importance DESC, updated_at DESC'; // D8:heat 恒 0,改用 hit_count
1921
+ if (limit) sql += ' LIMIT ' + Math.max(1, Math.min(1000, limit));
1922
+ const stmt = source ? this.db.prepare(sql) : this.db.prepare(sql);
1923
+ const rows = source ? stmt.all(source) : stmt.all();
1924
+ return rows.map(mapRow);
1925
+ }
1926
+
1927
+ /** 记忆中心查询:过滤(source/category/q)+ 排序 + 分页;返回 {items,total}。
1928
+ * source 不限 dsweb/dsh——文件导入等第三方来源(source='import')同样可按源过滤。 */
1929
+ queryOverviews({ source, category, q, branch, sort = 'updated', limit = 100, offset = 0 } = {}) {
1930
+ const where = [];
1931
+ const args = [];
1932
+ if (source) {
1933
+ where.push('source=?');
1934
+ args.push(source);
1935
+ }
1936
+ if (category === 'knowledge' || category === 'daily' || category === 'feeling') {
1937
+ where.push('category=?');
1938
+ args.push(category);
1939
+ }
1940
+ const qs = q ? String(q).trim() : '';
1941
+ if (qs) {
1942
+ where.push('(title LIKE ? OR summary LIKE ? OR keywords LIKE ?)');
1943
+ const like = '%' + qs + '%';
1944
+ args.push(like, like, like);
1945
+ }
1946
+ // 按枝过滤(方案 A,2026-09-21 起):归属解析 = conv_branch 显式覆盖 > dsh 会话的 session_meta > 主干。
1947
+ // 原来只对 DSH 会话有意义(dsweb/import 恒属主干),加了覆盖层后非会话源也能挂枝。
1948
+ const bsel = branch ? String(branch) : '';
1949
+ if (bsel) {
1950
+ if (bsel === TRUNK_ID) {
1951
+ // 主干 = 没被显式指到别的枝,且(非 dsh 源 或 没被会话表指到别的枝)。
1952
+ // 用 NOT EXISTS 而非 `IN (SELECT … WHERE branch_id='trunk')` —— 后者会漏掉
1953
+ // session_meta 里根本没有行的会话(历史会话、未走 session-start 的会话),
1954
+ // 让它们在按主干过滤时整批消失。未登记 = 主干,这是本设计的默认归属。
1955
+ where.push("NOT EXISTS (SELECT 1 FROM conv_branch cb WHERE cb.source=conv_overview.source AND cb.conv_id=conv_overview.conv_id AND cb.branch_id<>?)");
1956
+ args.push(TRUNK_ID);
1957
+ where.push("(source<>'dsh' OR NOT EXISTS (SELECT 1 FROM session_meta m WHERE m.session_id=conv_overview.conv_id AND m.branch_id<>?))");
1958
+ args.push(TRUNK_ID);
1959
+ } else {
1960
+ where.push("(EXISTS (SELECT 1 FROM conv_branch cb WHERE cb.source=conv_overview.source AND cb.conv_id=conv_overview.conv_id AND cb.branch_id=?) OR (source='dsh' AND EXISTS (SELECT 1 FROM session_meta m WHERE m.session_id=conv_overview.conv_id AND m.branch_id=?)))");
1961
+ args.push(bsel, bsel);
1962
+ }
1963
+ }
1964
+ const whereSql = where.length ? 'WHERE ' + where.join(' AND ') : '';
1965
+ const lim = Math.max(1, Math.min(500, Number(limit) || 100));
1966
+ const off = Math.max(0, Number(offset) || 0);
1967
+ // D8(2026-09-17):`heat` 列恒为 0(L1 的 computeHeat 在运行时算、从不落库),
1968
+ // 旧写法 `heat DESC` 等价于不排序 —— 记忆中心的"热度"档点了等于没排。
1969
+ // 改用真实落库的热度代理:hit_count 为主、importance 次之。
1970
+ const orderSql = sort === 'created' ? 'started_at DESC' : sort === 'heat' ? 'hit_count DESC, importance DESC, updated_at DESC' : 'updated_at DESC';
1971
+ const total = this.db.prepare(`SELECT COUNT(*) n FROM conv_overview ${whereSql}`).get(...args).n;
1972
+ const rows = this.db.prepare(
1973
+ `SELECT * FROM conv_overview ${whereSql} ORDER BY ${orderSql} LIMIT ? OFFSET ?`,
1974
+ ).all(...args, lim, off);
1975
+ return { items: rows.map(mapRow), total: Number(total), limit: lim, offset: off };
1976
+ }
1977
+
1978
+ /** 显式改名(D2):写 title 并上锁 —— 概述器重建、批量重命名此后都不再覆盖。
1979
+ * `by` 记录定名者:'user'=主人手改 / 'ai'=机器起名(界面三条路径统一显示「已定名」,
1980
+ * 归属只进悬浮说明)。默认"写过的就是最终版";要交还机器可用 unlockTitle。 */
1981
+ renameTitle(source, convId, title, { lock = true, by = 'user' } = {}) {
1982
+ const t = String(title ?? '').trim().replace(/\s+/g, ' ').slice(0, 120);
1983
+ if (!t) return { ok: false, reason: 'empty-title' };
1984
+ const who = by === 'ai' ? 'ai' : 'user';
1985
+ const r = this.db.prepare('UPDATE conv_overview SET title=?, title_locked=?, title_by=? WHERE source=? AND conv_id=?')
1986
+ .run(t, lock ? 1 : 0, who, String(source), String(convId));
1987
+ return { ok: Number(r.changes) > 0, title: t, locked: !!lock, by: who };
1988
+ }
1989
+
1990
+ unlockTitle(source, convId) {
1991
+ this.db.prepare('UPDATE conv_overview SET title_locked=0 WHERE source=? AND conv_id=?')
1992
+ .run(String(source), String(convId));
1993
+ }
1994
+
1995
+ /** 各来源条数(D8):记忆中心筛选栏用它显示"历史网页端 (1523)"这类计数。 */
1996
+ sourceCounts() {
1997
+ const rows = this.db.prepare('SELECT source, COUNT(*) AS n FROM conv_overview GROUP BY source ORDER BY n DESC').all();
1998
+ const bySource = {};
1999
+ let total = 0;
2000
+ for (const r of rows) {
2001
+ bySource[String(r.source)] = Number(r.n);
2002
+ total += Number(r.n);
2003
+ }
2004
+ return { total, bySource };
2005
+ }
2006
+
2007
+ /** 各枝条数(D9-a):记忆中心筛选栏用它显示「主干 (1590)」「枝名 (3)」。
2008
+ * 归属解析:conv_branch 显式覆盖 > dsh 会话的 session_meta > 主干(与 queryOverviews 口径一致)。 */
2009
+ branchCounts() {
2010
+ const rows = this.db.prepare(
2011
+ `SELECT COALESCE(cb.branch_id, m.branch_id, ?) AS bid, COUNT(*) AS n
2012
+ FROM conv_overview o
2013
+ LEFT JOIN conv_branch cb ON cb.source = o.source AND cb.conv_id = o.conv_id
2014
+ LEFT JOIN session_meta m ON o.source='dsh' AND m.session_id = o.conv_id
2015
+ GROUP BY bid`,
2016
+ ).all(TRUNK_ID);
2017
+ // ⚠️ 与 branch 表对账(审计 A#2,2026-09-22 修):归属行可能指向**已不存在的枝**
2018
+ // (历史遗留 / 手工改动),旧实现把它原样塞进 byBranch ⇒ 界面渲染出"名字 = 裸 UUID"的幽灵枝行。
2019
+ // 现在:只输出真实存在的枝;幽灵桶的计数**归入主干**(总数不变)并单独报 `phantom` 供观测。
2020
+ const known = new Set([TRUNK_ID]);
2021
+ try {
2022
+ for (const r of this.db.prepare('SELECT id FROM branch').all()) known.add(String(r.id));
2023
+ } catch { /* noop */ }
2024
+ const byBranch = {};
2025
+ let total = 0;
2026
+ let phantom = 0;
2027
+ for (const r of rows) {
2028
+ const bid = String(r.bid);
2029
+ const n = Number(r.n);
2030
+ total += n;
2031
+ if (!known.has(bid)) {
2032
+ phantom += n;
2033
+ byBranch[TRUNK_ID] = (byBranch[TRUNK_ID] || 0) + n;
2034
+ continue;
2035
+ }
2036
+ byBranch[bid] = (byBranch[bid] || 0) + n;
2037
+ }
2038
+ return { total, byBranch, phantom };
2039
+ }
2040
+
2041
+ setImportance(source, convId, importance) {
2042
+ this.db.prepare('UPDATE conv_overview SET importance=? WHERE source=? AND conv_id=?')
2043
+ .run(Number(importance) ? 1 : 0, String(source), String(convId));
2044
+ }
2045
+
2046
+ deleteOverview(source, convId) {
2047
+ this.db.prepare('DELETE FROM conv_overview WHERE source=? AND conv_id=?')
2048
+ .run(String(source), String(convId));
2049
+ }
2050
+
2051
+ /** 归档会话里"该清掉"的概述候选(2026-09-16 与用户定:清掉归档会话,未归档的测试会话不动)。
2052
+ * 默认保护两类:①置顶(importance≥1,用户显式要留)②已深摘(花过 LLM 且摘要更厚)。
2053
+ * 返回 {scanned, removable, kept};纯读,不改库。 */
2054
+ archivedOverviewCandidates({ protectPinned = true, protectDeep = true } = {}) {
2055
+ const rows = this.db.prepare(
2056
+ `SELECT o.conv_id, o.title, o.hit_count, o.importance, o.category, o.updated_at,
2057
+ (SELECT COUNT(*) FROM dsh_turns_raw t WHERE t.session_id = o.conv_id) AS turns
2058
+ FROM conv_overview o JOIN session_meta m ON m.session_id = o.conv_id
2059
+ WHERE o.source='dsh' AND m.archived=1
2060
+ ORDER BY o.updated_at DESC`,
2061
+ ).all().map(mapRow);
2062
+ const removable = [];
2063
+ const kept = [];
2064
+ for (const r of rows) {
2065
+ if (protectPinned && Number(r.importance) >= 1) { kept.push({ ...r, reason: 'pinned' }); continue; }
2066
+ if (protectDeep && this.kvGet('deep:' + r.conv_id)) { kept.push({ ...r, reason: 'deep' }); continue; }
2067
+ removable.push(r);
2068
+ }
2069
+ return { scanned: rows.length, removable, kept };
2070
+ }
2071
+
2072
+ /** 归档即清理:会话被归档时,若其概述未被保护则删掉(原文保留 —— 那是走过的路)。
2073
+ * 返回 {removed:boolean, reason}。 */
2074
+ cleanOverviewOnArchive(sessionId) {
2075
+ const sid = String(sessionId);
2076
+ const ov = this.overviewById('dsh', sid);
2077
+ if (!ov) return { removed: false, reason: 'no-overview' };
2078
+ if (Number(ov.importance) >= 1) return { removed: false, reason: 'pinned' };
2079
+ if (this.kvGet('deep:' + sid)) return { removed: false, reason: 'deep' };
2080
+ this.deleteOverview('dsh', sid);
2081
+ return { removed: true, reason: 'archived' };
2082
+ }
2083
+
2084
+ overviewById(source, convId) {
2085
+ const r = this.db.prepare('SELECT * FROM conv_overview WHERE source=? AND conv_id=?').get(source, String(convId));
2086
+ return r ? mapRow(r) : undefined;
2087
+ }
2088
+
2089
+ bumpHit(source, convId) {
2090
+ this.db.prepare(
2091
+ 'UPDATE conv_overview SET hit_count=hit_count+1, last_hit_at=? WHERE source=? AND conv_id=?',
2092
+ ).run(utcIso(), source, String(convId));
2093
+ }
2094
+
2095
+ // ---- raw turns (DSH incremental capture, passive) ----
2096
+ appendRawTurn(sessionId, { seq, role, ts, model, text }) {
2097
+ if (!sessionId || typeof text !== 'string' || !text) return;
2098
+ this.db.prepare(
2099
+ `INSERT INTO dsh_turns_raw (session_id, seq, role, ts, model, text)
2100
+ VALUES (?,?,?,?,?,?)
2101
+ ON CONFLICT(session_id, seq) DO UPDATE SET text=excluded.text`,
2102
+ // 写侧归一(修 A):一律存 ISO;数字/浮点串(如 "1789006881011.0")也归一后再落库。
2103
+ ).run(String(sessionId), Number(seq), String(role ?? '?'), toIso(ts), model || null, text);
2104
+ this.db.prepare(
2105
+ 'INSERT INTO session_meta(session_id, raw_seq) VALUES(?,?) ON CONFLICT(session_id) DO UPDATE SET raw_seq=excluded.raw_seq',
2106
+ ).run(String(sessionId), Number(seq));
2107
+ // 会话复活(2026-09-16):归档过的会话一旦有新轮次 → 清归档标记,概述器会按水位重建它的概述。
2108
+ this.db.prepare('UPDATE session_meta SET archived=0 WHERE session_id=? AND archived=1').run(String(sessionId));
2109
+ }
2110
+
2111
+ rawTurnCount(sessionId) {
2112
+ const r = this.db.prepare('SELECT COUNT(*) AS n FROM dsh_turns_raw WHERE session_id=?').get(String(sessionId));
2113
+ return r ? Number(r.n) : 0;
2114
+ }
2115
+
2116
+ rawSessionCount() {
2117
+ const r = this.db.prepare('SELECT COUNT(DISTINCT session_id) AS n FROM dsh_turns_raw').get();
2118
+ return r ? Number(r.n) : 0;
2119
+ }
2120
+
2121
+ // ---- session meta ----
2122
+ sessionMeta(sessionId) {
2123
+ const r = this.db.prepare('SELECT * FROM session_meta WHERE session_id=?').get(String(sessionId));
2124
+ return r || { session_id: String(sessionId), mode: null, archived: 0, raw_seq: 0 };
2125
+ }
2126
+
2127
+ setSessionMode(sessionId, mode) {
2128
+ this.db.prepare(
2129
+ `INSERT INTO session_meta(session_id, mode, mode_updated_at) VALUES(?,?,?)
2130
+ ON CONFLICT(session_id) DO UPDATE SET mode=excluded.mode, mode_updated_at=excluded.mode_updated_at`,
2131
+ ).run(String(sessionId), mode, utcIso());
2132
+ }
2133
+
2134
+ /** 清空会话模式(空会话回到"跟随当前默认"的语义;修法 A)。 */
2135
+ clearSessionMode(sessionId) {
2136
+ this.db.prepare('UPDATE session_meta SET mode=NULL, mode_updated_at=? WHERE session_id=?')
2137
+ .run(utcIso(), String(sessionId));
2138
+ }
2139
+
2140
+ /** 该会话是否已有真人轮次(空壳/空会话判定)。 */
2141
+ hasUserTurns(sessionId) {
2142
+ const r = this.db.prepare("SELECT COUNT(*) n FROM dsh_turns_raw WHERE session_id=? AND role='user'").get(String(sessionId));
2143
+ return Number(r?.n || 0) > 0;
2144
+ }
2145
+
2146
+ /** 最近一次"用户说话"的时间(ISO;供时间锚使用)。
2147
+ * 修 A:库里混着 ISO 与遗留数字串(`"1789006881011.0"`),文本排序不可靠 ——
2148
+ * ISO 形态取 SQL 最大值,数字形态逐个归一后比大小,两边取较新者。 */
2149
+ lastUserTurnAt() {
2150
+ const isoRow = this.db.prepare(
2151
+ "SELECT ts FROM dsh_turns_raw WHERE role='user' AND ts LIKE '____-__-__%' ORDER BY ts DESC LIMIT 1",
2152
+ ).get();
2153
+ let bestMs = isoRow ? toEpochMs(isoRow.ts) : null;
2154
+ let bestIso = isoRow ? toIso(isoRow.ts) : null;
2155
+ const others = this.db.prepare(
2156
+ "SELECT ts FROM dsh_turns_raw WHERE role='user' AND ts IS NOT NULL AND ts<>'' AND ts NOT LIKE '____-__-__%'",
2157
+ ).all();
2158
+ for (const r of others) {
2159
+ const ms = toEpochMs(r.ts);
2160
+ if (ms === null) continue;
2161
+ if (bestMs === null || ms > bestMs) {
2162
+ bestMs = ms;
2163
+ bestIso = new Date(ms).toISOString();
2164
+ }
2165
+ }
2166
+ return bestIso;
2167
+ }
2168
+
2169
+ markArchived(sessionId) {
2170
+ this.db.prepare(
2171
+ 'INSERT INTO session_meta(session_id, archived) VALUES(?,1) ON CONFLICT(session_id) DO UPDATE SET archived=1',
2172
+ ).run(String(sessionId));
2173
+ }
2174
+
2175
+ // ---- feedback queue(成长回路) ----
2176
+ addFeedback(row) {
2177
+ this.db.prepare(
2178
+ `INSERT INTO feedback_queue(created_at, session_id, message_id, rating, note, status)
2179
+ VALUES (?,?,?,?,?,'new')`,
2180
+ ).run(row.created_at || utcIso(), String(row.session_id ?? ''), String(row.message_id ?? ''), String(row.rating ?? 'negative'), String(row.note ?? ''));
2181
+ }
2182
+
2183
+ listFeedback({ status } = {}) {
2184
+ const rows = status
2185
+ ? this.db.prepare('SELECT * FROM feedback_queue WHERE status=? ORDER BY id DESC').all(status)
2186
+ : this.db.prepare('SELECT * FROM feedback_queue ORDER BY id DESC').all();
2187
+ return rows.map((r) => ({
2188
+ id: r.id, created_at: r.created_at, session_id: r.session_id, message_id: r.message_id,
2189
+ rating: r.rating, note: r.note, status: r.status,
2190
+ }));
2191
+ }
2192
+
2193
+ getFeedback(id) {
2194
+ const r = this.db.prepare('SELECT * FROM feedback_queue WHERE id=?').get(Number(id));
2195
+ return r ? {
2196
+ id: r.id, created_at: r.created_at, session_id: r.session_id, message_id: r.message_id,
2197
+ rating: r.rating, note: r.note, status: r.status,
2198
+ } : null;
2199
+ }
2200
+
2201
+ setFeedbackStatus(id, status) {
2202
+ this.db.prepare('UPDATE feedback_queue SET status=? WHERE id=?').run(String(status), Number(id));
2203
+ }
2204
+
2205
+ countFeedback(status) {
2206
+ const r = this.db.prepare('SELECT COUNT(*) n FROM feedback_queue WHERE status=?').get(String(status));
2207
+ return r ? Number(r.n) : 0;
2208
+ }
2209
+
2210
+ // ---- persona suggestions(语料提炼建议) ----
2211
+ addPersonaSuggestion({ kind, value, note, evidence }) {
2212
+ this.db.prepare(
2213
+ `INSERT INTO persona_suggestions(created_at, kind, value, note, evidence, status)
2214
+ VALUES (?,?,?,?,?,'new')`,
2215
+ ).run(utcIso(), String(kind), String(value ?? ''), String(note ?? ''), JSON.stringify(evidence ?? {}));
2216
+ }
2217
+
2218
+ hasPersonaSuggestion(kind, value) {
2219
+ const r = this.db.prepare('SELECT id FROM persona_suggestions WHERE kind=? AND value=?').get(String(kind), String(value));
2220
+ return !!r;
2221
+ }
2222
+
2223
+ listPersonaSuggestions({ status } = {}) {
2224
+ const rows = status
2225
+ ? this.db.prepare('SELECT * FROM persona_suggestions WHERE status=? ORDER BY id DESC').all(String(status))
2226
+ : this.db.prepare('SELECT * FROM persona_suggestions ORDER BY id DESC').all();
2227
+ return rows.map(mapSuggestion);
2228
+ }
2229
+
2230
+ getPersonaSuggestion(id) {
2231
+ const r = this.db.prepare('SELECT * FROM persona_suggestions WHERE id=?').get(Number(id));
2232
+ return r ? mapSuggestion(r) : null;
2233
+ }
2234
+
2235
+ setPersonaSuggestionStatus(id, status) {
2236
+ this.db.prepare('UPDATE persona_suggestions SET status=? WHERE id=?').run(String(status), Number(id));
2237
+ }
2238
+
2239
+ countPersonaSuggestion(status) {
2240
+ const r = this.db.prepare('SELECT COUNT(*) n FROM persona_suggestions WHERE status=?').get(String(status));
2241
+ return r ? Number(r.n) : 0;
2242
+ }
2243
+
2244
+ // ---- export / import (DESIGN §8;v2 范围:概述/队列/建议/深快照;人格由 API 层附载) ----
2245
+ kvList(prefix) {
2246
+ return this.db.prepare("SELECT key, value FROM kv WHERE key LIKE ? ORDER BY key").all(prefix + '%');
2247
+ }
2248
+
2249
+ kvDel(key) {
2250
+ this.db.prepare('DELETE FROM kv WHERE key=?').run(String(key));
2251
+ }
2252
+
2253
+ exportBundle({ includeRaw = false } = {}) {
2254
+ const overviews = this.db.prepare('SELECT * FROM conv_overview ORDER BY source, conv_id').all().map(mapRow);
2255
+ const deep = this.kvList('deep').map((r) => ({ key: r.key, value: r.value }));
2256
+ const body = {
2257
+ format: 'dsh-ling-memory',
2258
+ schemaVersion: SCHEMA_VERSION,
2259
+ exportScope: 2,
2260
+ exportedAt: utcIso(),
2261
+ sourceFingerprint: sha256Text(JSON.stringify(overviews)),
2262
+ origin: 'local-default',
2263
+ overviews,
2264
+ feedbackQueue: this.listFeedback().map((it) => ({
2265
+ created_at: it.created_at, session_id: it.session_id, message_id: it.message_id,
2266
+ rating: it.rating, note: it.note, status: it.status,
2267
+ })),
2268
+ suggestions: this.listPersonaSuggestions().map((it) => ({
2269
+ kind: it.kind, value: it.value, note: it.note, evidence: it.evidence, status: it.status, created_at: it.created_at,
2270
+ })),
2271
+ deep,
2272
+ };
2273
+ if (includeRaw) {
2274
+ body.rawTurns = this.db.prepare('SELECT * FROM dsh_turns_raw ORDER BY session_id, seq').all();
2275
+ }
2276
+ return body;
2277
+ }
2278
+
2279
+ /** 合并(DESIGN §8 v2):概述幂等;队列/建议按内容去重追加;deep 仅在目标存在对应概述时恢复。 */
2280
+ importBundle(bundle, { overwrite = false } = {}) {
2281
+ const report = { added: 0, skipped: 0, overwritten: 0, queueAdded: 0, queueSkipped: 0, suggAdded: 0, suggSkipped: 0, deepRestored: 0, deepSkipped: 0, errors: 0 };
2282
+ if (!bundle || bundle.format !== 'dsh-ling-memory') {
2283
+ report.errors = 1;
2284
+ return report;
2285
+ }
2286
+ for (const row of bundle.overviews || []) {
2287
+ try {
2288
+ // 来源保真:与 upsertOverview 同口径 —— 只有缺省才退回 dsweb。
2289
+ // (旧代码 `row.source === 'dsh' ? 'dsh' : 'dsweb'` 会把「文件导入(import)」错标成「网页端」)
2290
+ const src = String(row.source ?? '').trim() || 'dsweb';
2291
+ const existing = this.overviewById(src, row.conv_id);
2292
+ if (existing && !overwrite) {
2293
+ report.skipped += 1;
2294
+ continue;
2295
+ }
2296
+ this.upsertOverview({ ...row, origin: row.origin || bundle.origin || 'imported' });
2297
+ if (existing) report.overwritten += 1;
2298
+ else report.added += 1;
2299
+ } catch (e) {
2300
+ report.errors += 1;
2301
+ }
2302
+ }
2303
+ const hasFbStmt = this.db.prepare('SELECT COUNT(*) n FROM feedback_queue WHERE session_id=? AND message_id=?');
2304
+ for (const q of bundle.feedbackQueue || []) {
2305
+ const dup = hasFbStmt.get(String(q.session_id ?? ''), String(q.message_id ?? '')).n > 0;
2306
+ if (dup || !q.session_id || !q.message_id) {
2307
+ report.queueSkipped += 1;
2308
+ continue;
2309
+ }
2310
+ this.addFeedback({ session_id: q.session_id, message_id: q.message_id, rating: q.rating || 'negative', note: q.note || '', created_at: q.created_at });
2311
+ report.queueAdded += 1;
2312
+ }
2313
+ for (const s of bundle.suggestions || []) {
2314
+ if (!s.kind || !s.value || this.hasPersonaSuggestion(s.kind, s.value)) {
2315
+ report.suggSkipped += 1;
2316
+ continue;
2317
+ }
2318
+ this.addPersonaSuggestion({ kind: s.kind, value: s.value, note: s.note || '', evidence: s.evidence || {} });
2319
+ report.suggAdded += 1;
2320
+ }
2321
+ for (const d of bundle.deep || []) {
2322
+ const key = String(d.key || '');
2323
+ if (!key.startsWith('deep')) {
2324
+ report.deepSkipped += 1;
2325
+ continue;
2326
+ }
2327
+ const sid = key.slice(key.indexOf(':') + 1);
2328
+ const overview = this.overviewById('dsh', sid);
2329
+ if (key.startsWith('deep:') && (!overview || this.kvGet(key))) {
2330
+ report.deepSkipped += 1;
2331
+ continue;
2332
+ }
2333
+ if (overview && !this.kvGet(key)) {
2334
+ this.kvSet(key, String(d.value ?? ''));
2335
+ report.deepRestored += 1;
2336
+ } else {
2337
+ report.deepSkipped += 1;
2338
+ }
2339
+ }
2340
+ return report;
2341
+ }
2342
+
2343
+ fingerprint() {
2344
+ const rows = this.db.prepare('SELECT * FROM conv_overview ORDER BY source, conv_id').all();
2345
+ return sha256Text(JSON.stringify(rows.map(mapRow)));
2346
+ }
2347
+ }
2348
+
2349
+ function mapRow(r) {
2350
+ return {
2351
+ conv_id: r.conv_id,
2352
+ source: r.source,
2353
+ title: r.title,
2354
+ started_at: r.started_at,
2355
+ updated_at: r.updated_at,
2356
+ domain_tags: safeJson(r.domain_tags, []),
2357
+ category: r.category,
2358
+ keywords: safeJson(r.keywords, []),
2359
+ summary: r.summary,
2360
+ heat: r.heat,
2361
+ importance: r.importance,
2362
+ last_hit_at: r.last_hit_at,
2363
+ hit_count: r.hit_count,
2364
+ overview_ok: !!r.overview_ok,
2365
+ origin: r.origin,
2366
+ title_locked: !!r.title_locked,
2367
+ title_by: r.title_by || '',
2368
+ };
2369
+ }
2370
+
2371
+ function safeJson(s, fb) {
2372
+ try {
2373
+ return JSON.parse(s);
2374
+ } catch {
2375
+ return fb;
2376
+ }
2377
+ }
2378
+
2379
+ function mapSuggestion(r) {
2380
+ return {
2381
+ id: r.id,
2382
+ created_at: r.created_at,
2383
+ kind: r.kind,
2384
+ value: r.value,
2385
+ note: r.note,
2386
+ evidence: safeJson(r.evidence, {}),
2387
+ status: r.status,
2388
+ };
2389
+ }