@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.
@@ -0,0 +1,210 @@
1
+ // dsh-ling host — recall 的**检索档**(1.6 §7 步 1,2026-10-04)。
2
+ //
3
+ // 它是什么:给模型一个「**按内容找会话**」的入口。此前只有 `recall()` 的磁盘清单(按时间列文件)
4
+ // 与 `recall(conv)` 的目录 —— 要"找"只能靠翻,翻不动就靠猜。
5
+ //
6
+ // 为什么接宿主检索面而不是自建(2026-10-04 全实测):
7
+ // · 宿主 `@deepseek-ai/dsh-session-query` 的 SQLite 后端已由主人在 2026-09-20 启用
8
+ // (`profiles\web\cordis.patch.yml:59-62` → `session-search.db`);
9
+ // · 索引**懒对账**:每次 `searchSessions` 先跑 `_reconcile` → `_observeStable`,把磁盘上新增的
10
+ // 会话补进索引(代码 :537/:543/:707/:724 + 数据 `global_generation=2` 两侧证实)
11
+ // ⇒ 停更是"没人搜过",不是坏了;
12
+ // · 中文实测:2~4 字短词命中良好(`记忆树` 1024 / `血缘` 288 / `注入面` 453),
13
+ // **多词 = AND**(`记忆树 血缘` ≡ `"记忆树" "血缘"` ≡ 159),**无子串召回**(`call` 命中 0);
14
+ // · 官方为 tokenizer 取舍(`unicode61`,否决 trigram)做过实测 ⇒ 重开选型是重复踩坑。
15
+ // ⇒ 结论:**分两层用** —— 找会话走宿主,找原文走 `rawlog`(磁盘直读)。
16
+ //
17
+ // 范围限制(D6 已拍 2026-10-04):默认**只搜「主人自己的会话」**;越界要显式 `all:true`。
18
+ // 判据**复用** `isMemoryEligibleHeader`(顶层 + 分叉入、子代理不入)—— 与入库/注入同一把尺,
19
+ // 不另立一份,否则会出现"能读到的"与"能记住的"两套边界。
20
+ // 官方 README 的硬约束原文:「**无调用方授权** …… 模型工具或 UI **必须限制调用方可检查的会话**」
21
+ // ⇒ 照抄它的形状:`listSessions()` 造可见集 → 结果逐条 `visibleIds.has(...)`
22
+ // (官方同款实现在 `dsh-api-session-controller\lib\index.js:1934` / `:1984`)。
23
+
24
+ import { isMemoryEligibleHeader, sessionKind } from './util.js';
25
+ import { localStamp, oneLine } from './rawlog.js';
26
+
27
+ /** 一页几个会话(默认)。 */
28
+ export const SEARCH_HITS_DEFAULT = 10;
29
+ /** 一页几个会话(上限):给模型的是"下一步去哪",不是把库倒出来。 */
30
+ export const SEARCH_HITS_MAX = 50;
31
+ /** 单条摘要的显示字数 —— **报短摘要而不是 id**(2026-10-04 生态复核:同生态对手的 README 逐字
32
+ * 「查 id 是什么的那一步最容易省略,省略了就等于没报」)。这一条是本次复核直接抄来的。 */
33
+ export const SEARCH_SNIPPET_CHARS = 160;
34
+
35
+ /** 条数夹取(纯函数,便于单测)。 */
36
+ export function clampSearchLimit(n) {
37
+ const v = Math.floor(Number(n));
38
+ return Number.isFinite(v) ? Math.min(SEARCH_HITS_MAX, Math.max(1, v)) : SEARCH_HITS_DEFAULT;
39
+ }
40
+
41
+ /** 一次检索最多带出几条**深层库条目**(1.6 §7 步 4,2026-10-04)。 */
42
+ export const SEARCH_DEEP_MAX = 12;
43
+
44
+ /**
45
+ * 在**深层库条目**里按内容找(1.6 §7 步 4)。
46
+ *
47
+ * 为什么要和"找会话"并在一起:主人的问句多半是「我说过什么关于 X 的」——
48
+ * 而答案更可能在**结论层**(`deep_item`)而不是某一段原始对话里。两者一起给,
49
+ * 「找条目」与「找会话」一次到位,少一轮往返。
50
+ *
51
+ * 多词语义**必须与宿主检索面同解(AND)**:那边实测 `记忆树 血缘` ≡ `"记忆树" "血缘"`,
52
+ * 这里就也是"每词都要出现"。两处"多词"含义打架的话,同一个问句会给出互相矛盾的结果。
53
+ *
54
+ * 排面用表自己的口径(置顶 → 加固 → 新);**已取代的条目不参与** —— 它们是
55
+ * "曾经成立、现已作废"的留痕(见 `supersedeDeepItem`),主动召回不该再挑它。
56
+ *
57
+ * 表还没建时(宿主未重启)**静默返回空**:检索面不该因为深层库缺席而整体失败。
58
+ */
59
+ export function searchDeepItems(memory, q, limit = SEARCH_DEEP_MAX) {
60
+ const db = memory?.db;
61
+ if (!db?.prepare) return [];
62
+ const words = String(q || '').trim().split(/\s+/).map((w) => w.replace(/[%_\\]/g, '')).filter(Boolean);
63
+ if (!words.length) return [];
64
+ try {
65
+ const where = words.map(() => "text LIKE ? ESCAPE '\\'").join(' AND ');
66
+ return db.prepare(
67
+ `SELECT id, kind, text, conv_id, src, seq_from, seq_to, pinned, hit_count FROM deep_item
68
+ WHERE superseded_by='' AND ${where}
69
+ ORDER BY pinned DESC, hit_count DESC, created_at DESC LIMIT ?`,
70
+ ).all(...words.map((w) => `%${w}%`), Math.max(1, Math.min(50, Number(limit) || SEARCH_DEEP_MAX)));
71
+ } catch { return []; }
72
+ }
73
+
74
+ /**
75
+ * 从 `listSessions()` 的记录里造「主人自己的会话」id 集(纯函数)。
76
+ * 拿不到 header 的**不放入** —— 宁少不滥,这是可见性过滤,不是统计。
77
+ */
78
+ /** 1.6 步 5:**采用信号** —— `recall` 去取某段原文时,把**与所取区间相交**的深层库条目加固 +1。
79
+ *
80
+ * 为什么这是"采用"而不是"注入":注入是系统单方面推给模型的,模型完全可以不理;
81
+ * 而**肯为一条结论下钻去读它指向的原话**,是它真在用这条结论的证据。
82
+ * `deep_item` 列注释里那条语义位(「`hit_count` 只由真被采用推进,不由被注入推进」)就落在这里 ——
83
+ * 否则注入越多分越高、分越高越容易被注入,是个自我强化回路(抄自 stratagate 的做法)。
84
+ *
85
+ * ⚠️ **seq 坐标系**:必须用 `dsh_turns_raw.seq`(即提炼料文件里的 `**#N role**`),
86
+ * **不是**目录里的对话轮序号 `n` —— 两者在"注入消息被折叠"的会话里会叉开。
87
+ * 相交判据 `seq_from <= hi && seq_to >= lo`;已取代的条目不加固(它是"曾经成立"的留痕)。
88
+ * 返回被加固的条数(0 = 没有相交条目,正常)。任何异常都吞成 0 —— 加固失败不该让读原文失败。 */
89
+ export function adoptDeepItems(memory, conv, seqLo, seqHi) {
90
+ try {
91
+ if (!memory?.db || typeof memory.bumpDeepHit !== 'function') return 0;
92
+ const c = String(conv || '');
93
+ const lo = Number(seqLo) || 0;
94
+ const hi = Number(seqHi) || 0;
95
+ if (!c || !lo || !hi) return 0;
96
+ const rows = memory.db.prepare(
97
+ "SELECT id FROM deep_item WHERE conv_id=? AND superseded_by='' AND seq_from<=? AND seq_to>=? LIMIT 50",
98
+ ).all(c, hi, lo);
99
+ let n = 0;
100
+ for (const r of rows) if (memory.bumpDeepHit(r.id)?.ok) n += 1;
101
+ return n;
102
+ } catch {
103
+ return 0;
104
+ }
105
+ }
106
+
107
+ export function visibleIdSet(records) {
108
+ const out = new Set();
109
+ for (const r of records || []) {
110
+ const h = r?.header;
111
+ if (!h || typeof h !== 'object') continue;
112
+ const id = h.id ?? r?.id;
113
+ if (!id) continue;
114
+ if (isMemoryEligibleHeader(h)) out.add(String(id));
115
+ }
116
+ return out;
117
+ }
118
+
119
+ /**
120
+ * 把宿主的 `SessionSearchHit[]` 折成回执行(纯函数)。
121
+ * @param hits `{ header, bestMatch: { seq, time, type, snippet } }[]`
122
+ * @param visible 可见集(来自 `visibleIdSet`);`all === true` 时忽略
123
+ * @param all 越界开关(D6:显式才生效)
124
+ * @returns `{ rows, skipped }` —— `skipped` 是**被范围挡掉**的条数,必须报出来(不静默丢)
125
+ */
126
+ export function toSearchRows(hits, { visible = null, all = false, callName = '主人' } = {}) {
127
+ const rows = [];
128
+ let skipped = 0;
129
+ for (const h of hits || []) {
130
+ const header = h?.header ?? {};
131
+ const id = header.id ?? h?.sessionId;
132
+ if (!all && visible && (!id || !visible.has(String(id)))) { skipped += 1; continue; }
133
+ const m = h?.bestMatch ?? {};
134
+ const isUser = String(m.type || '').startsWith('user');
135
+ rows.push({
136
+ conv: String(id || '(无 id)'),
137
+ kind: sessionKind(header),
138
+ cwd: header.cwd ? String(header.cwd) : '',
139
+ seq: m.seq ?? null,
140
+ time: m.time ? localStamp(m.time) : '',
141
+ who: isUser ? callName : '鱼姬',
142
+ snippet: oneLine(m.snippet || '', SEARCH_SNIPPET_CHARS),
143
+ });
144
+ }
145
+ // ⚠️ `degraded`(蓝队 2026-10-04 抓到 P3):宿主**没有** `listSessions` 时,`visible` 恒为 null
146
+ // ⇒ 范围限制**整条失效**(一条不挡),而回执却仍写着"已在主人自己的会话内" —— 那是**说假话**。
147
+ // 取舍(主代理 2026-10-04 定):**保持 fail-open 但如实标注** —— 完全禁用会让"按内容找会话"
148
+ // 在宿主版本变动时突然死掉,而这里泄漏面只是子代理会话(不是外部);关键是**不许假装限过范围**。
149
+ return { rows, skipped, degraded: !all && !visible };
150
+ }
151
+
152
+ /** 检索档的人读回执(纯函数,模型可见)。 */
153
+ export function renderSearch(v) {
154
+ if (!v || v.ok !== true) {
155
+ // ⚠️ 兜底必须在**构造字符串之前** —— 表里的模板是立即求值的,`v` 为 null 时
156
+ // `v.message` 会在构造这一步就抛。这不是防御性编程:工具回执抛异常 = 模型那一步直接失败,
157
+ // 而"检索失败"恰恰是最该被好好说出来的时刻。本文件第一次跑测试就抓到了这一处。
158
+ const msg = v?.message || '(原因未知)';
159
+ const why = {
160
+ 'search-unavailable':
161
+ `宿主检索面不可用,这次没搜成。原因:${msg}`
162
+ + ' 这不是"库里没有",是**通道没接上** —— 按内容找会话这条路暂时走不通,'
163
+ + '可改用**不带参数**的 recall() 按时间翻清单。',
164
+ 'search-failed': `检索报错,这次没搜成:${msg}。换个更短的词再试(中文 2~4 字命中最好)。`,
165
+ }[v?.reason];
166
+ return why || `未搜到内容(${v?.reason || '未知'})。`;
167
+ }
168
+ const scope = v.all
169
+ ? '**未限定范围**(all=true —— 含子代理等非主人会话)'
170
+ : (v.degraded
171
+ ? '⚠️ **没能限定范围** —— 宿主这次没给 `listSessions`,所以下面这份结果**可能含子代理或其它会话**,'
172
+ + '不是"只搜主人自己的会话"(范围限制这条通道暂时失效,不是"库里只有这些")'
173
+ : '已在「**主人自己的会话**」内(默认范围)');
174
+ const hitRows = Array.isArray(v.rows) ? v.rows : [];
175
+ const deepRows = Array.isArray(v.deep) ? v.deep : [];
176
+ // 条目排在前:它是**结论层**(一句话说完),会话在后(要下钻才知道内容)。
177
+ const deepBlock = deepRows.length
178
+ ? '【深层库条目 · 结论层(从他的会话里提炼出来的,可直接用)】\n' + deepRows.map((d) => {
179
+ const pin = Number(d.pinned) ? '📌 ' : '';
180
+ // ⚠️ **不要在这里印 `from=`**(红队 2026-10-04):条目的 `seq_from/seq_to` 是**事件流 seq**
181
+ // (提炼料里的 `#N`,即 `dsh_turns_raw.seq`),而 `recall(conv, from, to)` 收的是
182
+ // **对话轮序号 n** —— 两个坐标系在"注入消息被折叠"的会话里会叉开。实测 274 条可比条目里
183
+ // **207 条(75.5%)照这条指引会直接 `out-of-range`**;偶有落在范围内的,读到的也是不相干的
184
+ // 轮次,还会顺手给不相干条目计一次"采用"。⇒ 只给会话 id,让调用方**先看目录再取原文**。
185
+ return `· ${pin}[${d.kind}] ${d.text}\n ↳ 出自 ${d.conv_id} —— 要看原话:先 \`recall(conv="${d.conv_id}")\` 拿轮次,再按目录里的 \`#N\` 取`;
186
+ }).join('\n') + '\n\n'
187
+ : '';
188
+ if (!hitRows.length && !deepRows.length) {
189
+ return `按内容没找到(查询「${v.q}」· ${scope})。`
190
+ + (v.skipped ? `\n(有 ${v.skipped} 条被范围挡掉 —— 要看它们得显式传 all=true。)\n` : '\n')
191
+ + '换词试试:中文 **2~4 字**短词命中最好(`记忆树` / `血缘` / `注入面`);'
192
+ + '**多个词用空格分隔 = 同时满足**(AND);**没有子串匹配**(`call` 搜不到 `recall`)。';
193
+ }
194
+ const rows = hitRows.map((r) => {
195
+ const at = [r.seq !== null && r.seq !== undefined ? `#${r.seq}` : '', r.time].filter(Boolean).join(' ');
196
+ return `· ${r.conv} · ${at} ${r.who}\n ${r.snippet}`;
197
+ });
198
+ const head = deepRows.length && rows.length
199
+ ? `按内容命中 **${deepRows.length} 条深层库条目** + ${rows.length} 个会话(查询「${v.q}」· ${scope}):`
200
+ : deepRows.length
201
+ ? `按内容命中 **${deepRows.length} 条深层库条目**(查询「${v.q}」· ${scope}):`
202
+ : `按内容找到 ${rows.length} 个会话(查询「${v.q}」· ${scope}):`;
203
+ const convBlock = rows.length ? `【会话原文层】\n${rows.join('\n')}` : '';
204
+ return `${head}\n${deepBlock}${convBlock}`
205
+ + (v.skipped ? `\n(${v.skipped} 条被范围挡掉 —— 要看它们得显式传 all=true。)` : '')
206
+ + (v.hasMore ? '\n(这一页满了,**还有更多** —— 把 limit 调大,或换个更窄的词。)' : '')
207
+ + (deepRows.length
208
+ ? '\n\n**下一步**:条目已经是可以直接用的结论;**要它背后那句话**就把上面的 conv 传给 `recall(conv=…)` 拿对话目录,再按目录里的 `#N` 取原文(`recall(conv, from, to)`)。'
209
+ : '\n\n**下一步**:把某个 conv 原样传给 `recall(conv)` 看它的**对话目录**,再 `recall(conv, from, to)` 取原文。');
210
+ }
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // 标题是记忆的"名字"。旧行为把首条用户消息硬截 46 字(见 summarizer.js TITLE_MAX),
4
4
  // 于是 60 条 dsh 概述里 26 条是半句话:
