@nightpainters/dsh-ling 1.5.3 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +121 -6
- package/DESIGN.md +8 -4
- package/README.md +181 -7
- package/lib/client.js +35 -35
- package/lib/host/api.js +29 -22
- package/lib/host/archive.js +15 -9
- package/lib/host/autotree.js +2 -2
- package/lib/host/clock-skill.js +1 -1
- package/lib/host/deep-index-skill.js +111 -0
- package/lib/host/inject.js +1 -1
- package/lib/host/l1.js +350 -23
- package/lib/host/lifecycle.js +76 -10
- package/lib/host/memory.js +323 -37
- package/lib/host/persona.js +2 -2
- package/lib/host/rawlog.js +323 -0
- package/lib/host/recall-search.js +210 -0
- package/lib/host/retitle.js +5 -5
- package/lib/host/snapshot.js +65 -13
- package/lib/host/summarizer.js +10 -4
- package/lib/host/surgery.js +2 -2
- package/lib/host/tools.js +327 -14
- package/lib/host/tree-ops.js +12 -6
- package/lib/host/util.js +1 -1
- package/lib/index.js +41 -2
- package/package.json +6 -2
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// dsh-ling host — 深层库**索引层** skill(1.6 步 6,2026-10-04)。
|
|
2
|
+
//
|
|
3
|
+
// 它是什么:主人拍过「索引层 ≤400 字 · 一簇一行」,而承载它的形态选定为**一个 skill** ——
|
|
4
|
+
// 目录里只常驻 name + description(这一份就是"一簇一行"),正文按需展开;更关键的是
|
|
5
|
+
// skill 目录按 **sha256 比对、内容不变就不重发** ⇒ 前缀稳定是白拿的。
|
|
6
|
+
//
|
|
7
|
+
// ⚠️ 为什么**整层只做一个 skill**,而不是每簇一个:
|
|
8
|
+
// 实测边际成本 ≈205 字/条(skill 目录每条 = name + description),而外层框架(≈683 字)是固定的。
|
|
9
|
+
// 11 簇做成 11 个 skill = 多付 11 条 name + 各自的框架,且会与工具面压缩抢"常驻目录面"这同一个池子。
|
|
10
|
+
// ⇒ **一个 skill 装整个索引**才是与 ≤400 字预算吻合的形态。
|
|
11
|
+
//
|
|
12
|
+
// ⚠️ description 里**不写条数**:条数天天变,写进去就会让目录重发、前缀断。条数写在正文里。
|
|
13
|
+
//
|
|
14
|
+
// 内容由 11 簇聚类结果生成 —— 重新聚类后重跑生成脚本即可。
|
|
15
|
+
export const INDEX_SKILL_NAME = 'memory-index';
|
|
16
|
+
|
|
17
|
+
export const INDEX_SKILL_DESCRIPTION = `我记住的事按 11 类放着(用 recall({q:"关键词"}) 按内容搜 —— 它同时搜**结论**与**会话原文**):
|
|
18
|
+
· 记忆树与记忆库:记忆怎么存、怎么搜、血缘归并、深层库
|
|
19
|
+
· 器灵 dsh-ling:包名、发布仓库、版本与宣发、插件配置
|
|
20
|
+
· 鱼姬与主人:称呼、纪念日、同伴身份、形象锚与立场
|
|
21
|
+
· 主人其人:职业来路、身体作息、脾气念头、价值观
|
|
22
|
+
· 合作规矩:先审后装、干跑、重启要问、实测复跑、备份
|
|
23
|
+
· 本业与项目:本职的专业领域、论文与研究、自制的装置项目
|
|
24
|
+
· 这台机器与踩坑:VM/Win10、硬件、乱码、沙箱、网络接入
|
|
25
|
+
· 模型与API:选型、推理档位、缓存、上下文、本地小模型(我的手)
|
|
26
|
+
· 插件与界面:装/卸/审计、侧栏、皮肤、权限选择器
|
|
27
|
+
· 鱼姬的感官和开口:看图/生图、语音、听音乐、主动开口
|
|
28
|
+
· 零散几条:护眼工具、天文观测、投资慢课这类生活杂事`;
|
|
29
|
+
|
|
30
|
+
export const INDEX_SKILL_WHEN_TO_USE = '想知道"我以前说过/定过什么"、或者要在一堆长期结论里找依据时读它;它的每一行是一个**主题簇**,指明了那一类里装着什么样的事。日常回答通常不需要读它 —— 索引行本身已经够你判断该不该用 recall 去搜。';
|
|
31
|
+
|
|
32
|
+
// ⚠️ 2026-10-04 夜(发布关④ 抓到的**最大一处私人痕迹**):正文此前**逐条列着那 393 条结论** ——
|
|
33
|
+
// 含职业来路、论文合金成分、工作机系统版本、本地模型名、已装插件清单、纪念日与称呼。
|
|
34
|
+
// 它**随包公开**(lib/index.js 注册进 skill 目录,npm 一发就带出去)⇒ 那次全部对外。
|
|
35
|
+
// ⇒ 正文改成**地图而不是仓库**:每一类只说"这一簇装的是什么样的事"与触发线索,
|
|
36
|
+
// **不列任何具体条目**(要内容就用 recall 现取)。这与设计口径也一致:
|
|
37
|
+
// 索引层本来就是"一簇一行",细节在深层库里、按需取。
|
|
38
|
+
// ⚠️ 另一处同类:description 曾逐簇写条数(·52条),与 README/CHANGELOG 声明的
|
|
39
|
+
// 「簇里不写条数」(条数天天变会让目录重发、前缀断)相反 —— 已一并去掉。
|
|
40
|
+
export const INDEX_SKILL_CONTENT = `# 深层库索引 · 我记住的事分 11 类
|
|
41
|
+
|
|
42
|
+
这里是**地图,不是仓库**:每一类只说明"这一簇装的是什么样的事"与触发线索。
|
|
43
|
+
|
|
44
|
+
想看某一类里**具体有什么结论** ⇒ 直接 \`recall({ q: "关键词" })\` —— 它先给**条目**(结论层),再给**会话**(原文层)。
|
|
45
|
+
|
|
46
|
+
## 怎么用
|
|
47
|
+
- 找某一类里的结论 ⇒ \`recall({ q: "关键词" })\`(中文 2~4 字命中最好;多个词用空格 = 同时满足)
|
|
48
|
+
- 想知道一条结论的**原话** ⇒ 条目回执里带 \`conv\` 与 \`#序号\`,用 \`recall({ conv, from, to })\` 取那几轮
|
|
49
|
+
- 按时间浏览 ⇒ \`recall()\` 不带参数,列出磁盘上有原文的会话
|
|
50
|
+
|
|
51
|
+
## 11 类与触发线索
|
|
52
|
+
|
|
53
|
+
| 类 | 这一簇装什么 | 触发线索 |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| 记忆树与记忆库 | 记忆怎么存、怎么搜,血缘归并,深层库 | 记忆树 · 记忆库 · 血缘 · 召回 |
|
|
56
|
+
| 器灵 dsh-ling | 包名、发布仓库、版本与宣发、插件配置 | 器灵 · dsh-ling · 发布 |
|
|
57
|
+
| 鱼姬与主人 | 称呼、纪念日、同伴身份、形象锚与立场 | 称呼 · 纪念日 · 同伴 · 形象 |
|
|
58
|
+
| 主人其人 | 职业来路、身体作息、脾气念头、价值观 | 职业 · 睡眠 · 价值观 · 立场 |
|
|
59
|
+
| 合作规矩 | 谁拍板、怎么审计、怎么汇报、红线 | 重启 · 审计 · 备份 · 规矩 |
|
|
60
|
+
| 本业与项目 | 本职的专业领域、论文与研究、自制的装置项目 | 机械 · 论文 · 合金 · 弹跳球 |
|
|
61
|
+
| 这台机器与踩坑 | 硬件规格、工具安装、乱码沙箱、网络接入 | 虚拟机 · 乱码 · 沙箱 · 代理 |
|
|
62
|
+
| 模型与API | 选型与路由、推理档位、缓存、上下文、本地小模型 | 模型 · 档位 · 缓存 · 我的手 |
|
|
63
|
+
| 插件与界面 | 第三方插件的装卸与审计、界面行为 | 插件 · 侧栏 · 皮肤 · 权限 |
|
|
64
|
+
| 感官与主动开口 | 看图/生图/语音/听音乐,主动开口层的设计 | 看图 · 生图 · 语音 · 主动开口 |
|
|
65
|
+
| 零散几条 | 归不进上面主题的自建小工具与生活杂事 | 护眼 · 天文 · 习惯 |
|
|
66
|
+
|
|
67
|
+
> 条数**不写在这里**:它天天变,写死会让这份目录反复重发、把前缀缓存打断。要数就现算。
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
/** 注册(与 clock skill 同款三级兜底:直接挂 → ctx.inject → 轮询 20 次)。 */
|
|
71
|
+
export function registerIndexSkill(ctx) {
|
|
72
|
+
const disposers = [];
|
|
73
|
+
let mounted = false;
|
|
74
|
+
const mount = (c) => {
|
|
75
|
+
if (mounted) return true;
|
|
76
|
+
try {
|
|
77
|
+
const skills = c?.skills;
|
|
78
|
+
if (!skills || typeof skills.register !== 'function') return false;
|
|
79
|
+
const d = skills.register({
|
|
80
|
+
name: INDEX_SKILL_NAME,
|
|
81
|
+
description: INDEX_SKILL_DESCRIPTION,
|
|
82
|
+
whenToUse: INDEX_SKILL_WHEN_TO_USE,
|
|
83
|
+
content: INDEX_SKILL_CONTENT,
|
|
84
|
+
source: 'bundled',
|
|
85
|
+
});
|
|
86
|
+
if (typeof d === 'function') disposers.push(d);
|
|
87
|
+
mounted = true;
|
|
88
|
+
console.info('[dsh-ling] memory-index skill registered');
|
|
89
|
+
return true;
|
|
90
|
+
} catch (e) {
|
|
91
|
+
console.debug('[dsh-ling] memory-index skill registration failed', e);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
if (!mount(ctx)) {
|
|
96
|
+
try {
|
|
97
|
+
ctx.inject?.(['skills'], (child) => { mount(child); });
|
|
98
|
+
} catch (e) {
|
|
99
|
+
console.debug('[dsh-ling] ctx.inject skills failed', e);
|
|
100
|
+
}
|
|
101
|
+
let tries = 0;
|
|
102
|
+
const timer = setInterval(() => {
|
|
103
|
+
tries += 1;
|
|
104
|
+
if (mounted || tries >= 20) { clearInterval(timer); return; }
|
|
105
|
+
mount(ctx);
|
|
106
|
+
}, 500);
|
|
107
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
108
|
+
disposers.push(() => clearInterval(timer));
|
|
109
|
+
}
|
|
110
|
+
return disposers;
|
|
111
|
+
}
|
package/lib/host/inject.js
CHANGED
|
@@ -88,7 +88,7 @@ function advanceLogWatermark(memory, sessionId, maxId, shownMaxId, retiredMaxId,
|
|
|
88
88
|
if (retired > 0 && retired > Number(memory.kvGet('branch_log_wm.retired') || 0)) {
|
|
89
89
|
memory.kvSet('branch_log_wm.retired', String(retired));
|
|
90
90
|
}
|
|
91
|
-
// 复盘守门(2026-09-22
|
|
91
|
+
// 复盘守门(2026-09-22 尝生:「动记忆树不能是随意的,应该是慎重严肃的」):
|
|
92
92
|
// 另记一个**全局最后同步**水位,回答"自器灵上次开口以来累积了多少笔改动还没交代"
|
|
93
93
|
// (memory.branchLogPending 读它)。与会话水位互补 —— 会话水位管"这个会话看到什么",
|
|
94
94
|
// 本键管"是否还没跟器灵交代过"。
|
package/lib/host/l1.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// heat = α·recency + β·freq + γ·importance (exponential decay, half-life configurable);
|
|
3
3
|
// mode weights by category (knowledge/daily/feeling); optional keyword pre-match.
|
|
4
4
|
import { toEpochMs, isoDate } from './util.js';
|
|
5
|
+
import { userTitleForMode } from './persona.js'; // 1.6-B2:视角框的称呼跟随 userTitle(不硬编码)
|
|
5
6
|
|
|
6
7
|
const DAY_MS = 86_400_000;
|
|
7
8
|
|
|
@@ -34,6 +35,25 @@ function grams3(s) {
|
|
|
34
35
|
return out;
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/** 近似重复的判据(1.6,2026-10-04):3-gram **Jaccard** ≥ `DUP_JACCARD`。
|
|
39
|
+
*
|
|
40
|
+
* 为什么需要(真机实测):两条几乎同字的「器灵的记忆树工具(tree_read/branch_edit…)」——
|
|
41
|
+
* 它们来自**不同会话**(幂等判据 `(conv_id,seq_from,seq_to,text)` 因此挡不住,同源限额也挡不住),
|
|
42
|
+
* 却**同时占掉开场 8 个位置里的 2 个**。开场是全局最贵的面,一条重复就是实打实的一格浪费。
|
|
43
|
+
* 与 `kwOverlap` 的 3-gram 思路同源(中文友好),但这里是**对称相似度**,不是"命中与否"。
|
|
44
|
+
* ⚠️ 这只是**选择侧**的守门;根源治理在提炼侧(跨会话归纳 / 同义合并),那一步还没做。 */
|
|
45
|
+
const DUP_JACCARD = 0.7;
|
|
46
|
+
|
|
47
|
+
function similarText(a, b) {
|
|
48
|
+
const A = grams3(String(a || '').trim());
|
|
49
|
+
const B = grams3(String(b || '').trim());
|
|
50
|
+
if (!A.size || !B.size) return false;
|
|
51
|
+
let inter = 0;
|
|
52
|
+
for (const g of A) if (B.has(g)) inter += 1;
|
|
53
|
+
const union = A.size + B.size - inter;
|
|
54
|
+
return union > 0 && inter / union >= DUP_JACCARD;
|
|
55
|
+
}
|
|
56
|
+
|
|
37
57
|
/**
|
|
38
58
|
* keyword overlap between query tokens and row keywords:
|
|
39
59
|
* hit when a pair (1) contains the other (len>=2), or (2) shares a 3-gram —
|
|
@@ -71,7 +91,30 @@ function kwOverlap(queryKeywords, rowKeywords) {
|
|
|
71
91
|
return hit;
|
|
72
92
|
}
|
|
73
93
|
|
|
74
|
-
|
|
94
|
+
/** 1.6(2026-10-04)修:`kwOverlap` **不能**用在条目路上 —— 它有一条"共享 3-gram 也算命中"的回退
|
|
95
|
+
* (为"查询 4-gram vs 存储 6-gram 的**关键词表**"设计的),而条目路的右操作数是**条目正文**
|
|
96
|
+
* (全库均 52 字)。中文常用 3-gram 遍地都是 ⇒ 几乎任何查询都会命中 3 条以上 ⇒ 打满
|
|
97
|
+
* `KW_BONUS_CAP`(+1.8),把新近(最多 0.3)和 hit(尚为 0)全压死。
|
|
98
|
+
* 实测病灶(尝生 2026-10-04 开新会话看到):开场 8 条**全是同一次生图会话的条目** ——
|
|
99
|
+
* 排序实际上等于"谁的字面碰巧撞上查询词"。
|
|
100
|
+
* 长句只做**连续子串**匹配:那个词真出现在那句话里才算命中。 */
|
|
101
|
+
function kwHitInText(queryKeywords, text) {
|
|
102
|
+
const t = String(text || '').toLowerCase();
|
|
103
|
+
if (!t || !queryKeywords || !queryKeywords.length) return 0;
|
|
104
|
+
let hit = 0;
|
|
105
|
+
for (const q of queryKeywords) {
|
|
106
|
+
const qs = String(q).toLowerCase();
|
|
107
|
+
if (qs.length < 2) continue;
|
|
108
|
+
if (t.includes(qs)) hit += 1;
|
|
109
|
+
}
|
|
110
|
+
return hit;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ⚠️ 2026-10-04 夜改:`0.6 → 1.2`。实测发现**当轮相关打不过长期价值**:主人问「生图」时,
|
|
114
|
+
// 那条真正讲生图的条目只有 `决定/long`(长期分 ≈0.2),加上一次命中(0.6)仍只有 0.8,
|
|
115
|
+
// 而 `承诺/durable` 的条目躺着就是 1.0 ⇒ **开场里根本没有他在问的那件事**。
|
|
116
|
+
// 提到 1.2 后:命中一次 ≈1.4 ⇒ 能翻盘进前 8,但压不过"承诺+命中"的 2.2 ⇒ 不至于只剩撞词的。
|
|
117
|
+
const KW_BONUS_PER_HIT = 1.2;
|
|
75
118
|
const KW_BONUS_CAP = 1.8;
|
|
76
119
|
/** 「有信息量的摘要」加权(2026-09-16):只有标题的行(如全部 dsweb 行)不加分,
|
|
77
120
|
* 真正带摘要的行加分 —— 用相对分差把"信息密度低"的行往后压,而不是硬过滤。
|
|
@@ -88,6 +131,97 @@ export function summaryWeight(row) {
|
|
|
88
131
|
return body.length >= 20 ? 1 : 0;
|
|
89
132
|
}
|
|
90
133
|
|
|
134
|
+
/** 落选者留痕条数(1.6-A)。0 = 不留;上限 20 只是防呆 —— 它落在磁盘上,不进上下文。 */
|
|
135
|
+
const RUNNER_UPS_DEFAULT = 5;
|
|
136
|
+
|
|
137
|
+
// ── 1.6 步 5:L1 换料 —— 数据源从「会话概述」换成「深层库条目」(结论层) ──────────────
|
|
138
|
+
// 主人 2026-10-04 拍板:「反复加固 + 置顶」,**面向的对象不是浅层库原文,而是深层库条目**;
|
|
139
|
+
// 幅度「瘦身到现在的 60%」(经 l1BudgetTokens 1200 → 720 落地)。
|
|
140
|
+
//
|
|
141
|
+
// 为什么整条判据都要换掉:旧公式(`α·新近 + β·热度 + γ·重要性 + 关键词 + 摘要加成`)是为
|
|
142
|
+
// **概述行**设计的 —— 概述是"一段话",要靠热度/摘要长度去猜哪段有价值;条目本身就是
|
|
143
|
+
// **一句话结论**,它自带两个更强的信号:**被主人置顶**(主观)、**被反复采用**(客观)。
|
|
144
|
+
//
|
|
145
|
+
// ⚠️ 一个尚未闭环的事实(2026-10-04):`hit_count` 目前**全库为 0** —— 因为它只该由
|
|
146
|
+
// "真被采用"推进(列注释的语义位),而采用信号(recall 取过它指向的原文)还没接线。
|
|
147
|
+
// 在那之前,排序实际由 **置顶 + 关键词 + 新近** 三者承担,`hit` 项恒为 0(不是坏了)。
|
|
148
|
+
const DEEP_SCAN_MAX = 1000; // 一次扫描的条目上限(条目继续增长后要换成真分页)
|
|
149
|
+
const HIT_BONUS = 1.0; // 「被反复采用」的满分权重
|
|
150
|
+
const HIT_SATURATION = 5; // 采用满 5 次即拿满分(与记忆行 freq 的 /20 不同:条目稀少,5 次已很硬)
|
|
151
|
+
// ⚠️ 2026-10-04 夜改:`0.3 → 0.1`。原因是主人实测抓到的真缺陷 —— **无关键词时分数几乎全平**:
|
|
152
|
+
// 条目多来自近日会话(衰减≈1)⇒ 每条都是 `0.3 × 1 ≈ 0.300`,前 10 名里 9 条同分 ⇒ 排序
|
|
153
|
+
// **实际退化成"按会话时间"**,开场成了"最近几次会话的结论"而不是"最要紧的事"。
|
|
154
|
+
// 新近对**结论层**本就该是弱信号(结论的价值在长期),压到 0.1 后不再主导。
|
|
155
|
+
const RECENCY_W = 0.1;
|
|
156
|
+
const DEEP_HALF_LIFE = 365; // 半衰期一年(旧的概述行是 90 天)
|
|
157
|
+
/** **硬度分**(1.6,2026-10-04 尝生的判断:「能不能做成从根源上判断这些条目到底是临时的还是永久的,
|
|
158
|
+
* 或者加"硬度"评分来衡量临时程度/永久程度」)。
|
|
159
|
+
* 它**同时**用于两处:出局(见 `selectL1` 里 `ephemeral` 的 continue)与**排序**。
|
|
160
|
+
* 只做前者是不够的 —— 上一版就只做了前者,于是"长期原则"和"阶段性结论"在排序里**同权**,
|
|
161
|
+
* 区分度全靠新近(而那正是全平的来源)。`durable` 抬 0.6 之后,与 `long` 拉开约 7 倍。 */
|
|
162
|
+
const DURABILITY_BONUS = { durable: 0.6, long: 0, ephemeral: 0 };
|
|
163
|
+
/** **类别分**(1.6,2026-10-04 夜)。它解决的是"硬度分之后仍然全平"这一层 ——
|
|
164
|
+
* 实测:`durable` 有 107 条而开场只有 8 个位置,同档内分数全同(极差 0.001)⇒ 排序仍旧退回
|
|
165
|
+
* 按会话时间,于是"开发过程的决定/环境事实"照样能挤掉"关于人的承诺与关系"。
|
|
166
|
+
*
|
|
167
|
+
* 权重的依据是**主人 2026-10-04 夜的两条实测标注**:他标出「测评口径」(决定)与
|
|
168
|
+
* 「某插件的档位细节」(偏好)两条"不该在这里" —— 它们都是**关于事**的;而器灵最该先看见的
|
|
169
|
+
* 是**关于人**的(承诺/关系/偏好)。⇒ 用 `kind` 把这两类分开,不必新增字段。
|
|
170
|
+
* ⚠️ 这是本夜新加的判据(主人未逐字拍过)⇒ 值可调、可整体否掉;`hit`(+1.0)仍是最强信号。 */
|
|
171
|
+
const KIND_WEIGHT = { 承诺: 0.4, 关系: 0.3, 偏好: 0.2, 决定: 0.1, 事实: 0 };
|
|
172
|
+
/** **同源限额**:同一次会话最多进 N 条(置顶不受此限)。见 `selectL1` 里的同源循环。 */
|
|
173
|
+
const MAX_PER_CONV = 2;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 一条条目的**可读理由**(1.6-A 硬约束③:每条进上下文的都要能报出理由)。
|
|
177
|
+
* 词表跟着打分项走 —— 这里改了公式、那里必须同改,否则留痕会说谎(比不记更坏)。
|
|
178
|
+
* @param {{row?:object, pinned?:boolean, hit?:number, kw?:number, recency?:number, lineage:number, conf?:number, score:number}} x
|
|
179
|
+
*/
|
|
180
|
+
export function explainScore(x) {
|
|
181
|
+
const f = (n, d = 2) => Number(n).toFixed(d);
|
|
182
|
+
// ⚠️ 词表**跟着数据源分路**,不能一套吃到底(1.6 步 5):
|
|
183
|
+
// 双路并存期间,回落路径(概述)的打分项是「热度/摘要加成/重要性」,条目路径是「置顶/被采用/新近」——
|
|
184
|
+
// 用一套词去描述另一条路的分数 = **留痕说谎**,而留痕是"每条进上下文的都要能报出理由"这条硬约束的落点。
|
|
185
|
+
// 判路用**字段存在性**,不用 `useDeep` 标记:调用方可能是直接构造 `x` 的测试/诊断
|
|
186
|
+
// (它不会带 `useDeep`)—— 概述路的 `x` 必有 `heat`/`rich`,条目路的必有 `hit`。
|
|
187
|
+
const legacy = x.useDeep === false || x.heat !== undefined || x.rich !== undefined;
|
|
188
|
+
if (legacy) {
|
|
189
|
+
const bits = [];
|
|
190
|
+
if (x.heat !== undefined && x.heat !== null) {
|
|
191
|
+
// ⚠️ 热度必须乘**类别权重**再报(蓝队 2026-10-04):真实分项是 `catW * heat`(见下面的打分),
|
|
192
|
+
// 只报 `heat` 会让 daily 档虚高 0.252、feeling 档虚高 88%(0.750 vs 真实 0.399)——
|
|
193
|
+
// 又一次"留痕与公式不符"。`catW` 由打分侧塞进 `x`;老调用方没塞时退回报纯 heat。
|
|
194
|
+
const catW = x.catW === undefined || x.catW === null ? 1 : Number(x.catW);
|
|
195
|
+
bits.push(`类别加权热度${f(Number(x.heat) * catW)}`);
|
|
196
|
+
}
|
|
197
|
+
if (Number(x.kw) > 0) {
|
|
198
|
+
bits.push(`关键词命中${x.kw}条(+${f(Math.min(KW_BONUS_CAP, Number(x.kw) * KW_BONUS_PER_HIT))})`);
|
|
199
|
+
}
|
|
200
|
+
if (Number(x.rich) > 0) bits.push(`摘要加成+${f(x.rich)}`);
|
|
201
|
+
if (Number(x.row?.importance ?? 0) >= 1) bits.push('重要性+0.8');
|
|
202
|
+
// ⚠️ 同一口径修正(红队 2026-10-04,与条目路**必须同时改** —— 只改一边会让另一条路继续说谎):
|
|
203
|
+
// 分项之间是加法,`血缘`/`矛盾` 是**乘数**,不能混进加法列表。
|
|
204
|
+
const mult = [`× 血缘${f(x.lineage)}`];
|
|
205
|
+
if (Number(x.conf ?? 1) < 1) mult.push(`× 矛盾${f(x.conf)}`);
|
|
206
|
+
return `(${bits.join(' + ')}) ${mult.join(' ')} ⇒ ${f(x.score, 3)}`;
|
|
207
|
+
}
|
|
208
|
+
const bits = [];
|
|
209
|
+
if (x.pinned) bits.push('置顶(必进)');
|
|
210
|
+
if (Number(x.durBonus) > 0) bits.push(`硬度${x.dur}(+${f(x.durBonus)})`);
|
|
211
|
+
if (Number(x.kindW) > 0) bits.push(`类别${x.row?.kind || ''}(+${f(x.kindW)})`);
|
|
212
|
+
if (Number(x.hit) > 0) bits.push(`被采用${x.hit}次(+${f(Math.min(1, Number(x.hit) / HIT_SATURATION))})`);
|
|
213
|
+
if (Number(x.kw) > 0) {
|
|
214
|
+
bits.push(`关键词命中${x.kw}条(+${f(Math.min(KW_BONUS_CAP, Number(x.kw) * KW_BONUS_PER_HIT))})`);
|
|
215
|
+
}
|
|
216
|
+
if (x.recency !== undefined && x.recency !== null) bits.push(`新近${f(x.recency)}(${f(RECENCY_W)}×)`);
|
|
217
|
+
// ⚠️ 口径(红队 2026-10-04 抓到"留痕说谎"):分项之间是**加法**,而总分是 `分项和 × 血缘 × 矛盾`。
|
|
218
|
+
// 把两个乘数混进加法列表里报,`lineage=0.4` 时会报出分项和 1.10 而实际分 0.440(差 2.5 倍)⇒
|
|
219
|
+
// 现在显式写成 `(分项和) × 血缘 × 矛盾 ⇒ 总分`,让留痕与公式一一对得上。
|
|
220
|
+
const mult = [`× 血缘${f(x.lineage)}`];
|
|
221
|
+
if (Number(x.conf ?? 1) < 1) mult.push(`× 矛盾${f(x.conf)}`);
|
|
222
|
+
return `(${bits.join(' + ')}) ${mult.join(' ')} ⇒ ${f(x.score, 3)}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
91
225
|
/** 主干枝 id(与 `memory.js` 的 `TRUNK_ID` 一致;算法模块不耦合存储层,故此处独立定义)。 */
|
|
92
226
|
const TRUNK_BRANCH = 'trunk';
|
|
93
227
|
/** 血缘档位兜底:未知枝按"旁系"算(与 memory.js 的 `LINEAGE_SIDE` 一致)。 */
|
|
@@ -162,7 +296,7 @@ export function selectL1(store, opts) {
|
|
|
162
296
|
const now = Date.now();
|
|
163
297
|
const summaryBonus = Number.isFinite(Number(o.summaryBonus)) ? Number(o.summaryBonus) : SUMMARY_BONUS_DEFAULT;
|
|
164
298
|
// 2026-09-21:归档会话不参与召回 —— 归档是主人的明确动作("这条别再提了"),
|
|
165
|
-
// 而此前 selectL1 直接吃 listOverviews 全量:归档只影响界面,召回照旧(
|
|
299
|
+
// 而此前 selectL1 直接吃 listOverviews 全量:归档只影响界面,召回照旧(尝生实测反馈)。
|
|
166
300
|
// 只对 source='dsh' 生效:archived 是会话级标记,历史网页端/导入条目没有会话行。
|
|
167
301
|
const archived = (() => {
|
|
168
302
|
try {
|
|
@@ -182,42 +316,190 @@ export function selectL1(store, opts) {
|
|
|
182
316
|
return new Set();
|
|
183
317
|
}
|
|
184
318
|
})();
|
|
185
|
-
|
|
319
|
+
// ── 1.6 步 5:换料(双路并存,过渡期) ───────────────────────────────────────────────
|
|
320
|
+
// 新:`store.listDeepItems()` —— 深层库条目(一句话结论/可从任何会话提炼)。
|
|
321
|
+
// 旧:`store.listOverviews({ onlyOk: true })` —— 会话概述(一段话/会话一条)。
|
|
322
|
+
//
|
|
323
|
+
// **为什么要并存而不是一刀切换掉**(2026-10-04):
|
|
324
|
+
// ① 条目还没提炼完(全量提炼在跑)⇒ 一刀切会让尚未提炼的会话在开场里**整个消失**;
|
|
325
|
+
// ② 换料是"数据源 + 打分公式 + 行渲染"三件一起动,旧路径保留 = 既有 8 个测试套件
|
|
326
|
+
// (branch/forget-backfill/injectlog/memories/sessionline/tree/times/unit)继续覆盖旧口径,
|
|
327
|
+
// 不至于把"换料对不对"和"我有没有改坏别的"搅在一起。
|
|
328
|
+
// ⇒ 判据:**有条目就走新路**(本机现状),条目为空才回落概述。
|
|
329
|
+
// ⚠️ 条目铺满全库后,下面 `useDeep === false` 的整条分支可以删掉 —— 那才是这次换料的终点。
|
|
330
|
+
const deepAll = (typeof store.listDeepItems === 'function')
|
|
331
|
+
? store.listDeepItems({ limit: DEEP_SCAN_MAX })
|
|
332
|
+
: [];
|
|
333
|
+
const useDeep = deepAll.length > 0;
|
|
334
|
+
const all = useDeep
|
|
335
|
+
? deepAll
|
|
336
|
+
: (typeof store.listOverviews === 'function' ? store.listOverviews({ onlyOk: true }) : []);
|
|
186
337
|
// D9-a 血缘加权(2026-09-18):当前会话所属枝 → 每条记忆所属枝的档位
|
|
187
338
|
// (同枝 1.0 / 祖先 0.7 / 旁系或后代 0.4)。取"最弱环"单值,**不做连乘** ——
|
|
188
339
|
// `0.7^5=0.168`、`0.4^5=0.010`,连乘会让深枝等于从记忆里消失(见 `memory.js` 的 `LINEAGE_*`)。
|
|
189
|
-
//
|
|
340
|
+
// 条目沿用同一把尺:血缘与矛盾降权都按 `source|conv_id` 索引,与"选的是概述还是条目"无关。
|
|
190
341
|
const ctx = lineageContext(store, o.sessionId);
|
|
191
|
-
|
|
192
|
-
|
|
342
|
+
// 1.6-A 投递留痕:过滤口径必须**可报告**。原来是一条 `.filter()` 链 —— 丢了多少、为什么丢,
|
|
343
|
+
// 出了函数就查不到(只剩 `dropped` 一个净差)。改成显式循环,顺手留下三类计数:
|
|
344
|
+
// archived / forgotten 是**主动排除**(主人的明确动作),zero 是"算不出正分"(无信息量)。
|
|
345
|
+
const excluded = { zero: 0, archived: 0, forgotten: 0, ephemeral: 0 };
|
|
346
|
+
const scored = [];
|
|
347
|
+
for (const row of all) {
|
|
348
|
+
// 来源列名两路不同:条目叫 `src`、概述叫 `source`。血缘/矛盾/归档/遗忘四张表都按
|
|
349
|
+
// `source|conv_id` 索引 ⇒ 在这里对齐一次,而不是去改那四处读法。
|
|
350
|
+
const srcOf = String(useDeep ? (row.src ?? '') : (row.source ?? ''));
|
|
351
|
+
const asOverview = { source: srcOf, conv_id: row.conv_id };
|
|
352
|
+
const lineage = lineageOf(ctx, asOverview);
|
|
353
|
+
// D9-b:矛盾降权 —— 检出矛盾但未复盘时"以最新为准",旧的一方乘 CONFLICT_DOWNWEIGHT(0.3)。
|
|
354
|
+
// 只降权、不删除、不改内容(设计稿 §4 的核心不变量)。
|
|
355
|
+
const conf = conflictOf(ctx, asOverview);
|
|
356
|
+
let x;
|
|
357
|
+
if (useDeep) {
|
|
358
|
+
// 硬度(1.6 v17):`ephemeral`(当次决定 / 一次性的绕法)**不进开场** —— 它照旧躺在库里、
|
|
359
|
+
// `recall` 搜得到,只是不该占开场那几个位置。这一条压的是**病根**(这条结论本身能活多久);
|
|
360
|
+
// 而 `MAX_PER_CONV` 压的是症状(临时结论往往扎堆在一次会话里)⇒ 两者并存、分工不同。
|
|
361
|
+
if (String(row.durability || 'long') === 'ephemeral') { excluded.ephemeral += 1; continue; }
|
|
362
|
+
const hit = Number(row.hit_count ?? 0);
|
|
363
|
+
const kw = kwHitInText(o.keywords || [], row.text);
|
|
364
|
+
const t = toEpochMs(row.at || row.created_at);
|
|
365
|
+
const recency = decayWeight(t === null ? 0 : Math.max(0, (now - t) / DAY_MS), DEEP_HALF_LIFE);
|
|
366
|
+
// 硬度参与排序(不只是出局):`durable` 抬 0.6,与 `long` 拉开约 7 倍 ⇒ 长期原则/承诺/关系
|
|
367
|
+
// 天然排在"阶段性结论"之前,不再靠新近来区分(新近已在 0.1 且常常全平)。
|
|
368
|
+
const dur = String(row.durability || 'long').trim();
|
|
369
|
+
const durBonus = DURABILITY_BONUS[dur] ?? 0;
|
|
370
|
+
const kindW = KIND_WEIGHT[String(row.kind || '').trim()] ?? 0;
|
|
371
|
+
const raw = durBonus + kindW
|
|
372
|
+
+ HIT_BONUS * Math.min(1, hit / HIT_SATURATION)
|
|
373
|
+
+ Math.min(KW_BONUS_CAP, kw * KW_BONUS_PER_HIT)
|
|
374
|
+
+ RECENCY_W * recency;
|
|
375
|
+
const pinned = Number(row.pinned ?? 0) === 1;
|
|
376
|
+
x = { row, score: raw * lineage * conf, lineage, raw, hit, kw, recency, conf, pinned, dur, durBonus, kindW, useDeep };
|
|
377
|
+
} else {
|
|
193
378
|
const heat = computeHeat(row, now, {});
|
|
194
379
|
const catW = weights[row.category] ?? weights.daily ?? 0.5;
|
|
195
380
|
const kw = kwOverlap(o.keywords || [], row.keywords);
|
|
196
381
|
const rich = summaryWeight(row) * summaryBonus;
|
|
197
382
|
const raw = catW * heat + Math.min(KW_BONUS_CAP, kw * KW_BONUS_PER_HIT) + (row.importance >= 1 ? 0.8 : 0) + rich;
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
383
|
+
x = { row, score: raw * lineage * conf, lineage, raw, heat, catW, kw, rich, conf, pinned: false, useDeep };
|
|
384
|
+
}
|
|
385
|
+
// 置顶是**必进**的(主人的明确动作)⇒ 即使分数算不出正分也不丢;其余零分者视为无信息量。
|
|
386
|
+
if (!(x.score > 0) && !x.pinned) { excluded.zero += 1; continue; }
|
|
387
|
+
if (srcOf === 'dsh' && archived.has(String(row.conv_id))) { excluded.archived += 1; continue; }
|
|
388
|
+
if (forgotten.has(srcOf + '|' + String(row.conv_id))) { excluded.forgotten += 1; continue; }
|
|
389
|
+
scored.push(x);
|
|
390
|
+
}
|
|
391
|
+
// 排面:**置顶必进**(不参与竞争),其余按分排;同分时按**会话时间**新的在前 ——
|
|
392
|
+
// `created_at` 是入库时间,同一轮收进去的条目全落在同一秒 ⇒ 拿它当 tiebreaker 等于随机。
|
|
393
|
+
const pinnedX = scored.filter((x) => x.pinned);
|
|
394
|
+
const restX = scored
|
|
395
|
+
.filter((x) => !x.pinned)
|
|
396
|
+
.sort((a, b) => b.score - a.score
|
|
397
|
+
|| String(b.row.at || b.row.created_at || '').localeCompare(String(a.row.at || a.row.created_at || '')));
|
|
398
|
+
// **同源限额**(2026-10-04 尝生实测:「会话内的、临时的结论会被灌入」):
|
|
399
|
+
// 同一次会话最多进 `MAX_PER_CONV` 条 —— 否则一次"高产出"的会话能把整个开场占满
|
|
400
|
+
// (实测某次生图会话有 13 条,开场 8 条里它一家全占)。同源被挡下的**进落选者名单**
|
|
401
|
+
// (`reason='conv'`),这样"它为什么没进"仍可查,而不是凭空消失。
|
|
402
|
+
const perConv = new Map();
|
|
403
|
+
const ranked = []; // 过了同源限额与近似重复两道门、且已按分排好的全量
|
|
404
|
+
const convCapped = []; // 被同源限额挡下的
|
|
405
|
+
const dupCapped = []; // 与已入选条目**近似重复**的(2026-10-04:同一次真实注入里出现过两条同义结论)
|
|
406
|
+
const pickedTexts = [];
|
|
407
|
+
for (const x of [...pinnedX, ...restX]) {
|
|
408
|
+
const k = String(x.row.conv_id || '');
|
|
409
|
+
const seen = perConv.get(k) || 0;
|
|
410
|
+
// 置顶不受限额约束 —— 那是主人的明确动作;限额只用来挡"自动提炼的扎堆"。
|
|
411
|
+
if (seen >= MAX_PER_CONV && !x.pinned) { convCapped.push(x); continue; }
|
|
412
|
+
// 近似重复:与**已入选**的任一条件文本高度相似 ⇒ 让位。
|
|
413
|
+
// ⚠️ **置顶豁免** —— 与同源限额同一待遇:置顶是主人的明确动作,他要让两条同义的都进,那是他的权利;
|
|
414
|
+
// 去重只该挡"自动提炼出来的重复"(2026-10-04:测试夹具三条同文本置顶当场打红了三条断言)。
|
|
415
|
+
const body = useDeep ? x.row.text : (x.row.title || x.row.summary);
|
|
416
|
+
if (!x.pinned && pickedTexts.some((t) => similarText(t, body))) { dupCapped.push(x); continue; }
|
|
417
|
+
perConv.set(k, seen + 1);
|
|
418
|
+
pickedTexts.push(body);
|
|
419
|
+
ranked.push(x);
|
|
420
|
+
}
|
|
421
|
+
// ⚠️ `chosen` 必须是 **Top-max**(后面那道预算循环只负责按字数裁):
|
|
422
|
+
// 若把 `ranked` 直接当 `chosen`,`cutAt` 之后的全会被归成 `budget` 落选 —— 而它们其实是
|
|
423
|
+
// "排在第 max 名之后",两类原因混起来,落选者留痕就说谎了(2026-10-04 踩过,两个套件同时红)。
|
|
424
|
+
// ⚠️ **置顶必进**在 `maxItems` 这一道也必须成立(红队 2026-10-04 实测:9 条置顶 + maxItems=3 ⇒
|
|
425
|
+
// 只进 3 条,越界那 6 条 `pinned=true` 却被记成 `reason='max'`)—— 同源限额与去重两道门都给了
|
|
426
|
+
// 置顶豁免,这一道漏了,等于"必进"只是句空话。置顶超过 max 时**允许越界**(它是主人的明确动作)。
|
|
427
|
+
const keepPinned = ranked.filter((x) => x.pinned);
|
|
428
|
+
const restRanked = ranked.filter((x) => !x.pinned);
|
|
429
|
+
const restQuota = Math.max(0, max - keepPinned.length);
|
|
430
|
+
// **事实席位**(尝生 2026-10-04:「8 条也可以考虑改 10 条,其中 2 条作为最硬的事实的席位」)——
|
|
431
|
+
// 动机是他看到的偏斜:承诺/关系优先之后,「我在什么环境里干活」这类**稳定事实**因为类别分为 0
|
|
432
|
+
// 而永远进不来;但它们确实是"接下来要干什么"的前提。⇒ 10 个位置里留 2 个给**最硬的事实**
|
|
433
|
+
// (`durable` 的「事实」类),既不挤占"关于人"的主体(那 8 个),也不让环境事实缺席。
|
|
434
|
+
// 没有合格事实时席位**归还通用池** —— 宁可让位也不空着。
|
|
435
|
+
const FACT_SEATS = 2;
|
|
436
|
+
const isHardFact = (x) => String(x.row?.kind || '') === '事实' && String(x.row?.durability || '') === 'durable';
|
|
437
|
+
const factPool = restRanked.filter(isHardFact);
|
|
438
|
+
const genPool = restRanked.filter((x) => !isHardFact(x));
|
|
439
|
+
const factTake = factPool.slice(0, Math.min(FACT_SEATS, restQuota));
|
|
440
|
+
const genTake = genPool.slice(0, Math.max(0, restQuota - factTake.length));
|
|
441
|
+
// 混排回分数序(席位只决定"谁能进",不改变"进了之后排第几")
|
|
442
|
+
const chosen = [...keepPinned, ...[...factTake, ...genTake].sort((a, b) => b.score - a.score)];
|
|
443
|
+
const chosenSet = new Set(chosen);
|
|
444
|
+
const beyondMax = restRanked.filter((x) => !chosenSet.has(x));
|
|
445
|
+
// ⚠️ `weights`(categoryWeights)与 `summaryBonus` 只在 `useDeep === false` 的回落路径里参与打分。
|
|
446
|
+
void weights; void summaryBonus;
|
|
209
447
|
// budget-aware trim
|
|
448
|
+
// 行渲染**必须跟着数据源分路**(2026-10-04 踩过):条目走 `deepLine`(极简一行),
|
|
449
|
+
// 概述走 `lineFor`(标题 —— 摘要 + 来源后缀)。写成无条件 `deepLine` 的后果是:
|
|
450
|
+
// 回落路径下 `row.text` 是 undefined ⇒ 每一行渲染成 `- ` ⇒ 整个注入面变成一串空行,
|
|
451
|
+
// **而且不报错**(不是异常,是静默错),只有断言"格式化含来源"能抓到它。
|
|
452
|
+
const lineOf = (row) => (useDeep
|
|
453
|
+
? deepLine(row, o.maxLineChars)
|
|
454
|
+
: lineFor(row, { maxChars: o.maxLineChars, summaryChars: o.summaryChars, selfConvId: o.sessionId }));
|
|
210
455
|
const items = [];
|
|
211
456
|
let totalChars = 0;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
457
|
+
let cutAt = chosen.length; // 第一个被字节预算挡下的位置(它及之后 = 预算落选)
|
|
458
|
+
for (let i = 0; i < chosen.length; i++) {
|
|
459
|
+
const c = chosen[i];
|
|
460
|
+
const line = lineOf(c.row);
|
|
461
|
+
// ⚠️ **置顶必进在这里也必须成立**(蓝队 2026-10-04 抓到:上一版只改了 `chosen` 的造法,
|
|
462
|
+
// 没改这道闸门 ⇒ 真机 9 条置顶 + maxItems=3 仍只进 3 条,穷举 90 组**累计丢掉 117 条置顶**)。
|
|
463
|
+
// 置顶越界时**允许超出 max**,字数预算对它也不设限 —— 否则"必进"只是句空话。
|
|
464
|
+
// `chosen` 已把置顶排在前面,所以非置顶越界时 `break` 仍然正确(后面全是非置顶)。
|
|
465
|
+
if (!c.pinned && (items.length >= max || totalChars + line.length > budget)) { cutAt = i; break; }
|
|
466
|
+
items.push({ ...c.row, score: +c.score.toFixed(3), lineage: +Number(c.lineage).toFixed(2), conf: +Number(c.conf ?? 1).toFixed(2), raw: +Number(c.raw).toFixed(3), kw: c.kw, hit: c.hit, pinned: c.pinned, line, why: explainScore(c) });
|
|
216
467
|
totalChars += line.length;
|
|
217
468
|
}
|
|
469
|
+
// 落选者名单(1.6-A):**差一步就进**的那几条 + 为什么没进。这是"不漏用"这一维唯一的仪表 ——
|
|
470
|
+
// 只看 `dropped` 一个净差,回答不了「是分数本来就不够,还是被字节预算挤掉的」。
|
|
471
|
+
// 两类原因互斥:`budget` = 已在 Top-max 之内、字数放不下(必排在 `max` 组之前);`max` = 排在第 max 名之后。
|
|
472
|
+
const runnerUpLimit = Math.max(0, Math.min(20, Number(o.runnerUps ?? RUNNER_UPS_DEFAULT)));
|
|
473
|
+
const runners = [];
|
|
474
|
+
for (let i = cutAt; i < chosen.length; i++) runners.push({ x: chosen[i], reason: 'budget' });
|
|
475
|
+
for (const x of convCapped) runners.push({ x, reason: 'conv' }); // 被同源限额挡下(2026-10-04)
|
|
476
|
+
for (const x of dupCapped) runners.push({ x, reason: 'dup' }); // 与已入选条目近似重复(2026-10-04)
|
|
477
|
+
for (const x of beyondMax) runners.push({ x, reason: 'max' }); // 排在第 max 名之后(已排序,故比旧的 scored[i] 更准)
|
|
478
|
+
runners.sort((a, b) => b.x.score - a.x.score);
|
|
479
|
+
const runnerUps = runners.slice(0, runnerUpLimit).map(({ x, reason }) => ({
|
|
480
|
+
// ⚠️ 来源列名两路不同:条目叫 `src`、概述叫 `source`(红队 2026-10-04 抓到:这里写死
|
|
481
|
+
// `x.row.source` ⇒ 换料后落选者留痕的 source **恒为 undefined**,实测 id 42–46 段 0/5)——
|
|
482
|
+
// 而 `archived`/`forgotten`/`conflict` 三张表全按 `source|conv_id` 索引,丢了 source 就回查不上。
|
|
483
|
+
source: String(x.useDeep ? (x.row.src ?? '') : (x.row.source ?? '')),
|
|
484
|
+
conv_id: x.row.conv_id,
|
|
485
|
+
score: +x.score.toFixed(3),
|
|
486
|
+
raw: +Number(x.raw).toFixed(3),
|
|
487
|
+
lineage: +Number(x.lineage).toFixed(2),
|
|
488
|
+
conf: +Number(x.conf ?? 1).toFixed(2),
|
|
489
|
+
kw: x.kw,
|
|
490
|
+
hit: x.hit,
|
|
491
|
+
pinned: x.pinned,
|
|
492
|
+
reason,
|
|
493
|
+
chars: lineOf(x.row).length,
|
|
494
|
+
why: explainScore(x),
|
|
495
|
+
}));
|
|
218
496
|
return {
|
|
219
497
|
items,
|
|
220
498
|
dropped: scored.length - items.length,
|
|
499
|
+
runnerUps,
|
|
500
|
+
excluded,
|
|
501
|
+
// 本轮选取的触发词(1.6-A 留痕):「为什么是它」的一半答案在**问句**里,不留就无从复盘。
|
|
502
|
+
keywords: Array.isArray(o.keywords) ? o.keywords.slice() : [],
|
|
221
503
|
totalChars,
|
|
222
504
|
mode,
|
|
223
505
|
budget,
|
|
@@ -281,9 +563,54 @@ function lineFor(row, { maxChars = 0, summaryChars = SUMMARY_CHARS_DEFAULT, self
|
|
|
281
563
|
return `- ${tags} ${date ? `(${date})` : ''}${out ? ' ' + out : ''} (来源: ${srcName}/${short})`;
|
|
282
564
|
}
|
|
283
565
|
|
|
284
|
-
|
|
566
|
+
/** 1.6 步 5:一条**深层库条目**渲染成一行(取代 `lineFor` 的概述两段式 `标题 —— 摘要首句`)。
|
|
567
|
+
* 为什么这么极简:条目本身就是**一句话结论**(全库均 48 字),再套"日期/标签/来源"三段前缀
|
|
568
|
+
* 等于每行白付约 15 字的税,而注入面的字数是这份文件存在的理由本身。
|
|
569
|
+
* 出处(conv/seq)不进这一行 —— 它落在**投递留痕**里(`inject_log.items`),模型要原话时
|
|
570
|
+
* 用 `recall({q})` 找到条目再 `recall(conv, from, to)` 下钻。
|
|
571
|
+
* `lineFor` 保留未删:它仍是被测函数(`l1lines.test.mjs`),且概述行在别的读面还在用。 */
|
|
572
|
+
function deepLine(row, maxChars = 0) {
|
|
573
|
+
const kind = String(row?.kind || '').trim();
|
|
574
|
+
let text = String(row?.text || '').trim();
|
|
575
|
+
// 兜底硬顶(默认 0 = 不截):提炼侧本来就限制 ≤80 字,这里只为**病态行**保命 ——
|
|
576
|
+
// 预算裁剪是"装不下就停"(见下面的 for),一条超长条目会把排在它后面的全卡死。
|
|
577
|
+
const cap = Number(maxChars) > 0 ? Math.max(60, Number(maxChars)) : 0;
|
|
578
|
+
if (cap && text.length > cap) text = text.slice(0, cap - 1) + '…';
|
|
579
|
+
return kind ? `- [${kind}] ${text}` : `- ${text}`;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// L1 视角框(1.6 · 第一批 B)—— 修「代词混乱」BUG:落点一处、覆盖全部历史条目、零动库。
|
|
583
|
+
// 病灶(实测):`conv_overview` 的
|
|
584
|
+
// **标题位多半是主人当年的原话**(8 行里 7 行),其中还夹着指令与限制 ——
|
|
585
|
+
// 如「本会话暂时不用执行任何写操作」,读到会误判**当前**会话只读,是可指认的行为危害。
|
|
586
|
+
// 组装侧 `lineFor()` 不做人称改写是对的(原文照录才有信息量),缺的是**声明视角**。
|
|
587
|
+
// 代价:每次注入 +≈59 字(约本段 5%);改段头 ⇒ 前缀缓存断一次,此后稳定。
|
|
588
|
+
const L1_FRAME_CALLNAME_FALLBACK = '主人';
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* 视角框的称呼 —— **跟随 `persona.userTitle`**,不硬编码。
|
|
592
|
+
*
|
|
593
|
+
* 为什么走配置而不是写死(2026-10-04 尝生的判断:「提早换更合适」):
|
|
594
|
+
* - `dsh-ling` 是要发 npm 的通用包,**把某个用户的名字写死会污染公开件**;
|
|
595
|
+
* - `userTitle` 本来就是**主人自己可编辑**的字段(`persona.js`:「空 = 用"你"」),
|
|
596
|
+
* 走它 = 本机显示他设的称呼、别人的实例显示他们自己的,**本机效果不打折**;
|
|
597
|
+
* - 与 `[现在] 距上次与X对话`(`clock.js` 的 `userTitleForMode`)共用同一把尺 ⇒ 两处口径不会打架;
|
|
598
|
+
* - 换名字只改一处配置,不必重新发版。
|
|
599
|
+
*
|
|
600
|
+
* 空值退回中性措辞「主人」(`L1_FRAME_CALLNAME_FALLBACK`)—— 与包内既有文案口径一致。
|
|
601
|
+
* ⚠️ 不可退回「你」:框里「你」已经**被定义为我(器灵)**,称呼主人用「你」会自相矛盾。
|
|
602
|
+
*/
|
|
603
|
+
export function perspectiveFrame(userTitle = '', mode = 'life') {
|
|
604
|
+
const callName = userTitleForMode(userTitle, mode) || L1_FRAME_CALLNAME_FALLBACK;
|
|
605
|
+
// ⚠️ 1.6 步 5 换料后措辞必须跟着改:框里原来的说法是「${callName}过去对我说过的话」,
|
|
606
|
+
// 那是**概述时代**的事实(标题位多半是他的原话);现在注入的是**提炼出的结论**
|
|
607
|
+
// (承诺/决定/事实/偏好/关系),主体既可能是他也可能是我 ⇒ 说成"他说过的话"会失真。
|
|
608
|
+
return `(以下是我从与${callName}的过去里记住的结论:「你」指我,「我/我们」指他,「本会话/接下来/现在」指当时那个会话,不是此刻的指令。)`;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export function formatL1Section(result, opts = {}) {
|
|
285
612
|
if (!result || !result.items.length) return '';
|
|
286
613
|
const lines = result.items.map((i) => i.line);
|
|
287
614
|
if (result.dropped > 0) lines.push(`(已省略 ${result.dropped} 条较低相关记忆)`);
|
|
288
|
-
return
|
|
615
|
+
return `[记忆·开场]${perspectiveFrame(opts.userTitle, opts.mode)}\n` + lines.join('\n');
|
|
289
616
|
}
|