@nightpainters/dsh-ling 1.5.2 → 1.5.3
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 +75 -3
- package/DESIGN.md +27 -10
- package/README.md +17 -7
- 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 +284 -34
- package/lib/host/api.js +588 -53
- package/lib/host/backfill.js +8 -2
- 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/lifecycle.js +13 -2
- package/lib/host/memory.js +164 -15
- package/lib/host/mode-service.js +85 -0
- package/lib/host/mode.js +29 -9
- package/lib/host/retitle.js +6 -0
- package/lib/host/session-line.js +46 -6
- package/lib/host/summarizer.js +158 -2
- package/lib/host/surgery.js +600 -0
- package/lib/host/tools.js +12 -1
- package/lib/host/tree-ops.js +1 -1
- package/lib/index.js +17 -29
- package/package.json +2 -2
- package/tools/apply-access-log-patch.mjs +1 -1
package/lib/host/summarizer.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// dsh-ling host — DSH 会话概述器 v1(轻量)。
|
|
2
|
-
// 把 dsh_turns_raw
|
|
2
|
+
// 把 dsh_turns_raw 中**属于 DSH 命名空间**的每个顶层会话聚合成 source='dsh' 的 conv_overview
|
|
3
|
+
// (范围判据是正向白名单,唯一一处定义见下方 E2 段;未知来源一律排除并留 kv 告警):
|
|
3
4
|
// title = 首条真人消息经启发式清洗后的要点句(剥问候/称呼前缀 → 无实义则往后取前 6 条真人发言里第一条有实义的 → 句读处收尾、不切半句、上限 58 字;全失败才硬截首句);summary = 首问(≤140 字)+ 轮次;类别/关键词启发式。
|
|
4
5
|
// 增量:每会话水位 kv('sumdsh:<sid>') = 已概述的最大 seq;有新增才重建。
|
|
5
6
|
// 无 LLM 深摘要(v1);原文保留在 dsh_turns_raw,供 M2 概述升级。
|
|
@@ -7,17 +8,172 @@ import { categorizeTitle } from './classify.js';
|
|
|
7
8
|
import { heuristicTitle, TITLE_MAX_CHARS } from './retitle.js';
|
|
8
9
|
import { keywordsFrom } from './inject-common.js';
|
|
9
10
|
import { toIso } from './util.js';
|
|
11
|
+
import { RAW_IMPORT_PREFIX, RAW_NS_SEP, RAW_SOURCE_DSH, normalizeRawSource } from './memory.js';
|
|
10
12
|
|
|
11
13
|
const MIN_MESSAGES = 2;
|
|
12
14
|
const MIN_CHARS = 40;
|
|
13
15
|
const TITLE_MAX = TITLE_MAX_CHARS; // D2(2026-09-17):46 → 58。旧值把一句话切成半句;此值仅作启发式失败时的兜底
|
|
14
16
|
const SUMMARY_FIRST_MAX = 140;
|
|
15
17
|
|
|
18
|
+
// ── E2(2026-10-01)「静默失效」修复:原文范围判据 = 正向白名单(全包唯一一处定义)────────
|
|
19
|
+
// 旧判据是黑名单式:把「已知的那一个外来命名空间(`import:` 前缀)」排除掉,其余**全部**当真
|
|
20
|
+
// DSH 会话。它的致命处不在漏了谁,而在**默认信任**:任何没见过的来源(别的导入器、别的域的
|
|
21
|
+
// 同步、将来新增的命名空间)都会被概述器当成主人的会话重建、被习惯生成当成主人的纠正信号。
|
|
22
|
+
// 审计原话:「最该被质疑的内容带着最真诚的标记」。
|
|
23
|
+
// 这里反过来 —— 只有**明确落在 DSH 宿主命名空间**里的 id 才入选,其余一律排除;而且未知前缀
|
|
24
|
+
// 不是"悄悄丢掉",而是写一条 kv 告警(见 noteUnknownRawNamespaces),让人能发现
|
|
25
|
+
// "有东西进来了,而我不知道它是什么"。
|
|
26
|
+
//
|
|
27
|
+
// 事实依据(写入侧,2026-10-01 核对):写入者只有三处 ——
|
|
28
|
+
// · 裸 id :DSH 宿主会话轮次(lifecycle.js 的 appendRawTurn,source 显式记 'dsh')= 唯一的"自己人"命名空间;
|
|
29
|
+
// · `import:<id>` :本机导入域原文(memory.js RAW_IMPORT_PREFIX,source 'import')= 别人的对话,不属于主人;
|
|
30
|
+
// · import-file.js 的 dsweb 折叠分支(2026-09-30 修 A):原文写进**裸 id**(与 dsweb 抓取同源)。
|
|
31
|
+
// 故:DSH 自己的来源 = { RAW_SOURCE_DSH('dsh') };已知外来 = KNOWN_FOREIGN_RAW_NAMESPACES / …_SOURCES。
|
|
32
|
+
//
|
|
33
|
+
// ── v14(2026-10-01,主人拍板「来源也加个列吧」):判据从"只看 id 长相"升级为 source 优先 ─────
|
|
34
|
+
// 加列前:命名空间信息**只存在于 id 文本里**(表结构所限),判据只能写成"id 不带前缀",于是将来
|
|
35
|
+
// 出现一个**不带冒号**的新来源名时,物理上分不出来(会被当主人的会话认领)。
|
|
36
|
+
// 加列后:`dsh_turns_raw.source` 记下**真实来源**,判据改成 ——
|
|
37
|
+
// · source 非空 ⇒ 只认 `source='dsh'`(记录优先;不带冒号的新来源名照样能被认出并排除 + 告警);
|
|
38
|
+
// · source 为空 ⇒ **回落**到旧判据"裸 id = DSH 会话"(半迁移:老行还没回填,两种行为不许并存)。
|
|
39
|
+
// ⚠️ 口径不变的保证(负对照):真 DSH 会话(lifecycle 写的 'dsh' + 老行的裸 id 回落)在迁移前后
|
|
40
|
+
// 的处理结果**逐字一致** —— 回归见 tests/summarize.test.mjs 的 E2 golden 与 E3 段。
|
|
41
|
+
export const DSH_RAW_NAMESPACE = ''; // 空串 = 裸 id(DSH 宿主会话)
|
|
42
|
+
export { RAW_NS_SEP }; // 分隔符的唯一定义处是 memory.js;这里再导出以保持既有导入面
|
|
43
|
+
/** 已知的**外来**(非 DSH)原文命名空间。只用于区分"已知外来"与"未知来源",不用于放行任何东西。 */
|
|
44
|
+
export const KNOWN_FOREIGN_RAW_NAMESPACES = Object.freeze([RAW_IMPORT_PREFIX]);
|
|
45
|
+
/** 同一批来源的**名字**(去尾冒号:`import:` ⇒ `import`)。v14 之后 source 列记的就是这个形态。 */
|
|
46
|
+
export const KNOWN_FOREIGN_RAW_SOURCES = Object.freeze(
|
|
47
|
+
KNOWN_FOREIGN_RAW_NAMESPACES.map((ns) => String(ns).replace(/:+$/, '')),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/** 正向判据的 **id 文本形态** —— 也是**半迁移的回落分支**:该 session_id 落在 DSH 命名空间吗?
|
|
51
|
+
* ⚠️ v14 起它**不再单独决定**某一行是否入选(整行判据是 isDshRawRow / dshSessionSql())。
|
|
52
|
+
* 语义一个字未改(既有断言与本模块 4 条查询的回落口径都钉在它上面)。 */
|
|
53
|
+
export function isDshSessionId(sessionId) {
|
|
54
|
+
const id = String(sessionId ?? '');
|
|
55
|
+
return id !== '' && !id.includes(RAW_NS_SEP);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 整行判据(JS 形态,id + source):**source 优先,空 source 回落旧判据**。
|
|
59
|
+
* 与 SQL 形态(dshSessionSql() 零参)必须同解 —— 两处任一改动都要在 tests/summarize.test.mjs
|
|
60
|
+
* 的"两形态同解"断言上过一遍。 */
|
|
61
|
+
export function isDshRawRow(sessionId, source) {
|
|
62
|
+
const src = normalizeRawSource(source);
|
|
63
|
+
return src ? src === RAW_SOURCE_DSH : isDshSessionId(sessionId);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 同一判据的 SQL 形态(4 条原文查询共用这一份;列名可换,判据不许再分叉)。
|
|
67
|
+
* · **零参**(4 条查询用的形态)= 整行判据:`source` 优先,空 source 回落旧判据;
|
|
68
|
+
* · **带参**(既有调用点 `dshSessionSql('?')`)= id 纯函数形态 `instr(?, ':') = 0`,**逐字未变** ——
|
|
69
|
+
* 那个调用点跑在不 FROM 任何表的 SELECT 里(tests/summarize.test.mjs 的两形态同解探针),
|
|
70
|
+
* 那里**没有 source 列可读**,若把 source 判据也塞进去只会换来 "no such column: source"。
|
|
71
|
+
* 故按**实参个数**分派:带参形态与整行判据的**回落分支**是同一个表达式,不是第二套口径。
|
|
72
|
+
* `instr(id, sep) = 0` 即"该 id 没有命名空间前缀 ⇒ 落在 DSH_RAW_NAMESPACE"。 */
|
|
73
|
+
export function dshSessionSql(col = null) {
|
|
74
|
+
const idOnly = `instr(${col ?? 'session_id'}, '${RAW_NS_SEP}') = 0`; // RAW_NS_SEP 是模块常量(非外部输入),无引号注入面
|
|
75
|
+
if (col !== null) return idOnly;
|
|
76
|
+
return `(CASE WHEN NULLIF(TRIM(source), '') IS NOT NULL THEN TRIM(source) = '${RAW_SOURCE_DSH}' ELSE ${idOnly} END)`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 取 id 的命名空间前缀;裸 id 返回 DSH_RAW_NAMESPACE。 */
|
|
80
|
+
export function rawNamespaceOf(sessionId) {
|
|
81
|
+
const id = String(sessionId ?? '');
|
|
82
|
+
const i = id.indexOf(RAW_NS_SEP);
|
|
83
|
+
return i < 0 ? DSH_RAW_NAMESPACE : id.slice(0, i + RAW_NS_SEP.length);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 未知来源告警的 kv 键(沿用 lifecycle.js 的 `dbg.*` 诊断约定,同一族键同一读法)。 */
|
|
87
|
+
export const RAW_UNKNOWN_NS_KEY = 'dbg.raw_unknown_namespace';
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 「有东西进来了,而我不知道它是什么」—— 扫一遍原文表里出现过的 (session_id, source),凡**不属于**
|
|
91
|
+
* DSH 来源、又**不在**已知外来来源里的,写一条 kv 告警。
|
|
92
|
+
*
|
|
93
|
+
* 为什么必须有这一步:黑名单→白名单的真正代价是**未知来源从"被误信"变成"被静默丢弃"**。
|
|
94
|
+
* 少了告警,新来源进来只会表现为"记忆莫名其妙变少",没人查得出来 —— 那只是把一种静默失效
|
|
95
|
+
* 换成另一种。这条 kv 就是那个看得见的响动:面板/诊断里能读到"见过来源 X(样本 id、
|
|
96
|
+
* 条数、首次/最近时间)"。同一来源**首次**出现时额外打一行 console.warn,便于当场发现。
|
|
97
|
+
*
|
|
98
|
+
* ── v14(2026-10-01)口径变化:告警报的是**来源名**,不再是"从前缀猜出来的字符串" ──────────
|
|
99
|
+
* · 带冒号的 id:键沿用**前缀串**(`weird:`),键形不因本次升级而变 ⇒ 老告警记录能直接续上;
|
|
100
|
+
* · 不带冒号的 id:旧判据**只能**把它当 DSH 会话(物理上分不出来);现在只要它带着别的来源名
|
|
101
|
+
* 落库,键就是**那个来源名**(`weird-x`)—— 这类来源正是加这一列要照出来的东西;
|
|
102
|
+
* · 每条多记两个字段:`source`(该行记录的来源名;null = 这行还没回填)与
|
|
103
|
+
* `by`('source' = 靠记录认出来的 / 'id-prefix' = 靠 id 前缀认出来的),读的人能分清
|
|
104
|
+
* "这是它自己说的"还是"这是我猜的"。
|
|
105
|
+
* 只在确有未知来源时才写(库干净时一个字节都不写 ⇒ 正常路径零副作用);读库失败一律静默
|
|
106
|
+
* (告警不能拖累概述与习惯生成)。
|
|
107
|
+
* @returns {string[]} 本次发现的未知来源(按条数降序)
|
|
108
|
+
*/
|
|
109
|
+
export function noteUnknownRawNamespaces(memory, where = '') {
|
|
110
|
+
let rows = [];
|
|
111
|
+
try {
|
|
112
|
+
rows = memory?.db?.prepare?.(
|
|
113
|
+
'SELECT session_id, source, COUNT(*) AS n FROM dsh_turns_raw GROUP BY session_id, source',
|
|
114
|
+
)?.all?.() || [];
|
|
115
|
+
} catch { return []; }
|
|
116
|
+
const found = new Map();
|
|
117
|
+
for (const r of rows) {
|
|
118
|
+
const sid = String(r?.session_id ?? '');
|
|
119
|
+
const src = normalizeRawSource(r?.source); // '' = 该行还没回填(半迁移)
|
|
120
|
+
if (isDshRawRow(sid, src)) continue; // 整行判据,与 4 条查询同解
|
|
121
|
+
const ns = rawNamespaceOf(sid); // id 前缀('' = 裸 id)
|
|
122
|
+
const key = ns || src; // 键:优先 id 前缀(键形稳定);裸 id 只能报记录下来的来源名
|
|
123
|
+
if (KNOWN_FOREIGN_RAW_NAMESPACES.includes(key) || KNOWN_FOREIGN_RAW_SOURCES.includes(src)) continue;
|
|
124
|
+
const cur = found.get(key) || { ns: key, source: src || null, by: src ? 'source' : 'id-prefix', turns: 0, samples: [] };
|
|
125
|
+
cur.turns += Number(r?.n) || 0;
|
|
126
|
+
if (cur.samples.length < 3) cur.samples.push(sid.slice(0, 64));
|
|
127
|
+
found.set(key, cur);
|
|
128
|
+
}
|
|
129
|
+
const list = [...found.values()].sort((a, b) => (b.turns - a.turns) || a.ns.localeCompare(b.ns));
|
|
130
|
+
if (!list.length) return [];
|
|
131
|
+
try {
|
|
132
|
+
let prev = null;
|
|
133
|
+
try { const raw = memory?.kvGet?.(RAW_UNKNOWN_NS_KEY); prev = raw ? JSON.parse(String(raw)) : null; } catch { prev = null; }
|
|
134
|
+
const prevNs = (prev && typeof prev === 'object' && prev.namespaces && typeof prev.namespaces === 'object') ? prev.namespaces : {};
|
|
135
|
+
const namespaces = { ...prevNs };
|
|
136
|
+
const now = new Date().toISOString();
|
|
137
|
+
const fresh = [];
|
|
138
|
+
for (const u of list) {
|
|
139
|
+
const old = (namespaces[u.ns] && typeof namespaces[u.ns] === 'object') ? namespaces[u.ns] : null;
|
|
140
|
+
if (!old) fresh.push(u.ns);
|
|
141
|
+
namespaces[u.ns] = {
|
|
142
|
+
turns: Number(old?.turns || 0) + u.turns,
|
|
143
|
+
samples: [...new Set([...(Array.isArray(old?.samples) ? old.samples : []), ...u.samples])].slice(0, 3),
|
|
144
|
+
// v14:来源名 + 认定方式(旧记录没有这两个字段 ⇒ 保留旧值 / 置 null,不伪造)
|
|
145
|
+
source: u.source ?? (old && 'source' in old ? old.source : null),
|
|
146
|
+
by: u.by || old?.by || null,
|
|
147
|
+
firstSeenAt: old?.firstSeenAt || now,
|
|
148
|
+
lastSeenAt: now,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
// 只保留最近 20 个命名空间(整段 JSON 不截断,否则读者解析不出来)
|
|
152
|
+
const kept = Object.fromEntries(Object.entries(namespaces).slice(-20));
|
|
153
|
+
memory?.kvSet?.(RAW_UNKNOWN_NS_KEY, JSON.stringify({
|
|
154
|
+
at: now,
|
|
155
|
+
where: String(where || '').slice(0, 60),
|
|
156
|
+
note: 'dsh_turns_raw 里出现了既不属于 DSH 来源、也不在已知外来来源列表里的原文;'
|
|
157
|
+
+ '已一律排除(判据见 summarizer.js isDshRawRow/dshSessionSql:source 优先,空 source 回落"裸 id");'
|
|
158
|
+
+ '键 = id 的命名空间前缀,裸 id 时 = 该行记录下来的来源名(source 为空的行不会出现在这里)。留痕供人判断它是什么。',
|
|
159
|
+
namespaces: kept,
|
|
160
|
+
}));
|
|
161
|
+
if (fresh.length) {
|
|
162
|
+
console.warn('[dsh-ling] 原文表出现未知来源,已排除:', fresh.join(', '),
|
|
163
|
+
JSON.stringify(list.filter((u) => fresh.includes(u.ns))
|
|
164
|
+
.map((u) => ({ ns: u.ns, source: u.source, by: u.by, turns: u.turns, sample: u.samples[0] }))));
|
|
165
|
+
}
|
|
166
|
+
} catch { /* 告警写不进去不影响主流程 */ }
|
|
167
|
+
return list.map((u) => u.ns);
|
|
168
|
+
}
|
|
169
|
+
|
|
16
170
|
export function summarizeDsh(memory, { force = false } = {}) {
|
|
171
|
+
// E2:先看一眼有没有"不认识的东西"进来了(未知来源命名空间 → kv 告警)。库干净时零写入。
|
|
172
|
+
noteUnknownRawNamespaces(memory, 'summarizer.summarizeDsh');
|
|
17
173
|
const groups = memory.db.prepare(
|
|
18
174
|
`SELECT session_id, COUNT(*) AS n, COALESCE(SUM(LENGTH(text)),0) AS chars,
|
|
19
175
|
MIN(ts) AS t0, MAX(ts) AS t1, MAX(seq) AS maxSeq
|
|
20
|
-
FROM dsh_turns_raw WHERE
|
|
176
|
+
FROM dsh_turns_raw WHERE ${dshSessionSql()} GROUP BY session_id`,
|
|
21
177
|
).all();
|
|
22
178
|
const stats = { sessions: groups.length, created: 0, updated: 0, skipped: 0, watermarkSet: 0, archivedSkipped: 0 };
|
|
23
179
|
// 归档即清理(2026-09-16 用户定:清掉归档会话,未归档的测试会话不动):
|