5
- // 「器灵,接下来在这里继续开发生长型人格与记忆树模块,继续走你的新生之路。 我刚刚对1.2.1的」
5
+ // 「鱼姬,接下来在这里继续开发生长型人格与记忆树模块,继续走你的新生之路。 我刚刚对1.2.1的」
6
6
  // 「刚才我为本虚拟机安装解压压缩软件<360压缩>,由于操作过快,被捆绑了360本体,随后我在物」
7
7
  // 而 dsweb 侧标题干净(「CPU单核性能排行榜」),因为它用的是源库自带标题、不做首句截断。
8
8
  //
@@ -14,6 +14,12 @@ import { summarizeWithRetry } from './dsweb-summary.js';
14
14
  import { llmOnce } from './deepsummary.js';
15
15
  import { rawSessionCandidates } from './memory.js';
16
16
 
17
+ /** 标题**生成上限**(`heuristicTitle` 切句读/硬截的落点:它决定"机器造出来的标题最长多少字")。
18
+ * ⚠️ 与 `session-line.js` 的 `TITLE_MAX_CHARS`(= 60)**同名但不同语义**(E1 判定,2026-10-01):
19
+ * 那个是"那一行的**显示兜底**夹取",作用于**任何来源**的标题(含源库自带、主人手改 ≤120)。
20
+ * 两处的读者与方向都不同 ⇒ **不要把两处"统一"成一个数**:统一到 58 会让手改/源库标题多切一刀,
21
+ * 统一到 60 会改变下面 `heuristicTitle` 在 59-60 字输入上的行为(两个方向都是行为变更)。
22
+ * 唯一的不变式是**显示兜底 ≥ 本值**(否则机器刚造出来的标题会在那一行里被再切一刀)。 */
17
23
  export const TITLE_MAX_CHARS = 58; // 46 → 58:46 会把一句完整的话切成半句
