@nightpainters/dsh-ling 1.5.3 → 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 = 14; // v14:dsh_turns_raw.source(来源从"推断"变"记录",2026-10-01 主人定「来源也加个列吧」—— 旧判据是"裸 id = DSH 会话",边界是"将来若出现不带冒号的新来源名,物理上分不出来");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; // 同枝
@@ -65,6 +65,18 @@ export const RAW_IMPORT_PREFIX = 'import' + RAW_NS_SEP;
65
65
  * ⚠️ 写侧一律落**非空**来源,故**新行永远不靠猜**;靠猜的只有历史行与"写入者没给来源"的缺省。 */
66
66
  export const RAW_SOURCE_DSH = 'dsh';
67
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
+
68
80
  /** 来源名归一:只做 TRIM(与读取侧 SQL 的 `TRIM(source)` **同解**,两形态必须逐字同判)。
69
81
  * ⚠️ 刻意不做别的花样(不去尾冒号、不转小写):归一每多一条规则,SQL 形态就得跟着模仿一条,
70
82
  * 两处一旦漂移就会出现"JS 说在范围内、SQL 说不在"这种最难查的分歧。写侧本来就不会给出
@@ -253,6 +265,76 @@ CREATE TABLE IF NOT EXISTS forgotten (
253
265
  actor TEXT NOT NULL DEFAULT 'user',
254
266
  PRIMARY KEY (source, conv_id)
255
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 之后建),新库与老库两条路都覆盖。
256
338
  CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL);
257
339
  `;
258
340
 
@@ -314,7 +396,7 @@ function migrateTitleCols(db) {
314
396
  }
315
397
  }
316
398
 
317
- /** v4→v5:记忆分枝(D9-a,2026-09-18 用户定方向)。
399
+ /** v4→v5:记忆分枝(D9-a,2026-09-18 尝生定方向)。
318
400
  * session_meta 加 branch_id(默认 'trunk' —— 存量会话天然属主干,迁移只填一列);
319
401
  * 建 branch 表并保证主干行存在。**记忆行不冗余存 branch**,靠 session → branch 推导:
320
402
  * 迁移成本最低,且枝归属变化时无需回写海量记忆行。 */
@@ -369,6 +451,35 @@ function migrateLogActor(db) {
369
451
  }
370
452
  }
371
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
+
372
483
  /** v13→v14:dsh_turns_raw 增列 source(来源从**推断**变**记录**,2026-10-01 主人定)。
373
484
  *
374
485
  * 为什么必须是**纯增列**:这张表是记忆的**原文底账**(真机库 5062 轮 / 104 会话),任何
@@ -423,8 +534,12 @@ export class MemoryStore {
423
534
  ensureDir(dbPath.slice(0, Math.max(0, dbPath.lastIndexOf('/'), dbPath.lastIndexOf('\\'))) || '.');
424
535
  this.dbPath = dbPath; // archive.js 用它推导归档目录(与库同级)
425
536
  this.db = new DatabaseSync(dbPath);
426
- 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 构造成功)。
427
541
  this.db.exec('PRAGMA busy_timeout=15000;');
542
+ this.db.exec('PRAGMA journal_mode=WAL;');
428
543
  this.db.exec(DDL);
429
544
  migrateSourceCheck(this.db); // v1→v2:去掉 conv_overview.source 的两源 CHECK
430
545
  migrateTitleCols(this.db); // v2→v4:title_locked + title_by(主人改过的标题不被机器覆盖)
@@ -432,6 +547,7 @@ export class MemoryStore {
432
547
  migrateSnapshotKind(this.db); // v9→v10:tree_snapshot.kind(区分自动备份与手动快照,审计 A#9)
433
548
  migrateLogActor(this.db); // v11→v12:branch_log.actor(区分"谁改的树",Part G G4)
434
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)
435
551
  this._kvGet = this.db.prepare('SELECT value FROM kv WHERE key=?');
436
552
  this._kvSet = this.db.prepare('INSERT INTO kv(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value');
437
553
  // 迁移**自证**(2026-10-01):纯增列理论上不会失败,但万一 ALTER 被库锁/权限挡下(它会静默 catch),
@@ -445,7 +561,12 @@ export class MemoryStore {
445
561
  // 它**一定**抛,而它只是"留一条可查的痕迹",不是主流程。修前异常一路冒到 lib/index.js 的 apply() ⇒ **整个插件
446
562
  // 加载失败**,那条为迁移失败准备的降级路径(本 else 分支 + 读侧回落"裸 id"判据)反而永远走不到(形同死代码)。
447
563
  // 只用 try 护**这一笔诊断写入**,不把 kvSet 整体改成吞异常(那会把别处的真错误一起吞掉);
448
- // "库真的打不开/表都建不出来"这类错误仍照旧往上抛(见构造器开头,DLL/DDL 未做任何兜底)。
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)。
449
570
  try {
450
571
  this.kvSet('dbg.raw_source_col', 'missing');
451
572
  } catch (e) {
@@ -461,7 +582,7 @@ export class MemoryStore {
461
582
  // v10 加 tree_snapshot.kind(ALTER,见 migrateSnapshotKind)。
462
583
  // v14 加 dsh_turns_raw.source(ALTER,见 migrateRawSource);失败时 schema_version **不前进**,
463
584
  // 下一次启动还会再试一遍(探列幂等 ⇒ 成功即跳过)。
464
- if (rawSourceOk && this.kvGet('schema_version') !== String(SCHEMA_VERSION)) this.kvSet('schema_version', String(SCHEMA_VERSION));
585
+ if (rawSourceOk && deepDurOk && this.kvGet('schema_version') !== String(SCHEMA_VERSION)) this.kvSet('schema_version', String(SCHEMA_VERSION));
465
586
  }
466
587
 
467
588
  close() {
@@ -802,7 +923,10 @@ export class MemoryStore {
802
923
 
803
924
  renameBranch(branchId, name, { lock = true } = {}) {
804
925
  const bid = String(branchId || '');
805
- 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);
806
930
  if (!bid) return { ok: false, reason: 'no-branch' };
807
931
  if (!nm) return { ok: false, reason: 'empty-name' };
808
932
  this.db.prepare('UPDATE branch SET name=?, name_locked=?, updated_at=? WHERE id=?')
@@ -819,8 +943,13 @@ export class MemoryStore {
819
943
  * 让它真正生效属于**改变检索语义**,需主人拍板(方案 a),不在本次审计修法内。 */
