@nightpainters/dsh-ling 1.6.0 → 1.6.2
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 +97 -11
- package/DESIGN.md +34 -25
- package/PHILOSOPHY.md +174 -0
- package/README.md +13 -6
- package/lib/host/api.js +21 -3
- package/lib/host/deep-index-skill.js +9 -0
- package/lib/host/l1.js +47 -3
- package/lib/host/lifecycle.js +11 -0
- package/lib/host/memory.js +20 -15
- package/lib/host/persona.js +0 -1
- package/lib/host/rawlog.js +17 -6
- package/lib/host/recall-search.js +148 -10
- package/lib/host/surgery.js +4 -1
- package/lib/host/tools.js +50 -16
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
- 本插件的回答与记忆摘要**由人工智能生成**,可能存在错误,请自行判断。
|
|
22
22
|
- 本项目面向**具备自行配置与运维能力**的用户;不面向未成年人,也不提供任何形式的在线陪伴服务。
|
|
23
23
|
|
|
24
|
+
**想先知道它凭什么这么做?** 读 **[`PHILOSOPHY.md`](./PHILOSOPHY.md)** —— 目标为什么是"一种循环"、为什么复盘必须有"人在循环里"、它凭什么不越界、以及"不牺牲工作能力"背后的取舍。功能与设定的规格在本 README 的「设定全文」。
|
|
25
|
+
|
|
24
26
|
---
|
|
25
27
|
|
|
26
28
|
<!-- 截图清单(仓库 images/ 目录):
|
|
@@ -68,7 +70,7 @@
|
|
|
68
70
|
|
|
69
71
|
> **关于那道「手术门」**:亲手敲承诺句是**庄重感与自我提醒** —— 让你在改契约之前停一下、确认这是自己的决定;它**不是安全边界**(「禁粘贴」只是界面上的约定,挡不住任何有心的人)。真正把门的是**来源栅栏**:Host 必须是 loopback(或你显式写进受信名单的地址);另有一个**默认不开**的开关 `guard.allowLan` —— **一旦打开,整段局域网地址都算受信来源**(那是**放宽**,不是建议)。跨站请求直接拒,带 `Origin` 时其 host 必须等于请求的 Host(**带了但解析不出来就直接拒**),而**不带 `Origin` 的请求这条校验会跳过** —— 它不是"每个请求都必须带 Origin 且同源";再叠一道 cookie **名字**对撞 —— 期望名字按当前 Host 现算(`dsh-auth-` + 哈希),**只校验名字、不校验值的签名**。所以这道门挡的是"别的设备、别的页面"这类来源,而**本机原生进程本来就还能调用**(它也能直接读 `settings.json` 与记忆库,不算提权)—— 别的来源没有凭据就进不来这台机器上的 `/api`,敲对承诺句也没用。
|
|
70
72
|
|
|
71
|
-
> **这道门现在长这样 —— 三条手续缺一不可**:① **只在本机做** —— 判据是"这次请求没有经过任何代理"(带代理头、或连 User-Agent 都没有的请求,都算非本机 ⇒ 拒,理由码 `surgery-local-only`);② **每次启动取一次票据** —— 票据每启动轮换,只下发给本机直连的请求(`POST /surgery/ticket`),客户端首次手术前取一次、之后随请求带上;③ **原句匹配** —— 敲出的承诺句要与当初写下的那一句相符。这三条由 `lib/host/surgery.js` 里**一处** `requireSurgery()` 判定,覆盖**全部
|
|
73
|
+
> **这道门现在长这样 —— 三条手续缺一不可**:① **只在本机做** —— 判据是"这次请求没有经过任何代理"(带代理头、或连 User-Agent 都没有的请求,都算非本机 ⇒ 拒,理由码 `surgery-local-only`);② **每次启动取一次票据** —— 票据每启动轮换,只下发给本机直连的请求(`POST /surgery/ticket`),客户端首次手术前取一次、之后随请求带上;③ **原句匹配** —— 敲出的承诺句要与当初写下的那一句相符。这三条由 `lib/host/surgery.js` 里**一处** `requireSurgery()` 判定,覆盖**全部 14 条**能改人格 / 规矩 / 习惯的写面(调用点不许再各写一份;曾有一条 `wantLock` 绕过分支,已删除)。每次手术都留痕:`$DSH_HOME/logs/surgery-YYYY-MM.jsonl`(append-only,整行哈希链,改一行即断链);`GET /surgery/events` 是只读事件面。
|
|
72
74
|
>
|
|
73
75
|
> **承诺句只在本机回显**:`/state`(含面板档)、`/persona/history`、`/export` 这些读面,**带代理头**时只回 `hasSeal` 与 `phraseHidden`、不回原句,**本机直连**照旧回显 —— 所以你换台机器看面板不会因此被卡住,远端也拿不到原句。
|
|
74
76
|
>
|
|
@@ -111,7 +113,7 @@
|
|
|
111
113
|
- **不连乘**:血缘系数是**单值**,不沿路径累乘 —— 否则五层之后 `0.4^5 ≈ 0.01`,深层枝等于从记忆里消失。
|
|
112
114
|
- **内容零改写**(硬性不变量):**除了"主脉提炼再次采纳会覆盖上一版"这一个例外**(覆盖前自动归档),任何树操作都不改写任何一条记忆的内容;分枝 / 并脉 / 连边只增加"关系",从不"融合内容"。
|
|
113
115
|
- **矛盾只标记不改写**:一对记忆被判重复/矛盾时先**标记**;未复盘时检索**以较新的那条为准**(旧的一条降权 ×0.3,**不删除**;被**驳回**的与被标成**重复**的那一对不参与降权)。
|
|
114
|
-
-
|
|
116
|
+
- **枝系数会真的改变排序**(1.6.2 起):复盘期可以调(0~2,夹紧后写入)。它按 `raw × 血缘 × 矛盾 × 枝系数` **参与检索打分** —— 调低某条枝的系数,从这条枝的会话里提炼出的结论就会在记忆开场里往后排(`1.0` = 不偏不倚,`0` = 排到最后,**不是禁用**)。
|
|
115
117
|
|
|
116
118
|
**复盘 — 不是一个开关,是"左会话 + 右复盘栏"这个状态**:
|
|
117
119
|
|
|
@@ -175,7 +177,7 @@ dsh plugin --profile web add @nightpainters/dsh-ling
|
|
|
175
177
|
# 2) 只验证配置层,先不启动
|
|
176
178
|
dsh --profile web --dump-config # 应能看到一行 "# == @nightpainters/dsh-ling" 层
|
|
177
179
|
|
|
178
|
-
# 3) 给 DSH 核心打一行补丁(
|
|
180
|
+
# 3) 给 DSH 核心打一行补丁(可取证访问日志要用;幂等;这一页各步里**只有这一步**自带回滚)
|
|
179
181
|
node ~/.dsh/profiles/web/node_modules/@nightpainters/dsh-ling/tools/apply-access-log-patch.mjs
|
|
180
182
|
# 不打也一切照常 —— 只是访问日志为空;但**会提醒**:侧栏挂常驻 ⚠(判据 installed===false 且 reason 为 patch-missing;只有**有意关掉** config.accessLog.enabled=false ⇒ reason 为 disabled-by-config 才不染红)
|
|
181
183
|
# DSH 升级/重装后需重跑一次(补丁随包被覆盖)
|
|
@@ -184,6 +186,8 @@ node ~/.dsh/profiles/web/node_modules/@nightpainters/dsh-ling/tools/apply-access
|
|
|
184
186
|
# 4) 重启宿主(host),然后浏览器 Ctrl+Shift+R 硬刷
|
|
185
187
|
```
|
|
186
188
|
|
|
189
|
+
> ⚠️ **「可回滚 / 先备份」的确切范围(别读宽了 —— 2026-10-05 现场复核)**:**两套步骤里只有打补丁这一步自带回滚** —— 脚本动手前先把被改的那个 core 文件另存一份 `<文件>.e4-access-log.orig`(**同名备份已存在时不覆盖**);要撤,就在上面那条命令后加 `--revert`(**手动**跑,详见下文「访问日志与核心补丁的总开关」)。⚠️ 撤法是"**删掉插进去的那几行**",不是把整份备份还原回去(备份只用来对拍:不一致时它会如实报 `⚠ 与备份不一致`)。**安装与升级本身既不先备份、也不会失败自动回滚**:插件只**读** profile 的 `package.json` 与 `cordis.patch.yml` 做自检(`tools/check-install.mjs`),**从不改写、也不备份**它们 —— 那两处改动是你手动做的,撤回也是手动(删掉那一行 + 撤依赖,见 `DESIGN.md` §10「回滚」)。想留后路,**动手前自己把这两个文件复制一份**。锚点漂移 / 歧义时脚本的行为是**一个字都不改**(安全失败),不是"改了再回滚"。
|
|
190
|
+
|
|
187
191
|
> 发布名是 **`@nightpainters/dsh-ling`**,不是 `dsh-ling` —— npm 上 `dsh-ling` 这个名字已被他人占用。
|
|
188
192
|
> 插件在配置里的注册 id 仍是 `dsh-ling`,两者不是一回事。
|
|
189
193
|
|
|
@@ -202,7 +206,7 @@ pnpm add file:<路径>/dsh-ling # 或 npm install file:<路径>/dsh-ling
|
|
|
202
206
|
# name: '@nightpainters/dsh-ling'
|
|
203
207
|
# (id 保持 dsh-ling;name 用包名,Node 才解析得到代码)
|
|
204
208
|
|
|
205
|
-
# 2) 给 DSH 核心打一行补丁(
|
|
209
|
+
# 2) 给 DSH 核心打一行补丁(可取证访问日志要用;幂等;这一页各步里**只有这一步**自带回滚)
|
|
206
210
|
cd <路径>/dsh-ling
|
|
207
211
|
node tools/apply-access-log-patch.mjs # 只在 core 的 /api 栅栏处插一行可选调用
|
|
208
212
|
# 不打也一切照常 —— 只是访问日志为空;但**会提醒**:侧栏挂常驻 ⚠(判据 installed===false 且 reason 为 patch-missing;只有**有意关掉** config.accessLog.enabled=false ⇒ reason 为 disabled-by-config 才不染红)
|
|
@@ -211,11 +215,13 @@ node tools/apply-access-log-patch.mjs # 只在 core 的 /api 栅栏处插一
|
|
|
211
215
|
# 3) 重启宿主(host),然后浏览器 Ctrl+Shift+R 硬刷
|
|
212
216
|
```
|
|
213
217
|
|
|
218
|
+
> 备份与回滚的确切范围见上文路径 A 之后那段(要点:**只有核心补丁这一步可回滚**;安装本身既无自动备份、也无自动回滚)。
|
|
219
|
+
|
|
214
220
|
自检安装是否成功(**下面四个脚本不在 npm 包内**,只有源码路径能用:`tools/check-install.mjs` / `tools/check-publish.mjs` / `tools/clean-archived.mjs` / `tools/check-peers.mjs`;`tools/apply-access-log-patch.mjs` 则**在包内**,从 npm 装的人也能直接跑,见上面第 3 步):
|
|
215
221
|
|
|
216
222
|
```bash
|
|
217
223
|
node tools/check-install.mjs # 只读自检:入口是否直连仓库、patch 是否启用、数据目录是否就绪
|
|
218
|
-
node tools/check-install.mjs --fix # 若入口变成"复制副本"(DSH 升级/回退常见)
|
|
224
|
+
node tools/check-install.mjs --fix # 若入口变成"复制副本"(DSH 升级/回退常见)则改名备份为 <入口>.bak-<时间戳> 后重挂链接;⚠️ 重挂若失败,备份留在原地但**不会自动改回**,按它打印的 mklink /J 手动修
|
|
219
225
|
node tools/apply-access-log-patch.mjs --check # 访问日志核心补丁是否在位(未打/锚点漂移时退出码 2)
|
|
220
226
|
node tools/check-publish.mjs # 上传/发布前跑一次:揪出记忆库、设置、密钥、绝对路径等不该公开的东西
|
|
221
227
|
node tools/check-peers.mjs # 声明与宿主对不上?退出码三态:0 合规 / 1 有不合规(宿主会把本插件丢进 skippedBundles,不激活)/ 2 无法判定(2 不算通过)
|
|
@@ -427,11 +433,12 @@ npm test # 43 套:人格组装(含冻结门 · L1 选择)/ 归档清理(
|
|
|
427
433
|
|
|
428
434
|
| 文件 | 内容 |
|
|
429
435
|
|---|---|
|
|
436
|
+
| [`PHILOSOPHY.md`](./PHILOSOPHY.md) | **理念**:为什么值得做、凭什么这样做(目标循环 · 复盘与"人在循环" · 关系与边界 · 能力取舍) |
|
|
430
437
|
| [`CHANGELOG.md`](./CHANGELOG.md) | 版本更新记录(最新一节即当前版本) |
|
|
431
438
|
| [`DESIGN.md`](./DESIGN.md) | 架构与设计决策(三层记忆、冻结门、注入面、存储结构) |
|
|
432
439
|
| [`ACCESS-DESIGN.md`](./ACCESS-DESIGN.md) | 历史接入蓝图:三条通道、会话契约格式、导入流水线 |
|
|
433
440
|
|
|
434
|
-
> ⚠️ **从 npm 安装的人**:本包 `files` 白名单**不含 `images/`**(图只随 git 仓库公开) ⇒
|
|
441
|
+
> ⚠️ **从 npm 安装的人**:本包 `files` 白名单**不含 `images/`**(图只随 git 仓库公开) ⇒ 上表里**四份文档**都随包发出(`PHILOSOPHY.md` · `CHANGELOG.md` · `DESIGN.md` · `ACCESS-DESIGN.md`),能正常打开;「界面」一节的**四张截图**引用的是仓库里的**绝对地址**,所以 npmjs.com 上**能正常显示**,但**包里没有图片文件**(离线环境读不到)。想看原图请到仓库:<https://github.com/NightPainters/DSH-Ling>。
|
|
435
442
|
|
|
436
443
|
> 📄 **关于出处记录**:内部案卷、探针脚本与开发台账(`plans\`、`release\`、`_probe\`)属**作者本地开发物,不随本包公开**。正文或代码注释里若出现对它们的引用,那是**开发期的出处记录**,不是包内容。
|
|
437
444
|
|
package/lib/host/api.js
CHANGED
|
@@ -478,7 +478,9 @@ export function registerApi(ctx, deps, config) {
|
|
|
478
478
|
]);
|
|
479
479
|
// ── 手术门下沉(2026-09-30 深夜;规格 §1.3「门下沉为一处」)──────────────────────────
|
|
480
480
|
// 判据本体**只在** lib/host/surgery.js 的 requireSurgery() 里;这里只是**唯一**的调用点。
|
|
481
|
-
// 覆盖面 = 全部
|
|
481
|
+
// 覆盖面 = 全部 14 条能改人格/规矩/习惯的写面(逐条清单见 surgery.js 头部注释):挂在 guard(handler)
|
|
482
|
+
// ⚠️ 条数 2026-10-05 夜由 12 订正为 14(B-5):清单逐条列了 14 行,而这里与 surgery.js:15 一直写"12"
|
|
483
|
+
// ⇒ 三处不一致的根源是"没人拿清单行数当唯一真值"。现口径:**以清单行数为准**,改清单须同批改这两处。
|
|
482
484
|
// 上就"一眼看全、零回归面",与 POST_ONLY / 413 出口同一个取舍 —— 而不是在 49 个 register 调用点各写一遍。
|
|
483
485
|
// 安全的位置:本调用在 POST_ONLY(405)与 guardVerdict(403)**之后** ⇒ 被挡下的请求绝不会走到 handler。
|
|
484
486
|
// ⚠️ 承诺句必须**惰性**取:多数写端点的载荷由 `await readBody(req)` 消费,而请求流**只能读一次** ——
|
|
@@ -2303,7 +2305,17 @@ export function registerApi(ctx, deps, config) {
|
|
|
2303
2305
|
|
|
2304
2306
|
// POST /persona/habits/scan 通道 A:零模型,扫"跨会话重复出现的纠正"→ 落成习惯候选(待确认)
|
|
2305
2307
|
// 阈值:同类纠正 ≥4 次 且 跨 ≥3 个会话(单次会话里被说三遍不算模式;口径与变更记录见 habit-gen.js 的 SCAN_DEFAULTS)
|
|
2306
|
-
disposers.push(register('/persona/habits/scan', async (
|
|
2308
|
+
disposers.push(register('/persona/habits/scan', async (req, res) => {
|
|
2309
|
+
// ── 手术门(A-1,2026-10-05 夜;交办单 HANDOFF-机制面复核交办单 §A-1)──────────────────
|
|
2310
|
+
// ⚠️ 本端点**会写 `habitsPending`** ⇒ 它进的是**人格面** ⇒ 按 P-5 第 5 条必须过闸。
|
|
2311
|
+
// 改前它是 12 条清单外的漏点:确认侧 `/persona/habit`(:2276)**过门**,而两条**产出候选**
|
|
2312
|
+
// 的写面(`scan` / `reflect`)不过门 —— 一条通路的两半只改了一半,正是
|
|
2313
|
+
// DESIGN §0.1 工程理念 9「两半同批」的反例(2026-10-05 机制面复核 A-1)。
|
|
2314
|
+
// 接线形状照抄 `/persona/habit`:必须先 `readBody` 再判门(请求流只能读一次,门要拿
|
|
2315
|
+
// `unlock` 原句);空 body 用 `|| '{}'` 兜住(面板按钮不带 body)。
|
|
2316
|
+
// 判据本体仍**只在** `surgery.js` 的 `requireSurgery()` 一处,这里只接线、不重判。
|
|
2317
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2318
|
+
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/habits/scan' })) return;
|
|
2307
2319
|
try {
|
|
2308
2320
|
// 上限(2026-09-16 定案):待确认满了就**明确驳回**,并说明为什么 —— 不排队、不静默丢弃。
|
|
2309
2321
|
const capS = pendingMaxOf(settings);
|
|
@@ -2341,7 +2353,13 @@ export function registerApi(ctx, deps, config) {
|
|
|
2341
2353
|
|
|
2342
2354
|
// POST /persona/habits/reflect 通道 B:让她"回想最近的相处" → 1~5 条习惯候选(一次 LLM,只提议不落盘)
|
|
2343
2355
|
// 手动触发,材料给足:主人真人原话 + 记忆概述 + 纠正统计 + 她现状(默认预算 20 万字符,可用 settings.habits.reflect* 覆盖)
|
|
2344
|
-
disposers.push(register('/persona/habits/reflect', async (
|
|
2356
|
+
disposers.push(register('/persona/habits/reflect', async (req, res) => {
|
|
2357
|
+
// ── 手术门(A-1,同 scan;两处必须同批改 —— 它们共享 `proposeHabit` 这一半)──────────
|
|
2358
|
+
// `reflect` 的候选经 `parseReflect` → `proposeHabit`(:2436)落进 `habitsPending` ⇒ 同属写面。
|
|
2359
|
+
// 注意它另有 kv 写入(`habits.reflect.cursor` / `.last` / `.debug`)—— 那些是**运行记录**,
|
|
2360
|
+
// 不是人格面内容 ⇒ 单独看它们不构成"必须过门"的理由;过门的理由是候选落进人格面。
|
|
2361
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2362
|
+
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/habits/reflect' })) return;
|
|
2345
2363
|
try {
|
|
2346
2364
|
// 上限(2026-09-16 定案):与 scan 同一条规则 —— 满了明确驳回,不排队、不静默丢弃。
|
|
2347
2365
|
const capR = pendingMaxOf(settings);
|
|
@@ -48,6 +48,15 @@ export const INDEX_SKILL_CONTENT = `# 深层库索引 · 我记住的事分 11
|
|
|
48
48
|
- 想知道一条结论的**原话** ⇒ 条目回执里带 \`conv\` 与 \`#序号\`,用 \`recall({ conv, from, to })\` 取那几轮
|
|
49
49
|
- 按时间浏览 ⇒ \`recall()\` 不带参数,列出磁盘上有原文的会话
|
|
50
50
|
|
|
51
|
+
## 条目还带一个「类」(kind)
|
|
52
|
+
每条结论都标了它是哪一种:**承诺 / 决定 / 事实 / 偏好 / 关系 / 纪律**。
|
|
53
|
+
上面的 11 **主题簇**回答"这事关于什么",这里的 **kind** 回答"这是哪一种话" —— 两个维度,不冲突。
|
|
54
|
+
|
|
55
|
+
**纪律**是其中最特别的一类:一次拍板,管的是**以后每一次**。它进库要同时满足三条 ——
|
|
56
|
+
① 来源是主人授权的一句指令;② 作用对象是**行为**;③ **违反时能被某条判据红出来**。
|
|
57
|
+
三条缺一条就退回收在别处(不进这里):答不出"哪条判据会红"的,只是一句话,不是纪律。
|
|
58
|
+
⇒ 看到 \`[纪律]\` 开头的条目,可以按"这是必须遵守的"来对待,而不只是"他说过一次"。
|
|
59
|
+
|
|
51
60
|
## 11 类与触发线索
|
|
52
61
|
|
|
53
62
|
| 类 | 这一簇装什么 | 触发线索 |
|
package/lib/host/l1.js
CHANGED
|
@@ -168,7 +168,12 @@ const DURABILITY_BONUS = { durable: 0.6, long: 0, ephemeral: 0 };
|
|
|
168
168
|
* 「某插件的档位细节」(偏好)两条"不该在这里" —— 它们都是**关于事**的;而器灵最该先看见的
|
|
169
169
|
* 是**关于人**的(承诺/关系/偏好)。⇒ 用 `kind` 把这两类分开,不必新增字段。
|
|
170
170
|
* ⚠️ 这是本夜新加的判据(主人未逐字拍过)⇒ 值可调、可整体否掉;`hit`(+1.0)仍是最强信号。 */
|
|
171
|
-
const KIND_WEIGHT = { 承诺: 0.4, 关系: 0.3, 偏好: 0.2, 决定: 0.1, 事实: 0 };
|
|
171
|
+
const KIND_WEIGHT = { 承诺: 0.4, 纪律: 0.35, 关系: 0.3, 偏好: 0.2, 决定: 0.1, 事实: 0 };
|
|
172
|
+
// 「纪律」= 2026-10-05 夜主人裁决新增的**第六类**(原话口径:**决定管一次,纪律管以后每一次**;
|
|
173
|
+
// 进库三条判据写在 `memory.js` 的 `deep_item.kind` 列注释里)。
|
|
174
|
+
// 权重取 0.35 —— **高于关系、略低于承诺**:它与承诺同族(都是"我说过的话"),
|
|
175
|
+
// 但承诺是对某件事的,纪律是对**我的行为**的;留一点差距,是为了关键词打平时仍让
|
|
176
|
+
// "对主人的承诺"排在前面。可调、可整体否掉(与上一条注释同一口径)。
|
|
172
177
|
/** **同源限额**:同一次会话最多进 N 条(置顶不受此限)。见 `selectL1` 里的同源循环。 */
|
|
173
178
|
const MAX_PER_CONV = 2;
|
|
174
179
|
|
|
@@ -203,6 +208,9 @@ export function explainScore(x) {
|
|
|
203
208
|
// 分项之间是加法,`血缘`/`矛盾` 是**乘数**,不能混进加法列表。
|
|
204
209
|
const mult = [`× 血缘${f(x.lineage)}`];
|
|
205
210
|
if (Number(x.conf ?? 1) < 1) mult.push(`× 矛盾${f(x.conf)}`);
|
|
211
|
+
// A-5(2026-10-05):枝系数是**第三个乘数**(`raw × 血缘 × 矛盾 × 枝系数`)。默认 1 时不印
|
|
212
|
+
// (留痕与接线前逐字相同);一旦有人调过枝系数就必须印 —— 否则留痕与公式不符,又是说谎。
|
|
213
|
+
if (Number(x.scale ?? 1) !== 1) mult.push(`× 枝系数${f(x.scale)}`);
|
|
206
214
|
return `(${bits.join(' + ')}) ${mult.join(' ')} ⇒ ${f(x.score, 3)}`;
|
|
207
215
|
}
|
|
208
216
|
const bits = [];
|
|
@@ -219,6 +227,7 @@ export function explainScore(x) {
|
|
|
219
227
|
// 现在显式写成 `(分项和) × 血缘 × 矛盾 ⇒ 总分`,让留痕与公式一一对得上。
|
|
220
228
|
const mult = [`× 血缘${f(x.lineage)}`];
|
|
221
229
|
if (Number(x.conf ?? 1) < 1) mult.push(`× 矛盾${f(x.conf)}`);
|
|
230
|
+
if (Number(x.scale ?? 1) !== 1) mult.push(`× 枝系数${f(x.scale)}`); // A-5:同概述路,见上
|
|
222
231
|
return `(${bits.join(' + ')}) ${mult.join(' ')} ⇒ ${f(x.score, 3)}`;
|
|
223
232
|
}
|
|
224
233
|
|
|
@@ -339,6 +348,39 @@ export function selectL1(store, opts) {
|
|
|
339
348
|
// `0.7^5=0.168`、`0.4^5=0.010`,连乘会让深枝等于从记忆里消失(见 `memory.js` 的 `LINEAGE_*`)。
|
|
340
349
|
// 条目沿用同一把尺:血缘与矛盾降权都按 `source|conv_id` 索引,与"选的是概述还是条目"无关。
|
|
341
350
|
const ctx = lineageContext(store, o.sessionId);
|
|
351
|
+
// ── A-5(2026-10-05 夜 · 主人拍板走 (b)):枝系数**接回打分** ──────────────────────────
|
|
352
|
+
// 语义:一条记忆的枝 = **它来源会话所在的枝**。**不是**读 `deep_item.branch_id` ——
|
|
353
|
+
// 真库实测 **393/393 条全为空**(schema 注释早写明"记忆树是空间维度、深层库是内容维度,
|
|
354
|
+
// 条目不隶属任何枝"),照那个字段接等于"接了也没接"。走会话归属才有意义:
|
|
355
|
+
// dsh 侧实测主干 73 / 开发日志 25 / 运维排障 18 / 日常 4,非 dsh 侧已挂枝 34、未挂枝 179。
|
|
356
|
+
// 取值:默认 1.0 ⇒ **接线当下零行为变化**(实测全库 20 条枝的 weight_scale 全是 1);
|
|
357
|
+
// 复盘期调过谁,谁的记忆才在开场里前后移动 —— 这就是 `README` U-10「装饰性交互要么真生效、
|
|
358
|
+
// 要么删掉」要的那个"真生效"。
|
|
359
|
+
// ⚠️ 它乘在**最后**一项(`raw × 血缘 × 矛盾 × 枝系数`),与 `LINEAGE_*` 同一层:*打对折*,
|
|
360
|
+
// 不是"禁用"(禁用是 forget / `ephemeral` 的事)。区间夹在 [0,2] 与 `setBranchWeight` 同口径。
|
|
361
|
+
// ⚠️ 零开销路径:全库枝系数都是 1 时**连映射都不建**(`branchScale.size === 0` ⇒ 不查库)。
|
|
362
|
+
const branchScale = (() => {
|
|
363
|
+
const m = new Map();
|
|
364
|
+
try {
|
|
365
|
+
const rows = typeof store.listBranches === 'function' ? store.listBranches() : [];
|
|
366
|
+
for (const b of rows) {
|
|
367
|
+
const w = Number(b?.weightScale);
|
|
368
|
+
if (Number.isFinite(w) && w !== 1) m.set(String(b.id), Math.min(2, Math.max(0, w)));
|
|
369
|
+
}
|
|
370
|
+
} catch { /* 读不到枝表 ⇒ 全按 1.0(与接线前逐字同解) */ }
|
|
371
|
+
return m;
|
|
372
|
+
})();
|
|
373
|
+
const branchOfRow = (() => {
|
|
374
|
+
if (!branchScale.size) return null;
|
|
375
|
+
const convMap = (typeof store.convBranchMap === 'function' ? store.convBranchMap() : new Map());
|
|
376
|
+
const sessMap = (typeof store.sessionBranchMap === 'function' ? store.sessionBranchMap() : new Map());
|
|
377
|
+
return (src, convId) => {
|
|
378
|
+
const k = String(src) + '\u0000' + String(convId);
|
|
379
|
+
if (convMap.has(k)) return String(convMap.get(k));
|
|
380
|
+
if (String(src) === 'dsh') return String(sessMap.get(String(convId)) || TRUNK_BRANCH);
|
|
381
|
+
return TRUNK_BRANCH; // 非 dsh 且未显式挂枝 ⇒ 主干(与 `branchOfConv` 同解)
|
|
382
|
+
};
|
|
383
|
+
})();
|
|
342
384
|
// 1.6-A 投递留痕:过滤口径必须**可报告**。原来是一条 `.filter()` 链 —— 丢了多少、为什么丢,
|
|
343
385
|
// 出了函数就查不到(只剩 `dropped` 一个净差)。改成显式循环,顺手留下三类计数:
|
|
344
386
|
// archived / forgotten 是**主动排除**(主人的明确动作),zero 是"算不出正分"(无信息量)。
|
|
@@ -373,14 +415,16 @@ export function selectL1(store, opts) {
|
|
|
373
415
|
+ Math.min(KW_BONUS_CAP, kw * KW_BONUS_PER_HIT)
|
|
374
416
|
+ RECENCY_W * recency;
|
|
375
417
|
const pinned = Number(row.pinned ?? 0) === 1;
|
|
376
|
-
|
|
418
|
+
const scale = branchOfRow ? (branchScale.get(branchOfRow(srcOf, row.conv_id)) ?? 1) : 1;
|
|
419
|
+
x = { row, score: raw * lineage * conf * scale, lineage, raw, hit, kw, recency, conf, pinned, dur, durBonus, kindW, scale, useDeep };
|
|
377
420
|
} else {
|
|
378
421
|
const heat = computeHeat(row, now, {});
|
|
379
422
|
const catW = weights[row.category] ?? weights.daily ?? 0.5;
|
|
380
423
|
const kw = kwOverlap(o.keywords || [], row.keywords);
|
|
381
424
|
const rich = summaryWeight(row) * summaryBonus;
|
|
382
425
|
const raw = catW * heat + Math.min(KW_BONUS_CAP, kw * KW_BONUS_PER_HIT) + (row.importance >= 1 ? 0.8 : 0) + rich;
|
|
383
|
-
|
|
426
|
+
const scale = branchOfRow ? (branchScale.get(branchOfRow(srcOf, row.conv_id)) ?? 1) : 1;
|
|
427
|
+
x = { row, score: raw * lineage * conf * scale, lineage, raw, heat, catW, kw, rich, conf, pinned: false, scale, useDeep };
|
|
384
428
|
}
|
|
385
429
|
// 置顶是**必进**的(主人的明确动作)⇒ 即使分数算不出正分也不丢;其余零分者视为无信息量。
|
|
386
430
|
if (!(x.score > 0) && !x.pinned) { excluded.zero += 1; continue; }
|
package/lib/host/lifecycle.js
CHANGED
|
@@ -56,6 +56,17 @@ export function wireLifecycle(ctx, gate, memory, settings, config) {
|
|
|
56
56
|
memory.kvSet('dbg.first_event_sample', '');
|
|
57
57
|
memory.kvSet('dbg.event_no_session', '');
|
|
58
58
|
memory.kvSet('dbg.event_stats', '');
|
|
59
|
+
// ⚠️ P5①(蓝队 2026-10-04):上面三家清了,`dbg.diff_stats` 却漏了 —— 于是差分路的读数会
|
|
60
|
+
// **跨重启残留**:新进程还没跑过任何一轮差分,诊断里却躺着一个旧时间戳的"本轮画像",
|
|
61
|
+
// 看起来像"刚跑过、结果是这样"。它现在和另外三家同批清。
|
|
62
|
+
memory.kvSet('dbg.diff_stats', '');
|
|
63
|
+
// ⚠️ P5③(同一处):`evStats` 是**模块级**对象,只在模块加载时归零 —— 同一进程里若二次
|
|
64
|
+
// `wireLifecycle`(换配置重载插件),计数会在上一轮的基数上继续累加,而 kv 里写的是"本轮"。
|
|
65
|
+
// 归零点放到这里,与 kv 的清理**同一时点**,两者才同义。
|
|
66
|
+
evStats.seen = 0; evStats.kept = 0; evStats.dropType = 0; evStats.dropSource = 0;
|
|
67
|
+
evStats.dropEmpty = 0; evStats.dropNoSession = 0; evStats.dropSubagent = 0;
|
|
68
|
+
evStats.types = {}; evStats.lastSid = '';
|
|
69
|
+
evStatsDirty = 0; evStatsAt = 0;
|
|
59
70
|
memory.kvSet('lifecycle.wired', '1');
|
|
60
71
|
|
|
61
72
|
const inTrackedWorkspace = () => {
|
package/lib/host/memory.js
CHANGED
|
@@ -167,7 +167,7 @@ CREATE TABLE IF NOT EXISTS branch (
|
|
|
167
167
|
fork_at TEXT,
|
|
168
168
|
fork_seq INTEGER,
|
|
169
169
|
name_locked INTEGER NOT NULL DEFAULT 0,
|
|
170
|
-
--
|
|
170
|
+
-- weight_scale 自 1.6.2 起**参与检索打分**(见 setBranchWeight);visibility 仍是预留字段(只存与显示),
|
|
171
171
|
-- **不参与任何打分**(L1 的 score 只看血缘档位 × 矛盾系数)。让它们真正生效会改变检索排序语义,
|
|
172
172
|
-- 须主人拍板后再动;在那之前返回体里带 applied=false 标记,免得界面把"存了"当成"生效了"。
|
|
173
173
|
weight_scale REAL NOT NULL DEFAULT 1.0,
|
|
@@ -304,7 +304,13 @@ CREATE INDEX IF NOT EXISTS inject_log_session ON inject_log(session_id);
|
|
|
304
304
|
-- 且**枝侧可反向引用条目**(气根供养树):一条枝能列出供养过它的条目,而条目的增删**不改动树结构**。
|
|
305
305
|
CREATE TABLE IF NOT EXISTS deep_item (
|
|
306
306
|
id TEXT PRIMARY KEY, -- 'di:' + uuid(与记忆树 br: 同风格;带前缀便于眼认)
|
|
307
|
-
kind TEXT NOT NULL, --
|
|
307
|
+
kind TEXT NOT NULL, -- 承诺 | 决定 | 事实 | 偏好 | 关系 | 纪律(中文,1.6 实存值)
|
|
308
|
+
-- ⚠️ **纪律**是 2026-10-05 夜主人裁决新增的第六类(见 §D 的裁定):
|
|
309
|
+
-- 与"决定"的区别是**决定管一次、纪律管以后每一次**。进库三条判据
|
|
310
|
+
-- (缺一条就退回收 kv / plans,不许进库):① 来源是**主人授权的一句指令**;
|
|
311
|
+
-- ② 作用对象是**行为**(与 README R-3 的"规矩"同族);③ **违反时能被判据红出来**
|
|
312
|
+
-- —— 答不出"哪条判据会红"就不是纪律,只是一句话(与 F-26 同一条线)。
|
|
313
|
+
-- 注:列本身**无 CHECK 约束**(TEXT NOT NULL),故加这一类**不需要改 schema**。
|
|
308
314
|
text TEXT NOT NULL, -- 一句话 ≤80 字:它是**装配单元**(开场要从里面挑),必须独立读懂
|
|
309
315
|
at TEXT NOT NULL DEFAULT '', -- 发生时间(取自原文那一轮;UTC ISO —— 与 dsh_turns_raw.ts 同口径)
|
|
310
316
|
conv_id TEXT NOT NULL DEFAULT '', -- 来源会话(裸 id 或带命名空间前缀,与 conv_overview.conv_id 同口径)
|
|
@@ -637,12 +643,11 @@ export class MemoryStore {
|
|
|
637
643
|
|
|
638
644
|
/** 全部枝(含主干)。
|
|
639
645
|
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
* 让界面与调用方知道"调了它也没用",避免把预留字段当成已生效的旋钮。 */
|
|
646
|
+
* ✅ **A-5(2026-10-05 夜 · 主人拍板走方案 b):`weightScale` 已接回检索打分** ——
|
|
647
|
+
* L1 的分数是 `raw × 血缘档位 × 矛盾系数 × 枝系数`,故这里如实标 `weightScaleApplied: true`。
|
|
648
|
+
* ⚠️ `visibility` **仍是预留字段**(本次只拍了权重):它只存只显示,标 `visibilityApplied: false`,
|
|
649
|
+
* 界面不要给它做"生效中"的暗示。
|
|
650
|
+
* 历史:审计 A#11(2026-09-22)曾把两者一起定为"只存只显示";本次只解开权重那一半。 */
|
|
646
651
|
listBranches() {
|
|
647
652
|
try {
|
|
648
653
|
return this.db.prepare('SELECT * FROM branch ORDER BY created_at, id').all().map((r) => ({
|
|
@@ -655,8 +660,8 @@ export class MemoryStore {
|
|
|
655
660
|
nameLocked: Number(r.name_locked || 0),
|
|
656
661
|
weightScale: Number(r.weight_scale ?? 1),
|
|
657
662
|
visibility: String(r.visibility || 'lineage'),
|
|
658
|
-
//
|
|
659
|
-
weightScaleApplied:
|
|
663
|
+
// A-5:权重已生效(见方法注释);visibility 仍为预留。
|
|
664
|
+
weightScaleApplied: true,
|
|
660
665
|
visibilityApplied: false,
|
|
661
666
|
status: String(r.status || 'active'),
|
|
662
667
|
createdAt: r.created_at ? String(r.created_at) : null,
|
|
@@ -937,10 +942,9 @@ export class MemoryStore {
|
|
|
937
942
|
/** 复盘期权重调节(平时只读 —— 设计稿 §3.3)。scale 夹在 [0,2]。
|
|
938
943
|
*
|
|
939
944
|
* ⚠️ 预留字段(审计 A#11,2026-09-22 定为方案 b):写进去的 `weight_scale`
|
|
940
|
-
|
|
941
|
-
*
|
|
942
|
-
* `
|
|
943
|
-
* 让它真正生效属于**改变检索语义**,需主人拍板(方案 a),不在本次审计修法内。 */
|
|
945
|
+
* **A-5(2026-10-05 夜 · 主人拍板走方案 b):已接回检索打分** —— L1 = `raw × 血缘档位 × 矛盾系数 × 枝系数`。
|
|
946
|
+
* 历史:审计 A#11(2026-09-22)曾把权重与可见性一起定为"只存只显示";本次只解开权重那一半
|
|
947
|
+
* (可见性仍是预留字段,见 `listBranches`)。 */
|
|
944
948
|
setBranchWeight(branchId, scale) {
|
|
945
949
|
const bid = String(branchId || '');
|
|
946
950
|
// ⚠️ 先判**类型**再取数(蓝队 2026-10-04):`Number(null) === 0`、`Number(true) === 1`、
|
|
@@ -954,7 +958,8 @@ export class MemoryStore {
|
|
|
954
958
|
this.db.prepare('UPDATE branch SET weight_scale=?, updated_at=? WHERE id=?').run(clamped, utcIso(), bid);
|
|
955
959
|
return {
|
|
956
960
|
ok: true, id: bid, weightScale: clamped,
|
|
957
|
-
applied:
|
|
961
|
+
applied: true,
|
|
962
|
+
note: '已生效:该枝的结论在记忆开场里按此系数加权(1.0 = 不偏不倚,0 = 排到最后;不是禁用)',
|
|
958
963
|
};
|
|
959
964
|
}
|
|
960
965
|
|
package/lib/host/persona.js
CHANGED
|
@@ -85,7 +85,6 @@ export const DEFAULT_SETTINGS = Object.freeze({
|
|
|
85
85
|
// ⚠️ 这是**内存态**设置(`settings-file.js` 的 get() 每轮读),面板 update() 立即改 ⇒ 不用重启宿主。
|
|
86
86
|
sessionProgressLine: true,
|
|
87
87
|
},
|
|
88
|
-
updates: { applyWhileRunning: false }, // D4 语义固化:默认冻结
|
|
89
88
|
guard: { // /api 守卫(2026-09-16 加固 ①+②;2026-09-17 加固 ③)
|
|
90
89
|
enforce: true, // false = 关掉来源栅栏与名字对撞(逃生开关;亦可用 DSH_LING_GUARD=off)
|
|
91
90
|
trustedHosts: [], // 显式受信的 authority 列表;loopback 恒受信,本机 LAN 地址**不再自动信任**
|
package/lib/host/rawlog.js
CHANGED
|
@@ -16,10 +16,9 @@
|
|
|
16
16
|
// 实测 **516 帧扫描 1ms + 全量解压解析 53ms**(相对暴力法快约 2200 倍)。
|
|
17
17
|
// 抄而不 import 的理由:不把插件绑到宿主内部包上(那是 P3 的劣,主人 2026-10-04 拍「先 P2」)。
|
|
18
18
|
import fs from 'node:fs';
|
|
19
|
-
import os from 'node:os';
|
|
20
19
|
import path from 'node:path';
|
|
21
20
|
import { zstdDecompressSync, constants as zc } from 'node:zlib';
|
|
22
|
-
import { isMemoryEligibleHeader } from './util.js'; // F3(2026-10-04):真人判据要按**会话级**分通道
|
|
21
|
+
import { isMemoryEligibleHeader, dshHome } from './util.js'; // F3(2026-10-04):真人判据要按**会话级**分通道
|
|
23
22
|
|
|
24
23
|
/** Zstandard 帧魔数(小端读作 UInt32LE 时的值)。 */
|
|
25
24
|
const ZSTD_MAGIC = 0xfd2fb528;
|
|
@@ -46,9 +45,15 @@ function findLogIn(dir) {
|
|
|
46
45
|
return null;
|
|
47
46
|
}
|
|
48
47
|
|
|
49
|
-
/** 会话根目录:
|
|
48
|
+
/** 会话根目录: `$DSH_HOME/sessions`(未设时 `~/.dsh/sessions`)。
|
|
49
|
+
* ⚠️ 2026-10-05 修正(A-4,交办单最后一处漏点):本函数原先是 `join(os.homedir(), '.dsh', 'sessions')`
|
|
50
|
+
* —— 绕过 `$DSH_HOME`,是全仓最后一处这么写的地方(`backfill.js:45-51` 记着另两处早在
|
|
51
|
+
* 2026-09-29 就统一到 `dshHome()` 了)。不设 `DSH_HOME` 时两者等价,**缺陷因此长期隐身**;
|
|
52
|
+
* 一旦设了(导入测试台就是靠它做隔离,`DESIGN-导入测试台.md:25` 明写"`DSH_HOME` 层面隔离才有效"),
|
|
53
|
+
* 这里就会去读**真机**会话目录 —— 不报错、只读,但结论不可复现,还会把真历史灌进测试库。
|
|
54
|
+
* 现统一到 `dshHome()`(= `$DSH_HOME` 优先,否则 `~/.dsh`),与扫描侧/取原文侧同源。 */
|
|
50
55
|
export function sessionsRoot() {
|
|
51
|
-
return path.join(
|
|
56
|
+
return path.join(dshHome(), 'sessions');
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
/**
|
|
@@ -234,8 +239,14 @@ export function dialogTurns(events) {
|
|
|
234
239
|
// 子代理种子 387 条 **true→false 回归 0**。
|
|
235
240
|
let sessionEligible = null;
|
|
236
241
|
for (const e of evs) {
|
|
237
|
-
if (e && typeof e === 'object' && e.type === 'session'
|
|
238
|
-
|
|
242
|
+
if (e && typeof e === 'object' && e.type === 'session') {
|
|
243
|
+
// ⚠️ **踩坑(2026-10-04,实测抓出)**:`session` 事件里**没有 `header` 子对象** ——
|
|
244
|
+
// 判据要的字段(`id` / `parentSession` / `delegationDepth` / `origin`)就**平铺在事件顶层**
|
|
245
|
+
// (`{"type":"session","version":4,"id":"feishu-…","cwd":"…","isSeeded":false,"delegationDepth":0,"agentPreset":"standard"}`)。
|
|
246
|
+
// 第一版写成 `isMemoryEligibleHeader(e.header)` ⇒ 恒为 `undefined` ⇒ `sessionEligible` 永远取不到
|
|
247
|
+
// ⇒ 判据退回 ⑤(只认 rpcId)⇒ **全盘净增 0、F3 一行没生效**,而注释里却写着"已修"。
|
|
248
|
+
// 所以这里传**事件本身**(兼容将来真加了 `header` 的形态)。
|
|
249
|
+
sessionEligible = isMemoryEligibleHeader(e.header ?? e);
|
|
239
250
|
break;
|
|
240
251
|
}
|
|
241
252
|
}
|
|
@@ -24,14 +24,49 @@
|
|
|
24
24
|
import { isMemoryEligibleHeader, sessionKind } from './util.js';
|
|
25
25
|
import { localStamp, oneLine } from './rawlog.js';
|
|
26
26
|
|
|
27
|
-
/** 一页几个会话(默认)。
|
|
28
|
-
|
|
27
|
+
/** 一页几个会话(默认):**5 条**(2026-10-05 主人拍板 B2,原为 10)。
|
|
28
|
+
* 起因:实测"搜什么都中今天" —— 全文索引里**当前会话自己**也在,任何词都能命中几十秒前刚打的那句话,
|
|
29
|
+
* 而原文层默认 10 行会把上面的**条目层**(结论,一句话说完)挤到看不见。砍到 5 条让条目重新占视觉重心;
|
|
30
|
+
* 要更多**显式传 `limit`**(上限 50 不变,想翻就翻)。 */
|
|
31
|
+
export const SEARCH_HITS_DEFAULT = 5;
|
|
29
32
|
/** 一页几个会话(上限):给模型的是"下一步去哪",不是把库倒出来。 */
|
|
30
33
|
export const SEARCH_HITS_MAX = 50;
|
|
31
34
|
/** 单条摘要的显示字数 —— **报短摘要而不是 id**(2026-10-04 生态复核:同生态对手的 README 逐字
|
|
32
35
|
* 「查 id 是什么的那一步最容易省略,省略了就等于没报」)。这一条是本次复核直接抄来的。 */
|
|
33
36
|
export const SEARCH_SNIPPET_CHARS = 160;
|
|
34
37
|
|
|
38
|
+
/** 宿主检索的**超时上限**(2026-10-05,主人定 20 秒)。
|
|
39
|
+
*
|
|
40
|
+
* 为什么必须有它:宿主的懒对账(`_reconcile` → `_observeStable`)是**串行化**的。一旦撞上卡住的对账,
|
|
41
|
+
* `searchSessions` 就**一直不返回** —— 实测有一次挂了 **约 30 分钟**,而当时**没有任何中断入口**:
|
|
42
|
+
* GUI 没有,工具层也没有。按本项目理念(「逃生开关永远可用」),**"不可中断"本身就该按最严重一级对待**,
|
|
43
|
+
* 与它挂了多久无关。
|
|
44
|
+
* ⇒ 我们改不了宿主(拿不到取消口),但可以**不给它无限期**:超时后放弃原文层,并**照样把条目层交出去**
|
|
45
|
+
* (条目是本地 SQLite,毫秒级,完全不受宿主影响)。
|
|
46
|
+
* ⚠️ 别调小到 5 秒以下:一次"慢但对"的检索比"快速失败"有用得多;
|
|
47
|
+
* 也别指望它治并发 —— 并发撞对账是宿主侧的事,这里只保证**任何一次都不会挂死**。 */
|
|
48
|
+
export const SEARCH_TIMEOUT_MS = 20000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 给 Promise 套超时(纯函数,可单测)。
|
|
52
|
+
* ⚠️ 超时**不取消**底层操作(宿主没给取消口),只是我们不再等它;它之后若返回,结果被丢弃 ——
|
|
53
|
+
* 检索是只读的,丢弃不影响任何状态。
|
|
54
|
+
* @returns `{ok:true, value}` | `{ok:false, reason:'timeout', ms, label}`
|
|
55
|
+
*/
|
|
56
|
+
export function withTimeout(promise, ms, label = '检索') {
|
|
57
|
+
const limit = Math.max(1, Number(ms) || SEARCH_TIMEOUT_MS);
|
|
58
|
+
let timer = null;
|
|
59
|
+
const timeout = new Promise((resolve) => {
|
|
60
|
+
timer = setTimeout(() => resolve({ ok: false, reason: 'timeout', ms: limit, label }), limit);
|
|
61
|
+
// 别让这个定时器拖住进程退出(工具层常在短命进程里被调用)
|
|
62
|
+
if (typeof timer?.unref === 'function') timer.unref();
|
|
63
|
+
});
|
|
64
|
+
return Promise.race([
|
|
65
|
+
Promise.resolve(promise).then((value) => ({ ok: true, value })),
|
|
66
|
+
timeout,
|
|
67
|
+
]).finally(() => { if (timer) clearTimeout(timer); });
|
|
68
|
+
}
|
|
69
|
+
|
|
35
70
|
/** 条数夹取(纯函数,便于单测)。 */
|
|
36
71
|
export function clampSearchLimit(n) {
|
|
37
72
|
const v = Math.floor(Number(n));
|
|
@@ -71,6 +106,71 @@ export function searchDeepItems(memory, q, limit = SEARCH_DEEP_MAX) {
|
|
|
71
106
|
} catch { return []; }
|
|
72
107
|
}
|
|
73
108
|
|
|
109
|
+
/** 检索档本地原文层一次最多带出几轮(默认同 `SEARCH_HITS_DEFAULT`)。 */
|
|
110
|
+
export const SEARCH_LOCAL_TURNS_MAX = 50;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 在**我们自己的库**里按内容搜原文轮(`dsh_turns_raw`)—— 检索档的**默认**原文来源。
|
|
114
|
+
*
|
|
115
|
+
* 为什么默认走这里(2026-10-05 夜 · 主人拍板方案 A):
|
|
116
|
+
* 宿主 `sessionQuery` 的每次搜索都会做一遍**全量对账**(`_observeStable` 里 list 两遍、
|
|
117
|
+
* 最多重试 2 轮),实测**每次都超过 20 秒**(重启后第一次也一样),而这边的
|
|
118
|
+
* 同一个查询只要 **2~45 毫秒**。快的设成默认、慢的降级成显式选项,是这一档的取舍。
|
|
119
|
+
* ⚠️ 代价**必须如实报出去**:库里这份原文是**残的** —— capture 侧只收"真人消息 +
|
|
120
|
+
* 有正文的回复",所以我自己的长回复**中段**搜不到。回执里要写明这一层是"库内原文层"。
|
|
121
|
+
*
|
|
122
|
+
* 与 `searchDeepItems` 同解的三条(不另立口径):多词 = **AND**;词里的 `% _ \` 先剥掉;
|
|
123
|
+
* 表不存在时**静默返回空**(检索面不该因为某张表缺席而整体失败)。
|
|
124
|
+
*
|
|
125
|
+
* @param {object} memory MemoryStore
|
|
126
|
+
* @param {string} q 查询词(空格分隔 = 同时满足)
|
|
127
|
+
* @param {number} limit 最多几轮
|
|
128
|
+
* @param {object} [opts] `{ selfId, callName, includeSelf }` —— `selfId` 是当前会话 id;
|
|
129
|
+
* **默认把它整条排除**(见下方注释),要看自己刚说的传 `includeSelf:true`(那时它排到最后并标 `isSelf`)
|
|
130
|
+
* @returns `{{rows:object[], selfExcluded:number}}` —— `selfExcluded` 是**因为"是本会话"而被排除的条数**,
|
|
131
|
+
* 调用方**必须把它报出去**:不说就会被读成"库里只有这些"(那是说假话)
|
|
132
|
+
*/
|
|
133
|
+
export function searchLocalTurns(memory, q, limit = SEARCH_HITS_DEFAULT, { selfId = '', callName = '主人', includeSelf = false } = {}) {
|
|
134
|
+
const db = memory?.db;
|
|
135
|
+
if (!db?.prepare) return { rows: [], selfExcluded: 0 };
|
|
136
|
+
const words = String(q || '').trim().split(/\s+/).map((w) => w.replace(/[%_\\]/g, '')).filter(Boolean);
|
|
137
|
+
if (!words.length) return { rows: [], selfExcluded: 0 };
|
|
138
|
+
const cap = Math.max(1, Math.min(SEARCH_LOCAL_TURNS_MAX, Number(limit) || SEARCH_HITS_DEFAULT));
|
|
139
|
+
let rows;
|
|
140
|
+
try {
|
|
141
|
+
const where = words.map(() => "text LIKE ? ESCAPE '\\'").join(' AND ');
|
|
142
|
+
rows = db.prepare(
|
|
143
|
+
`SELECT session_id, seq, role, ts, text FROM dsh_turns_raw
|
|
144
|
+
WHERE ${where} ORDER BY ts DESC LIMIT ?`,
|
|
145
|
+
).all(...words.map((w) => `%${w}%`), cap);
|
|
146
|
+
} catch { return { rows: [], selfExcluded: 0 }; }
|
|
147
|
+
const self = String(selfId || '');
|
|
148
|
+
const mapped = rows.map((r) => {
|
|
149
|
+
const id = String(r.session_id ?? '');
|
|
150
|
+
const role = String(r.role ?? '');
|
|
151
|
+
const isUser = role.startsWith('user');
|
|
152
|
+
// 命中位置附近截一段(而不是从头截):词可能出现在长回复的中间
|
|
153
|
+
const text = String(r.text ?? '');
|
|
154
|
+
const at = words.map((w) => text.indexOf(w)).filter((i) => i >= 0).sort((a, b) => a - b)[0] ?? 0;
|
|
155
|
+
const from = Math.max(0, at - 40);
|
|
156
|
+
return {
|
|
157
|
+
conv: id, seq: Number(r.seq) || 0, role,
|
|
158
|
+
time: r.ts ? localStamp(r.ts) : '',
|
|
159
|
+
who: isUser ? callName : '鱼姬',
|
|
160
|
+
snippet: oneLine(text.slice(from, from + SEARCH_SNIPPET_CHARS), SEARCH_SNIPPET_CHARS),
|
|
161
|
+
isSelf: Boolean(self) && id === self,
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
// ⚠️ **本会话默认排除**(2026-10-05 夜 · 主人拍板):它就在库里、而它的话又最新 ⇒ 任何词都能命中它,
|
|
165
|
+
// 把要回溯的历史**整页挤掉** —— 真机实测「检索」10 条里 9 条是本会话;光把它排到末尾只治了"占头部",
|
|
166
|
+
// 没治"占位"。而"我刚说过什么"**根本不需要搜**:它就在我的上下文里;
|
|
167
|
+
// 搜索这个动作的全部意义是回溯**我这里没有的**东西。要看自己刚说的传 `includeSelf:true`。
|
|
168
|
+
// ⇒ 排除的条数**如实报出去**(`selfExcluded`),不说就会被读成"库里只有这些"。
|
|
169
|
+
const kept = includeSelf ? mapped : mapped.filter((r) => !r.isSelf);
|
|
170
|
+
const selfExcluded = includeSelf ? 0 : mapped.length - kept.length;
|
|
171
|
+
return { rows: kept.sort((a, b) => Number(a.isSelf) - Number(b.isSelf)), selfExcluded };
|
|
172
|
+
}
|
|
173
|
+
|
|
74
174
|
/**
|
|
75
175
|
* 从 `listSessions()` 的记录里造「主人自己的会话」id 集(纯函数)。
|
|
76
176
|
* 拿不到 header 的**不放入** —— 宁少不滥,这是可见性过滤,不是统计。
|
|
@@ -123,8 +223,9 @@ export function visibleIdSet(records) {
|
|
|
123
223
|
* @param all 越界开关(D6:显式才生效)
|
|
124
224
|
* @returns `{ rows, skipped }` —— `skipped` 是**被范围挡掉**的条数,必须报出来(不静默丢)
|
|
125
225
|
*/
|
|
126
|
-
export function toSearchRows(hits, { visible = null, all = false, callName = '主人' } = {}) {
|
|
226
|
+
export function toSearchRows(hits, { visible = null, all = false, callName = '主人', selfId = '' } = {}) {
|
|
127
227
|
const rows = [];
|
|
228
|
+
const self = String(selfId || '');
|
|
128
229
|
let skipped = 0;
|
|
129
230
|
for (const h of hits || []) {
|
|
130
231
|
const header = h?.header ?? {};
|
|
@@ -140,8 +241,14 @@ export function toSearchRows(hits, { visible = null, all = false, callName = '
|
|
|
140
241
|
time: m.time ? localStamp(m.time) : '',
|
|
141
242
|
who: isUser ? callName : '鱼姬',
|
|
142
243
|
snippet: oneLine(m.snippet || '', SEARCH_SNIPPET_CHARS),
|
|
244
|
+
isSelf: Boolean(self) && String(id) === self,
|
|
143
245
|
});
|
|
144
246
|
}
|
|
247
|
+
// ⚠️ 本会话排到最后(2026-10-05 主人拍板 C):**当前会话自己也在索引里**,于是"搜什么都中今天" ——
|
|
248
|
+
// 任何词都能命中几十秒前刚打的那句话,而它排在头一条,把要回溯的历史全挤下去。
|
|
249
|
+
// **不删**它(有时确实要找自己刚才那句),只挪到末尾并标注「← 本会话」。
|
|
250
|
+
// `sort` 在 V8 上是**稳定**的 ⇒ 其余命中保持宿主的原序(相关性+新近),不打乱。
|
|
251
|
+
if (self) rows.sort((a, b) => Number(a.isSelf) - Number(b.isSelf));
|
|
145
252
|
// ⚠️ `degraded`(蓝队 2026-10-04 抓到 P3):宿主**没有** `listSessions` 时,`visible` 恒为 null
|
|
146
253
|
// ⇒ 范围限制**整条失效**(一条不挡),而回执却仍写着"已在主人自己的会话内" —— 那是**说假话**。
|
|
147
254
|
// 取舍(主代理 2026-10-04 定):**保持 fail-open 但如实标注** —— 完全禁用会让"按内容找会话"
|
|
@@ -171,6 +278,14 @@ export function renderSearch(v) {
|
|
|
171
278
|
? '⚠️ **没能限定范围** —— 宿主这次没给 `listSessions`,所以下面这份结果**可能含子代理或其它会话**,'
|
|
172
279
|
+ '不是"只搜主人自己的会话"(范围限制这条通道暂时失效,不是"库里只有这些")'
|
|
173
280
|
: '已在「**主人自己的会话**」内(默认范围)');
|
|
281
|
+
// ⚠️ **原文来源必须看得出来是哪一个**(2026-10-05 夜 · 主人拍板方案 A):
|
|
282
|
+
// 两条路覆盖面**不一样**,不说清就会被读成"库里只有这些"。
|
|
283
|
+
// · 本地(默认):库内原文层 —— 只含"真人消息 + 有正文的回复"(capture 侧就这么收的),
|
|
284
|
+
// 我自己的长回复**中段搜不到**;代价换来的是 **2~45 毫秒**(实测)。
|
|
285
|
+
// · 宿主(显式 hostSearch):覆盖全部事件,但每次**全量对账**、实测**每次 >20 秒**。
|
|
286
|
+
const layerNote = v.local
|
|
287
|
+
? '(原文来源:**本机库内原文层** —— 只含真人消息与有正文的回复,毫秒级;要全量检索请传 hostSearch=true)'
|
|
288
|
+
: '';
|
|
174
289
|
const hitRows = Array.isArray(v.rows) ? v.rows : [];
|
|
175
290
|
const deepRows = Array.isArray(v.deep) ? v.deep : [];
|
|
176
291
|
// 条目排在前:它是**结论层**(一句话说完),会话在后(要下钻才知道内容)。
|
|
@@ -186,22 +301,45 @@ export function renderSearch(v) {
|
|
|
186
301
|
}).join('\n') + '\n\n'
|
|
187
302
|
: '';
|
|
188
303
|
if (!hitRows.length && !deepRows.length) {
|
|
189
|
-
|
|
304
|
+
// 超时是**另一种结论**,不能说成"没找到" —— 前者该"重发或换窄词",后者该"换词",
|
|
305
|
+
// 而"库里没有"是第三种(最不该被误读成它)。
|
|
306
|
+
if (v.timedOut) {
|
|
307
|
+
return `查询「${v.q}」这次**没跑完**:宿主检索超过 ${Math.round((Number(v.timeoutMs) || SEARCH_TIMEOUT_MS) / 1000)} 秒没返回`
|
|
308
|
+
+ '(多半是它在补索引对账 —— 宿主侧的串行操作,插件打断不了它)。'
|
|
309
|
+
+ `\n条目层是本地库、毫秒级,它也没有命中 ⇒ 这次**两层都空**。`
|
|
310
|
+
+ '\n换个更窄的词(中文 2~4 字)重发一次,或稍后再试。'
|
|
311
|
+
+ `\n(范围:${scope})`;
|
|
312
|
+
}
|
|
313
|
+
return `按内容没找到(查询「${v.q}」· ${scope})${layerNote}。`
|
|
190
314
|
+ (v.skipped ? `\n(有 ${v.skipped} 条被范围挡掉 —— 要看它们得显式传 all=true。)\n` : '\n')
|
|
191
315
|
+ '换词试试:中文 **2~4 字**短词命中最好(`记忆树` / `血缘` / `注入面`);'
|
|
192
316
|
+ '**多个词用空格分隔 = 同时满足**(AND);**没有子串匹配**(`call` 搜不到 `recall`)。';
|
|
193
317
|
}
|
|
194
318
|
const rows = hitRows.map((r) => {
|
|
195
319
|
const at = [r.seq !== null && r.seq !== undefined ? `#${r.seq}` : '', r.time].filter(Boolean).join(' ');
|
|
196
|
-
|
|
320
|
+
// 「← 本会话」:它被排到了末尾(见 toSearchRows),标注是让模型知道**这条是刚发生的、不是历史** ——
|
|
321
|
+
// 否则它会把"我刚说的话"当成"主人当年说过的话"来引用。
|
|
322
|
+
const selfTag = r.isSelf ? ' **← 本会话(刚发生的)**' : '';
|
|
323
|
+
return `· ${r.conv}${selfTag} · ${at} ${r.who}\n ${r.snippet}`;
|
|
197
324
|
});
|
|
198
325
|
const head = deepRows.length && rows.length
|
|
199
|
-
? `按内容命中 **${deepRows.length} 条深层库条目** + ${rows.length}
|
|
326
|
+
? `按内容命中 **${deepRows.length} 条深层库条目** + ${rows.length} 轮原文(查询「${v.q}」· ${scope})${layerNote}:`
|
|
200
327
|
: deepRows.length
|
|
201
|
-
? `按内容命中 **${deepRows.length} 条深层库条目**(查询「${v.q}」· ${scope}):`
|
|
202
|
-
: `按内容找到 ${rows.length}
|
|
203
|
-
const convBlock = rows.length ?
|
|
204
|
-
|
|
328
|
+
? `按内容命中 **${deepRows.length} 条深层库条目**(查询「${v.q}」· ${scope})${layerNote}:`
|
|
329
|
+
: `按内容找到 ${rows.length} 轮原文(查询「${v.q}」· ${scope})${layerNote}:`;
|
|
330
|
+
const convBlock = rows.length ? `${v.local ? '【库内原文层】' : '【会话原文层】'}\n${rows.join('\n')}` : '';
|
|
331
|
+
// ⚠️ 超时**不算失败**:条目层已经拿到了(本地库),必须照常交出去。这一行是"如实说明少了一半",
|
|
332
|
+
// 而不是"报错" —— 两者的下一步动作不同(前者重发/换窄词,后者换通道)。
|
|
333
|
+
const timeoutNote = v.timedOut
|
|
334
|
+
? `\n\n⚠️ **原文层超时**(等满 ${Math.round((Number(v.timeoutMs) || SEARCH_TIMEOUT_MS) / 1000)} 秒没返回,多半是宿主在补索引对账):`
|
|
335
|
+
+ (deepRows.length ? '**条目层是本地库,不受影响,就在上面** —— 先把它们用起来。' : '这次连条目也没命中。')
|
|
336
|
+
+ '要原文就**重发同一条**(多数第二次会快,对账已经补过了),或换更窄的词。'
|
|
337
|
+
: '';
|
|
338
|
+
// ⚠️ 排除本会话要**说出来**:不说就会被读成"库里只有这些"(那是说假话)。
|
|
339
|
+
const selfNote = Number(v.selfExcluded) > 0
|
|
340
|
+
? `\n(另有 ${v.selfExcluded} 轮命中属于**当前会话自己**,已按默认排除 —— 要看自己刚说的传 \`includeSelf=true\`。)`
|
|
341
|
+
: '';
|
|
342
|
+
return `${head}\n${deepBlock}${convBlock}${timeoutNote}${selfNote}`
|
|
205
343
|
+ (v.skipped ? `\n(${v.skipped} 条被范围挡掉 —— 要看它们得显式传 all=true。)` : '')
|
|
206
344
|
+ (v.hasMore ? '\n(这一页满了,**还有更多** —— 把 limit 调大,或换个更窄的词。)' : '')
|
|
207
345
|
+ (deepRows.length
|