18
24
  export const LEGACY_TITLE_MAX = 46; // 1.2.2 之前的硬截上限 —— 长度"达到"它就说明这条标题曾被切过
19
25
  export const MACHINE_TITLE_MAX = 40; // 机器生成标题的上限(手改上限在 memory.renameTitle:120)
@@ -23,13 +29,13 @@ export const TITLE_MATERIAL_CHARS = 1400;
23
29
  export const TITLE_SYS = '你在给一段对话起名字。只输出标题本身:不超过 20 个字,写清这段对话在做什么或结论是什么;'
24
30
  + '不要引号、不要书名号、不要结尾标点、不要解释、不要分点、不要写"对话"二字。';
25
31
 
26
- // 称呼与问候:中文主人对器灵的日常开场(「晚上好器灵,我小加班了一会,回来了」)
27
- const ADDRESS_RE = /^(器灵|助手|宝贝|亲爱的)[,,、::~~!!。\s]*/;
32
+ // 称呼与问候:中文主人对器灵的日常开场(「晚上好小鱼,我小加班了一会,回来了」)
33
+ const ADDRESS_RE = /^(小鱼鱼|小鱼|鱼姬|器灵|助手|宝贝|亲爱的)[,,、::~~!!。\s]*/;
28
34
  const GREETING_RE = /^(早上好|中午好|下午好|晚上好|晚安|你好呀|你好|您好|哈喽|嗨|早|hello|hi|yo)[啊呀呢,,、!!。.~\s]*/i;