820
944
  setBranchWeight(branchId, scale) {
821
945
  const bid = String(branchId || '');
822
- const v = Number(scale);
823
- 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' };
824
953
  const clamped = Math.max(0, Math.min(2, v));
825
954
  this.db.prepare('UPDATE branch SET weight_scale=?, updated_at=? WHERE id=?').run(clamped, utcIso(), bid);
826
955
  return {
@@ -829,11 +958,22 @@ export class MemoryStore {
829
958
  };
830
959
  }
831
960
 
832
- /** 连边:两条枝之间的横向关联(如 力学 ↔ 材料科学)。 */
961
+ /** 连边:两条枝之间的横向关联(如 力学 ↔ 材料科学)。
962
+ * 反向是否合并看 kind —— 见顶层的 `DIRECTED_LINK_KINDS`。 */
833
963
  linkVeins(fromBranch, toBranch, { kind = 'related', note = '' } = {}) {
834
964
  const a = String(fromBranch || '');
835
965
  const b = String(toBranch || '');
836
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
+ }
837
977
  this.db.prepare(`INSERT INTO vein_link (from_branch,to_branch,kind,note,created_at)
838
978
  VALUES (?,?,?,?,?)
839
979
  ON CONFLICT(from_branch,to_branch) DO UPDATE SET kind=excluded.kind, note=excluded.note`)
@@ -843,8 +983,13 @@ export class MemoryStore {
843
983
 
844
984
  unlinkVein(fromBranch, toBranch) {
845
985
  try {
846
- const r = this.db.prepare('DELETE FROM vein_link WHERE from_branch=? AND to_branch=?')
847
- .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);
848
993
  return { ok: true, removed: Number(r.changes || 0) };
849
994
  } catch {
850
995
  return { ok: false, reason: 'db' };
@@ -867,7 +1012,7 @@ export class MemoryStore {
867
1012
 
868
1013
  // ---- 软遗忘(Part G G2,2026-09-25) ----
869
1014
  //
870
- // 原则(用户 2026-09-25 定):**永远不做彻底删除**。遗忘 = 先把原数据归档落盘(原文 + 概述 +
1015
+ // 原则(尝生 2026-09-25 定):**永远不做彻底删除**。遗忘 = 先把原数据归档落盘(原文 + 概述 +
871
1016
  // 改动日志),再在库里打一个标记,召回路径据标记排除。行仍在库里、归档文件在磁盘上 ——
872
1017
  // 两条独立还原路径,任何一条都能反悔。
873
1018
  //
@@ -901,24 +1046,39 @@ export class MemoryStore {
901
1046
  * ⚠️ 仍然缺的一环在**调用侧**(`tools.js` 的 forget handler):它只判 `arc.ok`,
902
1047
  * 不判 `arc.empty` ⇒ 想彻底堵住 B-05 还得在那儿加一句守卫(见审计台账)。
903
1048
  */
904
- forgetEntry({ source, convId, reason = '', archivePath = '', actor = 'user', archiveTurns = null } = {}) {
1049
+ forgetEntry({ source, convId, reason = '', archivePath = '', actor = 'user', archiveTurns = null, archiveEmpty = undefined } = {}) {
905
1050
  const s = String(source || '').trim();
906
1051
  const c = String(convId || '').trim();
907
1052
  if (!s || !c) return { ok: false, reason: 'bad-key' };
908
1053
  const dir = String(archivePath || '').trim();
909
1054
  if (!dir) return { ok: false, reason: 'empty-archive', message: '没有归档路径 —— 先归档再打标记' };
910
- if (archiveTurns === null || archiveTurns === undefined) {
911
- let n = 0;
912
- try {
913
- n = existsSync(join(dir, 'turns.jsonl'))
914
- ? readFileSync(join(dir, 'turns.jsonl'), 'utf8').split('\n').filter((l) => l.trim()).length
915
- : 0;
916
- } catch { n = 0; }
917
- if (n === 0) {
918
- 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
+ }
919
1081
  }
920
- } else if (!(Number(archiveTurns) > 0)) {
921
- return { ok: false, reason: 'empty-archive', message: '归档里没有原文(0 轮)', archivePath: dir };
922
1082
  }
923
1083
  const oneLine = (x, n) => String(x == null ? '' : x).replace(/[\r\n\t]+/g, ' ').trim().slice(0, n);
924
1084
  try {
@@ -982,7 +1142,7 @@ export class MemoryStore {
982
1142
  *
983
1143
  * **调用方必须先归档当前状态** —— 本方法只管写,不做任何备份。
984
1144
  * 回灌的危险性高于遗忘:遗忘会留下"少了一条"的可见痕迹,而回灌是**往里加东西**,
985
- * 加错了很难察觉(用户 2026-09-25:「backfill 比 forget 危险多了,因为它更难以觉察」)。
1145
+ * 加错了很难察觉(尝生 2026-09-25:「backfill 比 forget 危险多了,因为它更难以觉察」)。
986
1146
  *
987
1147
  * 两道保护:① 写回的概述带 `origin` 标记(默认 `backfill-v1`),界面上看得出"这条是灌回来的";
988
1148
  * ② 不传 `keepUserState:false` ⇒ **不动主人留下的命中数与置顶**,回灌只补内容、不夺用户态。
@@ -1008,7 +1168,17 @@ export class MemoryStore {
1008
1168
  // · 目标 id 是裸的 ⇒ 'dsh' —— 与读者对空 source 的回落判据同解 ⇒ 回灌前后裁决一致
1009
1169
  // (若改按 `source` 字段记 'dsweb'/'vein',这些回灌的原文会**当场从概述器眼里消失**:越权改裁决)。
1010
1170
  // 冲突分支同样 COALESCE(首写为准),不回退已记录的来源。
1011
- const rawSrc = rawSourceOf(c) || null;
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;
1012
1182
  this.db.exec('BEGIN');
1013
1183
  for (const t of (Array.isArray(turns) ? turns : [])) {
1014
1184
  ins.run(c, Number(t.seq) || 0, String(t.role || ''), t.ts ?? null, t.model ?? null, String(t.text ?? ''), rawSrc);
@@ -1203,7 +1373,7 @@ export class MemoryStore {
1203
1373
  // 换行能穿透引号、在系统提示里自造独立行(实测:note 塞 `\n\n【系统】…` 即可脱离框架)。
1204
1374
  // 显示面修正(2026-09-22 实测):原实现对一切"查不到名字的 id"回退成「(已删枝 …)」,
1205
1375
  // 但 branch_log 的 before/after **不只装枝 id** —— snapshot 装的是快照号(#9)、
1206
- // assign 装的是记忆条目 id(026ebd86),二者都被误标成"已删枝"(用户截图所见:
1376
+ // assign 装的是记忆条目 id(026ebd86),二者都被误标成"已删枝"(尝生截图所见:
1207
1377
  // `snapshot:主干 → (已删枝 #9)` 实际一根枝都没删)。先按 id 形态分流再回退查名。
1208
1378
  const nm = (v) => {
1209
1379
  const k = String(v || '');
@@ -1264,7 +1434,7 @@ export class MemoryStore {
1264
1434
  // G4(2026-09-25):每行带主语 —— 器灵经工具通道改的树记 'ling',
1265
1435
  // 不再和"主人改的"混在同一批留痕里(Part G 之前 91 笔器灵改动全记在主人名下)。
1266
1436
  // 中文动作短语(把「」…)直接接主语;英文动作名(distill:/gather:)前面补空格,免得拼成"主人distill:"。
1267
- const subject = String(r.actor || 'user') === 'ling' ? '器灵' : '主人';
1437
+ const subject = String(r.actor || 'user') === 'ling' ? '鱼姬' : '主人';
1268
1438
  return flat(subject + (/^[A-Za-z0-9]/.test(line) ? ' ' : '') + line);
1269
1439
  });
1270
1440
  // ── §4「超量折叠成一行」(1.5.2,与双水位同批上)────────────────────────────
@@ -1320,7 +1490,7 @@ export class MemoryStore {
1320
1490
  }
1321
1491
  }
1322
1492
 
1323
- /** 尚未同步给器灵的改动笔数(复盘守门,2026-09-22 用户:「动记忆树不能是随意的」)。
1493
+ /** 尚未同步给器灵的改动笔数(复盘守门,2026-09-22 尝生:「动记忆树不能是随意的」)。
1324
1494
  *
1325
1495
  * 判据:自器灵**上一次开口**(注入推进水位)以来累积了多少笔结构改动。
1326
1496
  * 水位取 `branch_log_wm.last`(由 `inject.js` 的 advanceLogWatermark 在投递时写入),
@@ -1341,7 +1511,7 @@ export class MemoryStore {
1341
1511
  let pending = 0;
1342
1512
  let maxId = 0;
1343
1513
  try {
1344
- // 'snapshot'(保存快照)不计入待交代笔数(2026-09-25 用户:「单纯保存不弹修改」)。
1514
+ // 'snapshot'(保存快照)不计入待交代笔数(2026-09-25 尝生:「单纯保存不弹修改」)。
1345
1515
  // 依据:保存快照**不改树**——它只是把当前结构另存一份,是"大改之前的保险",
1346
1516
  // 不是"改动"。把它算成债会让主人每存一次就看到「⚠ 有 N 笔改动还没跟器灵说过」,
1347
1517
  // 而实际上树一个字节没变。留痕仍写进 branch_log(审计价值保留),只是不推进债。
@@ -1412,7 +1582,7 @@ export class MemoryStore {
1412
1582
  if (p && n.id !== TRUNK_ID) p.children.push(n);
1413
1583
  else roots.push(n);
1414
1584
  }
1415
- // 层级 = **计算属性**(用户 2026-09-19 定):按相对深度切,树长高时自动重算,**零回写**。
1585
+ // 层级 = **计算属性**(尝生 2026-09-19 定):按相对深度切,树长高时自动重算,**零回写**。
1416
1586
  // 于是"几条主脉并成一条更大的主脉 ⇒ 原节点自动降级"是视图效果,不需要回写任何一行记忆 ——
1417
1587
  // 这正是"任何树操作都不改写记忆内容"这条核心不变量能成立的原因。
1418
1588
  // 档位:0=主干(恒 1 条);(0,0.25]=主脉;≤0.62=枝;其余=叶 ⇒ 最多 1 主干 / 2 主脉 / 3 枝 / 4 叶。
@@ -1450,7 +1620,7 @@ export class MemoryStore {
1450
1620
  convBranch: this.db.prepare('SELECT source, conv_id, branch_id FROM conv_branch').all(),
1451
1621
  links: this.db.prepare('SELECT from_branch, to_branch, kind, note FROM vein_link').all(),
1452
1622
  };
1453
- // 名字留空 → 自动命名;不写「未命名」(2026-09-22 用户:手工/自动 + 日期时间即可)
1623
+ // 名字留空 → 自动命名;不写「未命名」(2026-09-22 尝生:手工/自动 + 日期时间即可)
1454
1624
  const nm = String(name || '').trim() || (k === 'auto' ? '自动快照 - ' : '手动快照 - ') + localStamp();
1455
1625
  const r = this.db.prepare('INSERT INTO tree_snapshot (at,name,note,kind,data) VALUES (?,?,?,?,?)')
1456
1626
  .run(now, nm.slice(0, 80), String(note || '').slice(0, 300), k, JSON.stringify(data));
@@ -1834,7 +2004,7 @@ export class MemoryStore {
1834
2004
  }
1835
2005
 
1836
2006
  /** 检索侧降权映射:`"source\\u0000convId" → 系数`。
1837
- * 规则(用户 2026-09-20 定):**复盘裁定的按裁定;未复盘的以最新为准**(旧的一方降权)。
2007
+ * 规则(尝生 2026-09-20 定):**复盘裁定的按裁定;未复盘的以最新为准**(旧的一方降权)。
1838
2008
  * `duplicate` 不降权(两条内容相同,降谁都会丢信息);`dismissed` 一律不降权。 */
1839
2009
  conflictDowngradeMap() {
1840
2010
  const m = new Map();
@@ -1880,7 +2050,7 @@ export class MemoryStore {
1880
2050
  * 已归档会话 id 集合(2026-09-21,D9-b 用)。
1881
2051
  *
1882
2052
  * 归档 = 主人主动封存、不再参与日常召回的那批会话。矛盾扫描必须排除它们,
1883
- * 否则会拿几个月前已封存的对话互相判"矛盾",把待复盘队列塞满噪音(用户实测反馈)。
2053
+ * 否则会拿几个月前已封存的对话互相判"矛盾",把待复盘队列塞满噪音(尝生实测反馈)。
1884
2054
  * 一次取全量而非逐行查 session_meta —— scan 里要判上千条,逐行查是 O(n) 次 SQL。
1885
2055
  */
1886
2056
  archivedConvIdSet() {
@@ -2055,11 +2225,11 @@ export class MemoryStore {
2055
2225
  if (!body) return { ok: false, reason: 'empty-text' };
2056
2226
  const veinId = String(row.vein_id || '');
2057
2227
  if (!veinId) return { ok: false, reason: 'no-vein' };
2058
- // ── A-01(1.5.1 红蓝对抗;用户 2026-09-25 裁决「备份并覆盖」)──────────────────
2228
+ // ── A-01(1.5.1 红蓝对抗;尝生 2026-09-25 裁决「备份并覆盖」)──────────────────
2059
2229
  // 采纳写入的键是 `(source='vein', conv_id=<主脉 id>)` ⇒ **同一主脉的两条候选先后采纳
2060
2230
  // 会命中同一个 ON CONFLICT,后一条静默吃掉前一条**(无留痕、无归档 —— 数据真的没了;
2061
2231
  // 红队 A 与红队 E 各自独立复现)。
2062
- // 用户的裁决与理由:主脉记忆是一种**概括**,它理应随主脉本身一起成长 ⇒ **覆盖**是对的;
2232
+ // 尝生的裁决与理由:主脉记忆是一种**概括**,它理应随主脉本身一起成长 ⇒ **覆盖**是对的;
2063
2233
  // 但覆盖必须能回头 ⇒ 动手前把旧版**归档落盘**,归档失败就**拒绝覆盖**
2064
2234
  // (与 forget 那条"归档失败绝不打标记"同一条纪律),并把结果回传调用方去记总变动日志。
2065
2235
  let replaced = null;
@@ -2353,6 +2523,122 @@ export class MemoryStore {
2353
2523
  ).run(utcIso(), source, String(convId));
2354
2524
  }
2355
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
+
2356
2642
  // ---- raw turns (DSH incremental capture, passive) ----
2357
2643
  /** 追加一条原文轮次(幂等:(session_id,seq) 冲突时更新正文)。
2358
2644
  * v14:`source` 记**这条原文属于哪个来源**(取值口径见 RAW_SOURCE_DSH 段)。缺省 null ⇒ 按 id
@@ -63,8 +63,8 @@ export const DEFAULT_SETTINGS = Object.freeze({
63
63
  },
64
64
  memory: {
65
65
  l1Enabled: true,
66
- l1BudgetTokens: 1200,
67
- l1MaxItems: 8,
66
+ l1BudgetTokens: 720, // 1.6 步 5:1200 → 720(「瘦身到现在的 60%」,尝生 2026-10-04 拍)
67
+ l1MaxItems: 10, // 1.6:8 → 10(尝生 2026-10-04:「8 条也可以考虑改 10 条,其中 2 条作为最硬的事实的席位」)
68
68
  l1MaxLineChars: 180, // 兜底硬顶:只对病态长标题生效(截断优先落在句末)
69
69
  l1SummaryChars: 110, // 摘要段预算:第一句超此长度才硬截并标 …(2026-09-16 与用户定案)
70
70
  categoryWeights: { // mode -> 领域类别权重(D6,先验倾向)