@nightpainters/dsh-ling 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,13 +9,13 @@ import { ensureDir, sha256Text, utcIso, mergeDeep, toIso, toEpochMs } from './ut
9
9
  // 且把 memory 当参数传(依赖注入)⇒ memory.js 引它**不成环**。
10
10
  import { archiveBefore } from './archive.js';
11
11
 
12
- const SCHEMA_VERSION = 13; // v13:forgotten(软遗忘标记 —— 永远不彻底删除,先归档再打标记);v12:branch_log.actor(G4 器灵侧通道 —— 工具改的树必须记在"器灵"名下,不再一律记成主人);v11:vein_suggestions(主脉提炼审核队列 R7 —— 设计稿 §5:提炼必须走审核,不直接进记忆);
12
+ const SCHEMA_VERSION = 17; // v17:deep_item.durability(条目「硬度」,1.6,2026-10-04 —— 主人原话「同会话使用硬数量上限来卡吗,这可能有点机械,能不能做成从根源上判断这些条目到底是临时的还是永久的」⇒ 病根不是"同一会话进了太多条",而是**临时的东西被判成了长期记忆**);v16:deep_item(深层库条目,1.6 步 2,2026-10-04 —— 纯新增表,DDL 的 IF NOT EXISTS 即迁移;与记忆树并立、条目不被枝隶属);v15:inject_log(投递留痕,1.6-A,2026-10-03 —— 纯新增表,DDL 的 IF NOT EXISTS 即迁移);v14:dsh_turns_raw.source(来源从"推断"变"记录",2026-10-01 主人定「来源也加个列吧」—— 旧判据是"裸 id = DSH 会话",边界是"将来若出现不带冒号的新来源名,物理上分不出来");v13:forgotten(软遗忘标记 —— 永远不彻底删除,先归档再打标记);v12:branch_log.actor(G4 器灵侧通道 —— 工具改的树必须记在"鱼姬"名下,不再一律记成主人);v11:vein_suggestions(主脉提炼审核队列 R7 —— 设计稿 §5:提炼必须走审核,不直接进记忆);
13
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
14
 
15
15
  /** 主干枝 id(D9-a 记忆分枝,2026-09-18):存量会话与记忆天然属于主干。 */
16
16
  export const TRUNK_ID = 'trunk';
17
17
 
18
- /** 血缘档位(用户 2026-09-18 定档 1.0/0.7/0.4)。
18
+ /** 血缘档位(尝生 2026-09-18 定档 1.0/0.7/0.4)。
19
19
  * 取"最弱环"单值,不做连乘 —— `0.7^5=0.168`、`0.4^5=0.010`,连乘会让深枝等于从记忆里消失,
20
20
  * 且每层舍入都会乘进后面。单值 + 有界,权重才可解释、可复现。 */
21
21
  export const LINEAGE_SAME = 1.0; // 同枝
@@ -43,11 +43,58 @@ export const BRANCH_LOG_WM_RETIRED = 'branch_log_wm.retired';
43
43
  * `<= 0` = 关闭时间窗(全部按近期展开)。 */
44
44
  export const BRANCH_LOG_WINDOW_DAYS = 3;
45
45
 
46
- /** 原文命名空间(G4/2026-09-17):dsh_turns_raw 无 source 维,DSH 会话与导入原文共表。
46
+ /** 命名空间分隔符(原文 id 的 `<ns>:<id>` 形态)。**唯一定义处**在本模块:
47
+ * summarizer.js 从这里取并再导出(既有导入面不变),免得"分隔符"出现两份定义。 */
48
+ export const RAW_NS_SEP = ':';
49
+
50
+ /** 原文命名空间(G4/2026-09-17):dsh_turns_raw 曾无 source 维,DSH 会话与导入原文共表。
47
51
  * 若不隔离,概述器会把导入内容当成 DSH 会话重建(GROUP BY session_id 认领)→ 跨源双份;
48
52
  * 同 id 时更会互相覆盖原文(不可恢复)。故导入原文一律落 'import:<conv_id>'。
49
53
  * 历史数据(1.2.2 之前)的导入原文仍是裸 id —— 读取侧保留回退,写入侧一律带前缀。 */
50
- export const RAW_IMPORT_PREFIX = 'import:';
54
+ export const RAW_IMPORT_PREFIX = 'import' + RAW_NS_SEP;
55
+
56
+ /* ── v14(2026-10-01)来源列:把"来源"从**推断**变成**记录** ─────────────────────────
57
+ * 为什么加(主人原话:「来源也加个列吧」):在此之前的范围判据全建在 id 文本的形状上
58
+ * ("有没有冒号")—— 它是**推断**,边界很硬:将来出现一个**不带冒号**的新来源名,物理上
59
+ * 分不出来(会被当成主人的 DSH 会话认领)。有了这一列,写入者当场把来源记下来,读者照读。
60
+ * 取值口径(三方共用,不许各写一份):
61
+ * · 'dsh' —— DSH 宿主会话轮次(lifecycle.js 捕获)。**读者唯一认作"自己人"的来源**;
62
+ * · 命名空间名(去尾冒号),如 'import' —— 一切外来来源(别人的对话);
63
+ * · NULL/'' —— **只可能来自本列落地之前的存量行**(半迁移态)。读者对这类行回落到旧判据
64
+ * "裸 id = DSH 会话"(见 summarizer 的整行判据),免得同一份库里两种行为并存。
65
+ * ⚠️ 写侧一律落**非空**来源,故**新行永远不靠猜**;靠猜的只有历史行与"写入者没给来源"的缺省。 */
66
+ export const RAW_SOURCE_DSH = 'dsh';
67
+
68
+ /** 枝名的**唯一**长度上限(2026-10-04,蓝队抓到 rename 库侧 60 / 上层 80 两套数)。
69
+ * ⚠️ 三处必须共用它:`memory.renameBranch`、`tree-ops.opBranchRename`、`tree-ops.opBranchCreate` ——
70
+ * 只要有一处自己写数,回执/留痕就会与库里那条不一致(违反 D-T03「回执的名字必须来自库」)。 */
71
+ export const BRANCH_NAME_MAX = 80;
72
+
73
+ /** **有向** kind 的连边:反向边是**不同语义**(「A 是 B 的前提」≠「B 是 A 的前提」)⇒ **不与反向合并**。
74
+ * 无向 kind(`related`/`example`/`contrast`)的两端互换是同一条边 ⇒ 合并成一行。
75
+ * ⚠️ 2026-10-04(蓝队抓到"主代理引入的方向翻转"):上一版对**所有** kind 都做反向合并 ⇒
76
+ * 请求 `link B→A(prerequisite)` 会被落成 `A→B`,且原 `note` 被覆盖消失 —— 在有向边上是真错误。
77
+ * ⚠️ 必须定义在**顶层**(它曾被误插进 class 体内 ⇒ 语法错)。 */
78
+ export const DIRECTED_LINK_KINDS = new Set(['prerequisite', 'supersede']);
79
+
80
+ /** 来源名归一:只做 TRIM(与读取侧 SQL 的 `TRIM(source)` **同解**,两形态必须逐字同判)。
81
+ * ⚠️ 刻意不做别的花样(不去尾冒号、不转小写):归一每多一条规则,SQL 形态就得跟着模仿一条,
82
+ * 两处一旦漂移就会出现"JS 说在范围内、SQL 说不在"这种最难查的分歧。写侧本来就不会给出
83
+ * 带冒号的来源名(rawSourceOf 已去尾冒号),所以这里没有需要补救的畸形输入。 */
84
+ export function normalizeRawSource(source) {
85
+ return String(source ?? '').trim();
86
+ }
87
+
88
+ /** 写入侧/回填侧:按 session_id 得出该行应落的来源名(裸 id ⇒ 'dsh';`<ns>:<id>` ⇒ 'ns')。
89
+ * ⚠️ 注意这是**按 id 形状推导**、不是"记录" —— 它只用于两处:
90
+ * ① 写入者没显式给 source 时的缺省(与读者回落判据同解 ⇒ 老裁决一个字都不变);
91
+ * ② 存量行回填(显式动作,见 backfillRawSource)。
92
+ * 真来源应当由写入方**显式给**(lifecycle 给 'dsh'、导入给 'import'),那才是这一列的意义。 */
93
+ export function rawSourceOf(sessionId) {
94
+ const id = String(sessionId ?? '');
95
+ const i = id.indexOf(RAW_NS_SEP);
96
+ return i < 0 ? RAW_SOURCE_DSH : id.slice(0, i);
97
+ }
51
98
 
52
99
  /** 写入侧:按来源得出该会话原文应落的 session_id。 */
