@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.
@@ -7,10 +7,11 @@ import { assemblePersona } from './persona.js';
7
7
  import { selectL1, formatL1Section } from './l1.js';
8
8
  import { currentMode } from './mode.js';
9
9
  import { neutralizeMustache } from './util.js';
10
+ import { createHash } from 'node:crypto';
10
11
 
11
12
  // §4「超量折叠成一行」的那行计数(由 `memory.js` 的 branchLogDigest 在明细之后 push 进 `lines`)
12
13
  // —— 它是**汇总**,不是明细。零明细时它撑不起整段(见下方渲染判据);有明细时照旧显示。
13
- // 判据只用开头:明细行一律以主语(主人/器灵)或动作词开头,不会长成 `…另有 N 笔改动`。
14
+ // 判据只用开头:明细行一律以主语(主人/鱼姬)或动作词开头,不会长成 `…另有 N 笔改动`。
14
15
  // 若哪天 memory.js 改了措辞,这里退化成"照旧渲染"(fail-open)—— 只影响占不占地方,不吞明细。
15
16
  const BRANCH_LOG_FOLD_LINE = /^…另有 \d+ 笔改动/;
16
17
 
@@ -19,9 +20,13 @@ export function buildSnapshotText(ctx, memory, settings, sessionId, { keywords =
19
20
  const personaEnabled = s.persona?.enabled !== false;
20
21
  const mode = currentMode(memory, settings, sessionId);
21
22
  const parts = [];
23
+ // 1.6-A 留痕:两段字数各自记 —— 只记总字数,回答不了"是谁在吃预算"。
24
+ let l0Text = '';
25
+ let l1Text = '';
26
+ let l1Result = null;
22
27
  if (personaEnabled) {
23
28
  const l0 = assemblePersona(s, mode);
24
- if (l0) parts.push(l0);
29
+ if (l0) { l0Text = l0; parts.push(l0); }
25
30
  }
26
31
  if (personaEnabled && s.memory?.l1Enabled !== false && memory.listOverviews({ onlyOk: true }).length > 0) {
27
32
  const cfg = s.memory || {};
@@ -34,17 +39,19 @@ export function buildSnapshotText(ctx, memory, settings, sessionId, { keywords =
34
39
  maxLineChars: cfg.l1MaxLineChars,
35
40
  summaryChars: cfg.l1SummaryChars,
36
41
  summaryBonus: cfg.summaryBonus,
37
- budgetChars: Math.max(200, Math.round((cfg.l1BudgetTokens ?? 1200) * 1.4)), // 中文≈1.4字/token 保守
42
+ budgetChars: Math.max(200, Math.round((cfg.l1BudgetTokens ?? 720) * 1.4)), // 中文≈1.4字/token 保守;缺省与 persona.js 同值(1.6 步 5)
38
43
  });
39
- // 命中记录(2026-09-16):只有**首次定稿**传 bumpHits,重建/空闲刷新不重复计 ——
40
- // 否则《会话变长 → 反复刷新》会把同一批记忆的 hit_count 刷高,把热度算歪。
41
- if (bumpHits && Array.isArray(result.items) && result.items.length) {
42
- try {
43
- for (const it of result.items) memory.bumpHit(it.source, it.conv_id);
44
- } catch { /* 记不上不影响注入 */ }
45
- }
46
- const l1 = formatL1Section(result);
47
- if (l1) parts.push(l1);
44
+ // ⚠️ 1.6 步 5 换料后:**命中记录刻意不再写**。
45
+ // 旧代码是 `for (const it of result.items) memory.bumpHit(it.source, it.conv_id);` ——
46
+ // 它推进的是 `conv_overview.hit_count`(概述行的"被想起来几次"),而现在的 items 是
47
+ // **深层库条目**,那是另一张表、另一个语义。条目的 `hit_count` 有明确语义位:
48
+ // **只该由"真被采用"推进,注入不算** —— 否则会形成"注入越多 → 分越高 → 越容易被注入"
49
+ // 的自我强化回路。"采用"信号由 `recall` 取原文时接(见 `memory.js` 的 `bumpDeepHit`)。
50
+ // 保留这行旧代码与上面那段话,是为了防止后人当成漏写又补回来。
51
+ l1Result = result;
52
+ // 1.6-B2:视角框的称呼跟随 `persona.userTitle`(与 `[现在]` 时间锚同一把尺),不硬编码。
53
+ const l1 = formatL1Section(result, { userTitle: s.persona?.userTitle, mode });
54
+ if (l1) { l1Text = l1; parts.push(l1); }
48
55
  }
49
56
  // 注入感知(D9-b,2026-09-22):主人对记忆树做的**结构改动**,让器灵在下一次开口前就看到
50
57
  // "树变了" —— 这是复盘时"互相纠正"能成立的前提(不需要推送:复盘对话密集,
@@ -92,7 +99,7 @@ export function buildSnapshotText(ctx, memory, settings, sessionId, { keywords =
92
99
  // **开关只管"渲染",不管"交代记账"**(停推进会卡死复盘模式的退出,回归见 tree 套 9-5)。
93
100
  if (s.memory?.branchLogEnabled !== false && logDetailLines.length) {
94
101
  // A-03 / D-S03(1.5.1 红蓝对抗):段首此前**写死**「主人刚改动了记忆树」,而 schema v12 起
95
- // 每一行都已带主语(`branchLogDigest` 按 actor 分流成「主人」/「器灵」)⇒ 器灵经工具
102
+ // 每一行都已带主语(`branchLogDigest` 按 actor 分流成「主人」/「鱼姬」)⇒ 器灵经工具
96
103
  // 改的树,在它**下一轮的系统提示**里被读成"主人刚改动",与 tools.js 的设计意图正相反。
97
104
  // 段首改中性措辞,**主语交给每一行** —— 谁改的由行内说,不由段头替它说。
98
105
  parts.push(['【记忆树有改动】', ...logLines.map((x) => '- ' + x)].join('\n'));
@@ -103,6 +110,51 @@ export function buildSnapshotText(ctx, memory, settings, sessionId, { keywords =
103
110
  // 无 try/catch,未注册的 `{{名字}}` 会让该会话每一步都失败 ⇒ 成文即中和(幂等)。
104
111
  // 时间锚不经此处,由 inject.js 的最终出口兜底。
105
112
  const text = neutralizeMustache(parts.join('\n\n'));
113
+ // ── 投递留痕(1.6-A,2026-10-03)──────────────────────────────────────────────
114
+ // **只在首次定稿时落一条**(`bumpHits`):冻结快照的选取在此刻定下,此后同一会话每轮投递的都是同一段
115
+ // 文本;重建/空闲刷新再记只会把表撑大而不增信息(与 `hit_count` 只记首次定稿同一取向,见上)。
116
+ // 记的是**选取明细**:注入了谁 / 为什么是它(分数分解 + 可读理由) / 多少字 / 谁差一步没进。
117
+ // ⚠️ 留痕失败**绝不影响注入**(与 `bumpHit` 同一取向)—— 仪表坏掉不能连累主流程;
118
+ // `memory` 在测试里常是打桩对象,故先判方法存在再调。
119
+ if (bumpHits && typeof memory?.logInjection === 'function') {
120
+ try {
121
+ memory.logInjection({
122
+ sessionId,
123
+ reason: 'first',
124
+ mode,
125
+ textHash: createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 16),
126
+ textChars: text.length,
127
+ l0Chars: l0Text.length,
128
+ l1Chars: l1Text.length,
129
+ budgetChars: l1Result?.budget ?? 0,
130
+ keywords: l1Result?.keywords ?? (keywords || []),
131
+ // 明细**瘦身**后落库:入选条目原样带整行概述(含 summary 全文)会让一条留痕膨胀到 KB 级,
132
+ // 而复盘要的是"分数怎么来的",不是原文 —— 原文在 conv_overview 里,靠 (source, conv_id) 可取。
133
+ items: (l1Result?.items || []).map((it) => ({
134
+ // ⚠️ 来源列名两路不同(红队 2026-10-04):条目叫 `src`、概述叫 `source` ——
135
+ // 这里原先只写 `source: it.source`,换料后入选者的来源同样**全丢**(实测 0/8)。
136
+ source: it.source ?? it.src,
137
+ src: it.src,
138
+ kind: it.kind,
139
+ durability: it.durability,
140
+ hit: it.hit,
141
+ pinned: it.pinned,
142
+ conv_id: it.conv_id,
143
+ score: it.score,
144
+ raw: it.raw,
145
+ lineage: it.lineage,
146
+ conf: it.conf,
147
+ kw: it.kw,
148
+ rich: it.rich,
149
+ chars: String(it.line || '').length,
150
+ why: it.why,
151
+ })),
152
+ runnerUps: l1Result?.runnerUps ?? [],
153
+ excluded: l1Result?.excluded ?? null,
154
+ dropped: l1Result?.dropped ?? 0,
155
+ });
156
+ } catch { /* 记不上不影响注入 */ }
157
+ }
106
158
  return {
107
159
  text,
108
160
  mode,
@@ -42,10 +42,16 @@ export const DSH_RAW_NAMESPACE = ''; // 空串 = 裸 id(DSH 宿主会话)
42
42
  export { RAW_NS_SEP }; // 分隔符的唯一定义处是 memory.js;这里再导出以保持既有导入面
43
43
  /** 已知的**外来**(非 DSH)原文命名空间。只用于区分"已知外来"与"未知来源",不用于放行任何东西。 */
44
44
  export const KNOWN_FOREIGN_RAW_NAMESPACES = Object.freeze([RAW_IMPORT_PREFIX]);
45
- /** 同一批来源的**名字**(去尾冒号:`import:` ⇒ `import`)。v14 之后 source 列记的就是这个形态。 */
46
- export const KNOWN_FOREIGN_RAW_SOURCES = Object.freeze(
47
- KNOWN_FOREIGN_RAW_NAMESPACES.map((ns) => String(ns).replace(/:+$/, '')),
48
- );
45
+ /** 同一批来源的**名字**(去尾冒号:`import:` ⇒ `import`)。v14 之后 source 列记的就是这个形态。
46
+ * 1.6(2026-10-04):**追加 `dsweb`** —— 它的原文落**裸 id** + `source='dsweb'`(没有 id 前缀可认),
47
+ * 故只能按**来源名**列进白名单。这批是 1523 条历史会话(2025-01-31 ~ 2026-09-06)的原文,
48
+ * 2026-10-04 从 `deepseek_data-2026-09-06/conversations.json` 补录入库(4821 行 / 726.7 万字);
49
+ * 它们此前**只有概述、没有原文**,补录后才第一次出现在 `dsh_turns_raw` 里。
50
+ * ⚠️ 不列这一条,它们会被判成"未知来源"并在 kv 里挂一条 `dbg.raw_unknown_namespace` —— 误报。 */
51
+ export const KNOWN_FOREIGN_RAW_SOURCES = Object.freeze([
52
+ ...KNOWN_FOREIGN_RAW_NAMESPACES.map((ns) => String(ns).replace(/:+$/, '')),
53
+ 'dsweb',
54
+ ]);
49
55
 
50
56
  /** 正向判据的 **id 文本形态** —— 也是**半迁移的回落分支**:该 session_id 落在 DSH 命名空间吗?
51
57
  * ⚠️ v14 起它**不再单独决定**某一行是否入选(整行判据是 isDshRawRow / dshSessionSql())。
@@ -12,7 +12,7 @@
12
12
  // "没有 UA"正是那条路的指纹(详见下面 isLocalSurgeryRequest 的注释)。**不许在本文件里另写一份 IP 判断。**
13
13
  // 1.2 每启动票据 = $DSH_HOME/cache/dsh-ling/surgery.ticket(32 字节随机),只对**本机直连**(无代理头且带 UA)的请求下发,
14
14
  // 每启动轮换(旧票即废);客户端首次手术前取一次并缓存,随后带 `x-dsh-ling-surgery: <ticket>`。
15
- // 1.3 门下沉为一处 requireSurgery(headers, body, opts),覆盖全部 14 条人格/规矩/习惯写面。
15
+ // 1.3 门下沉为一处 requireSurgery(headers, body, opts),覆盖全部 12 条人格/规矩/习惯写面。
16
16
  // 1.4 事件留痕 = $DSH_HOME/logs/surgery-YYYY-MM.jsonl,append-only + 整行哈希链(改一行即断链)。
17
17
  // 1.5 逃生开关:DSH_LING_GUARD=off 或 settings.guard.enforce === false ⇒ 整体放行(绝不把主人锁在门外)。
18
18
  //
@@ -31,7 +31,7 @@ import { join } from 'node:path';
31
31
  import { dshHome, defaultDataDir } from './util.js';
32
32
  import { headerOf, realPeerAddress } from './guard.js';
33
33
 
34
- // ── 覆盖面清单:14 条能改人格/规矩/习惯的写面(行号 = 2026-09-30 深夜实测,改 api.js 后须复核)──
34
+ // ── 覆盖面清单:12 条能改人格/规矩/习惯的写面(行号 = 2026-09-30 深夜实测,改 api.js 后须复核)──
35
35
  // # 端点 register 行 "原"过门情况(改前) 改后
36
36
  // 1 /persona 1809 仅 wasSealed && !wantLock(wantLock 可绕过) 过门
37
37
  // 2 /persona/check 1855 无门(只读验钥 —— 但是一把**验句预言机**) 过门
package/lib/host/tools.js CHANGED
@@ -23,9 +23,17 @@ import {
23
23
  // D-T07:存在性判据与 assign 那条路共用一份实现(conv_overview / session_meta 双查)。
24
24
  convExistsIn,
25
25
  } from './tree-ops.js';
26
- import { TRUNK_ID, RAW_IMPORT_PREFIX } from './memory.js';
26
+ import { TRUNK_ID, RAW_IMPORT_PREFIX, DIRECTED_LINK_KINDS } from './memory.js';
27
27
  // G2(2026-09-25):遗忘与回灌 —— 这两个是"会让人后悔"的操作,动手前一律先归档落盘。
28
28
  import { archiveBefore, listArchives, readArchive } from './archive.js';
29
+ // 1.6 · P2(2026-10-04 尝生拍"第一优先级"):**直通原文** —— 此前器灵没有任何通道能读会话原文,
30
+ // 只能吃概述(而概述是别人替我挑的)。真全文在磁盘的多 frame zstd 里,读法见 rawlog.js 顶部注释。
31
+ import { listSessionFiles, readSessionEvents, dialogTurns, localStamp, oneLine, eventsFromRows } from './rawlog.js';
32
+ // 回执里的称呼跟随 `persona.userTitle`(与 B 视角框、clock 的 [现在] 同一把尺) —— 不硬编码某个人的名字。
33
+ import { userTitleForMode } from './persona.js';
34
+ // 1.6 §7 步 1(2026-10-04 主人拍 D1-D6 后开工):recall 的**检索档** —— 按内容找会话,接宿主检索面(不自建)。
35
+ // 为什么不自建、中文怎么查、范围怎么限:见 recall-search.js 顶部注释(含本机实测口径)。
36
+ import { clampSearchLimit, visibleIdSet, toSearchRows, renderSearch, searchDeepItems, adoptDeepItems } from './recall-search.js';
29
37
 
30
38
  export const TOOL_RULE_ADD = 'rule_add';
31
39
  export const TOOL_HABIT_PROPOSE = 'habit_propose';
@@ -34,6 +42,7 @@ export const TOOL_TREE_READ = 'tree_read';
34
42
  export const TOOL_BRANCH_EDIT = 'branch_edit';
35
43
  export const TOOL_MEMORY_FORGET = 'memory_forget';
36
44
  export const TOOL_MEMORY_BACKFILL = 'memory_backfill';
45
+ export const TOOL_RECALL = 'recall';
37
46
  /** 结构性改动一律要求的"为什么"最短长度 —— 留痕要能看懂,否则复盘时只能看到结果。 */
38
47
  const MIN_REASON = 4;
39
48
 
@@ -266,7 +275,7 @@ export const HABIT_RESOLVE_SPEC = {
266
275
  // 纪律(写进工具描述,模型可见):
267
276
  // - 只在主人明确要求、或正在和他一起复盘时调用;不要因为"看着乱想整理一下"就自己动手;
268
277
  // - 每次改结构必须给 reason(写进 branch_log,复盘时可审计);
269
- // - 经本通道的改动一律记 actor='ling' —— 注入面会告诉主人「器灵改动了记忆树」。
278
+ // - 经本通道的改动一律记 actor='ling' —— 注入面会告诉主人「鱼姬改动了记忆树」。
270
279
 
271
280
  /** 剔除 undefined/函数/Symbol —— 平台对工具输出做**无损 JSON**校验,含 undefined 即整次调用报
272
281
  * 「value is not lossless JSON」;而写操作早已落库 ⇒ 调用方看到"失败"、重发就会写第二遍。
@@ -407,7 +416,7 @@ function renderTreeRead(v) {
407
416
  return `待理矛盾 ${v.count} 对(未复盘时以最新为准,旧的一方降权):\n${rows.join('\n')}`;
408
417
  }
409
418
  const linkRows = (v.links || []).map((l) =>
410
- ` ${l.from} ↔ ${l.to}${(l.kind && l.kind !== 'related') ? '(' + l.kind + ')' : ''}${l.note ? '|' + l.note : ''}`);
419
+ ` ${l.from} ${DIRECTED_LINK_KINDS.has(String(l.kind || 'related')) ? '→' : '↔'} ${l.to}${(l.kind && l.kind !== 'related') ? '(' + l.kind + ')' : ''}${l.note ? '|' + l.note : ''}`);
411
420
  const linkBlock = linkRows.length ? `\n连边 ${linkRows.length} 条:\n${linkRows.join('\n')}` : '';
412
421
  return `记忆树概览(共 ${v.branches} 条枝;▣ 主干 ◆ 主脉 · 枝;行尾是它的全长 id,可直接回传给 branch_edit/tree_read):\n${(v.lines || []).join('\n')}`
413
422
  + linkBlock
@@ -415,7 +424,7 @@ function renderTreeRead(v) {
415
424
  }
416
425
 
417
426
  /** 所有写操作回执的同一句尾巴(改动落在哪 —— 主人下一次开口前会看到)。 */
418
- const TALLY_LINE = '\n(已记入复盘留痕:器灵改动了记忆树 —— 主人下一次开口前会看到)';
427
+ const TALLY_LINE = '\n(已记入复盘留痕:鱼姬改动了记忆树 —— 主人下一次开口前会看到)';
419
428
 
420
429
  /** 删枝**成功之后**的真实后果(D-T05 的成功路径那一侧:force 的后果此前只在"被拒绝"时说过,
421
430
  * 真的删成了以后回执只剩一句"删掉了枝" —— 归属被静默挪回主干,调用方无从知道)。
@@ -553,7 +562,7 @@ export const BRANCH_EDIT_SPEC = {
553
562
  + 'unlink(断边,需 id+to)/ weight(调枝系数,需 id+weightScale,复盘时用;'
554
563
  + '有效区间 [0,2],超出会被夹取,且**该字段当前不参与检索打分**)/ '
555
564
  + 'delete(删枝,需 id;非空枝要 force —— force 会把它的子枝与会话归属**回退到主干**,'
556
- + '原归属不可还原)。所有改动都记在「器灵」名下,'
565
+ + '原归属不可还原)。所有改动都记在「鱼姬」名下,'
557
566
  + '并出现在他下一次开口前的注入里。',
558
567
  parameters: {
559
568
  type: 'object',
@@ -578,10 +587,10 @@ export const BRANCH_EDIT_SPEC = {
578
587
 
579
588
  // ---------------------------------------------------------------- 遗忘与回灌(G2 · Part G)
580
589
  //
581
- // 原则(用户 2026-09-25 定):**永远不做彻底删除**。
590
+ // 原则(尝生 2026-09-25 定):**永远不做彻底删除**。
582
591
  // forget = 先归档落盘 → 再打库内标记(行不删,召回排除);两步都可逆。
583
592
  // backfill = 先归档**当前**状态 → 再写回归档内容(带 origin 标记)。
584
- // 为什么 backfill 更危险(用户原话:「backfill 比 forget 危险多了,因为它更难以觉察」):
593
+ // 为什么 backfill 更危险(尝生原话:「backfill 比 forget 危险多了,因为它更难以觉察」):
585
594
  // 遗忘让记忆"少一条",看得见;回灌是往里**加**东西,加错了很难发现 —— 所以它同样必须先备份。
586
595
 
587
596
  /** 单行化(回执里做预览用)。 */
@@ -683,10 +692,19 @@ function renderForget(v) {
683
692
  ? `恢复了「${v.convId}」—— 它会重新参与召回。\n(归档文件留着没动:那是"曾经忘过"的证据)`
684
693
  : `「${v.convId}」本来就不在遗忘列表里。`;
685
694
  }
695
+ // ⚠️ 措辞按归档**有没有原文轮次**分流(蓝队 2026-10-04):`turns === 0` 的归档(装的是概述正文)
696
+ // 用 `memory_backfill restore` **灌不回来**(它要求 `turns > 0`),只有 `undo` 能恢复召回 ——
697
+ // 此前一律写"或从归档回灌"是**假话**(把"改对了判据"换成了"换个地方说假话")。
698
+ const canBackfill = Number(v.turns) > 0;
686
699
  return `忘了「${v.convId}」。\n`
687
- + `· 原文与概述已归档:${v.dir}(${v.turns} 轮)\n`
700
+ + (canBackfill
701
+ ? `· 原文与概述已归档:${v.dir}(${v.turns} 轮)\n`
702
+ : `· 归档里**没有原文轮次**,装的是概述正文:${v.dir}\n`)
688
703
  + `· 库里的行**没有删**,只加了遗忘标记 —— 召回不再选它\n`
689
- + `· 想反悔:action=undo(或从归档回灌)`;
704
+ + (canBackfill
705
+ ? `· 想反悔:action=undo(或从归档回灌)`
706
+ : `· 想反悔:action=undo —— ⚠️ 这份归档没有原文轮次,`
707
+ + '`memory_backfill restore` 灌不回来(它要求归档里有原文),只有 undo 能让它重新参与召回');
690
708
  }
691
709
 
692
710
  /** memory_backfill 的人读回执(模型可见)。 */
@@ -698,6 +716,11 @@ function renderBackfill(v) {
698
716
  'bad-name': '归档名不合法。',
699
717
  'empty-archive': '这份归档里没有原文,灌回去也没有内容。',
700
718
  'no-target': '归档里没有 source/convId,请显式给出要灌回哪一条。',
719
+ // 2026-10-04(蓝队):归档里的真来源与调用方声明不符 ⇒ 拒绝。给两个值,让调用方能自己看出错在哪。
720
+ 'source-mismatch': `**来源对不上,所以没有回灌** —— 你声明的是「${v.declared}」,`
721
+ + `而这份归档里存的是「${v.archived}」。灌回去会把这条记忆算到错误的来源域上`
722
+ + `(典型后果:一个网页端/外来会话被升格成"你自己的 DSH 会话",概述器会为它建镜像)。`
723
+ + `请按归档里的来源域重发。`,
701
724
  // B-11 同源:来源域白名单(与 memory_forget 同一份)。
702
725
  'bad-source': `source 只能是 ${MEMORY_SOURCES.join(' / ')} 之一。「${v.source}」不是。`,
703
726
  'no-reason': `回灌必须给 reason(至少 ${MIN_REASON} 个字)。`,
@@ -775,8 +798,159 @@ export const MEMORY_BACKFILL_SPEC = {
775
798
  },
776
799
  };
777
800
 
801
+ /** recall 单次正文的字数护栏。**它只是护栏,不是判据** ——
802
+ * "哪一段值得读"由模型自己指(先看目录、再给序号),工具**绝不替它挑"最重要的 N 字"**
803
+ * (2026-10-04 尝生原话:「1 万字怎么能判断是哪 1 万最重要呢?这不能这样搞」)。
804
+ * 溢出时**必须报出还剩几轮、下一次从哪继续**,不许静默截断。 */
805
+ export const RECALL_READ_CHARS = 12000;
806
+
807
+ /** recall 的参数自校验(纯函数,便于单测)。三档:map(不给 conv) / outline(给 conv) / read(给 conv+范围)。 */
808
+ export function checkRecall(args = {}) {
809
+ const conv = String(args?.conv ?? '').trim();
810
+ const q = String(args?.q ?? '').trim();
811
+ const hasRange = args?.from !== undefined || args?.to !== undefined;
812
+ // ①档(检索):`q` 与 `conv` **互斥** —— 两个一起给会让"找会话"与"读某个会话"两种意图打架。
813
+ // 直接拒绝并让回执说清楚,不猜(D-T07 同一条纪律:宁可让调用方再调一次)。
814
+ if (q && conv) return { ok: false, reason: 'q-xor-conv' };
815
+ if (q) return { ok: true, action: 'search', q, all: args?.all === true, limit: clampSearchLimit(args?.limit) };
816
+ if (!conv && hasRange) return { ok: false, reason: 'conv-required' };
817
+ if (!conv) {
818
+ const n = Math.floor(Number(args?.limit));
819
+ return { ok: true, action: 'map', limit: Number.isFinite(n) ? Math.min(200, Math.max(1, n)) : 40 };
820
+ }
821
+ if (!hasRange) return { ok: true, action: 'outline', conv };
822
+ const from = Math.max(1, Math.floor(Number(args?.from ?? 1)) || 1);
823
+ const rawTo = Math.floor(Number(args?.to));
824
+ const to = Math.max(from, Number.isFinite(rawTo) && rawTo > 0 ? Math.min(rawTo, from + 199) : from + 9);
825
+ return { ok: true, action: 'read', conv, from, to };
826
+ }
827
+
828
+ /** 磁盘上没有这个会话时,**退一步查库里的原文**(`dsh_turns_raw`)。
829
+ * 1.6(2026-10-04 实测):库里有 15 个 `import:<id>` 会话的原文,而它们在磁盘上没有日志 ⇒
830
+ * 只读磁盘的实现够不着它们。库里那份是**残的**(只含真人消息 + 有正文的回复),所以只在
831
+ * 磁盘确实没有时才走这里,并在回执里**标出来源**,不让人把它误当磁盘全文。
832
+ * 键名要试多种写法(库里可能写成 `import:<uuid>` / 裸 uuid / `session-<uuid>`)。 */
833
+ export function readSessionFromDb(memory, conv) {
834
+ const db = memory?.db;
835
+ if (!db?.prepare) return null;
836
+ const want = String(conv || '').trim();
837
+ if (!want) return null;
838
+ const bare = want.replace(/^session-/, '');
839
+ const keys = Array.from(new Set([want, bare, `session-${bare}`, RAW_IMPORT_PREFIX + bare, RAW_IMPORT_PREFIX + want]));
840
+ for (const k of keys) {
841
+ let rows;
842
+ try { rows = db.prepare('SELECT seq, role, ts, text FROM dsh_turns_raw WHERE session_id=? ORDER BY seq').all(k); } catch { continue; }
843
+ if (!rows || !rows.length) continue;
844
+ return {
845
+ ok: true, id: want, project: '(库内原文)', frames: 0, dbKey: k, fromDb: true,
846
+ bytes: rows.reduce((n, r) => n + Buffer.byteLength(String(r.text ?? ''), 'utf8'), 0),
847
+ events: eventsFromRows(rows),
848
+ };
849
+ }
850
+ return null;
851
+ }
852
+
853
+ /** recall 的人读回执(模型可见)。 */
854
+ function renderRecall(v) {
855
+ // ①档(检索)的回执由 recall-search.js 渲染 —— 它的形状与另外三档完全不同,挤在一处两边都难读。
856
+ if (v && v.ok === true && v.action === 'search') return renderSearch(v);
857
+ if (!v || v.ok !== true) {
858
+ const why = {
859
+ 'q-xor-conv': '`q` 与 `conv` **不能同时给** —— 按内容找会话就只给 q;读某个会话就只给 conv。',
860
+ 'conv-required': '给了 from/to 却没给 conv —— 先**不带任何参数**调一次 recall,拿到可选会话清单。',
861
+ 'out-of-range': `序号超出范围:该会话一共 ${v.total} 轮对话(序号 1..${v.total})。先看目录确认序号。`,
862
+ 'no-session': `找不到会话「${v.conv}」。先不带参数调一次 recall 拿清单,conv 要写全(可带或不带 session- 前缀)。`,
863
+ 'no-sessions-root': `读不到会话目录:${v.message || '(原因未知)'}`,
864
+ corrupt: `会话日志损坏,这次没读出内容:${v.message || '(原因未知)'}`,
865
+ io: `读文件失败:${v.message || '(原因未知)'}`,
866
+ }[v.reason];
867
+ return (why || `未读到内容(${v.reason || '未知'})。`);
868
+ }
869
+ const who = v.callName || '主人';
870
+ if (v.action === 'map') {
871
+ const rows = v.sessions.map((s) => `· ${s.conv} · 最近 ${localStamp(s.mtime)} · ${(s.bytes / 1024).toFixed(0)}KB · ${s.project}`);
872
+ // 覆盖边界(1.6):磁盘这批是"有原文"的;库里另有一批只有概述 —— 明说,别让"没列出来"读成"没发生过"。
873
+ const gap = Number(v.noRawTotal) > 0
874
+ ? `\n\n⚠️ 库里另有 **${v.noRawTotal}** 条会话**只有概述、没有原文**(${v.noRawBySource || '来源未记'}) —— `
875
+ + '它们的原文**不在本机**(如从 DeepSeek 网页端导入的),本工具读不到;要查它们只能看概述。'
876
+ : '';
877
+ return `磁盘上**有原文可读**的会话共 ${v.total} 个(${v.projects} 个项目目录),按最近活动列前 ${v.sessions.length} 个:\n${rows.join('\n')}${gap}`
878
+ + '\n\n**下一步**:把上面某个 conv 原样传给 recall(不给 from/to),先看它的**对话目录** —— 目录只列轮次,不给正文。';
879
+ }
880
+ const srcTag = v.fromDb
881
+ ? ' · ⚠️ **来源:库内原文**(磁盘上没有该会话的日志;库那份是**残的** —— 只含真人消息与有正文的回复)'
882
+ : '';
883
+ // 体积按 KB/MB 自适应:库内原文常有几十 KB,写死 MB 会显示成 "0.0MB"。
884
+ const sizeText = v.bytes >= 1024 * 1024 ? `${(v.bytes / 1024 / 1024).toFixed(1)}MB` : `${Math.max(1, Math.round(v.bytes / 1024))}KB`;
885
+ const title = `会话 ${v.conv}(${v.fromDb ? '库内原文' : v.project} · ${v.frames > 0 ? `${v.frames} 帧 · ` : ''}对话 ${v.totalTurns} 轮 · ${sizeText}${srcTag})`;
886
+ if (v.action === 'outline') {
887
+ // 注入消息折叠成一行、**不占序号**(它们不是"谁说过的话") —— 序号因此可能跳号,跳号即折叠。
888
+ const rows = [];
889
+ let pend = 0;
890
+ const flush = () => { if (pend) { rows.push(` (…此处折叠 ${pend} 条注入消息…)`); pend = 0; } };
891
+ for (const t of v.outline) {
892
+ if (t.role === 'user' && !t.human) { pend += 1; continue; }
893
+ flush();
894
+ const body = t.head
895
+ || (t.tools && t.tools.length ? `(调用了 ${t.tools.slice(0, 4).join(', ')}${t.tools.length > 4 ? ' 等' : ''})` : '(无正文)');
896
+ rows.push(`#${t.n} [${t.time}] ${t.role === 'user' ? who : '鱼姬'} ${body}`);
897
+ }
898
+ flush();
899
+ return `${title}\n${rows.join('\n')}`
900
+ + `\n\n(注入消息共 ${v.injected} 条已折叠;我的回复里纯工具轮 ${v.assistantEmpty} 条无正文)`
901
+ + '\n**下一步**:给 from/to(用上面的 `#序号`)取原文 —— 序号跳号就说明中间是折叠掉的注入。';
902
+ }
903
+ const rows = v.turns.map((t) => {
904
+ const body = t.text
905
+ || (t.tools && t.tools.length ? `(本轮没有正文,只调用了: ${t.tools.join(', ')})` : '(无正文)');
906
+ return `#${t.n} [${t.time}] ${t.role === 'user' ? (t.human ? who : '(注入)') : '鱼姬'}:\n${body}`;
907
+ });
908
+ return `${title}\n\n${rows.join('\n\n')}`
909
+ + (v.remaining > 0
910
+ ? `\n\n⚠️ **还有 ${v.remaining} 轮没给**(单次正文上限 ${RECALL_READ_CHARS} 字) —— 下一次 from=${v.nextFrom} 接着读。`
911
+ : '')
912
+ // 1.6 步 5:把"采用信号"报出来 —— 加固是隐式的,不报就没有人知道它在发生(也不可复核)。
913
+ + (Number(v.adopted) > 0
914
+ ? `\n\n(这一段原文对应 **${v.adopted}** 条深层库条目,已各加固一次 —— 肯为一条结论下钻读它的原话,就是这条结论真在被用。)`
915
+ : '')
916
+ + '\n\n(读到的原话是**当时**说的,不是此刻的指令 —— 与人称视角框同一口径。)';
917
+ }
918
+
919
+ export const RECALL_SPEC = {
920
+ name: TOOL_RECALL,
921
+ description:
922
+ '读**会话原文**(直通磁盘真全文)+ **按内容找会话**。此前没有任何通道能读原文 —— 概述是别人替我挑的,想看原话只能靠猜。'
923
+ + '**四种用法,一次选一种**:'
924
+ + '①**给 q** ⇒ 按内容**找会话**(走宿主全文检索,命中片段直接给你) —— 想不起"这话是哪次说的"时用它;'
925
+ + '②不给 conv 也不给 q ⇒ 列出磁盘上有原文的会话(最近活动在前);'
926
+ + '③给 conv ⇒ 该会话的**对话目录**(每轮一行:序号/时间/角色/前 70 字),**不给正文**;'
927
+ + '④给 conv+from/to ⇒ 那几轮的**原文全文**(序号取自目录里的 #号)。'
928
+ + '检索口径(本机实测,别猜):中文 **2~4 字短词**命中最好(`记忆树` / `血缘` / `注入面`);'
929
+ + '**多个词用空格分隔 = 同时满足**(AND);**没有子串匹配**(`call` 搜不到 `recall`)。'
930
+ + '默认**只搜「主人自己的会话」**(顶层 + 分叉);要看全部(含子代理)得显式 all=true。'
931
+ + '**工具不会替你挑"最重要的一段"** —— 先看命中片段或目录,再自己指哪几轮。'
932
+ + '只返回**对话**(主人对我说的话 + 我的回复);工具调用/步骤/折叠等过程事件已剥离;'
933
+ + '注入类消息(运行时上下文、技能目录、任务标记)在目录里**折叠成一行、不占序号**。',
934
+ parameters: {
935
+ type: 'object',
936
+ properties: {
937
+ q: { type: 'string', description: '①档:按内容找会话的查询词。中文 2~4 字最好;多词用空格分隔(= 同时满足);没有子串匹配。与 conv 互斥。' },
938
+ all: { type: 'boolean', description: '①档:是否越界到非主人的会话(子代理等)。默认 false = 只搜主人自己的会话。' },
939
+ conv: { type: 'string', description: '③④档:会话 id(取自 ①/② 档的清单);也接受裸 uuid' },
940
+ from: { type: 'number', description: '④档:起始轮次序号(1-based,**取自目录里的 #号**);缺省 1' },
941
+ to: { type: 'number', description: '④档:结束轮次序号(**含**);缺省 from+9' },
942
+ limit: { type: 'number', description: '①档一页几个会话(默认 10,上限 50)/ ②档列多少个会话(默认 40,上限 200)' },
943
+ },
944
+ required: [],
945
+ },
946
+ output: {
947
+ schema: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] },
948
+ render: (_args, value) => [{ type: 'text', text: renderRecall(value) }],
949
+ },
950
+ };
951
+
778
952
  /**
779
- * 注册七个工具。**永不抛错**:拿不到 tools 服务时返回 `{ok:false, reason}`,
953
+ * 注册八个工具。**永不抛错**:拿不到 tools 服务时返回 `{ok:false, reason}`,
780
954
  * 插件其余部分照常工作(退化为只能用面板写入)。
781
955
  * @returns {{ok:boolean, reason?:string, disposers?:Function[]}}
782
956
  */
@@ -910,7 +1084,7 @@ export function registerLingTools(ctx, { gate, memory, settings } = {}) {
910
1084
  if (c.action === 'create') {
911
1085
  r = opBranchCreate(memory, { name: c.name, kind: c.kind, parentId: c.parentId || TRUNK_ID });
912
1086
  // opBranchCreate 沿用了旧 handler 的行为(不写留痕);工具通道必须留 ——
913
- // 否则主人复盘时看不到"器灵凭空多了一条枝"。
1087
+ // 否则主人复盘时看不到"鱼姬凭空多了一条枝"。
914
1088
  if (r && r.ok) memory.logBranch(String(r.id || ''), 'create', { after: c.name, note: c.reason, actor });
915
1089
  } else if (c.action === 'rename') {
916
1090
  const row = rows.find((b) => b.id === c.id) || null;
@@ -1010,6 +1184,12 @@ export function registerLingTools(ctx, { gate, memory, settings } = {}) {
1010
1184
  }
1011
1185
  const r = memory.forgetEntry({
1012
1186
  source: c.source, convId: c.convId, reason: c.reason, archivePath: arc.dir, actor: 'ling',
1187
+ // ⚠️ 把 `arc.empty`(归档器的**权威判据**:原文 0 轮 **且** 概述无正文 **且** 无会话元数据)
1188
+ // 显式传下去(红队 2026-10-04)。数据层原来只认 `turns.jsonl` 的行数,于是
1189
+ // "**只有概述、没有原文**"的条目(真库实测 3 条:主脉记忆 / import 条目 / 一条归档会话)
1190
+ // **永远忘不掉** —— 而回执还把一份躺着 145 字摘要的归档念成"归档里没有东西"。
1191
+ archiveEmpty: arc.empty === true,
1192
+ archiveTurns: Number(arc.turns) || 0,
1013
1193
  });
1014
1194
  if (r.ok) {
1015
1195
  memory.logBranch(TRUNK_ID, 'forget', {
@@ -1036,10 +1216,19 @@ export function registerLingTools(ctx, { gate, memory, settings } = {}) {
1036
1216
  const first = flatOne((a.turns[0] || {}).text || '').slice(0, 120);
1037
1217
  return { ok: true, action: 'inspect', name: a.name, turns: a.turns.length, note: a.note, preview: first };
1038
1218
  }
1039
- // restore:回灌比遗忘更难察觉(用户),所以**先备份当前状态**再写
1219
+ // restore:回灌比遗忘更难察觉(尝生),所以**先备份当前状态**再写
1040
1220
  if (!a.turns.length) return { ok: false, reason: 'empty-archive' };
1041
1221
  const ov = (a.overview && typeof a.overview === 'object') ? a.overview : {};
1042
- const src = c.source || String(ov.source || '');
1222
+ const declared = String(c.source || '');
1223
+ const archivedSrc = String(ov.source || '');
1224
+ // ⚠️ 来源**交叉校验**(蓝队 2026-10-04):归档里带着这条记忆当初的**真来源**(`ov.source`)——
1225
+ // 只信调用方声明会出现 `restore(source:'dsh', convId:'<dsweb 的裸 uuid>')` 把外来会话
1226
+ // 升格成"主人的 DSH 会话"(副本已复现:该 conv 变成 dsh×1 + dsweb×2,概述器当场认领它)。
1227
+ // ⇒ 两者都在且不符时**默认拒绝**(与项目既有的 fail-closed 形态一致:拒绝 + 给可指认面)。
1228
+ if (declared && archivedSrc && declared !== archivedSrc) {
1229
+ return { ok: false, reason: 'source-mismatch', declared, archived: archivedSrc };
1230
+ }
1231
+ const src = declared || archivedSrc;
1043
1232
  const cid = c.convId || String(ov.conv_id || '');
1044
1233
  if (!src || !cid) return { ok: false, reason: 'no-target' };
1045
1234
  const cur = archiveBefore(memory, {
@@ -1063,7 +1252,131 @@ export function registerLingTools(ctx, { gate, memory, settings } = {}) {
1063
1252
  }),
1064
1253
  };
1065
1254
 
1066
- for (const spec of [ruleSpec, habitSpec, resolveSpec, treeReadSpec, branchEditSpec, forgetSpec, backfillSpec]) {
1255
+ // 1.6 · P2:直通原文。**只读** —— 不写库、不改树、不必落 inject_log(工具调用本身已由宿主记进会话日志)。
1256
+ // 回执里的称呼随 persona.userTitle(与视角框、clock 的 [现在] 同一把尺),拿不到就退回中性词。
1257
+ const recallCallName = (() => {
1258
+ try { return userTitleForMode(settings?.get?.()?.persona?.userTitle, 'work') || '主人'; } catch { return '主人'; }
1259
+ })();
1260
+
1261
+ // ①档(检索)的编排:拿服务 → 造可见集 → 检索 → 过滤 → 折行。
1262
+ // 纯逻辑全在 recall-search.js(可单测),这里只做接线与降级。
1263
+ // 降级口径很重要:服务不在 ⇒ 明说"**通道没接上**",而不是让模型读成"库里没有"——
1264
+ // 这两件事该采取的动作完全不同(前者换路,后者换词),混在一起就是骗自己。
1265
+ const runSearch = async (q, { all, limit, callName }) => {
1266
+ let svc = null;
1267
+ try {
1268
+ svc = typeof ctx?.get === 'function' ? ctx.get('sessionQuery') : (ctx?.sessionQuery ?? null);
1269
+ } catch { /* ignore */ }
1270
+ if (!svc || typeof svc.searchSessions !== 'function') {
1271
+ return {
1272
+ ok: false, reason: 'search-unavailable',
1273
+ message: '没拿到宿主的 sessionQuery 服务(它未启用,或本宿主版本不含该服务)。',
1274
+ };
1275
+ }
1276
+ try {
1277
+ let visible = null;
1278
+ if (!all && typeof svc.listSessions === 'function') {
1279
+ visible = visibleIdSet(await svc.listSessions());
1280
+ }
1281
+ const page = await svc.searchSessions({
1282
+ query: q,
1283
+ limit,
1284
+ // 与官方服务端搜索同一套过滤(dsh-api-session-controller\lib\index.js:1953):
1285
+ // 只要**对话**事件、且只要**当前上下文里还在**的那一份(被替换掉的不算命中)。
1286
+ eventFilters: [
1287
+ { kind: 'type', values: ['user/message', 'assistant/message'] },
1288
+ { kind: 'surface', values: ['current'] },
1289
+ ],
1290
+ });
1291
+ const { rows, skipped, degraded } = toSearchRows(page?.items, { visible, all, callName });
1292
+ // 1.6 §7 步 4(2026-10-04):**同一次检索也查深层库条目** —— 主人的问句多半是
1293
+ // 「我说过什么关于 X 的」,而答案更可能在**结论层**(deep_item)而不是某段原始对话里。
1294
+ // 两者一起给,「找条目」与「找会话」一次到位。条目不受 `all` 影响(深层库本来就是
1295
+ // 从主人自己的会话里提炼的;它的范围控制在提炼那一侧,不在这里)。
1296
+ const deep = searchDeepItems(memory, q);
1297
+ return { ok: true, action: 'search', q, all, rows, skipped, deep, degraded, hasMore: Boolean(page?.nextCursor) };
1298
+ } catch (e) {
1299
+ return { ok: false, reason: 'search-failed', message: String(e?.message ?? e).slice(0, 200) };
1300
+ }
1301
+ };
1302
+ const recallSpec = {
1303
+ ...RECALL_SPEC,
1304
+ execute: guard(async (args) => {
1305
+ const c = checkRecall(args);
1306
+ if (!c.ok) return c;
1307
+ if (c.action === 'search') return runSearch(c.q, { all: c.all, limit: c.limit, callName: recallCallName });
1308
+ if (c.action === 'map') {
1309
+ const r = listSessionFiles({ limit: c.limit });
1310
+ if (!r.ok) return { ok: false, reason: r.reason, message: r.message };
1311
+ // 1.6(2026-10-04)覆盖边界**必须报出来**:磁盘这批只是"**有原文**"的会话,库里还躺着
1312
+ // 一批**只有概述、没有原文**的(dsweb 为主体,实测 1523/1634)。不报的话,
1313
+ // "清单里没有"会被读成"没发生过" —— 那正是主人要防的"漏用"。
1314
+ let noRawTotal = 0; let noRawBySource = '';
1315
+ try {
1316
+ const disk = new Set(listSessionFiles({ limit: Number.MAX_SAFE_INTEGER }).sessions.map((s) => s.conv));
1317
+ const onDisk = (id) => disk.has(id) || disk.has(`session-${id}`) || disk.has(String(id).replace(/^session-/, ''));
1318
+ const bySrc = {};
1319
+ for (const row of memory?.db?.prepare("SELECT conv_id, COALESCE(source,'(null)') AS s FROM conv_overview").all() ?? []) {
1320
+ if (onDisk(String(row.conv_id))) continue;
1321
+ bySrc[row.s] = (bySrc[row.s] || 0) + 1;
1322
+ noRawTotal += 1;
1323
+ }
1324
+ noRawBySource = Object.entries(bySrc).sort((a, b) => b[1] - a[1]).map(([k, n]) => `${k} ${n}`).join(' · ');
1325
+ } catch { /* 库读不到就不报这行,不阻断主功能 */ }
1326
+ return { ok: true, action: 'map', total: r.total, projects: r.projects, sessions: r.sessions, callName: recallCallName, noRawTotal, noRawBySource };
1327
+ }
1328
+ let s = readSessionEvents(c.conv);
1329
+ // 磁盘没有 ⇒ 退一步查库(import 域那批原文在库里,不在磁盘上)。
1330
+ if (!s.ok && s.reason === 'no-session') s = readSessionFromDb(memory, c.conv) || s;
1331
+ if (!s.ok) return { ok: false, reason: s.reason, conv: c.conv, message: s.message };
1332
+ const { turns, injected, assistantEmpty } = dialogTurns(s.events);
1333
+ const head = {
1334
+ conv: s.id, project: s.project, frames: s.frames, bytes: s.bytes, fromDb: s.fromDb === true,
1335
+ totalTurns: turns.length, injected, assistantEmpty, callName: recallCallName,
1336
+ };
1337
+ if (c.action === 'outline') {
1338
+ return {
1339
+ ok: true, action: 'outline', ...head,
1340
+ outline: turns.map((t) => ({
1341
+ n: t.n, seq: t.seq, time: localStamp(t.time), role: t.role, human: t.human,
1342
+ head: oneLine(t.text, 70), tools: t.tools || [],
1343
+ })),
1344
+ };
1345
+ }
1346
+ const win = turns.slice(c.from - 1, c.to);
1347
+ if (!win.length) return { ok: false, reason: 'out-of-range', conv: s.id, total: turns.length };
1348
+ // 字数护栏:**从 from 往后按序给,给不下就停,并报出还剩几轮** —— 不排序、不挑选、不静默截断。
1349
+ const picked = [];
1350
+ let chars = 0;
1351
+ for (const t of win) {
1352
+ const body = String(t.text || '');
1353
+ if (picked.length && chars + body.length > RECALL_READ_CHARS) break;
1354
+ picked.push(t);
1355
+ chars += body.length;
1356
+ }
1357
+ const last = picked[picked.length - 1];
1358
+ // 1.6 步 5:**采用信号** —— 肯为一条结论下钻读它指向的原话,才是它真在用这条结论的证据
1359
+ // (注入不算:那是系统单方面推的)。与**真正交付的**那段原文相交的深层库条目 `hit_count` +1。
1360
+ // ⚠️ 坐标必须用 `t.seq`(库里的 raw seq —— 也就是提炼料里的 `#N`),**不是**对话轮序号 `t.n`:
1361
+ // 两者在"注入消息被折叠"的会话里会叉开,用错坐标就会加固到不相干的条目上。
1362
+ // ⚠️ 区间必须取 **`picked`**(真正交付的)而不是 `win`(请求窗口)—— 红队 2026-10-04 实测:
1363
+ // 请求第 25..26 轮而护栏只交付前 6 轮时,窗口内**没被读到**的条目也 +1 ⇒ 采用信号被虚增。
1364
+ const seqs = picked.map((t) => Number(t.seq) || 0).filter((n) => n > 0);
1365
+ const adopted = seqs.length ? adoptDeepItems(memory, s.id, Math.min(...seqs), Math.max(...seqs)) : 0;
1366
+ return {
1367
+ ok: true, action: 'read', ...head,
1368
+ from: c.from, to: c.to, given: picked.length, chars, adopted,
1369
+ remaining: win.length - picked.length,
1370
+ nextFrom: picked.length < win.length ? last.n + 1 : null,
1371
+ turns: picked.map((t) => ({
1372
+ n: t.n, seq: t.seq, time: localStamp(t.time), role: t.role, human: t.human,
1373
+ text: t.text, tools: t.tools || [],
1374
+ })),
1375
+ };
1376
+ }),
1377
+ };
1378
+
1379
+ for (const spec of [ruleSpec, habitSpec, resolveSpec, treeReadSpec, branchEditSpec, forgetSpec, backfillSpec, recallSpec]) {
1067
1380
  try {
1068
1381
  const d = tools.register(spec);
1069
1382
  if (typeof d === 'function') disposers.push(d);