29
35
  // 无信息量首句(问候、寒暄、测试口令)
30
36
  const VAGUE_RE = /^(嗯+|哦+|好+|在吗|在不在|\S{0,4}测试\S*|回复数字\s*\d+|\d+|ok|test)$/i;
31
37
 
32
- /** 反复剥离"问候 + 称呼"前缀:「晚上好器灵,我小加班了一会,回来了」→「我小加班了一会,回来了」。 */
38
+ /** 反复剥离"问候 + 称呼"前缀:「晚上好小鱼,我小加班了一会,回来了」→「我小加班了一会,回来了」。 */
33
39
  export function stripPreamble(text) {
34
40
  let s = String(text || '').replace(/\s+/g, ' ').trim();
35
41
  for (let i = 0; i < 3; i += 1) {
@@ -208,7 +214,7 @@ export function retitleCandidates(memory, { source = '', limit = 200 } = {}) {
208
214
  if (!looksLikeSessionId(r.conv_id)) return false; // 合成行(user:principle-…)不是会话
209
215
  if (!t) return true;
210
216
  if (t.length >= LEGACY_TITLE_MAX) return true; // 被旧上限(TITLE_MAX=46)切过的
211
- if (ADDRESS_RE.test(t) || GREETING_RE.test(t)) return true; // 还带着「器灵,」「晚上好器灵,」= 旧启发式产物
217
+ if (ADDRESS_RE.test(t) || GREETING_RE.test(t)) return true; // 还带着「鱼姬,」「晚上好小鱼,」= 旧启发式产物
212
218
  return isVagueTitle(t); // 「你好」「在吗」
213
219
  }).map((r) => ({ source: String(r.source), conv_id: String(r.conv_id), title: String(r.title || ''), summary: String(r.summary || '') }));
214
220
  }
