@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.
- package/ACCESS-DESIGN.md +4 -3
- package/CHANGELOG.md +150 -9
- package/DESIGN.md +32 -11
- package/README.md +194 -10
- package/lib/audit/access-log.js +46 -12
- package/lib/audit/index.js +2 -2
- package/lib/audit/patch-spec.js +1 -1
- package/lib/client.js +319 -69
- package/lib/host/api.js +602 -60
- package/lib/host/archive.js +15 -9
- package/lib/host/autotree.js +2 -2
- package/lib/host/backfill.js +8 -2
- package/lib/host/clock-skill.js +1 -1
- package/lib/host/deep-index-skill.js +111 -0
- package/lib/host/feedback.js +112 -2
- package/lib/host/freeze.js +52 -1
- package/lib/host/guard.js +38 -1
- package/lib/host/habit-gen.js +12 -4
- package/lib/host/import-file.js +62 -6
- package/lib/host/import-log.js +80 -2
- 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 +483 -48
- package/lib/host/mode-service.js +85 -0
- package/lib/host/mode.js +29 -9
- package/lib/host/persona.js +2 -2
- package/lib/host/rawlog.js +317 -0
- package/lib/host/recall-search.js +210 -0
- package/lib/host/retitle.js +11 -5
- package/lib/host/session-line.js +46 -6
- package/lib/host/snapshot.js +65 -13
- package/lib/host/summarizer.js +164 -2
- package/lib/host/surgery.js +600 -0
- package/lib/host/tools.js +339 -15
- package/lib/host/tree-ops.js +13 -7
- package/lib/host/util.js +1 -1
- package/lib/index.js +58 -31
- package/package.json +6 -2
- package/tools/apply-access-log-patch.mjs +1 -1
package/lib/host/archive.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-ling host — 改动前的归档落盘(Part G · G2,2026-09-25)
|
|
2
2
|
//
|
|
3
|
-
// **原则(
|
|
3
|
+
// **原则(尝生定):永远不做彻底删除。**
|
|
4
4
|
// 任何"清除/覆盖"类的操作,动手之前先把原数据**完整写到磁盘上的历史目录**,并附一份
|
|
5
5
|
// 人可读的改动日志 —— 谁、什么时候、为什么、以及怎么还原。
|
|
6
6
|
//
|
|
@@ -77,9 +77,15 @@ export function archiveBefore(memory, { source, convId, kind = 'forget', reason
|
|
|
77
77
|
// 标记永久留下、id 永不召回、界面还显示"忘过了",而回执写着"原文与概述已归档"。
|
|
78
78
|
// 这里把判据**显式化**:`empty` 看的是**归档内容**(原文轮数 + 正文有无),
|
|
79
79
|
// **不是**目标是否存在 —— 目的就是让"还打不打得动标记"这件事有据可依。
|
|
80
|
+
// ⚠️ 2026-10-04 修(B-05 回归网 + 红队 P1 + 蓝队复核共同定位),判据从三条腿收成**两条**:
|
|
81
|
+
// ① 旧判据 `!data.sessionMeta` 太宽 —— 一条**空壳**元数据(`raw_seq=0`)被当成"有内容",
|
|
82
|
+
// 于是**真空归档被放行**,而 B-05 的实害正是"标记永久留下、id 永不召回、界面显示忘过了";
|
|
83
|
+
// ② 一度改成 `raw_seq > 0`,但那是**水位**("历史上捕获过多少事件"),**不是"现在库里有多少内容"**
|
|
84
|
+
// —— 合成用例 `raw_seq=5 + 0 轮 + 无概述` 会因此放行并落标记(潜在 fail-open,真库 0 例)。
|
|
85
|
+
// ⇒ 只留**内容本身**两条腿:原文轮数、概述正文。这样"只有概述正文"的条目能忘得掉
|
|
86
|
+
// (红队实测真库 3 条:概述 145/148/254 字、0 轮原文),而"什么都没保住"的一律挡住。
|
|
80
87
|
const empty = (data.turns || []).length === 0
|
|
81
|
-
&& !String(data.overview?.summary || '').trim()
|
|
82
|
-
&& !data.sessionMeta;
|
|
88
|
+
&& !String(data.overview?.summary || '').trim();
|
|
83
89
|
try {
|
|
84
90
|
mkdirSync(dir, { recursive: true });
|
|
85
91
|
// 原文逐行 JSONL —— 能 grep、能逐行还原,比一个大 JSON 数组更耐损
|
|
@@ -99,8 +105,8 @@ export function archiveBefore(memory, { source, convId, kind = 'forget', reason
|
|
|
99
105
|
|
|
100
106
|
/** 改动日志 —— 这份文件是给**将来的人**看的,所以写全:是什么、为什么、怎么还原。 */
|
|
101
107
|
function readme({ at, s, c, kind, reason, actor, turns }) {
|
|
102
|
-
const who = actor === 'ling' ? '
|
|
103
|
-
// A-01(1.5.1):第三种 kind —— 主脉记忆覆盖(
|
|
108
|
+
const who = actor === 'ling' ? '鱼姬(工具通道)' : '主人(界面通道)';
|
|
109
|
+
// A-01(1.5.1):第三种 kind —— 主脉记忆覆盖(同一主脉再次采纳提炼结果;尝生裁决"备份并覆盖")。
|
|
104
110
|
const what = kind === 'forget' ? '遗忘(软标记 —— **库里的行没有删**)'
|
|
105
111
|
: kind === 'vein-overwrite' ? '主脉记忆**覆盖**(同一主脉再次采纳 —— **上一版正文完整存在这里**)'
|
|
106
112
|
: '回灌(用归档恢复一条记忆 —— **覆盖前的状态已存这里**)';
|
|
@@ -108,14 +114,14 @@ function readme({ at, s, c, kind, reason, actor, turns }) {
|
|
|
108
114
|
// 「怎么还原」按 kind 分三档。用**字符串拼接**而不是嵌套模板,免得外层模板里的反引号打架。
|
|
109
115
|
const restoreBlock = kind === 'forget'
|
|
110
116
|
? '**库里没有删任何行** —— 只是多了一条 `forgotten` 标记,召回路径不再选它。撤销方式二选一:\n\n'
|
|
111
|
-
+ '1.
|
|
117
|
+
+ '1. 让鱼姬调工具撤销(`memory_forget` 的 `undo`),或\n'
|
|
112
118
|
+ '2. 直接删标记:\n ```sql\n DELETE FROM forgotten WHERE source=\'' + s + '\' AND conv_id=\'' + c + '\';\n ```'
|
|
113
119
|
: kind === 'vein-overwrite'
|
|
114
120
|
? '覆盖只动了 `conv_overview` 里那一行(主脉自己的记忆),原文与会话元数据不受影响。要把上一版换回去:\n\n'
|
|
115
|
-
+ '1.
|
|
121
|
+
+ '1. 让鱼姬按本目录 `overview.json` 的 `summary` **再采纳一次**旧正文,或\n'
|
|
116
122
|
+ '2. 直接写回:\n ```sql\n UPDATE conv_overview SET summary=\'<overview.json 的 summary>\'\n WHERE source=\'' + s + '\' AND conv_id=\'' + c + '\';\n ```'
|
|
117
123
|
: '回灌只是把归档写回去,而**它覆盖之前的状态存在这里**。要退回去:\n\n'
|
|
118
|
-
+ '1.
|
|
124
|
+
+ '1. 让鱼姬再回灌一次旧档(`memory_backfill`),或\n'
|
|
119
125
|
+ '2. 按下面「万一库本身没了」的步骤写回。';
|
|
120
126
|
return `# 归档:${title}的原始数据
|
|
121
127
|
|
|
@@ -144,7 +150,7 @@ ${restoreBlock}
|
|
|
144
150
|
|
|
145
151
|
## 为什么保留
|
|
146
152
|
|
|
147
|
-
|
|
153
|
+
忘错了要能回头。这是尝生定的规矩:**永远不做彻底删除** ——
|
|
148
154
|
所以这个目录是"曾经发生过什么"的证据,不是垃圾。不要随手清。
|
|
149
155
|
|
|
150
156
|
> 生成于 dsh-ling · Part G(器灵侧正规通道)
|
package/lib/host/autotree.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// dsh-ling host — 一键生成记忆树(D9-b,2026-09-20
|
|
1
|
+
// dsh-ling host — 一键生成记忆树(D9-b,2026-09-20 与尝生定案)。
|
|
2
2
|
//
|
|
3
3
|
// 主干 1593 条太粗,需要先把树"初步划一遍"。分两阶段:
|
|
4
4
|
// ① 机械分段 —— 按时序相邻性把全库概述切成若干簇(零成本、确定、可复现);
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// ③ 主人审核后落库(apply 在 api.js 侧) —— 建枝/主脉 + 挂会话。
|
|
7
7
|
//
|
|
8
8
|
// 为什么不把 1593 条直接丢给模型:装不进上下文,而且"按时序切段"本就是机械活。
|
|
9
|
-
// 模型擅长的是"给一段对话起主题名",不是"在 1593 条里做聚类"(设计稿 §8 /
|
|
9
|
+
// 模型擅长的是"给一段对话起主题名",不是"在 1593 条里做聚类"(设计稿 §8 / 尝生 2026-09-20)。
|
|
10
10
|
//
|
|
11
11
|
// 不变量:本模块**只读概述索引**、只产出候选,**从不改写任何记忆内容**。
|
|
12
12
|
|
package/lib/host/backfill.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// 写侧走传入的 MemoryStore(与运行中实例同库,免并发双开)。
|
|
4
4
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import {
|
|
6
|
+
import { dshHome } from './util.js'; // $DSH_HOME 优先,否则 ~/.dsh —— 与 deepsummary.js 同一个来源
|
|
7
7
|
import { categorizeTitle } from './classify.js';
|
|
8
8
|
import { keywordsFrom } from './inject-common.js';
|
|
9
9
|
import { decodeAll } from './zstd.js';
|
|
@@ -42,9 +42,15 @@ export function pickSessionFile(dir) {
|
|
|
42
42
|
* 为什么必须是一个显式函数:不传 root 时,扫描侧有默认值照常工作,而归因探针侧
|
|
43
43
|
* 拿到 `undefined` → `readdirSync(undefined)` 抛错被吞 → 恒判 `no-sessions-root`,
|
|
44
44
|
* 界面渲染成「未找到 DSH 会话目录:undefined」(BUG-2 归因几乎不可达)。
|
|
45
|
+
* ⚠️ 2026-09-29 修正:本函数原先是 `join(homedir(), '.dsh', 'sessions')`,只与**扫描侧**
|
|
46
|
+
* 同源;而**取原文侧** `deepsummary.js` 的 `findSessionFile()` 走 `join(dshHome(), 'sessions')`
|
|
47
|
+
* —— 同一语义两个来源。本机不设 DSH_HOME 时两者等价(缺陷因此长期隐身);一旦设了
|
|
48
|
+
* (例如导入测试台),两者就分叉:「本机 DSH 扫描」会去读**真机**会话目录 —— 不报错、只读,
|
|
49
|
+
* 但结果不可复现,还会把真历史灌进测试库。
|
|
50
|
+
* 现统一到 `dshHome()`(= `$DSH_HOME` 优先,否则 `~/.dsh`):扫描侧 / 归因探针侧 / 取原文侧三者同一个来源。
|
|
45
51
|
*/
|
|
46
52
|
export function defaultSessionsRoot() {
|
|
47
|
-
return join(
|
|
53
|
+
return join(dshHome(), 'sessions');
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
/** 候选会话文件: ~/.dsh/sessions/<workspace>/<sid>/{session[.vN].jsonl.zstd} */
|
package/lib/host/clock-skill.js
CHANGED
|
@@ -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/feedback.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
// dsh-ling host — 反馈成长回路(message-feedback 消费方,REPORT-04)。
|
|
2
|
-
// 平台 messageFeedback 无推送 → 低频轮询 list()
|
|
2
|
+
// ⚠️ 2026-10-01 订正(旧注释"平台 messageFeedback 无推送 → 低频轮询 list()"**已被证伪**):
|
|
3
|
+
// 宿主 `@deepseek-ai/dsh-message-feedback` 有推送 —— 冷会话写入 flush 后
|
|
4
|
+
// `lib/index.js:288` 发 **`feedback/committed`**(载荷 = SessionInspection:
|
|
5
|
+
// `{ meta, inheritedEventCount, events }`,末条即本次提交的事件);
|
|
6
|
+
// 活会话消费方按它 README.zh.md:58 的口径看 `session/event` 里的
|
|
7
|
+
// `feedback/message-put` / `feedback/message-delete`。
|
|
8
|
+
// ⇒ 现走**事件驱动**(registerFeedbackListener),不再 60 秒轮询 sidecar。
|
|
3
9
|
// 语义(人审闭环):
|
|
4
10
|
// negative + note → 修订建议队列(绝不自动改人格;用户在 UI 逐条 采纳/忽略)
|
|
5
11
|
// positive → 该 DSH 会话概述热度加权(bumpHit)
|
|
@@ -69,9 +75,99 @@ export function ingestFeedbackEntries(memory, entries, { seen } = {}) {
|
|
|
69
75
|
return out;
|
|
70
76
|
}
|
|
71
77
|
|
|
78
|
+
/** 事件名单一来源(测试钉它;改名时两处一起红,不会静默漂移)。 */
|
|
79
|
+
export const FEEDBACK_COMMITTED_EVENT = 'feedback/committed';
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 提交事件列表 → 当前反馈条目(纯函数)。
|
|
83
|
+
* 与宿主 message-feedback 同口径:put 覆盖同 messageId 的旧值,delete 抹掉它。
|
|
84
|
+
* 末条是本次提交的事件 —— 无该项就返回空(不算错,也绝不抛)。
|
|
85
|
+
* @param {string} sid 所属会话
|
|
86
|
+
* @param {Array} events SessionInspection.events
|
|
87
|
+
* @returns {Array<{sessionId:string,messageId:string,rating:string,note:string,createdAt:number}>}
|
|
88
|
+
*/
|
|
89
|
+
export function entriesFromSessionEvents(sid, events) {
|
|
90
|
+
const list = Array.isArray(events) ? events : [];
|
|
91
|
+
const last = list[list.length - 1];
|
|
92
|
+
const t = last?.type;
|
|
93
|
+
if (t !== 'feedback/message-put' && t !== 'feedback/message-delete') return [];
|
|
94
|
+
const sessionId = String(sid ?? last?.data?.sessionId ?? '');
|
|
95
|
+
if (!sessionId) return [];
|
|
96
|
+
const map = new Map();
|
|
97
|
+
for (const ev of list) {
|
|
98
|
+
const d = ev?.data;
|
|
99
|
+
if (!d) continue;
|
|
100
|
+
if (ev.type === 'feedback/message-put' && d.item?.messageId && d.item?.rating) {
|
|
101
|
+
map.set(String(d.item.messageId), {
|
|
102
|
+
messageId: String(d.item.messageId),
|
|
103
|
+
rating: String(d.item.rating),
|
|
104
|
+
note: d.item.note ?? '',
|
|
105
|
+
createdAt: d.item.createdAt ?? d.item.updatedAt ?? Date.now(),
|
|
106
|
+
});
|
|
107
|
+
} else if (ev.type === 'feedback/message-delete' && d.messageId) {
|
|
108
|
+
map.delete(String(d.messageId));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return [...map.values()].map((it) => ({ sessionId, ...it }));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 挂推送监听(U7 正解)。
|
|
116
|
+
* - 冷会话:`feedback/committed`,载荷是完整事件日志 ⇒ 重放出**当前**条目(update/delete 都算准);
|
|
117
|
+
* - 活会话:`session/event` 的 `feedback/message-put|delete`,按事件本身取条目;
|
|
118
|
+
* - delete 事件**不**反向撤单:已入队的修订建议保持原状(与旧轮询口径一致,登记在案)。
|
|
119
|
+
* 绝不抛:宿主在 `feedback/committed` 上等观察方,抛错只会让它写日志。
|
|
120
|
+
* @returns {() => void} 卸载函数(交给 ctx.effect / disposers)
|
|
121
|
+
*/
|
|
122
|
+
export function registerFeedbackListener(ctx, memory) {
|
|
123
|
+
const offs = [];
|
|
124
|
+
const handle = (label, fn) => async (...args) => {
|
|
125
|
+
try {
|
|
126
|
+
fn(...args);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
console.debug(`[dsh-ling] feedback ${label} handler error`, e);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
try {
|
|
132
|
+
offs.push(ctx.on(FEEDBACK_COMMITTED_EVENT, handle('committed', (inspection) => {
|
|
133
|
+
// 只认驱动本回调的那条事件;其余类型视作"无提交"(宿主 no-op append 也会发这个通知)
|
|
134
|
+
const last = inspection?.events?.[inspection.events.length - 1];
|
|
135
|
+
if (last?.type !== 'feedback/message-put') return;
|
|
136
|
+
const sid = String(inspection?.meta?.id ?? last?.data?.sessionId ?? '');
|
|
137
|
+
const entries = entriesFromSessionEvents(sid, inspection?.events);
|
|
138
|
+
if (entries.length) processFeedbackEntries(memory, entries, { seen: loadSeen(memory) });
|
|
139
|
+
})));
|
|
140
|
+
} catch (e) {
|
|
141
|
+
console.debug('[dsh-ling] feedback committed subscribe failed', e);
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
offs.push(ctx.on('session/event', handle('live-event', (session, event) => {
|
|
145
|
+
const t = event?.type;
|
|
146
|
+
if (t !== 'feedback/message-put' && t !== 'feedback/message-delete') return;
|
|
147
|
+
const sid = String(
|
|
148
|
+
session?.id ?? event?.data?.sessionId ?? session?.header?.id ?? '',
|
|
149
|
+
);
|
|
150
|
+
const entries = t === 'feedback/message-put'
|
|
151
|
+
? entriesFromSessionEvents(sid, [event])
|
|
152
|
+
: [];
|
|
153
|
+
if (entries.length) processFeedbackEntries(memory, entries, { seen: loadSeen(memory) });
|
|
154
|
+
})));
|
|
155
|
+
} catch (e) {
|
|
156
|
+
console.debug('[dsh-ling] feedback live subscribe failed', e);
|
|
157
|
+
}
|
|
158
|
+
memory.kvSet('feedback.wired', FEEDBACK_COMMITTED_EVENT + '+session/event');
|
|
159
|
+
return () => {
|
|
160
|
+
for (const d of offs) {
|
|
161
|
+
try { d(); } catch { /* ignore */ }
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
72
166
|
/**
|
|
73
167
|
* 从平台 message_feedback sidecar 文件结构映射条目(tables.sessions 形态)。
|
|
74
|
-
*
|
|
168
|
+
* ⚠️ 2026-10-01 起**不再是活路径**:宿主推送已接管(见文件头);
|
|
169
|
+
* 该 sidecar 在 Web 组合下自 2026/9/11 起零写入。此处只服务
|
|
170
|
+
* `tools/process-feedback.mjs` 这个人工兜底 CLI 与既有回归测试。
|
|
75
171
|
*/
|
|
76
172
|
export function entriesFromFeedbackFile(fileObj) {
|
|
77
173
|
const out = [];
|
|
@@ -117,3 +213,17 @@ export function dismissSuggestion(memory, id) {
|
|
|
117
213
|
memory.setFeedbackStatus(Number(id), 'dismissed');
|
|
118
214
|
return { ok: true, id: Number(id) };
|
|
119
215
|
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* U7 正解入口:吃一批平台反馈,交给 ingestFeedbackEntries。
|
|
219
|
+
* 幂等:同一 (会话,消息) 只处理一次(kv `fb.seen`);返回 null = 这批没有可处理项。
|
|
220
|
+
* 注意 `{seen}` 必须是**本次调用前**的快照(调用后 seenSet 已含新键)。
|
|
221
|
+
*/
|
|
222
|
+
export function processFeedbackEntries(memory, entries, { seen } = {}) {
|
|
223
|
+
const res = ingestFeedbackEntries(memory, entries, { seen });
|
|
224
|
+
if (res.queued + res.boosted + res.seenAdded > 0) {
|
|
225
|
+
memory.kvSet('feedback.last', JSON.stringify({ at: utcIso(), ...res }));
|
|
226
|
+
return res;
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
package/lib/host/freeze.js
CHANGED
|
@@ -6,11 +6,61 @@ import { pick } from './util.js';
|
|
|
6
6
|
|
|
7
7
|
const MODES = new Set(['work', 'life']);
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* A(审计 §四.4,2026-10-01 修):`_byId` 的**基数上限** —— 只对"全缺省空壳"条目生效。
|
|
11
|
+
*
|
|
12
|
+
* 为什么会有无界增长:`GET /state?sessionId=<任意字符串>` 经 stateFor → `gate.isRunning` /
|
|
13
|
+
* `pendingCount` / `snapshotOf`,而这三个方法都走 `_state()`,**先建条目再读** ⇒ 任意 id 都会
|
|
14
|
+
* 在内存里留下一条。条目不存在与条目为全缺省,对读者**不可区分**(实测:`/state` 这条路上
|
|
15
|
+
* 除 `_state()` 外没有任何写入点 —— mode 由 mode.js 从 memory/settings 读,不写 gate),
|
|
16
|
+
* 所以上限可以只清理空壳。
|
|
17
|
+
*
|
|
18
|
+
* 为什么不会动到真读数:带读数的条目**一律不淘汰** —— `running`(agent/status 写)、
|
|
19
|
+
* `pending`(act 写)、`snap`/`snapStale`(markSnap / 失效路径写)、`mode`(ensure 写)。
|
|
20
|
+
* 于是"已存在会话的读数不受影响"是**结构上**成立的(不是靠概率),
|
|
21
|
+
* `snapshotIds()` 枚举的定稿会话也不会因淘汰而消失。
|
|
22
|
+
*
|
|
23
|
+
* N=128 的依据:一台机器上真实并发的会话是个位数;带 snap 的定稿会话属于"非空壳",
|
|
24
|
+
* 根本不在这条上限的管辖内(故不会误伤历史)。128 比真实需要高约两个数量级,
|
|
25
|
+
* 同时把"任意 id ⇒ 无限增长"封成一个常数。要更紧/更松可经构造函数传 `maxIdleSessions`。
|
|
26
|
+
*/
|
|
27
|
+
const MAX_IDLE_SESSIONS_DEFAULT = 128;
|
|
28
|
+
|
|
9
29
|
export class FreezeGate {
|
|
10
|
-
constructor({ onFlush } = {}) {
|
|
30
|
+
constructor({ onFlush, maxIdleSessions = MAX_IDLE_SESSIONS_DEFAULT } = {}) {
|
|
11
31
|
this._byId = new Map();
|
|
12
32
|
this._onFlush = onFlush || (() => {});
|
|
13
33
|
this._recent = null; // 最近定稿快照的会话(API "当前会话"启发式)
|
|
34
|
+
const n = Number(maxIdleSessions);
|
|
35
|
+
this._maxIdle = Number.isFinite(n) && n >= 0 ? Math.floor(n) : MAX_IDLE_SESSIONS_DEFAULT;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 全缺省空壳?(新建时就是这个形状;任何真读数都会让它不再是空壳) */
|
|
39
|
+
_isIdleShell(s) {
|
|
40
|
+
return !s.running && !s.snap && !s.snapStale && !s.mode && s.pending.length === 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A:超过上限时,按**插入顺序**(Map 的迭代顺序)淘汰最老的**空壳**;带读数的一律留下。
|
|
45
|
+
* `keepId` = 刚建的那一条(它也是空壳,但必须留给本次调用方)。
|
|
46
|
+
* @returns {number} 本次淘汰条数(自检/测试可读)
|
|
47
|
+
*/
|
|
48
|
+
_trimIdleShells(keepId) {
|
|
49
|
+
if (this._byId.size <= this._maxIdle) return 0;
|
|
50
|
+
let dropped = 0;
|
|
51
|
+
for (const [id, s] of this._byId) {
|
|
52
|
+
if (this._byId.size <= this._maxIdle) break;
|
|
53
|
+
if (id === keepId) continue; // 本次要用的那一条不许被自己挤掉
|
|
54
|
+
if (!this._isIdleShell(s)) continue; // 带读数:永不淘汰(这就是"读数不受影响"的保证)
|
|
55
|
+
this._byId.delete(id);
|
|
56
|
+
dropped += 1;
|
|
57
|
+
}
|
|
58
|
+
return dropped;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 门内会话条目数(自检/测试用;正常读者不需要它 —— 读数是 per-session 的)。 */
|
|
62
|
+
size() {
|
|
63
|
+
return this._byId.size;
|
|
14
64
|
}
|
|
15
65
|
|
|
16
66
|
/** @returns {{mode:string, running:boolean, pending:string[], snap:object|null}} */
|
|
@@ -19,6 +69,7 @@ export class FreezeGate {
|
|
|
19
69
|
if (!s) {
|
|
20
70
|
s = { mode: null, running: false, pending: [], snap: null, snapStale: false };
|
|
21
71
|
this._byId.set(sessionId, s);
|
|
72
|
+
this._trimIdleShells(sessionId);
|
|
22
73
|
}
|
|
23
74
|
return s;
|
|
24
75
|
}
|
package/lib/host/guard.js
CHANGED
|
@@ -105,6 +105,26 @@ export function cookieValueOf(cookieHeader, name) {
|
|
|
105
105
|
return undefined;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* 反向代理追加的**真实对端地址**(2026-09-30 加固⑤;判据与对照实验见 `tests\guard.test.mjs` 第 8 段「反代场景加固⑤」)。
|
|
110
|
+
*
|
|
111
|
+
* nginx 的 `$proxy_add_x_forwarded_for` = `$http_x_forwarded_for`, `$remote_addr`
|
|
112
|
+
* ⇒ **最后一段永远是 nginx 亲自追加、客户端无法影响的地址**(客户端伪造只能污染前面的段)。
|
|
113
|
+
* 无 XFF 时回退 `X-Real-IP`(同样是 nginx 亲自设置)。
|
|
114
|
+
*
|
|
115
|
+
* ⚠️ **不许"优化"成取第一段** —— 取首段时,攻击者只要自己先塞一句
|
|
116
|
+
* `X-Forwarded-For: 127.0.0.1`,整条判据立刻失效(同段「攻击②」有逐行对照)。
|
|
117
|
+
*/
|
|
118
|
+
export function realPeerAddress(headers) {
|
|
119
|
+
const xff = headerOf(headers, 'x-forwarded-for');
|
|
120
|
+
if (typeof xff === 'string' && xff.trim() !== '') {
|
|
121
|
+
const parts = xff.split(',').map((s) => s.trim()).filter(Boolean);
|
|
122
|
+
if (parts.length) return parts[parts.length - 1];
|
|
123
|
+
}
|
|
124
|
+
const real = headerOf(headers, 'x-real-ip');
|
|
125
|
+
return typeof real === 'string' && real.trim() !== '' ? real.trim() : undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
108
128
|
/**
|
|
109
129
|
* 判定一次 /api 请求是否放行。
|
|
110
130
|
* @param {object} headers 请求头
|
|
@@ -117,9 +137,26 @@ export function checkRequest(headers, { trustedHosts = [], allowLan = false } =
|
|
|
117
137
|
if (!host) return { ok: false, reason: 'no-host' };
|
|
118
138
|
const hostUrl = parseAuthority(host);
|
|
119
139
|
if (!hostUrl) return { ok: false, reason: 'bad-host' };
|
|
140
|
+
|
|
141
|
+
const loopbackHost = isLoopbackHostname(hostUrl.hostname);
|
|
142
|
+
|
|
143
|
+
// ── 2026-09-30 加固⑤(反代场景):**loopback 免检只对"真的来自本机"的请求成立** ──────────
|
|
144
|
+
// 沿革:旧代码"Host 长得像 loopback 就直接短路跳过受信名单",这条假设在**引入反向代理之后失效** ——
|
|
145
|
+
// nginx 的 `proxy_set_header Host $http_host` 把 Host 变成**客户端的自我声明**,
|
|
146
|
+
// 而 cookie 那边只校验**名字**(名字是 `sha256(authority)` 的公开派生,客户端自算即可)⇒
|
|
147
|
+
// 「伪造 Host: 127.0.0.1:3080 + 自算 cookie 名」曾构成一条**不需要任何凭据**的公网通路。
|
|
148
|
+
// 判据:反向代理**一定会追加** XFF/X-Real-IP(`$proxy_add_x_forwarded_for` 把真实对端**追加在最后一段**),
|
|
149
|
+
// 所以"带着代理头、却声称自己是 loopback"= 伪造 ⇒ 拒。**无代理头的本机直连照旧免检**(见 §六 回归表)。
|
|
150
|
+
if (loopbackHost) {
|
|
151
|
+
const peer = realPeerAddress(headers);
|
|
152
|
+
if (peer !== undefined && !isLoopbackHostname(peer)) {
|
|
153
|
+
return { ok: false, reason: 'spoofed-loopback-host' };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
120
157
|
// 2026-09-17 加固③(G1):LAN 地址只在显式 allowLan 时才进受信集(旧版无条件并入,是整个 P0 的根)
|
|
121
158
|
const trusted = allowLan ? [...lanAddresses(), ...trustedHosts] : trustedHosts;
|
|
122
|
-
if (!
|
|
159
|
+
if (!loopbackHost && !isTrustedAuthority(hostUrl, trusted)) {
|
|
123
160
|
return { ok: false, reason: 'untrusted-host' };
|
|
124
161
|
}
|
|
125
162
|
if (headerOf(headers, 'sec-fetch-site') === 'cross-site') return { ok: false, reason: 'cross-site' };
|
package/lib/host/habit-gen.js
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
import { CORRECTION_BUCKETS } from './corpus-suggest.js';
|
|
16
16
|
import { extractJson } from './genesis.js';
|
|
17
17
|
import { isoDate } from './util.js';
|
|
18
|
+
// E2(2026-10-01):「哪些原文属于主人自己的会话」这一判据**不在这里另立一份** ——
|
|
19
|
+
// 它是全包唯一一处定义,住在 summarizer.js(正向白名单 + 未知来源 kv 告警)。
|
|
20
|
+
// 旧写法(黑名单式排除已知的 import: 前缀)在本文件里有 3 处,已统一改为共用判据。
|
|
21
|
+
import { dshSessionSql, noteUnknownRawNamespaces } from './summarizer.js';
|
|
18
22
|
|
|
19
23
|
export const SCAN_DEFAULTS = Object.freeze({
|
|
20
24
|
minHits: 4, // 同类纠正至少出现几次(2026-09-16 用户定案:3 → 4)
|
|
@@ -34,9 +38,11 @@ export function scanCorrections(memory, opts = {}) {
|
|
|
34
38
|
const { minHits, minSessions, maxTurns, sampleLimit } = { ...SCAN_DEFAULTS, ...opts };
|
|
35
39
|
let rows = [];
|
|
36
40
|
try {
|
|
41
|
+
// E2:未知来源命名空间留痕(库干净时零写入);随后只扫 DSH 自己的会话。
|
|
42
|
+
noteUnknownRawNamespaces(memory, 'habit-gen.scanCorrections');
|
|
37
43
|
rows = memory.db.prepare(
|
|
38
|
-
// G4:习惯只能从「主人自己的会话」长出来 ——
|
|
39
|
-
|
|
44
|
+
// G4:习惯只能从「主人自己的会话」长出来 —— 排除一切非 DSH 命名空间(别人的对话)
|
|
45
|
+
`SELECT session_id, text FROM dsh_turns_raw WHERE role='user' AND ${dshSessionSql()} ORDER BY seq DESC LIMIT ?`,
|
|
40
46
|
).all(maxTurns);
|
|
41
47
|
} catch {
|
|
42
48
|
return []; // 库结构异常时静默退化(生成器不能拖累别处)
|
|
@@ -149,12 +155,14 @@ export function buildReflectMaterial(memory, opts = {}) {
|
|
|
149
155
|
|
|
150
156
|
// ③ 主人说过的话(真人原话;分轮读取 —— 每轮读上一轮没读过的那一段)
|
|
151
157
|
try {
|
|
152
|
-
|
|
158
|
+
// E2:同上,未知来源命名空间留痕后按正向白名单统计(两处查询共用同一判据)
|
|
159
|
+
noteUnknownRawNamespaces(memory, 'habit-gen.buildReflectMaterial');
|
|
160
|
+
const total = Number(memory?.db?.prepare?.(`SELECT COUNT(*) n FROM dsh_turns_raw WHERE role='user' AND ${dshSessionSql()}`).get()?.n) || 0;
|
|
153
161
|
stats.turnTotal = total;
|
|
154
162
|
let off = Math.max(0, Number(cursor?.turnOffset) || 0);
|
|
155
163
|
if (total > 0 && off >= total) { off = 0; stats.wrapped = true; }
|
|
156
164
|
const rows = total > 0
|
|
157
|
-
? (memory.db.prepare(
|
|
165
|
+
? (memory.db.prepare(`SELECT ts, text FROM dsh_turns_raw WHERE role='user' AND ${dshSessionSql()} ORDER BY rowid DESC LIMIT ? OFFSET ?`).all(turnLimit, off) || [])
|
|
158
166
|
: [];
|
|
159
167
|
const lines = rows
|
|
160
168
|
.map((r) => { const t = clean(r?.text); return t ? `- (${isoDate(r?.ts) || '?'}) ${t.slice(0, 140)}` : ''; })
|
package/lib/host/import-file.js
CHANGED
|
@@ -217,13 +217,14 @@ export function normalizeImportItem(raw) {
|
|
|
217
217
|
* 落库一批(≤500/批,幂等):
|
|
218
218
|
* - 折叠:同 conv_id 命中 dsweb 旧域(官方导出与历史抓取同源)→ 元数据与原文并入 dsweb,
|
|
219
219
|
* 并移除本域残留副本(避免 L1 双份召回);
|
|
220
|
+
* ⚠️ A(2026-09-30):折叠**绝不覆盖目标会话已有的原文** —— 详见下面折叠分支的注释与 `foldSkippedRaw`;
|
|
220
221
|
* - 否则:新行全量 upsert;已存在只刷新 标题/时间/类别/关键词(不动 summary/importance/置顶/热度);
|
|
221
222
|
* - 完整档 raw 轮次按 (session_id,seq) 覆盖写,重复导入天然幂等。
|
|
222
|
-
* @returns {{ok, newRows, refreshed, folded, removedImport, accepted, degraded, rejected}}
|
|
223
|
+
* @returns {{ok, newRows, refreshed, folded, removedImport, foldSkippedRaw, accepted, degraded, rejected}}
|
|
223
224
|
*/
|
|
224
225
|
export async function applyImportItems(memory, items) {
|
|
225
226
|
const rejected = [];
|
|
226
|
-
const stat = { ok: true, accepted: 0, newRows: 0, refreshed: 0, upgraded: 0, folded: 0, removedImport: 0, degraded: 0, degradedReasons: {} };
|
|
227
|
+
const stat = { ok: true, accepted: 0, newRows: 0, refreshed: 0, upgraded: 0, folded: 0, removedImport: 0, foldSkippedRaw: 0, degraded: 0, degradedReasons: {} };
|
|
227
228
|
const list = Array.isArray(items) ? items : [];
|
|
228
229
|
for (let i = 0; i < list.length; i++) {
|
|
229
230
|
const n = normalizeImportItem(list[i]);
|
|
@@ -254,9 +255,62 @@ export async function applyImportItems(memory, items) {
|
|
|
254
255
|
stat.removedImport += 1;
|
|
255
256
|
}
|
|
256
257
|
stat.folded += 1;
|
|
257
|
-
// 折叠分支的原文归 dsweb 域(裸 id):它本就与 dsweb 抓取同源,G4 的 import
|
|
258
|
-
|
|
259
|
-
|
|
258
|
+
// 折叠分支的原文归 dsweb 域(裸 id):它本就与 dsweb 抓取同源,G4 的 import 前缀不适用。
|
|
259
|
+
// ── A(2026-09-30,修缺陷「折叠覆盖真原文」)────────────────────────────────
|
|
260
|
+
// 缺陷:旧实现把**导入侧的 seq** 直接 appendRawTurn 进这个 dsweb 会话,而
|
|
261
|
+
// `dsh_turns_raw` 的主键是 (session_id, seq)、`appendRawTurn` 是
|
|
262
|
+
// `ON CONFLICT(session_id, seq) DO UPDATE SET text=excluded.text`
|
|
263
|
+
// ⇒ 折叠侧 seq 从 1 重新数,与目标会话既有轮次**必然同号**,于是目标里的真原文
|
|
264
|
+
// 被逐条静默换成导入副本:**行数不增、内容被换、一个字都不报**(离线测试台 S6d 实测
|
|
265
|
+
// clobbered=3)。属"会丢数据 + 静默"那一档。
|
|
266
|
+
// 判据(顺序不可调):
|
|
267
|
+
// ① 目标**一个字原文都没有** ⇒ 照旧写入(这是"把原文补进去"的升级路径,**不许堵死**);
|
|
268
|
+
// ② 目标已有原文 ⇒ 逐条按 (role,text) 比:
|
|
269
|
+
// · 完全相同 ⇒ 跳过(幂等:重复导入同一份不报错、也不算丢失);
|
|
270
|
+
// · 不同 ⇒ **保留现有的**(绝不覆盖),计入 `foldSkippedRaw`。
|
|
271
|
+
// ⚠️ **不许**"按 max(seq) 往后追加"(同一段对话在库里变成两份 ⇒ 污染 L1 检索),
|
|
272
|
+
// **也不许**整段替换(那是丢真的方向)。
|
|
273
|
+
// 代价(已留证):目标已有原文且内容不同时,导入侧的这几轮**进不去** —— 这是**故意的**取舍:
|
|
274
|
+
// 宁可少一份副本,不可换掉真原文。它必须**说出来**(foldSkippedRaw → 回执/界面/账本),
|
|
275
|
+
// 静默与丢失同样不可接受。
|
|
276
|
+
// ── A'(2026-09-30 补,G4 裂缝「判据漏了 import 命名空间」)────────────────────
|
|
277
|
+
// 只看写入目标(裸 id)会漏 `import:<id>` 侧:若该会话在 import 命名空间下**已有原文**
|
|
278
|
+
// (例如先前做过一次普通文件导入),而裸 id 这侧 0 轮,折叠就会把同一段对话**再写一份**
|
|
279
|
+
// 进裸 id ⇒ 库里同一段对话出现两个 session_id、两份原文 —— 正是折叠机制本来要消灭的
|
|
280
|
+
// 「L1 双份召回」。故"已有原文"按**候选链任一侧**判(复用 memory.rawSessionCandidates,
|
|
281
|
+
// 与上方 upgraded 判据同一套口径,不另发明)。
|
|
282
|
+
// 任一侧已有原文 ⇒ 不写裸 id、也不动 `import:` 侧的行;被挡下的轮数照旧计入 foldSkippedRaw。
|
|
283
|
+
// ⚠️ 这里**不做**数据迁移(把 `import:` 侧原文搬到裸 id):那是另一档风险,留待拍板。
|
|
284
|
+
const targetHasRaw = rawSessionCandidates(IMPORT_SOURCE, id).some((k) => memory.rawTurnCount(k) > 0);
|
|
285
|
+
if (!targetHasRaw) {
|
|
286
|
+
for (const t of n.rawTurns) {
|
|
287
|
+
// v14(2026-10-01)来源列:这里**刻意不给** source,由 appendRawTurn 按 id 形状推导(裸 id ⇒ 'dsh')。
|
|
288
|
+
// 两条理由:① 本分支的写入目标由候选链决定,来源应当与**实际写进去的那个 id**同解(读者的回落
|
|
289
|
+
// 判据就是这个),写死一个常量会在 id 形态变化时与它脱钩;② 折叠进来的原文按现有裁决**仍被
|
|
290
|
+
// 概述器认领**(它落在裸 id 上)—— 若改记成 'dsweb',这些轮次会当场从概述器眼里消失,
|
|
291
|
+
// 那是**越权改裁决**,不属于"加一列"的授权范围(真机库实测该分支 0 行,留作残余风险上报)。
|
|
292
|
+
memory.appendRawTurn(id, { seq: t.seq, role: t.role, ts: t.ts, model: null, text: t.text });
|
|
293
|
+
}
|
|
294
|
+
} else if (n.rawTurns.length) {
|
|
295
|
+
// (role,text) 指纹:JSON 化再比 —— role 与 text 里可能出现分隔符,拼接字符串会误判相同。
|
|
296
|
+
// ── A''(2026-09-30 补,C′ 裂缝「指纹只取写入目标」)─────────────────────────
|
|
297
|
+
// A' 只把"要不要写"的存在判据扩到了候选链;**逐条比对的指纹**仍只取写入目标(裸 id)
|
|
298
|
+
// ⇒ 两侧都有原文时,`import:` 侧**已有的同一轮**再次进来会被算成"未替换"
|
|
299
|
+
// (计数偏大、方向保守:把幂等重复说成丢失)。既然"已有原文"按候选链任一侧判,
|
|
300
|
+
// 指纹集就必须**同一口径** —— 两侧的轮次合起来建集合,否则同一条链上并存两种口径。
|
|
301
|
+
// ⚠️ 本分支以 `targetHasRaw === true` 进入,里面**一行都不写** ⇒ 这里只让**计数更准**:
|
|
302
|
+
// 不动任何数据、不做迁移(`import:` 侧的行原样留着)。
|
|
303
|
+
const fp = (role, text) => JSON.stringify([String(role), String(text)]);
|
|
304
|
+
const have = new Set();
|
|
305
|
+
for (const k of rawSessionCandidates(IMPORT_SOURCE, id)) {
|
|
306
|
+
for (const r of memory.db.prepare('SELECT role, text FROM dsh_turns_raw WHERE session_id=?').all(k)) {
|
|
307
|
+
have.add(fp(r.role, r.text));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
for (const t of n.rawTurns) {
|
|
311
|
+
if (have.has(fp(t.role, t.text))) continue; // 幂等:同 (role,text) 已在 ⇒ 跳过,不计
|
|
312
|
+
stat.foldSkippedRaw += 1; // 不同 ⇒ 保留现有的,只计数(一个字节都不写)
|
|
313
|
+
}
|
|
260
314
|
}
|
|
261
315
|
continue;
|
|
262
316
|
}
|
|
@@ -282,9 +336,11 @@ export async function applyImportItems(memory, items) {
|
|
|
282
336
|
}
|
|
283
337
|
// G4(2026-09-17):导入原文落 'import:<id>' 命名空间 —— 与 DSH 会话原文物理隔离。
|
|
284
338
|
// 否则概述器(GROUP BY session_id)会把它当成 DSH 会话重建(跨源双份),同 id 时还会互相覆盖原文。
|
|
339
|
+
// 写入侧坐实(2026-10-01 grep):本文件的实际来源命名空间 = `IMPORT_SOURCE`('import'),
|
|
340
|
+
// 落成的 id = `rawSessionId(IMPORT_SOURCE, id)` = `import:<id>` ⇒ v14 的 source 列记 'import'。
|
|
285
341
|
const rawId = rawSessionId(IMPORT_SOURCE, id);
|
|
286
342
|
for (const t of n.rawTurns) {
|
|
287
|
-
memory.appendRawTurn(rawId, { seq: t.seq, role: t.role, ts: t.ts, model: null, text: t.text });
|
|
343
|
+
memory.appendRawTurn(rawId, { seq: t.seq, role: t.role, ts: t.ts, model: null, text: t.text, source: IMPORT_SOURCE });
|
|
288
344
|
}
|
|
289
345
|
}
|
|
290
346
|
return { ok: true, ...stat, rejected };
|