53
100
  export function rawSessionId(source, convId) {
@@ -98,6 +145,10 @@ CREATE TABLE IF NOT EXISTS dsh_turns_raw (
98
145
  ts TEXT,
99
146
  model TEXT,
100
147
  text TEXT NOT NULL DEFAULT '',
148
+ -- v14(2026-10-01):**真实来源标记**(dsh | import | … 见 RAW_SOURCE_DSH 段口径)。
149
+ -- 可空:NULL 只代表"本列落地之前的存量行"(半迁移态),读者对它回落到旧的"裸 id"判据。
150
+ -- ⚠️ 列尾追加(不换主键、不重建表、不动既有列)⇒ 老库上是一条秒级 ALTER。
151
+ source TEXT,
101
152
  PRIMARY KEY (session_id, seq)
102
153
  );
103
154
  CREATE TABLE IF NOT EXISTS session_meta (
@@ -214,6 +265,76 @@ CREATE TABLE IF NOT EXISTS forgotten (
214
265
  actor TEXT NOT NULL DEFAULT 'user',
215
266
  PRIMARY KEY (source, conv_id)
216
267
  );
268
+ -- v15(2026-10-03,1.6-A 投递留痕):**每一次真正定稿的注入**落一条 —— 回答「注入了谁 / 为什么是它 / 多少字」。
269
+ -- 与 tree_snapshot(树的结构快照)、branch_log(树的改动)都不同:它记的是**上下文投放这一动作**本身。
270
+ -- 动机(主人 2026-10-03 定的调子「目的不是省钱,是防止注意力稀释」):判据是**行为** ⇒ 行为必须先有仪表;
271
+ -- 此前"选得准不准 / 有没有漏用 / 面板读数是否等于实际注入"三问全无底账。
272
+ -- ⚠️ **只写不读**:本轮不加工具、不进注入面、不占"工具面最多 +1"的预算(主人拍板前的形态选择见交接 §4)。
273
+ CREATE TABLE IF NOT EXISTS inject_log (
274
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
275
+ at TEXT NOT NULL,
276
+ session_id TEXT NOT NULL DEFAULT '',
277
+ reason TEXT NOT NULL DEFAULT '', -- first(首次定稿=真注入) | rebuild | refresh | manual
278
+ mode TEXT NOT NULL DEFAULT '', -- work | life
279
+ text_hash TEXT NOT NULL DEFAULT '', -- 定稿全文 sha256 前 16 位(校验「面板读数 ≠ 实际注入」)
280
+ text_chars INTEGER NOT NULL DEFAULT 0, -- 定稿全文**字符数**(口径同面板 cliChars,**不是** token)
281
+ l0_chars INTEGER NOT NULL DEFAULT 0, -- 人格段字数
282
+ l1_chars INTEGER NOT NULL DEFAULT 0, -- 记忆开场段字数(含视角框)
283
+ budget_chars INTEGER NOT NULL DEFAULT 0, -- L1 条目预算(= l1BudgetTokens × 1.4)
284
+ keywords TEXT NOT NULL DEFAULT '[]', -- 触发本轮选取的关键词(「为什么是它」的一半答案在问句里)
285
+ items TEXT NOT NULL DEFAULT '[]', -- 入选明细(含分数分解与 why 中文理由)
286
+ runner_ups TEXT NOT NULL DEFAULT '[]', -- **差一步落选**的若干条(含 budget/max 两种原因)
287
+ excluded TEXT NOT NULL DEFAULT '{}', -- 主动排除的计数(archived/forgotten/zero)
288
+ dropped INTEGER NOT NULL DEFAULT 0, -- 落选总数(= scored - items)
289
+ note TEXT NOT NULL DEFAULT ''
290
+ );
291
+ CREATE INDEX IF NOT EXISTS inject_log_at ON inject_log(at);
292
+ CREATE INDEX IF NOT EXISTS inject_log_session ON inject_log(session_id);
293
+ -- v16(2026-10-04,1.6 深层库 · 步 2):**结论层**的落点。
294
+ -- 浅层库(dsh_turns_raw / conv_overview)存的是**过程与会话流水**;深层库装的是从原文里提炼出的、
295
+ -- 可独立读懂的**结论** —— 承诺 / 决定 / 事实 / 偏好 / 关系。
296
+ -- 主人 2026-10-04 给的架构口径(逐字):「从浅层记忆库落入深层库的时候,就是从浅层库中提取关键的东西,
297
+ -- 落入深层库,把浅层库的那些什么工具调用,还有过时历史,比如反复调节UI之类的东西剥离掉,重要的东西
298
+ -- 比如我答应过你要尽快做完记忆库然后做主动开口层。然后在召回的时候再重新装配。」
299
+ -- 为什么 kind 是必需列(它不只是分类标签):**它是主动召回的钩子** —— 「承诺」该在相关事情被提起时
300
+ -- 冒出来,「事实」不该主动冒头。而主动召回(push)的判据 = **反复加固 + 置顶**,主人 2026-10-04 拍板
301
+ -- 明确「面向的对象不是某条记忆的浅层库原文,而是深层库的条目」—— 即这张表的行。
302
+ -- 与记忆树的分工(D1 已拍,主人原话「相当于记忆树的根须或者榕树类气根」):**两者并立** ——
303
+ -- 条目**不隶属**任何枝(记忆树是空间维度、深层库是内容维度),但 branch_id 可留空亦可填,
304
+ -- 且**枝侧可反向引用条目**(气根供养树):一条枝能列出供养过它的条目,而条目的增删**不改动树结构**。
305
+ CREATE TABLE IF NOT EXISTS deep_item (
306
+ id TEXT PRIMARY KEY, -- 'di:' + uuid(与记忆树 br: 同风格;带前缀便于眼认)
307
+ kind TEXT NOT NULL, -- commitment | decision | fact | preference | relation | todo
308
+ text TEXT NOT NULL, -- 一句话 ≤80 字:它是**装配单元**(开场要从里面挑),必须独立读懂
309
+ at TEXT NOT NULL DEFAULT '', -- 发生时间(取自原文那一轮;UTC ISO —— 与 dsh_turns_raw.ts 同口径)
310
+ conv_id TEXT NOT NULL DEFAULT '', -- 来源会话(裸 id 或带命名空间前缀,与 conv_overview.conv_id 同口径)
311
+ seq_from INTEGER NOT NULL DEFAULT 0, -- 原文区间起点(**可下钻验证** —— 这是"可审计"的落点)
312
+ seq_to INTEGER NOT NULL DEFAULT 0, -- 原文区间终点(= seq_from 表示单轮)
313
+ src TEXT NOT NULL DEFAULT '', -- 来源域:dsh | dsweb | import | vein(口径见 RAW_SOURCE_DSH 段)
314
+ hit_count INTEGER NOT NULL DEFAULT 0, -- 加固次数。⚠️ **只该由"真被采用"推进,不由"被注入"推进** ——
315
+ -- 注入就加分会造成自我强化(注入越多→分越高→越易被注入);
316
+ -- 采用信号 = **recall 取过它指向的原文**(肯下钻读原话才算在用)。
317
+ pinned INTEGER NOT NULL DEFAULT 0, -- 主人置顶(主动召回的第二个判据)
318
+ durability TEXT NOT NULL DEFAULT 'long', -- 硬度(1.6 v17):durable(永久) | long(长期) | ephemeral(当次)。
319
+ -- 判的是「**这条结论本身能活多久**」,不是它当时多重要 ——
320
+ -- 主人拍板选了一个插件版本是 ephemeral,拍板"效果优先于成本"是 durable。
321
+ -- ⚠️ ephemeral **不进开场**(选择侧过滤掉),但**照旧留在库里、recall 搜得到**:
322
+ -- 判严的代价远小于判松了(判松 = 每次开口都在推一条早失效的临时决定)。
323
+ superseded_by TEXT NOT NULL DEFAULT '', -- 被哪条取代 —— 沿用老规矩「只追不替」:旧的留痕、不删
324
+ origin TEXT NOT NULL DEFAULT 'auto', -- auto(提炼) | user(主人手写);origin='user' 的不许被自动覆盖
325
+ branch_id TEXT NOT NULL DEFAULT '', -- 气根:空 = 未挂枝;非空 = 被这条枝反向引用(不参与树的层级计算)
326
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
327
+ updated_at TEXT NOT NULL DEFAULT ''
328
+ );
329
+ CREATE INDEX IF NOT EXISTS deep_item_kind ON deep_item(kind);
330
+ CREATE INDEX IF NOT EXISTS deep_item_conv ON deep_item(conv_id);
331
+ CREATE INDEX IF NOT EXISTS deep_item_branch ON deep_item(branch_id);
332
+ CREATE INDEX IF NOT EXISTS deep_item_rank ON deep_item(pinned DESC, hit_count DESC);
333
+ -- 注意:deep_item_durability 索引故意不在这里建 —— DDL 先于迁移执行,而老库的 deep_item 表已存在
334
+ -- ⇒ CREATE INDEX ON deep_item(durability) 会因列还不存在而失败,且它与其他语句同在一个 db.exec(DDL) 里
335
+ -- ⇒ 整条 DDL 失败 ⇒ 插件加载失败(2026-10-04 实测,全链 44 套全绿却没抓到 —— 因为测试库全是新建的,
336
+ -- 只有"老库 + 新代码"这一组合会炸,而生产库正是这个组合)。
337
+ -- 它的落点在 migrateDeepDurability 里(ALTER 之后建),新库与老库两条路都覆盖。
217
338
  CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL);
218
339
  `;
219
340
 
@@ -275,7 +396,7 @@ function migrateTitleCols(db) {
275
396
  }
276
397
  }
277
398
 
278
- /** v4→v5:记忆分枝(D9-a,2026-09-18 用户定方向)。
399
+ /** v4→v5:记忆分枝(D9-a,2026-09-18 尝生定方向)。
279
400
  * session_meta 加 branch_id(默认 'trunk' —— 存量会话天然属主干,迁移只填一列);
280
401
  * 建 branch 表并保证主干行存在。**记忆行不冗余存 branch**,靠 session → branch 推导:
281
402
  * 迁移成本最低,且枝归属变化时无需回写海量记忆行。 */
@@ -330,6 +451,58 @@ function migrateLogActor(db) {
330
451
  }
331
452
  }
332
453
 
454
+ /** v16→v17:deep_item 增列 durability(条目的「硬度」,1.6,2026-10-04)。
455
+ *
456
+ * 为什么需要(主人原话,逐字):「**同会话使用硬数量上限来卡吗,这可能有点机械,能不能做成
457
+ * 从根源上判断这些条目到底是临时的还是永久的,或者加"硬度"评分**」。
458
+ * 他说得对:`MAX_PER_CONV = 2` 压的是**症状** —— 病根是"**临时的东西被判成了长期记忆**",
459
+ * 同源扎堆只是这个病根碰巧表现出的形状(临时决定往往扎堆在一次会话里)。硬度上线后,
460
+ * 选择侧按"这条能活多久"过滤,同源限额退居护栏(它仍在,防的是另一种极端)。
461
+ *
462
+ * 存量行没有硬度可考 ⇒ 默认回填 `'long'`(中性档:既不冒进进开场、也不被永久排除);
463
+ * 真正的分档由标注流程单独写入(不是本迁移的职责 —— 迁移只保证**列在**)。
464
+ * 返回 boolean 供 `schema_version` 前进判定(与 `migrateRawSource` 同一约定:失败则不前进,
465
+ * 下次启动再试;探列幂等,成功即跳过)。 */
466
+ function migrateDeepDurability(db) {
467
+ try {
468
+ const cols = db.prepare('PRAGMA table_info(deep_item)').all().map((c) => String(c.name));
469
+ if (!cols.length) return true; // 表还不存在(极老的库,DDL 会建全)—— 没什么可迁的
470
+ if (!cols.includes('durability')) {
471
+ db.exec("ALTER TABLE deep_item ADD COLUMN durability TEXT NOT NULL DEFAULT 'long'");
472
+ }
473
+ // 索引**在这里**建,不在 DDL 里:DDL 先于迁移执行,老库建索引会因列不存在而让**整条 DDL** 失败。
474
+ // 放在 ALTER 之后 ⇒ 新库(列已由 DDL 建出)与老库(刚 ALTER 完)两条路都对。
475
+ db.exec('CREATE INDEX IF NOT EXISTS deep_item_durability ON deep_item(durability)');
476
+ return true;
477
+ } catch (e) {
478
+ console.warn('[dsh-ling] deep_item.durability 迁移失败(忽略):', String(e?.message ?? e).slice(0, 200));
479
+ return false;
480
+ }
481
+ }
482
+
483
+ /** v13→v14:dsh_turns_raw 增列 source(来源从**推断**变**记录**,2026-10-01 主人定)。
484
+ *
485
+ * 为什么必须是**纯增列**:这张表是记忆的**原文底账**(真机库 5062 轮 / 104 会话),任何
486
+ * "重建表 / 换主键 / 重写行"的迁移都会在一条语句里同时动到全部行的字节;而 ADD COLUMN 是
487
+ * SQLite 的**元数据操作** —— 不触碰任何既有行、不动主键,失败面最小(红线:不许重建表)。
488
+ * 故这一列**不做回填**:历史行的 source 留 NULL,读者按旧判据("裸 id = DSH 会话")回落 ⇒
489
+ * 迁移前后**裁决逐字一致**;要动历史行得走显式动作 backfillRawSource(会汇报条数)。
490
+ * 幂等:先 `PRAGMA table_info` 探列,已有直接返回 —— 可重复运行,第二遍是零写入。
491
+ * 代价:老库上一条 ALTER,毫秒级;不重建表 ⇒ 不阻塞宿主启动。
492
+ * @returns {boolean} 列是否确实在位(迁移后自证 —— 供构造器写 `dbg.raw_source_col` 与失败告警) */
493
+ function migrateRawSource(db) {
494
+ const has = () => {
495
+ try { return db.prepare('PRAGMA table_info(dsh_turns_raw)').all().some((c) => String(c.name) === 'source'); } catch { return false; }
496
+ };
497
+ try {
498
+ if (has()) return true; // 已迁移(或新库:DDL 里已带此列)
499
+ db.exec('ALTER TABLE dsh_turns_raw ADD COLUMN source TEXT');
500
+ } catch (e) {
501
+ console.warn('[dsh-ling] dsh_turns_raw.source 迁移失败(忽略):', String(e?.message ?? e).slice(0, 200));
502
+ }
503
+ return has(); // 自证:ALTER 是否真的生效(失败 ⇒ 构造器留一条可见的诊断,而不是让读侧静默抛错)
504
+ }
505
+
333
506
  /** 会话级成员"可展开"的谓词(审计 A#6,2026-09-22)。
334
507
  *
335
508
  * 展开(`branchMembers`)与计数(`branchMemberCounts` / `branchTree().members`)必须共用这一份 SQL,
@@ -361,21 +534,55 @@ export class MemoryStore {
361
534
  ensureDir(dbPath.slice(0, Math.max(0, dbPath.lastIndexOf('/'), dbPath.lastIndexOf('\\'))) || '.');
362
535
  this.dbPath = dbPath; // archive.js 用它推导归档目录(与库同级)
363
536
  this.db = new DatabaseSync(dbPath);
364
- this.db.exec('PRAGMA journal_mode=WAL;');
537
+ // ⚠️ **`busy_timeout` 必须在任何可能撞锁的语句之前**(蓝队 2026-10-04 实测):它原先在
538
+ // `journal_mode=WAL` **之后**一行 ⇒ 非 WAL 库 + 别的进程持写锁时,`journal_mode` 那一句
539
+ // **23 毫秒**就抛 `database is locked`,连等待都没等。换序后同一句会等满 timeout,
540
+ // 而"锁在等待期内释放"的情形直接构造成功(蓝队实测:4,000ms 的锁 ⇒ 4,072ms 构造成功)。
365
541
  this.db.exec('PRAGMA busy_timeout=15000;');
542
+ this.db.exec('PRAGMA journal_mode=WAL;');
366
543
  this.db.exec(DDL);
367
544
  migrateSourceCheck(this.db); // v1→v2:去掉 conv_overview.source 的两源 CHECK
368
545
  migrateTitleCols(this.db); // v2→v4:title_locked + title_by(主人改过的标题不被机器覆盖)
369
546
  migrateBranchCols(this.db); // v4→v5:branch 表 + session_meta.branch_id(记忆分枝 D9-a)
370
547
  migrateSnapshotKind(this.db); // v9→v10:tree_snapshot.kind(区分自动备份与手动快照,审计 A#9)
371
548
  migrateLogActor(this.db); // v11→v12:branch_log.actor(区分"谁改的树",Part G G4)
549
+ const rawSourceOk = migrateRawSource(this.db); // v13→v14:dsh_turns_raw.source(来源从推断变记录,2026-10-01)
550
+ const deepDurOk = migrateDeepDurability(this.db); // v16→v17:deep_item.durability(条目硬度,1.6)
372
551
  this._kvGet = this.db.prepare('SELECT value FROM kv WHERE key=?');
373
552
  this._kvSet = this.db.prepare('INSERT INTO kv(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value');
553
+ // 迁移**自证**(2026-10-01):纯增列理论上不会失败,但万一 ALTER 被库锁/权限挡下(它会静默 catch),
554
+ // 读侧的 source 判据就会去读一列不存在的列 —— 症状是"概述器/习惯扫描每次抛 no such column",
555
+ // 而它只会在 console.debug 里出现一次。故失败时留一条可查的 kv + 一行 warn(成功路径**零 kv 写入**,
556
+ // 免得"干净库的 kv 键集合不变"这条既有断言被诊断项污染;陈旧告警顺手清掉)。
557
+ if (rawSourceOk) {
558
+ try { this.kvDel('dbg.raw_source_col'); } catch { /* 旧告警清不掉不影响主流程 */ }
559
+ } else {
560
+ // ⚠️ 这一笔**必须**留在 try 里(红队 2026-10-01 实测的阻断项):库只读(SQLITE_READONLY)/被写锁(SQLITE_BUSY)时
561
+ // 它**一定**抛,而它只是"留一条可查的痕迹",不是主流程。修前异常一路冒到 lib/index.js 的 apply() ⇒ **整个插件
562
+ // 加载失败**,那条为迁移失败准备的降级路径(本 else 分支 + 读侧回落"裸 id"判据)反而永远走不到(形同死代码)。
563
+ // 只用 try 护**这一笔诊断写入**,不把 kvSet 整体改成吞异常(那会把别处的真错误一起吞掉);
564
+ // "库真的打不开/表都建不出来"这类错误仍照旧往上抛(见构造器开头,DDL 未做任何兜底)。
565
+ // ⚠️ 2026-10-04 补(主人拍板「按错误码分流」):**锁竞争不是"真的打不开"** ——
566
+ // `errcode 5(SQLITE_BUSY)/ 6(SQLITE_LOCKED)` 是"等一会儿就能开",由 **apply 层**
567
+ // (`lib/index.js`)按错误码做有界重试 + 降级;`8(READONLY)/ 11(CORRUPT)` 等才照旧上抛。
568
+ // 本构造器自己不吞异常(那一层不该决定"插件要不要活"),但**降级语义**在这里被保证:
569
+ // 构造失败的最终后果是"记忆功能不可用",而不是"插件加载失败"(后者是 fail-dead)。
570
+ try {
571
+ this.kvSet('dbg.raw_source_col', 'missing');
572
+ } catch (e) {
573
+ console.warn('[dsh-ling] 迁移诊断没能写进库(少了 dbg.raw_source_col 这条可查痕迹,不影响运行):'
574
+ + String(e?.message ?? e).slice(0, 200));
575
+ }
576
+ console.warn('[dsh-ling] dsh_turns_raw.source 列缺失(迁移未生效)—— 概述器与习惯扫描的来源判据会读不到该列,'
577
+ + '请检查库锁/磁盘权限;读侧回落判据(instr(session_id, 冒号) = 0)不受影响,但需人工处置。');
578
+ }
374
579
  // schema_version 必须跟随真实结构更新,不能只写一次:
375
580
  // v5 加了 branch 表与 session_meta.branch_id,若仍留 '4',后续会话会误判库结构。
376
581
  // v6 加 vein_link + memory_conflict 两张新表(纯新增,无 ALTER,DDL 的 IF NOT EXISTS 即迁移)。
377
582
  // v10 加 tree_snapshot.kind(ALTER,见 migrateSnapshotKind)。
378
- if (this.kvGet('schema_version') !== String(SCHEMA_VERSION)) this.kvSet('schema_version', String(SCHEMA_VERSION));
583
+ // v14 加 dsh_turns_raw.source(ALTER,见 migrateRawSource);失败时 schema_version **不前进**,
584
+ // 下一次启动还会再试一遍(探列幂等 ⇒ 成功即跳过)。
585
+ if (rawSourceOk && deepDurOk && this.kvGet('schema_version') !== String(SCHEMA_VERSION)) this.kvSet('schema_version', String(SCHEMA_VERSION));
379
586
  }
380
587
 
381
588
  close() {
@@ -716,7 +923,10 @@ export class MemoryStore {
716
923
 
717
924
  renameBranch(branchId, name, { lock = true } = {}) {
718
925
  const bid = String(branchId || '');
719
- const nm = String(name || '').trim().slice(0, 60);
926
+ // ⚠️ 上限与 `tree-ops.opBranchRename`(80)与 `opBranchCreate`(80)**必须同一个数**
927
+ // (蓝队 2026-10-04):这里曾是 60、上层是 80 ⇒ 库里比回执短 20 字,而回执/留痕都拿
928
+ // 入参说话 ⇒ 违反本项目自己立的 D-T03(「回执的数字与名字必须来自库」)。
929
+ const nm = String(name || '').trim().slice(0, BRANCH_NAME_MAX);
720
930
  if (!bid) return { ok: false, reason: 'no-branch' };
721
931
  if (!nm) return { ok: false, reason: 'empty-name' };
722
932
  this.db.prepare('UPDATE branch SET name=?, name_locked=?, updated_at=? WHERE id=?')
@@ -733,8 +943,13 @@ export class MemoryStore {
733
943
  * 让它真正生效属于**改变检索语义**,需主人拍板(方案 a),不在本次审计修法内。 */
734
944
  setBranchWeight(branchId, scale) {
735
945
  const bid = String(branchId || '');
736
- const v = Number(scale);
737
- if (!bid || !Number.isFinite(v)) return { ok: false, reason: 'bad-arg' };
946
+ // ⚠️ 先判**类型**再取数(蓝队 2026-10-04):`Number(null) === 0`、`Number(true) === 1`、
947
+ // `Number([]) === 0` —— 只过 `Number()` 会让"手滑传了 null / true / 空数组"**静默写成 0/1**,
948
+ // 而 0 在界面上是"这条枝权重为零"的**合法读数**,没人看得出它是误传(红队实测:
949
+ // `weightScale: null` ⇒ `ok:true`、库里真写成 0、回执还当成主人的意图)。
950
+ if (typeof scale !== 'number' || !Number.isFinite(scale)) return { ok: false, reason: 'bad-arg' };
951
+ const v = scale;
952
+ if (!bid) return { ok: false, reason: 'bad-arg' };
738
953
  const clamped = Math.max(0, Math.min(2, v));
739
954
  this.db.prepare('UPDATE branch SET weight_scale=?, updated_at=? WHERE id=?').run(clamped, utcIso(), bid);
740
955
  return {
@@ -743,11 +958,22 @@ export class MemoryStore {
743
958
  };
744
959
  }
745
960
 
746
- /** 连边:两条枝之间的横向关联(如 力学 ↔ 材料科学)。 */
961
+ /** 连边:两条枝之间的横向关联(如 力学 ↔ 材料科学)。
962
+ * 反向是否合并看 kind —— 见顶层的 `DIRECTED_LINK_KINDS`。 */
747
963
  linkVeins(fromBranch, toBranch, { kind = 'related', note = '' } = {}) {
748
964
  const a = String(fromBranch || '');
749
965
  const b = String(toBranch || '');
750
966
  if (!a || !b || a === b) return { ok: false, reason: 'bad-arg' };
967
+ // ⚠️ 反向去重(红队 2026-10-04):主键是**有序对** `(from_branch,to_branch)` ⇒
968
+ // `link A→B` 之后再 `link B→A` 会落**两行**,而 `unlink A→B` 只删一条 ⇒
969
+ // 回执说"断开了边",树里其实还连着。连边是**无向关系**(related/prerequisite/…),
970
+ // 两端反写不构成第二条边 ⇒ 写入前先看反向在不在,在就原地更新那一行。
971
+ const rev = this.db.prepare('SELECT rowid AS rid FROM vein_link WHERE from_branch=? AND to_branch=?').get(b, a);
972
+ if (rev && !DIRECTED_LINK_KINDS.has(String(kind || 'related'))) {
973
+ this.db.prepare('UPDATE vein_link SET kind=?, note=? WHERE from_branch=? AND to_branch=?')
974
+ .run(String(kind || 'related'), String(note || '').slice(0, 300), b, a);
975
+ return { ok: true, from: a, to: b, reverse: true };
976
+ }
751
977
  this.db.prepare(`INSERT INTO vein_link (from_branch,to_branch,kind,note,created_at)
752
978
  VALUES (?,?,?,?,?)
753
979
  ON CONFLICT(from_branch,to_branch) DO UPDATE SET kind=excluded.kind, note=excluded.note`)
@@ -757,8 +983,13 @@ export class MemoryStore {
757
983
 
758
984
  unlinkVein(fromBranch, toBranch) {
759
985
  try {
760
- const r = this.db.prepare('DELETE FROM vein_link WHERE from_branch=? AND to_branch=?')
761
- .run(String(fromBranch || ''), String(toBranch || ''));
986
+ // ⚠️ 双向删(红队 2026-10-04):历史数据里可能已经同时躺着 `A→B` 与 `B→A` 两行
987
+ // (主键是有序对,旧写入侧没挡反向)⇒ 只删一个方向会让回执说"断开了"而树里还连着。
988
+ const a = String(fromBranch || '');
989
+ const b = String(toBranch || '');
990
+ const r = this.db.prepare(
991
+ 'DELETE FROM vein_link WHERE (from_branch=? AND to_branch=?) OR (from_branch=? AND to_branch=?)',
992
+ ).run(a, b, b, a);
762
993
  return { ok: true, removed: Number(r.changes || 0) };
763
994
  } catch {
764
995
  return { ok: false, reason: 'db' };
@@ -781,7 +1012,7 @@ export class MemoryStore {
781
1012
 
782
1013
  // ---- 软遗忘(Part G G2,2026-09-25) ----
783
1014
  //
784
- // 原则(用户 2026-09-25 定):**永远不做彻底删除**。遗忘 = 先把原数据归档落盘(原文 + 概述 +
1015
+ // 原则(尝生 2026-09-25 定):**永远不做彻底删除**。遗忘 = 先把原数据归档落盘(原文 + 概述 +
785
1016
  // 改动日志),再在库里打一个标记,召回路径据标记排除。行仍在库里、归档文件在磁盘上 ——
786
1017
  // 两条独立还原路径,任何一条都能反悔。
787
1018
  //
@@ -815,24 +1046,39 @@ export class MemoryStore {
815
1046
  * ⚠️ 仍然缺的一环在**调用侧**(`tools.js` 的 forget handler):它只判 `arc.ok`,
816
1047
  * 不判 `arc.empty` ⇒ 想彻底堵住 B-05 还得在那儿加一句守卫(见审计台账)。
817
1048
  */
818
- forgetEntry({ source, convId, reason = '', archivePath = '', actor = 'user', archiveTurns = null } = {}) {
1049
+ forgetEntry({ source, convId, reason = '', archivePath = '', actor = 'user', archiveTurns = null, archiveEmpty = undefined } = {}) {
819
1050
  const s = String(source || '').trim();
820
1051
  const c = String(convId || '').trim();
821
1052
  if (!s || !c) return { ok: false, reason: 'bad-key' };
822
1053
  const dir = String(archivePath || '').trim();
823
1054
  if (!dir) return { ok: false, reason: 'empty-archive', message: '没有归档路径 —— 先归档再打标记' };
824
- if (archiveTurns === null || archiveTurns === undefined) {
825
- let n = 0;
826
- try {
827
- n = existsSync(join(dir, 'turns.jsonl'))
828
- ? readFileSync(join(dir, 'turns.jsonl'), 'utf8').split('\n').filter((l) => l.trim()).length
829
- : 0;
830
- } catch { n = 0; }
831
- if (n === 0) {
832
- return { ok: false, reason: 'empty-archive', message: '归档里没有原文(0 轮)', archivePath: dir };
1055
+ // ⚠️ 判据来源(红队 2026-10-04):调用方(归档器)显式给出 `archiveEmpty` 时**以它为准** ——
1056
+ // 它看的是"原文轮数 + 概述正文 + 会话元数据"**三样**;不给时才退回旧的验盘判据(只看
1057
+ // `turns.jsonl` 行数)。旧判据单独用会让"**只有概述、没有原文**"的条目永远忘不掉
1058
+ // (真库实测 3 条),而且回执会把一份装着摘要的归档说成"归档里什么都没有"。
1059
+ if (archiveEmpty === true) {
1060
+ return { ok: false, reason: 'empty-archive', message: '归档里没有任何内容(0 轮原文、概述也没有正文)', archivePath: dir };
1061
+ }
1062
+ // `archiveEmpty === false` = 归档器确认"**三样里至少一样有内容**"(典型:概述在、原文 0 轮)
1063
+ // ⇒ **放行**。这正是本次要修的那一类(旧判据只看 `turns.jsonl`,于是它永远忘不掉)。
1064
+ // 其余情形(没给判定)按旧判据走:调用方声明的轮数优先,没声明就验盘 ——
1065
+ // **声明 0 轮同样拒绝**(声明不能把空的说成有的;这条语义有断言钉着,不许丢)。
1066
+ if (archiveEmpty === undefined) {
1067
+ if (archiveTurns !== null && archiveTurns !== undefined) {
1068
+ if (!(Number(archiveTurns) > 0)) {
1069
+ return { ok: false, reason: 'empty-archive', message: '归档里没有原文(0 轮)', archivePath: dir };
1070
+ }
1071
+ } else {
1072
+ let n = 0;
1073
+ try {
1074
+ n = existsSync(join(dir, 'turns.jsonl'))
1075
+ ? readFileSync(join(dir, 'turns.jsonl'), 'utf8').split('\n').filter((l) => l.trim()).length
1076
+ : 0;
1077
+ } catch { n = 0; }
1078
+ if (n === 0) {
1079
+ return { ok: false, reason: 'empty-archive', message: '归档里没有原文(0 轮)', archivePath: dir };
1080
+ }
833
1081
  }
834
- } else if (!(Number(archiveTurns) > 0)) {
835
- return { ok: false, reason: 'empty-archive', message: '归档里没有原文(0 轮)', archivePath: dir };
836
1082
  }
837
1083
  const oneLine = (x, n) => String(x == null ? '' : x).replace(/[\r\n\t]+/g, ' ').trim().slice(0, n);
838
1084
  try {
@@ -896,7 +1142,7 @@ export class MemoryStore {
896
1142
  *
897
1143
  * **调用方必须先归档当前状态** —— 本方法只管写,不做任何备份。
898
1144
  * 回灌的危险性高于遗忘:遗忘会留下"少了一条"的可见痕迹,而回灌是**往里加东西**,
899
- * 加错了很难察觉(用户 2026-09-25:「backfill 比 forget 危险多了,因为它更难以觉察」)。
1145
+ * 加错了很难察觉(尝生 2026-09-25:「backfill 比 forget 危险多了,因为它更难以觉察」)。
900
1146
  *
901
1147
  * 两道保护:① 写回的概述带 `origin` 标记(默认 `backfill-v1`),界面上看得出"这条是灌回来的";
902
1148
  * ② 不传 `keepUserState:false` ⇒ **不动主人留下的命中数与置顶**,回灌只补内容、不夺用户态。
@@ -913,12 +1159,29 @@ export class MemoryStore {
913
1159
  if (!s || !c) return { ok: false, reason: 'bad-key' };
914
1160
  let wroteTurns = 0;
915
1161
  try {
916
- const ins = this.db.prepare(`INSERT INTO dsh_turns_raw (session_id,seq,role,ts,model,text)
917
- VALUES (?,?,?,?,?,?) ON CONFLICT(session_id,seq) DO UPDATE SET
918
- role=excluded.role, ts=excluded.ts, model=excluded.model, text=excluded.text`);
1162
+ const ins = this.db.prepare(`INSERT INTO dsh_turns_raw (session_id,seq,role,ts,model,text,source)
1163
+ VALUES (?,?,?,?,?,?,?) ON CONFLICT(session_id,seq) DO UPDATE SET
1164
+ role=excluded.role, ts=excluded.ts, model=excluded.model, text=excluded.text,
1165
+ source=COALESCE(dsh_turns_raw.source, excluded.source)`);
1166
+ // v14:回灌也要带来源。取 `rawSourceOf(c)`(= 写入目标 id 的形状)而**不是**概述的 `source` 字段:
1167
+ // · 目标 id 带前缀(import:)⇒ 'import' —— 与写入侧同一口径;
1168
+ // · 目标 id 是裸的 ⇒ 'dsh' —— 与读者对空 source 的回落判据同解 ⇒ 回灌前后裁决一致
1169
+ // (若改按 `source` 字段记 'dsweb'/'vein',这些回灌的原文会**当场从概述器眼里消失**:越权改裁决)。
1170
+ // 冲突分支同样 COALESCE(首写为准),不回退已记录的来源。
1171
+ // ⚠️ 来源列优先取**调用方声明的** `s`,不再一律按目标 id 形状回推(红队 2026-10-04):
1172
+ // `rawSourceOf(裸 uuid)` 恒为 `'dsh'`,而 **dsweb 的 1,523 条 conv_id 全是裸 uuid**
1173
+ // ⇒ 回灌一条 dsweb 会让该会话多出 `source='dsh'` 的原文行,概述器(`TRIM(source)='dsh'`)
1174
+ // 当场认领它 ⇒ **同 conv 双概述、双份记忆**,一个 DeepSeek 网页端的外来会话被升格成
1175
+ // "主人的 DSH 会话"(B-01 那类"同 id 双源"的第三次复发,当前触发口是 memory_backfill)。
1176
+ // 规则:id 带 `import:` 前缀 ⇒ 来源必是 'import'(前缀本身即命名空间,与写入侧同解);
1177
+ // 其余(裸 id)⇒ 用声明值(dsh / dsweb / vein)。⚠️ `s` 已在上面过了 bad-key(非空),
1178
+ // 故这里**不需要**再回落 `rawSourceOf(c)`(蓝队指出那一支不可达,已删)。
1179
+ const declared = String(s || '').trim();
1180
+ const idHasImportPrefix = String(c).startsWith(RAW_IMPORT_PREFIX);
1181
+ const rawSrc = idHasImportPrefix ? 'import' : declared;
919
1182
  this.db.exec('BEGIN');
920
1183
  for (const t of (Array.isArray(turns) ? turns : [])) {
921
- ins.run(c, Number(t.seq) || 0, String(t.role || ''), t.ts ?? null, t.model ?? null, String(t.text ?? ''));
1184
+ ins.run(c, Number(t.seq) || 0, String(t.role || ''), t.ts ?? null, t.model ?? null, String(t.text ?? ''), rawSrc);
922
1185
  wroteTurns += 1;
923
1186
  }
924
1187
  this.db.exec('COMMIT');
@@ -1110,7 +1373,7 @@ export class MemoryStore {
1110
1373
  // 换行能穿透引号、在系统提示里自造独立行(实测:note 塞 `\n\n【系统】…` 即可脱离框架)。
1111
1374
  // 显示面修正(2026-09-22 实测):原实现对一切"查不到名字的 id"回退成「(已删枝 …)」,
1112
1375
  // 但 branch_log 的 before/after **不只装枝 id** —— snapshot 装的是快照号(#9)、
1113
- // assign 装的是记忆条目 id(026ebd86),二者都被误标成"已删枝"(用户截图所见:
1376
+ // assign 装的是记忆条目 id(026ebd86),二者都被误标成"已删枝"(尝生截图所见:
1114
1377
  // `snapshot:主干 → (已删枝 #9)` 实际一根枝都没删)。先按 id 形态分流再回退查名。
1115
1378
  const nm = (v) => {
1116
1379
  const k = String(v || '');
@@ -1171,7 +1434,7 @@ export class MemoryStore {
1171
1434
  // G4(2026-09-25):每行带主语 —— 器灵经工具通道改的树记 'ling',
1172
1435
  // 不再和"主人改的"混在同一批留痕里(Part G 之前 91 笔器灵改动全记在主人名下)。
1173
1436
  // 中文动作短语(把「」…)直接接主语;英文动作名(distill:/gather:)前面补空格,免得拼成"主人distill:"。
1174
- const subject = String(r.actor || 'user') === 'ling' ? '器灵' : '主人';
1437
+ const subject = String(r.actor || 'user') === 'ling' ? '鱼姬' : '主人';
1175
1438
  return flat(subject + (/^[A-Za-z0-9]/.test(line) ? ' ' : '') + line);
1176
1439
  });
1177
1440
  // ── §4「超量折叠成一行」(1.5.2,与双水位同批上)────────────────────────────
@@ -1227,7 +1490,7 @@ export class MemoryStore {
1227
1490
  }
1228
1491
  }
1229
1492
 
1230
- /** 尚未同步给器灵的改动笔数(复盘守门,2026-09-22 用户:「动记忆树不能是随意的」)。
1493
+ /** 尚未同步给器灵的改动笔数(复盘守门,2026-09-22 尝生:「动记忆树不能是随意的」)。
1231
1494
  *
1232
1495
  * 判据:自器灵**上一次开口**(注入推进水位)以来累积了多少笔结构改动。
1233
1496
  * 水位取 `branch_log_wm.last`(由 `inject.js` 的 advanceLogWatermark 在投递时写入),
@@ -1248,7 +1511,7 @@ export class MemoryStore {
1248
1511
  let pending = 0;
1249
1512
  let maxId = 0;
1250
1513
  try {
1251
- // 'snapshot'(保存快照)不计入待交代笔数(2026-09-25 用户:「单纯保存不弹修改」)。
1514
+ // 'snapshot'(保存快照)不计入待交代笔数(2026-09-25 尝生:「单纯保存不弹修改」)。
1252
1515
  // 依据:保存快照**不改树**——它只是把当前结构另存一份,是"大改之前的保险",
1253
1516
  // 不是"改动"。把它算成债会让主人每存一次就看到「⚠ 有 N 笔改动还没跟器灵说过」,
1254
1517
  // 而实际上树一个字节没变。留痕仍写进 branch_log(审计价值保留),只是不推进债。
@@ -1319,7 +1582,7 @@ export class MemoryStore {
1319
1582
  if (p && n.id !== TRUNK_ID) p.children.push(n);
1320
1583
  else roots.push(n);
1321
1584
  }
1322
- // 层级 = **计算属性**(用户 2026-09-19 定):按相对深度切,树长高时自动重算,**零回写**。
1585
+ // 层级 = **计算属性**(尝生 2026-09-19 定):按相对深度切,树长高时自动重算,**零回写**。
1323
1586
  // 于是"几条主脉并成一条更大的主脉 ⇒ 原节点自动降级"是视图效果,不需要回写任何一行记忆 ——
1324
1587
  // 这正是"任何树操作都不改写记忆内容"这条核心不变量能成立的原因。
1325
1588
  // 档位:0=主干(恒 1 条);(0,0.25]=主脉;≤0.62=枝;其余=叶 ⇒ 最多 1 主干 / 2 主脉 / 3 枝 / 4 叶。
@@ -1357,7 +1620,7 @@ export class MemoryStore {
1357
1620
  convBranch: this.db.prepare('SELECT source, conv_id, branch_id FROM conv_branch').all(),
1358
1621
  links: this.db.prepare('SELECT from_branch, to_branch, kind, note FROM vein_link').all(),
1359
1622
  };
1360
- // 名字留空 → 自动命名;不写「未命名」(2026-09-22 用户:手工/自动 + 日期时间即可)
1623
+ // 名字留空 → 自动命名;不写「未命名」(2026-09-22 尝生:手工/自动 + 日期时间即可)
1361
1624
  const nm = String(name || '').trim() || (k === 'auto' ? '自动快照 - ' : '手动快照 - ') + localStamp();
1362
1625
  const r = this.db.prepare('INSERT INTO tree_snapshot (at,name,note,kind,data) VALUES (?,?,?,?,?)')
1363
1626
  .run(now, nm.slice(0, 80), String(note || '').slice(0, 300), k, JSON.stringify(data));
@@ -1741,7 +2004,7 @@ export class MemoryStore {
1741
2004
  }
1742
2005
 
1743
2006
  /** 检索侧降权映射:`"source\\u0000convId" → 系数`。
1744
- * 规则(用户 2026-09-20 定):**复盘裁定的按裁定;未复盘的以最新为准**(旧的一方降权)。
2007
+ * 规则(尝生 2026-09-20 定):**复盘裁定的按裁定;未复盘的以最新为准**(旧的一方降权)。
1745
2008
  * `duplicate` 不降权(两条内容相同,降谁都会丢信息);`dismissed` 一律不降权。 */
1746
2009
  conflictDowngradeMap() {
1747
2010
  const m = new Map();
@@ -1787,7 +2050,7 @@ export class MemoryStore {
1787
2050
  * 已归档会话 id 集合(2026-09-21,D9-b 用)。
1788
2051
  *
1789
2052
  * 归档 = 主人主动封存、不再参与日常召回的那批会话。矛盾扫描必须排除它们,
1790
- * 否则会拿几个月前已封存的对话互相判"矛盾",把待复盘队列塞满噪音(用户实测反馈)。
2053
+ * 否则会拿几个月前已封存的对话互相判"矛盾",把待复盘队列塞满噪音(尝生实测反馈)。
1791
2054
  * 一次取全量而非逐行查 session_meta —— scan 里要判上千条,逐行查是 O(n) 次 SQL。
1792
2055
  */
1793
2056
  archivedConvIdSet() {
@@ -1962,11 +2225,11 @@ export class MemoryStore {
1962
2225
  if (!body) return { ok: false, reason: 'empty-text' };
1963
2226
  const veinId = String(row.vein_id || '');
1964
2227
  if (!veinId) return { ok: false, reason: 'no-vein' };
1965
- // ── A-01(1.5.1 红蓝对抗;用户 2026-09-25 裁决「备份并覆盖」)──────────────────
2228
+ // ── A-01(1.5.1 红蓝对抗;尝生 2026-09-25 裁决「备份并覆盖」)──────────────────
1966
2229
  // 采纳写入的键是 `(source='vein', conv_id=<主脉 id>)` ⇒ **同一主脉的两条候选先后采纳
1967
2230
  // 会命中同一个 ON CONFLICT,后一条静默吃掉前一条**(无留痕、无归档 —— 数据真的没了;
1968
2231
  // 红队 A 与红队 E 各自独立复现)。
1969
- // 用户的裁决与理由:主脉记忆是一种**概括**,它理应随主脉本身一起成长 ⇒ **覆盖**是对的;
2232
+ // 尝生的裁决与理由:主脉记忆是一种**概括**,它理应随主脉本身一起成长 ⇒ **覆盖**是对的;
1970
2233
  // 但覆盖必须能回头 ⇒ 动手前把旧版**归档落盘**,归档失败就**拒绝覆盖**
1971
2234
  // (与 forget 那条"归档失败绝不打标记"同一条纪律),并把结果回传调用方去记总变动日志。
1972
2235
  let replaced = null;
@@ -2260,15 +2523,139 @@ export class MemoryStore {
2260
2523
  ).run(utcIso(), source, String(convId));
2261
2524
  }
2262
2525
 
2526
+ // ---- 投递留痕(1.6-A,2026-10-03)----
2527
+ /** 落一条**定稿注入**的留痕。调用方须自己 try/catch —— 这里是**仪表**,不是主流程:
2528
+ * 少记一条是可惜,因留痕而让注入失败是事故(与 `bumpHit` 同一取向)。
2529
+ * 入参一律宽容(缺项写默认):字段宁可空着,也不要为了"记全"把异常抛回注入路径。 */
2530
+ logInjection({
2531
+ sessionId = '', reason = '', mode = '', textHash = '', textChars = 0,
2532
+ l0Chars = 0, l1Chars = 0, budgetChars = 0, keywords = null, items = null,
2533
+ runnerUps = null, excluded = null, dropped = 0, note = '',
2534
+ } = {}) {
2535
+ const J = (v, dft) => JSON.stringify(v ?? dft);
2536
+ this.db.prepare(
2537
+ `INSERT INTO inject_log
2538
+ (at, session_id, reason, mode, text_hash, text_chars, l0_chars, l1_chars, budget_chars,
2539
+ keywords, items, runner_ups, excluded, dropped, note)
2540
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
2541
+ ).run(
2542
+ utcIso(), String(sessionId || ''), String(reason || ''), String(mode || ''),
2543
+ String(textHash || ''), Number(textChars) || 0, Number(l0Chars) || 0, Number(l1Chars) || 0,
2544
+ Number(budgetChars) || 0, J(keywords, []), J(items, []), J(runnerUps, []), J(excluded, {}),
2545
+ Number(dropped) || 0, String(note || ''),
2546
+ );
2547
+ }
2548
+
2549
+ /** 读最近若干条投递留痕(新→旧)。本轮只给测试与将来的 `recall(query, kind)` 用:
2550
+ * **不挂读面** —— 不加工具、不进注入面(1.6 交接 §4 硬约束①「投放入口最多 +1」)。 */
2551
+ listInjections({ limit = 20, sessionId = '', parse = true } = {}) {
2552
+ const n = Math.max(1, Math.min(500, Number(limit) || 20));
2553
+ const rows = sessionId
2554
+ ? this.db.prepare('SELECT * FROM inject_log WHERE session_id=? ORDER BY id DESC LIMIT ?').all(String(sessionId), n)
2555
+ : this.db.prepare('SELECT * FROM inject_log ORDER BY id DESC LIMIT ?').all(n);
2556
+ if (!parse) return rows;
2557
+ const j = (s, dft) => { try { return JSON.parse(s); } catch { return dft; } };
2558
+ return rows.map((r) => ({
2559
+ ...r,
2560
+ keywords: j(r.keywords, []),
2561
+ items: j(r.items, []),
2562
+ runner_ups: j(r.runner_ups, []),
2563
+ excluded: j(r.excluded, {}),
2564
+ }));
2565
+ }
2566
+
2567
+ // ---- 深层库条目(1.6 步 2/3,2026-10-04)----
2568
+ // 主人给的架构口径(逐字):「从浅层记忆库落入深层库的时候,就是从浅层库中提取关键的东西,落入深层库,
2569
+ // 把浅层库的那些什么工具调用,还有过时历史剥离掉……然后在召回的时候再重新装配」。
2570
+ // 三条语义位(见 DDL 注释,谁改都不许违反):hit_count **只由"真被采用"推进** · superseded_by **只追不替** ·
2571
+ // branch_id 是**气根**(枝侧反向引用,不动树结构)。
2572
+ /** 新增一条条目。**幂等判据 = (conv_id, seq_from, seq_to, text)** —— 同一段原文重复提炼不会写两遍
2573
+ * (与 appendRawTurn 的取向一致:提炼器可以安全重跑)。`origin='user'` 的条目由调用方自己保护,
2574
+ * 本方法只管写。返回 `{ok,id,existed,reason?}`:existed=true 表示撞上了已有条目。 */
2575
+ addDeepItem({ kind = '', text = '', at = '', convId = '', seqFrom = 0, seqTo = 0, src = '', origin = 'auto', branchId = '', durability = 'long' } = {}) {
2576
+ const k = String(kind || '').trim();
2577
+ const t = String(text || '').trim();
2578
+ if (!k || !t) return { ok: false, id: '', existed: false, reason: 'empty' };
2579
+ const cv = String(convId || '');
2580
+ const a = Number(seqFrom) || 0;
2581
+ const b = Number(seqTo) || 0;
2582
+ const dup = this.db.prepare('SELECT id FROM deep_item WHERE conv_id=? AND seq_from=? AND seq_to=? AND text=?').get(cv, a, b, t);
2583
+ if (dup) return { ok: true, id: dup.id, existed: true };
2584
+ const id = 'di:' + randomUUID();
2585
+ const srcV = String(src || '') || rawSourceOf(cv);
2586
+ // 硬度(1.6 v17):只认三档,其余一律按 'long' —— 不让一个拼错的档名把条目变成"永不进场"。
2587
+ const dur = ['durable', 'long', 'ephemeral'].includes(String(durability)) ? String(durability) : 'long';
2588
+ this.db.prepare(
2589
+ `INSERT INTO deep_item (id, kind, text, at, conv_id, seq_from, seq_to, src, origin, branch_id, durability, updated_at)
2590
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
2591
+ ).run(id, k, t, String(at || ''), cv, a, b, srcV, String(origin || 'auto'), String(branchId || ''), dur, utcIso());
2592
+ return { ok: true, id, existed: false };
2593
+ }
2594
+
2595
+ /** 读条目。默认**排除已被取代的**(`superseded_by` 非空)—— 那些是"曾经成立、现已作废"的留痕,
2596
+ * 主动召回不该再挑它;复盘历史时显式 `includeSuperseded:true`。排面按置顶→加固→新。 */
2597
+ listDeepItems({ kind = '', convId = '', branchId = '', limit = 50, includeSuperseded = false } = {}) {
2598
+ const n = Math.max(1, Math.min(1000, Number(limit) || 50));
2599
+ const w = [];
2600
+ const p = [];
2601
+ if (kind) { w.push('kind=?'); p.push(String(kind)); }
2602
+ if (convId) { w.push('conv_id=?'); p.push(String(convId)); }
2603
+ if (branchId) { w.push('branch_id=?'); p.push(String(branchId)); }
2604
+ if (!includeSuperseded) w.push("superseded_by=''");
2605
+ const sql = `SELECT * FROM deep_item${w.length ? ' WHERE ' + w.join(' AND ') : ''}`
2606
+ + ' ORDER BY pinned DESC, hit_count DESC, created_at DESC LIMIT ?';
2607
+ return this.db.prepare(sql).all(...p, n);
2608
+ }
2609
+
2610
+ deepItemById(id) {
2611
+ return this.db.prepare('SELECT * FROM deep_item WHERE id=?').get(String(id));
2612
+ }
2613
+
2614
+ /** 取代:**只追不替** —— 旧的标上 `superseded_by`,**不删**(复盘时要能看见"当初是怎么定的")。 */
2615
+ supersedeDeepItem(oldId, newId) {
2616
+ const o = this.deepItemById(oldId);
2617
+ const nw = this.deepItemById(newId);
2618
+ if (!o || !nw) return { ok: false, reason: 'not-found' };
2619
+ this.db.prepare('UPDATE deep_item SET superseded_by=?, updated_at=? WHERE id=?')
2620
+ .run(String(newId), utcIso(), String(oldId));
2621
+ return { ok: true, id: String(oldId), by: String(newId) };
2622
+ }
2623
+
2624
+ /** 加固 +1。⚠️ **只该由"真被采用"调用** —— 注入不算(见列注释:注入就加分会形成自我强化回路)。
2625
+ * 采用信号的首选 = `recall` 取过它指向的原文(那条路已在投递留痕面内)。 */
2626
+ bumpDeepHit(id) {
2627
+ const r = this.db.prepare('UPDATE deep_item SET hit_count=hit_count+1, updated_at=? WHERE id=?')
2628
+ .run(utcIso(), String(id));
2629
+ return { ok: r.changes > 0 };
2630
+ }
2631
+
2632
+ setDeepPinned(id, pinned = true) {
2633
+ const r = this.db.prepare('UPDATE deep_item SET pinned=?, updated_at=? WHERE id=?')
2634
+ .run(pinned ? 1 : 0, utcIso(), String(id));
2635
+ return { ok: r.changes > 0, pinned: pinned ? 1 : 0 };
2636
+ }
2637
+
2638
+ deepItemCount() {
2639
+ return Number(this.db.prepare('SELECT COUNT(*) AS n FROM deep_item').get()?.n) || 0;
2640
+ }
2641
+
2263
2642
  // ---- raw turns (DSH incremental capture, passive) ----
2264
- appendRawTurn(sessionId, { seq, role, ts, model, text }) {
2643
+ /** 追加一条原文轮次(幂等:(session_id,seq) 冲突时更新正文)。
2644
+ * v14:`source` 记**这条原文属于哪个来源**(取值口径见 RAW_SOURCE_DSH 段)。缺省 null ⇒ 按 id
2645
+ * 形状推导(rawSourceOf)—— 与读者对空 source 的回落判据**同解**,故老调用点一个字不改也写不错;
2646
+ * 但**知道来源的调用方应当显式给**(lifecycle 给 'dsh'、导入给 'import'):那才是这一列的意义。 */
2647
+ appendRawTurn(sessionId, { seq, role, ts, model, text, source = null }) {
2265
2648
  if (!sessionId || typeof text !== 'string' || !text) return;
2649
+ const src = normalizeRawSource(source) || rawSourceOf(sessionId);
2266
2650
  this.db.prepare(
2267
- `INSERT INTO dsh_turns_raw (session_id, seq, role, ts, model, text)
2268
- VALUES (?,?,?,?,?,?)
2269
- ON CONFLICT(session_id, seq) DO UPDATE SET text=excluded.text`,
2651
+ `INSERT INTO dsh_turns_raw (session_id, seq, role, ts, model, text, source)
2652
+ VALUES (?,?,?,?,?,?,?)
2653
+ ON CONFLICT(session_id, seq) DO UPDATE SET text=excluded.text,
2654
+ source=COALESCE(dsh_turns_raw.source, excluded.source)`,
2655
+ // 冲突分支的来源取 COALESCE(首写为准):同一轮被重扫/重导时**不把已记录的来源降级成推断值**
2656
+ // (例如一行已被显式记成某个外来来源,重扫不该把它改回 'dsh');而 NULL 的存量行会被填上。
2270
2657
  // 写侧归一(修 A):一律存 ISO;数字/浮点串(如 "1789006881011.0")也归一后再落库。
2271
- ).run(String(sessionId), Number(seq), String(role ?? '?'), toIso(ts), model || null, text);
2658
+ ).run(String(sessionId), Number(seq), String(role ?? '?'), toIso(ts), model || null, text, src || null);
2272
2659
  this.db.prepare(
2273
2660
  'INSERT INTO session_meta(session_id, raw_seq) VALUES(?,?) ON CONFLICT(session_id) DO UPDATE SET raw_seq=excluded.raw_seq',
2274
2661
  ).run(String(sessionId), Number(seq));
@@ -2276,6 +2663,54 @@ export class MemoryStore {
2276
2663
  this.db.prepare('UPDATE session_meta SET archived=0 WHERE session_id=? AND archived=1').run(String(sessionId));
2277
2664
  }
2278
2665
 
2666
+ /** 存量行来源回填(v14,**可选**动作,2026-10-01)。**只有显式调用才会跑** —— 构造器与概述器
2667
+ * 都不会自己碰它:这是唯一会**写历史行**的动作,偷偷跑等于让这一列的可信度打折(读者会以为
2668
+ * 每行的来源都是写入当时记下来的)。规则(顺序不可调):
2669
+ * ① 只填 `source IS NULL` 或空串的行 —— **已有的非空来源绝不覆盖**(记录优先于推断);
2670
+ * ② 值按 session_id 推导(rawSourceOf:裸 id ⇒ 'dsh';`<ns>:<id>` ⇒ 'ns');
2671
+ * ③ 幂等(第二遍 filled=0);④ 汇报条数(filled / 按来源分组 / 推不出来的 unresolved)。
2672
+ * ⚠️ 回填只给**已经存在的行**加一个字段,**不改变任何裁决** —— 推导口径与读者的空 source 回落
2673
+ * 判据同解(裸 id ⇒ dsh、带前缀 ⇒ 排除),迁移前后同一份库的概述集逐字相同。
2674
+ * ⚠️ 代价(明说):历史行的 source 是**事后推断**的,不是当场记的 —— 若某个老来源的 id 恰好是裸
2675
+ * 形状(不带冒号),回填会把它**固化成 'dsh'**,此后再也分不出来。故本动作须由人显式触发。
2676
+ * @returns {{ok:boolean,filled:number,scanned:number,bySource:Object,unresolved:number}} */
2677
+ backfillRawSource() {
2678
+ const out = { ok: true, filled: 0, scanned: 0, bySource: {}, unresolved: 0 };
2679
+ try {
2680
+ const rows = this.db.prepare(
2681
+ "SELECT session_id, COUNT(*) AS n FROM dsh_turns_raw WHERE source IS NULL OR TRIM(source)='' GROUP BY session_id",
2682
+ ).all();
2683
+ const upd = this.db.prepare(
2684
+ "UPDATE dsh_turns_raw SET source=? WHERE session_id=? AND (source IS NULL OR TRIM(source)='')",
2685
+ );
2686
+ this.db.exec('BEGIN');
2687
+ for (const r of rows) {
2688
+ const sid = String(r?.session_id ?? '');
2689
+ const n = Number(r?.n) || 0;
2690
+ out.scanned += n;
2691
+ const src = rawSourceOf(sid); // 裸 id ⇒ 'dsh';'<ns>:<id>' ⇒ 'ns'
2692
+ if (!src) { out.unresolved += n; continue; } // id 以冒号开头(推不出来源名)⇒ 留 NULL,交给读者回落/告警
2693
+ const res = upd.run(src, sid);
2694
+ const filled = Number(res?.changes || 0);
2695
+ out.filled += filled;
2696
+ out.bySource[src] = (out.bySource[src] || 0) + filled;
2697
+ }
2698
+ this.db.exec('COMMIT');
2699
+ } catch (e) {
2700
+ try { this.db.exec('ROLLBACK'); } catch { /* ignore */ }
2701
+ return { ...out, ok: false, message: String(e?.message ?? e).slice(0, 160) };
2702
+ }
2703
+ return out;
2704
+ }
2705
+
2706
+ /** 来源分布(只读;给报告/诊断用):`{[source]: 行数}`,NULL 记作 '(null)'。 */
2707
+ rawSourceCounts() {
2708
+ const rows = this.db.prepare(
2709
+ "SELECT COALESCE(NULLIF(TRIM(source),''),'(null)') AS s, COUNT(*) AS n FROM dsh_turns_raw GROUP BY s ORDER BY n DESC",
2710
+ ).all();
2711
+ return Object.fromEntries(rows.map((r) => [String(r.s), Number(r.n)]));
2712
+ }
2713
+
2279
2714
  rawTurnCount(sessionId) {
2280
2715
  const r = this.db.prepare('SELECT COUNT(*) AS n FROM dsh_turns_raw WHERE session_id=?').get(String(sessionId));
2281
2716
  return r ? Number(r.n) : 0;
@@ -2518,7 +2953,7 @@ export class MemoryStore {
2518
2953
  * ⚠️ 代价是 0/0 也是"合法键值",所以下面三个缓存字段**只在重算真正完成后才推**。
2519
2954
  * ⚠️ 别把这两个字段的存在当成"守卫一定在工作":`statSync` 抽风/漏 import 时它恒返 [0,0],
2520
2955
  * 面板照旧显示、测试照旧全绿,而守卫是**静默空转**的(本轮就踩过:忘了 import statSync,
2521
- * ReferenceError 被本方法的 catch 吞掉)。要确认它活着,见 _probe/tmp-fp-wal-guard.mjs 的
2956
+ * ReferenceError 被本方法的 catch 吞掉)。要确认它活着,得跑一次性运行时探针的
2522
2957
  * 【陷阱守卫】断言:它必须返回真实 size/mtime,不能是 [0,0]。 */
2523
2958
  _walStamp() {
2524
2959
  try {
@@ -2532,7 +2967,7 @@ export class MemoryStore {
2532
2967
  /** data_version 守卫(1.5.2):缓存键的**第三**来源,专补 `-wal` 那一维的盲区。
2533
2968
  * 上面的 `_walStamp()` 有个洞:维护/备份脚本若用 `journal_mode=DELETE` 改库,**根本没有 `-wal`
2534
2969
  * 文件**,stat 恒返 [0,0] 哨兵 ⇒ 外进程改完面板还是旧指纹。`PRAGMA data_version` 正好互补
2535
- * (实测见 _probe/tmp-dv-semantics.mjs,数值原样):
2970
+ * (一次性运行时探针实测,数值原样):
2536
2971
  * · 本连接自己写 ⇒ **不变**(2→2) ⇒ 它**不能**替代 `total_changes()`,只补位;
2537
2972
  * · 别的连接提交 ⇒ **变**(2→3;DELETE 模式 1→2);
2538
2973
  * · 外连接写完立刻 `wal_checkpoint(TRUNCATE)` 把 -wal 截成 0 ⇒ **照样变**(3→4);