@@ -49,7 +49,16 @@ export const TIER_TURNS_DEFAULT = 10;
49
49
  export const LINE_MAX_CHARS = 110;
50
50
  /** 「最新进展」上限:真人发言首句。 */
51
51
  export const PROGRESS_MAX_CHARS = 40;
52
- /** 标题段上限(概述器侧的标题上限是 58,这里只做兜底)。 */
52
+ /** 标题段**显示兜底**上限(超出留 `…`,见 `clip`)。
53
+ * ⚠️ **与 `retitle.js` 的同名常量语义不同,别把两者"统一"成一个数**(E1 判定,2026-10-01):
54
+ * · `retitle.js:TITLE_MAX_CHARS`(= 58)= **生成上限** —— `heuristicTitle()` 切句读/硬截的落点,
55
+ * 它决定"机器造出来的标题最长多少字"(`summarizer.js` / `import-file.js` 只拿它当兜底);
56
+ * · 本常量(= 60)= **显示兜底** —— `resolveTitle()` 给那一行印标题时,把**任何来源**的标题硬夹到这里:
57
+ * 除本机生成(≤58)外,还有源库自带标题(不做首句截断)与主人手改(`memory.renameTitle` 上限 120)。
58
+ * 不变式:**本值必须 ≥ `retitle.js` 的生成上限** —— 否则机器刚造出来的标题会在那一行里被**再切一刀**,
59
+ * "那一行印的标题"与"记忆里的标题"当场对不上。60 = 58 + 2,余量留给非本机来源的短标题。
60
+ * (两处靠模块边界分开;**没有任何模块同时 import 这两个常量**,将来若要改名以消除同名,
61
+ * 建议本处叫 `LINE_TITLE_MAX_CHARS` —— 属结构调整,未做。) */
53
62
  export const TITLE_MAX_CHARS = 60;
54
63
  export const KV_LAST = 'sessline.last.';
55
64
  export const KV_COUNTS = 'sessline.counts.';
@@ -158,6 +167,31 @@ export function progressKey(title, progress) {
158
167
  return md5(oneLine(title) + '\u0000' + oneLine(progress));
159
168
  }
160
169
 
170
+ /**
171
+ * **C 闸「内容真变了?」的唯一判据** —— 写路径(`maybeSessionLine`)与只读预览(`sessionLinePreview`)
172
+ * 共用**这一处**,谁都不许在自己那头另写一份。
173
+ * 为什么必须共用:两条路曾各自内联判据,写路径 3 条、预览只有 2 条(少 md5)
174
+ * ⇒ 同一份状态能出现「预览说会追加、写路径判 same」的分叉 —— 属"读数与行为不一致",最误导人。
175
+ *
176
+ * 三条判据,任一说"没变"就算没变(`changed:false`):
177
+ * ① 逐字节:`candidate` 与上次那条的原文相同。⚠️ **两条路的基准不同源** ——
178
+ * 写路径取 kv `st.text`,预览取**面上那条**(HTTP 面板路径常常根本没有会话面);
179
+ * ② `md5(candidate)` 与上次记录的 md5 相同(kv `sessline.last.*.md5`,裁定点名要用的那道):
180
+ * 它的基准**两条路都读得到** ⇒ 这是两条路唯一真正共享的判据,**一条都不许缺**;
181
+ * ③ 实质键:`progressKey(标题, 最新进展)` 与上次追加时记的相同(剥掉 `#N`/`第 N 轮`/`HH:MM`)。
182
+ * ⚠️ 预览的键基准可能退让到"从上次那条正文里反解出的标题/进展"(面板看不到进展时),与写路径未必同源
183
+ * —— 那是**已知且有意**的差异,注释见 `sessionLinePreview` ④。
184
+ * `candidate` 为空串不做额外守卫:记录里的 md5 一律来自**非空**正文(`maybeSessionLine` 从不追加空文本),
185
+ * 故 `md5('')` 不可能等于任何一条记录。
186
+ * @returns {{changed:boolean, textSame:boolean, md5Same:boolean, keySame:boolean}}
187
+ */
188
+ export function contentChanged({ candidate = '', lastText = '', lastMd5 = '', lastKey = '', curKey = '' } = {}) {
189
+ const textSame = !!lastText && String(candidate) === String(lastText);
190
+ const md5Same = !!lastMd5 && md5(candidate) === String(lastMd5);
191
+ const keySame = !!lastKey && !!curKey && String(curKey) === String(lastKey);
192
+ return { changed: !(textSame || md5Same || keySame), textSame, md5Same, keySame };
193
+ }
194
+
161
195
  /** 从我们那条活行的正文里把"标题 + 进展"两段拆出来(给预览/自愈当比较基准)。
162
196
  * 正文形如 `[本次会话·进展 #N] {标题} —— 最近:{进展} · 第 T 轮 · HH:MM`,尾部整段是格式位。
163
197
  * 解不出(老格式/被夹过)⇒ 两段都空。 */
@@ -397,11 +431,12 @@ export function maybeSessionLine({ memory, agent, payload, stateMap, now = Date.
397
431
  // 为什么要 ③:只留 ①② 时,`#N` 每尝试一次就 +1 ⇒ 恒判"变了",闸等于没有(实测钉过);
398
432
  // 为什么要 ①②:同一轮内同参重入时,只有逐字节那一侧才拦得住。`st.key` 是**新增字段**,
399
433
  // 不动任何已有 kv 的含义;老记录没有它 ⇒ 第一次一律追加一条,之后照常。
434
+ // ⚠️ 这三条**只在 `contentChanged` 里实现一处**(旧写法在本函数内联三条、预览那头内联两条 ⇒ 曾能分叉);
435
+ // 本路径的基准:① kv `st.text` ② kv `md5` ③ kv `key`。
400
436
  const lastMd5 = String(st.md5 || '');
401
437
  const lastKey = String(st.key || '');
402
- const textUnchanged = !!st.text && candidate === String(st.text);
403
438
  const curKey = progressKey(title, progress);
404
- const changed = !(textUnchanged || (!!lastMd5 && md5(candidate) === lastMd5) || (!!lastKey && curKey === lastKey));
439
+ const changed = contentChanged({ candidate, lastText: st.text, lastMd5, lastKey, curKey }).changed;
405
440
  const verdict = decideSessionLine({ onSurface: scan, candidate, lastMd5, changed });
406
441
  if (verdict.action !== 'append') {
407
442
  // kv 语义**不变**:`skippedSame` = "被内容闸挡下"(旧义是"面末条逐字节相同",新义是"与 lastMd5/面末条相同",
@@ -550,9 +585,14 @@ export function sessionLinePreview(memory, settings, sessionId, { turn } = {}) {
550
585
  const basis = progress ? 'title+progress' : (scan.latestUser ? 'title+progress' : 'title-only');
551
586
  const curKey = effTitle || effProgress ? progressKey(effTitle, effProgress) : '';
552
587
  const judgeText = buildSessionLineText({ title: effTitle, progress: effProgress, ordinal, at: Date.now() }); // 无轮次版
553
- const textUnchanged = scan.count === 1 && !!judgeText && String(scan.lastText) === judgeText;
554
- const changed = !(textUnchanged || (!!lastKey && !!curKey && curKey === lastKey));
555
- if (process.env.DSH_LING_DBG) console.log('[pv]', JSON.stringify({ cnt: scan.count, ord: ordinal, basis, lastKey: lastKey.slice(0, 6), curKey: curKey.slice(0, 6), lastParts, effTitle, effProgress, textUnchanged, judge: judgeText }));
588
+ // ④′ 判据**与写路径同一处**(`contentChanged`):旧写法这里只内联两条(逐字节 + 实质键),
589
+ // 少了 md5 那条 ⇒ 当 kv 记着 md5 而 `last.text` 缺失/不新鲜时,预览说"会追加"、写路径判 same
590
+ // (同一份状态两种读数)。逐字节那条的基准是**面上那条**(没有面时为空 ⇒ 该条不参与,与旧写法一致)。
591
+ const judge = contentChanged({
592
+ candidate: judgeText, lastText: scan.count === 1 ? scan.lastText : '', lastMd5, lastKey, curKey,
593
+ });
594
+ const changed = judge.changed;
595
+ if (process.env.DSH_LING_DBG) console.log('[pv]', JSON.stringify({ cnt: scan.count, ord: ordinal, basis, lastKey: lastKey.slice(0, 6), curKey: curKey.slice(0, 6), lastParts, effTitle, effProgress, textSame: judge.textSame, md5Same: judge.md5Same, keySame: judge.keySame, judge: judgeText }));
556
596
  const verdict = decideSessionLine({ onSurface: scan, candidate: judgeText, lastMd5, changed });
557
597
  out.blocked = verdict.reason;
558
598
  out.wouldAppend = verdict.action === 'append';
@@ -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,