@nightpainters/dsh-ling 1.5.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.
Files changed (51) hide show
  1. package/ACCESS-DESIGN.md +119 -0
  2. package/CHANGELOG.md +627 -0
  3. package/DESIGN.md +461 -0
  4. package/LICENSE +201 -0
  5. package/NOTICE +7 -0
  6. package/README.md +236 -0
  7. package/cordis.patch.yml +16 -0
  8. package/lib/audit/access-log.js +533 -0
  9. package/lib/audit/index.js +92 -0
  10. package/lib/audit/patch-spec.js +195 -0
  11. package/lib/client.js +5069 -0
  12. package/lib/host/api.js +2584 -0
  13. package/lib/host/archive.js +194 -0
  14. package/lib/host/autotree.js +131 -0
  15. package/lib/host/backfill.js +267 -0
  16. package/lib/host/classify.js +10 -0
  17. package/lib/host/clock-skill.js +141 -0
  18. package/lib/host/clock.js +132 -0
  19. package/lib/host/corpus-suggest.js +139 -0
  20. package/lib/host/deepsummary.js +338 -0
  21. package/lib/host/dsweb-summary.js +225 -0
  22. package/lib/host/feedback.js +119 -0
  23. package/lib/host/freeze.js +144 -0
  24. package/lib/host/genesis.js +176 -0
  25. package/lib/host/guard.js +184 -0
  26. package/lib/host/habit-gen.js +273 -0
  27. package/lib/host/import-file.js +291 -0
  28. package/lib/host/import-log.js +129 -0
  29. package/lib/host/inject-common.js +25 -0
  30. package/lib/host/inject.js +285 -0
  31. package/lib/host/l1.js +282 -0
  32. package/lib/host/lifecycle.js +367 -0
  33. package/lib/host/memory.js +2389 -0
  34. package/lib/host/mode.js +101 -0
  35. package/lib/host/persona.js +214 -0
  36. package/lib/host/retitle.js +214 -0
  37. package/lib/host/rules.js +195 -0
  38. package/lib/host/scan-dsweb.js +111 -0
  39. package/lib/host/seal.js +17 -0
  40. package/lib/host/settings-file.js +49 -0
  41. package/lib/host/snapshot.js +168 -0
  42. package/lib/host/suggestions.js +47 -0
  43. package/lib/host/summarizer.js +103 -0
  44. package/lib/host/tone-advice.js +112 -0
  45. package/lib/host/tools.js +1067 -0
  46. package/lib/host/tree-ops.js +330 -0
  47. package/lib/host/util.js +165 -0
  48. package/lib/host/zstd.js +63 -0
  49. package/lib/index.js +263 -0
  50. package/package.json +69 -0
  51. package/tools/apply-access-log-patch.mjs +295 -0
@@ -0,0 +1,194 @@
1
+ // dsh-ling host — 改动前的归档落盘(Part G · G2,2026-09-25)
2
+ //
3
+ // **原则(用户定):永远不做彻底删除。**
4
+ // 任何"清除/覆盖"类的操作,动手之前先把原数据**完整写到磁盘上的历史目录**,并附一份
5
+ // 人可读的改动日志 —— 谁、什么时候、为什么、以及怎么还原。
6
+ //
7
+ // 为什么单独成模块:遗忘与回灌是这套系统里仅有的两个"会让人后悔"的操作。
8
+ // 库内标记(`forgotten` 表)与磁盘归档是**两条独立的还原路径**:库里没删行,所以撤销标记即可;
9
+ // 就算库没了,归档文件还在。任何一条断了都还有另一条。
10
+
11
+ import { writeFileSync, mkdirSync, existsSync, readdirSync, readFileSync } from 'node:fs';
12
+ import { join, dirname } from 'node:path';
13
+
14
+ /** 本地时间戳(目录名用;不用 toISOString —— 那是 UTC,会和主人的钟差 8 小时)。 */
15
+ function localStamp(d = new Date()) {
16
+ const p = (n) => String(n).padStart(2, '0');
17
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
18
+ }
19
+
20
+ /** 人看的时间(写进日志)。 */
21
+ function humanTime(d = new Date()) {
22
+ const p = (n) => String(n).padStart(2, '0');
23
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
24
+ }
25
+
26
+ /** 短 id(目录名用;完整 id 太长,而归档目录要能一眼看出对应哪一条)。 */
27
+ function shortId(s) {
28
+ return String(s || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 12) || 'unknown';
29
+ }
30
+
31
+ /** 归档根目录:与 memory.db 同级,便于整体搬走/备份。 */
32
+ export function archiveRootOf(memory) {
33
+ const dbPath = String(memory?.dbPath || '');
34
+ const base = dbPath ? dirname(dbPath) : '.';
35
+ return join(base, 'forgotten');
36
+ }
37
+
38
+ /**
39
+ * 把一条记忆的**全部数据**归档落盘。
40
+ *
41
+ * @param {object} memory MemoryStore(需要 exportEntry / dbPath)
42
+ * @param {object} opts
43
+ * @param {string} opts.source 来源域(dsh / dsweb / import)
44
+ * @param {string} opts.convId 条目 id
45
+ * @param {string} opts.kind 'forget' | 'backfill'(决定目录前缀与日志措辞)
46
+ * @param {string} opts.reason 为什么动它(人写的话)
47
+ * @param {string} opts.actor 'user' | 'ling'
48
+ * @returns {{ok:boolean, dir?:string, turns?:number, empty?:boolean, isForget?:boolean, reason?:string, message?:string}}
49
+ * `ok:true` 只代表"文件写下去了";**`empty:true` 代表这份归档什么都没保住**(0 轮原文 + 无正文
50
+ * + 无会话元数据)。会"按归档放行"的破坏性调用方(forget 那种 fail-closed 纪律)必须同时看
51
+ * `ok && !empty` —— 否则空归档会把"没有备份"伪装成"备份好了"(B-05)。
52
+ */
53
+ export function archiveBefore(memory, { source, convId, kind = 'forget', reason = '', actor = 'user' } = {}) {
54
+ const s = String(source || '').trim();
55
+ const c = String(convId || '').trim();
56
+ if (!s || !c) return { ok: false, reason: 'bad-key' };
57
+ let data;
58
+ try {
59
+ data = memory.exportEntry(s, c);
60
+ } catch (e) {
61
+ return { ok: false, reason: 'export-failed', message: String(e?.message ?? e).slice(0, 160) };
62
+ }
63
+ const at = new Date();
64
+ const stem = `${localStamp(at)}-${kind}-${shortId(c)}`;
65
+ let dir = join(archiveRootOf(memory), stem);
66
+ // ⚠️ B-07(1.5.1 红蓝对抗,实测踩到):同一秒内对**同一对象**归档两次会撞上同一个目录名,
67
+ // 而 `mkdirSync({recursive:true})` 对已存在的目录**不报错**、随后的 `writeFileSync` 直接覆盖
68
+ // ⇒ 上一份归档**静默消失**。归档的全部价值就在于"它还在那儿",所以名字必须唯一。
69
+ // (A-01 的"备份并覆盖"尤其吃这一条:连续采纳两次候选完全可能落在同一秒。)
70
+ for (let i = 2; existsSync(dir) && i < 1000; i += 1) dir = join(archiveRootOf(memory), `${stem}-${i}`);
71
+ const isForget = kind === 'forget';
72
+ // ── B-05(1.5.1 红蓝对抗):归档**空不空**必须如实回报 ────────────────────────────
73
+ // 此前只回 `ok` + `turns`,而 `ok:true` 只说明"文件写下去了":对一条不存在的条目
74
+ // (或概述行在、原文已被硬删的条目)归档出来是 0 轮 —— 文件系统上**确实有**一个目录,
75
+ // 所以判据「归档成功」为真,可它什么都没保住。调用方(fail-closed 的那条纪律)因此
76
+ // 无法区分"备份好了"与"备份了一份空的",就会接着打遗忘标记:
77
+ // 标记永久留下、id 永不召回、界面还显示"忘过了",而回执写着"原文与概述已归档"。
78
+ // 这里把判据**显式化**:`empty` 看的是**归档内容**(原文轮数 + 正文有无),
79
+ // **不是**目标是否存在 —— 目的就是让"还打不打得动标记"这件事有据可依。
80
+ const empty = (data.turns || []).length === 0
81
+ && !String(data.overview?.summary || '').trim()
82
+ && !data.sessionMeta;
83
+ try {
84
+ mkdirSync(dir, { recursive: true });
85
+ // 原文逐行 JSONL —— 能 grep、能逐行还原,比一个大 JSON 数组更耐损
86
+ const turnsText = (data.turns || []).map((t) => JSON.stringify(t)).join('\n');
87
+ writeFileSync(join(dir, 'turns.jsonl'), turnsText ? turnsText + '\n' : '', 'utf8');
88
+ writeFileSync(join(dir, 'overview.json'), JSON.stringify(data.overview || null, null, 2), 'utf8');
89
+ writeFileSync(join(dir, 'session-meta.json'), JSON.stringify(data.sessionMeta || null, null, 2), 'utf8');
90
+ writeFileSync(join(dir, 'README.md'), readme({
91
+ at, s, c, kind, reason, actor, turns: (data.turns || []).length,
92
+ }), 'utf8');
93
+ } catch (e) {
94
+ return { ok: false, reason: 'write-failed', message: String(e?.message ?? e).slice(0, 160) };
95
+ }
96
+ if (!existsSync(join(dir, 'README.md'))) return { ok: false, reason: 'write-failed' };
97
+ return { ok: true, dir, turns: (data.turns || []).length, empty, isForget };
98
+ }
99
+
100
+ /** 改动日志 —— 这份文件是给**将来的人**看的,所以写全:是什么、为什么、怎么还原。 */
101
+ function readme({ at, s, c, kind, reason, actor, turns }) {
102
+ const who = actor === 'ling' ? '器灵(工具通道)' : '主人(界面通道)';
103
+ // A-01(1.5.1):第三种 kind —— 主脉记忆覆盖(同一主脉再次采纳提炼结果;用户裁决"备份并覆盖")。
104
+ const what = kind === 'forget' ? '遗忘(软标记 —— **库里的行没有删**)'
105
+ : kind === 'vein-overwrite' ? '主脉记忆**覆盖**(同一主脉再次采纳 —— **上一版正文完整存在这里**)'
106
+ : '回灌(用归档恢复一条记忆 —— **覆盖前的状态已存这里**)';
107
+ const title = kind === 'forget' ? '遗忘前' : kind === 'vein-overwrite' ? '主脉记忆覆盖前' : '回灌前';
108
+ // 「怎么还原」按 kind 分三档。用**字符串拼接**而不是嵌套模板,免得外层模板里的反引号打架。
109
+ const restoreBlock = kind === 'forget'
110
+ ? '**库里没有删任何行** —— 只是多了一条 `forgotten` 标记,召回路径不再选它。撤销方式二选一:\n\n'
111
+ + '1. 让器灵调工具撤销(`memory_forget` 的 `undo`),或\n'
112
+ + '2. 直接删标记:\n ```sql\n DELETE FROM forgotten WHERE source=\'' + s + '\' AND conv_id=\'' + c + '\';\n ```'
113
+ : kind === 'vein-overwrite'
114
+ ? '覆盖只动了 `conv_overview` 里那一行(主脉自己的记忆),原文与会话元数据不受影响。要把上一版换回去:\n\n'
115
+ + '1. 让器灵按本目录 `overview.json` 的 `summary` **再采纳一次**旧正文,或\n'
116
+ + '2. 直接写回:\n ```sql\n UPDATE conv_overview SET summary=\'<overview.json 的 summary>\'\n WHERE source=\'' + s + '\' AND conv_id=\'' + c + '\';\n ```'
117
+ : '回灌只是把归档写回去,而**它覆盖之前的状态存在这里**。要退回去:\n\n'
118
+ + '1. 让器灵再回灌一次旧档(`memory_backfill`),或\n'
119
+ + '2. 按下面「万一库本身没了」的步骤写回。';
120
+ return `# 归档:${title}的原始数据
121
+
122
+ | | |
123
+ |---|---|
124
+ | 操作 | ${what} |
125
+ | 对象 | \`source=${s}\` · \`conv_id=${c}\` |
126
+ | 时间 | ${humanTime(at)} |
127
+ | 操作者 | ${who} |
128
+ | 原因 | ${reason || '(未填写)'} |
129
+
130
+ ## 这里有什么
131
+
132
+ - \`turns.jsonl\` —— 原文 **${turns}** 轮(每行一条 JSON,字段与库表 \`dsh_turns_raw\` 同构)
133
+ - \`overview.json\` —— 概述行(标题 / 摘要 / 关键词 / 权重 / 置顶 …)
134
+ - \`session-meta.json\` —— 会话元数据(模式 / 枝归属 …)
135
+
136
+ ## 怎么还原
137
+
138
+ ${restoreBlock}
139
+
140
+ 万一库本身没了(换机 / 误删),用这里的文件重建:
141
+
142
+ - 原文:\`turns.jsonl\` 逐行 \`INSERT INTO dsh_turns_raw (session_id,seq,role,ts,model,text) VALUES (…)\`
143
+ - 概述:按 \`overview.json\` 写回 \`conv_overview\`
144
+
145
+ ## 为什么保留
146
+
147
+ 忘错了要能回头。这是用户定的规矩:**永远不做彻底删除** ——
148
+ 所以这个目录是"曾经发生过什么"的证据,不是垃圾。不要随手清。
149
+
150
+ > 生成于 dsh-ling · Part G(器灵侧正规通道)
151
+ `;
152
+ }
153
+
154
+ /** 列出归档目录(最近的在前)—— 回答"这里能回灌什么"。 */
155
+ export function listArchives(memory, { limit = 50 } = {}) {
156
+ const root = archiveRootOf(memory);
157
+ let names = [];
158
+ try {
159
+ names = readdirSync(root).filter((n) => /^\d{8}-\d{6}-/.test(n));
160
+ } catch {
161
+ return [];
162
+ }
163
+ names.sort().reverse();
164
+ const n = Math.max(1, Math.min(500, Number(limit) || 50));
165
+ return names.slice(0, n).map((name) => {
166
+ const dir = join(root, name);
167
+ let turns = 0;
168
+ try {
169
+ turns = readFileSync(join(dir, 'turns.jsonl'), 'utf8').split('\n').filter((l) => l.trim()).length;
170
+ } catch { /* 没有原文的归档也算一条记录 */ }
171
+ return { name, dir, turns };
172
+ });
173
+ }
174
+
175
+ /** 读回一个归档目录(名字取自 listArchives)。 */
176
+ export function readArchive(memory, name) {
177
+ const safe = String(name || '').replace(/[^A-Za-z0-9_-]/g, '');
178
+ if (!safe) return { ok: false, reason: 'bad-name' };
179
+ const dir = join(archiveRootOf(memory), safe);
180
+ const out = { ok: true, name: safe, dir, turns: [], overview: null, note: '' };
181
+ try {
182
+ out.turns = readFileSync(join(dir, 'turns.jsonl'), 'utf8')
183
+ .split('\n').filter((l) => l.trim())
184
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } })
185
+ .filter(Boolean);
186
+ } catch { /* 允许无原文 */ }
187
+ try { out.overview = JSON.parse(readFileSync(join(dir, 'overview.json'), 'utf8')); } catch { /* ignore */ }
188
+ try {
189
+ // 日志里的"对象/操作者/原因"三行 —— 回灌之前让人先看一眼:当初为什么把它忘掉的
190
+ const md = readFileSync(join(dir, 'README.md'), 'utf8');
191
+ out.note = md.split('\n').filter((l) => /操作者|原因|对象/.test(l)).join(' ').replace(/\s+/g, ' ').slice(0, 300);
192
+ } catch { /* ignore */ }
193
+ return out;
194
+ }
@@ -0,0 +1,131 @@
1
+ // dsh-ling host — 一键生成记忆树(D9-b,2026-09-20 与用户定案)。
2
+ //
3
+ // 主干 1593 条太粗,需要先把树"初步划一遍"。分两阶段:
4
+ // ① 机械分段 —— 按时序相邻性把全库概述切成若干簇(零成本、确定、可复现);
5
+ // ② 小助手命名 —— 每簇一次调用,起一个主题名(模型只做"命名"这一件语义活)。
6
+ // ③ 主人审核后落库(apply 在 api.js 侧) —— 建枝/主脉 + 挂会话。
7
+ //
8
+ // 为什么不把 1593 条直接丢给模型:装不进上下文,而且"按时序切段"本就是机械活。
9
+ // 模型擅长的是"给一段对话起主题名",不是"在 1593 条里做聚类"(设计稿 §8 / 用户 2026-09-20)。
10
+ //
11
+ // 不变量:本模块**只读概述索引**、只产出候选,**从不改写任何记忆内容**。
12
+
13
+ export const AUTOTREE_SYS = [
14
+ '你是记忆整理助手。用户会给你一组同期的对话标题(按时间先后排列)。',
15
+ '请判断这组对话共同围绕的主题,并起一个简短的中文名字。',
16
+ '要求:',
17
+ '1) 名字 2~10 个字,是主题(如「工程力学」「记忆系统设计」「装修与工具」),不要用时间或"对话记录"这类空话;',
18
+ '2) 若这组对话主题分散、看不出共同主题,名字就写「零散记录」;',
19
+ '3) 只输出一行,格式:名字|一句话说明',
20
+ '不要输出任何其他内容。',
21
+ ].join('\n');
22
+
23
+ const DAY = 86400000;
24
+
25
+ /** 宽松时间解析(取不到记 0 —— 0 视为"无时间",不参与间隔切分)。 */
26
+ export function tsOf(s) {
27
+ const t = Date.parse(String(s || ''));
28
+ return Number.isFinite(t) ? t : 0;
29
+ }
30
+
31
+ /**
32
+ * 机械分段:先按源分组,组内按时序排序,相邻间隔 > gapDays(或单簇超过 maxSize)即切一刀。
33
+ * 返回 [{ id, source, count, from, to, items:[{source,convId,title,at,ts,hasSummary}] }]。
34
+ * ⚠️ 时间缺失(ts=0)的条目不会触发切分,只受 maxSize 约束 —— 避免"无时间"被误当成"间隔巨大"。
35
+ */
36
+ export function bucketize(rows, { gapDays = 14, maxSize = 40 } = {}) {
37
+ const bySource = new Map();
38
+ for (const r of rows || []) {
39
+ const src = String((r && r.source) || '');
40
+ if (!bySource.has(src)) bySource.set(src, []);
41
+ bySource.get(src).push({
42
+ source: src,
43
+ convId: String((r && r.convId) || ''),
44
+ title: String((r && r.title) || ''),
45
+ at: String((r && r.at) || ''),
46
+ ts: tsOf(r && r.at),
47
+ hasSummary: !!(r && r.hasSummary),
48
+ });
49
+ }
50
+ const gap = Math.max(1, Number(gapDays) || 14) * DAY;
51
+ const cap = Math.max(5, Math.min(200, Number(maxSize) || 40));
52
+ const out = [];
53
+ for (const [src, list] of bySource) {
54
+ list.sort((a, b) => (a.ts || 0) - (b.ts || 0) || a.convId.localeCompare(b.convId));
55
+ let cur = [];
56
+ const flush = () => {
57
+ if (!cur.length) return;
58
+ out.push({
59
+ id: 'bk:' + src + ':' + out.length,
60
+ source: src,
61
+ count: cur.length,
62
+ from: cur[0].at,
63
+ to: cur[cur.length - 1].at,
64
+ items: cur,
65
+ });
66
+ cur = [];
67
+ };
68
+ for (const it of list) {
69
+ const prev = cur[cur.length - 1];
70
+ if (prev && it.ts && prev.ts && it.ts - prev.ts > gap) flush();
71
+ else if (cur.length >= cap) flush();
72
+ cur.push(it);
73
+ }
74
+ flush();
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /** 把一簇压成给模型看的标题清单(只给标题,不给摘要 —— 省 token 且够用)。 */
80
+ export function bucketDigest(bucket, { maxTitles = 40 } = {}) {
81
+ const items = (bucket && bucket.items) || [];
82
+ return items
83
+ .slice(0, Math.max(1, Number(maxTitles) || 40))
84
+ .map((it) => '- ' + (it.title || it.convId))
85
+ .join('\n');
86
+ }
87
+
88
+ /** 解析模型输出的一行「名字|说明」。宽容:去掉前缀符号/引号/编号。 */
89
+ export function parseName(text) {
90
+ const first = String(text || '')
91
+ .split('\n')
92
+ .map((s) => s.trim())
93
+ .filter(Boolean)[0] || '';
94
+ const clean = first.replace(/^[#*\-•\d.、\s]+/, '').trim();
95
+ if (!clean) return { name: '', note: '' };
96
+ const parts = clean.split(/[||]/);
97
+ const name = String(parts[0] || '').replace(/[「」"']/g, '').trim().slice(0, 30);
98
+ const note = String(parts.slice(1).join(' ') || '').trim().slice(0, 120);
99
+ return { name, note };
100
+ }
101
+
102
+ /**
103
+ * 给一簇起名。call 注入 summarizeWithRetry(便于测试与替换)。
104
+ * 返回 { ok, name, note, ms } 或 { ok:false, error }。
105
+ */
106
+ export async function nameBucket(baseUrl, model, bucket, { maxTokens = 512, timeoutMs = 90000, call = null } = {}) {
107
+ if (typeof call !== 'function') return { ok: false, error: 'no-call' };
108
+ const digest = bucketDigest(bucket);
109
+ if (!digest) return { ok: false, error: 'empty-bucket' };
110
+ const text = '来源:' + String((bucket && bucket.source) || '') + '\n'
111
+ + '时间:' + String((bucket && bucket.from) || '?') + ' ~ ' + String((bucket && bucket.to) || '?') + '\n'
112
+ + '共 ' + Number((bucket && bucket.count) || 0) + ' 条,标题如下:\n' + digest;
113
+ const r = await call(baseUrl, model, { text, system: AUTOTREE_SYS, maxTokens, timeoutMs });
114
+ if (!r || !r.ok) return { ok: false, error: String((r && r.error) || 'failed') };
115
+ const { name, note } = parseName(r.summary);
116
+ if (!name) return { ok: false, error: 'empty-name' };
117
+ return { ok: true, name, note, ms: Number(r.ms || 0) };
118
+ }
119
+
120
+ /** 把若干命名结果合并成"应用计划"的默认形态(每簇一枝,挂在指定父下)。 */
121
+ export function planFromNamed(named, { parentId = 'trunk' } = {}) {
122
+ return (named || [])
123
+ .filter((n) => n && n.ok && n.bucketId)
124
+ .map((n) => ({
125
+ bucketId: String(n.bucketId),
126
+ mode: 'branch',
127
+ name: String(n.name || ''),
128
+ parentId: String(parentId || 'trunk'),
129
+ note: String(n.note || ''),
130
+ }));
131
+ }
@@ -0,0 +1,267 @@
1
+ // dsh-ling host — DSH 存量会话回溯(把捕捉上线前的磁盘会话纳入记忆)。
2
+ // 语义与 tools/backfill-dsh.mjs 一致,读侧独立为模块供 API 使用;
3
+ // 写侧走传入的 MemoryStore(与运行中实例同库,免并发双开)。
4
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { homedir } from 'node:os';
7
+ import { categorizeTitle } from './classify.js';
8
+ import { keywordsFrom } from './inject-common.js';
9
+ import { decodeAll } from './zstd.js';
10
+
11
+ /**
12
+ * 会话转储文件名(代次可变)。
13
+ * DSH 升级会改写文件名:session.jsonl.zstd → session.v3.jsonl.zstd → session.v4.jsonl.zstd …
14
+ * 旧的写死匹配漏掉 v3/v4(本机实测 204 个会话只看见 53 个),故一律按此正则认。
15
+ */
16
+ export const SESSION_FILE_RE = /^session(?:\.v(\d+))?\.jsonl\.zstd$/;
17
+
18
+ /**
19
+ * 在一个会话目录里挑出会话转储文件。
20
+ * 规则:匹配 SESSION_FILE_RE → 取版本号最高的一代(无版本号视为 0,即最低)→ 跳过 0 字节文件(写盘未完成)。
21
+ * @returns {{f:string,name:string,ver:number,size:number}|null} 无匹配返回 null
22
+ */
23
+ export function pickSessionFile(dir) {
24
+ let names;
25
+ try { names = readdirSync(dir); } catch { return null; }
26
+ let best = null;
27
+ for (const name of names.slice().sort()) { // 排序:同代次多文件时结果稳定
28
+ const m = SESSION_FILE_RE.exec(name);
29
+ if (!m) continue;
30
+ const ver = m[1] ? Number(m[1]) : 0;
31
+ let st;
32
+ try { st = statSync(join(dir, name)); } catch { continue; }
33
+ if (!st.isFile() || st.size === 0) continue;
34
+ if (!best || ver > best.ver) best = { f: join(dir, name), name, ver, size: st.size };
35
+ }
36
+ return best;
37
+ }
38
+
39
+ /**
40
+ * DSH 会话根目录的默认位置 —— **单一来源**:`candidateSessionFiles` 的默认值与
41
+ * `scanDshHistory` 的入口解析共用它,不允许各写一份。
42
+ * 为什么必须是一个显式函数:不传 root 时,扫描侧有默认值照常工作,而归因探针侧
43
+ * 拿到 `undefined` → `readdirSync(undefined)` 抛错被吞 → 恒判 `no-sessions-root`,
44
+ * 界面渲染成「未找到 DSH 会话目录:undefined」(BUG-2 归因几乎不可达)。
45
+ */
46
+ export function defaultSessionsRoot() {
47
+ return join(homedir(), '.dsh', 'sessions');
48
+ }
49
+
50
+ /** 候选会话文件: ~/.dsh/sessions/<workspace>/<sid>/{session[.vN].jsonl.zstd} */
51
+ export function candidateSessionFiles(root = defaultSessionsRoot()) {
52
+ const jobs = [];
53
+ let ws;
54
+ try { ws = readdirSync(root, { withFileTypes: true }); } catch { return jobs; }
55
+ for (const w of ws) {
56
+ if (!w.isDirectory()) continue;
57
+ const wsDir = join(root, w.name);
58
+ let subs;
59
+ try { subs = readdirSync(wsDir, { withFileTypes: true }); } catch { continue; }
60
+ for (const d of subs) {
61
+ if (!d.isDirectory()) continue;
62
+ const hit = pickSessionFile(join(wsDir, d.name));
63
+ if (hit) jobs.push({ f: hit.f, ws: w.name, sid: d.name, file: hit.name, ver: hit.ver });
64
+ }
65
+ }
66
+ return jobs;
67
+ }
68
+
69
+ /**
70
+ * 「0 候选」的归因探针(仅在扫到 0 时调用,代价可忽略)。
71
+ * 让「一个会话文件都没匹配上」与「这台机器根本没历史」可区分(BUG-2)。
72
+ */
73
+ function emptyScanInfo(root) {
74
+ const info = { root, rootExists: false, sessionDirs: 0, emptyFiles: 0, fileNames: [] };
75
+ let ws;
76
+ try { ws = readdirSync(root, { withFileTypes: true }); } catch { return withNote(info); }
77
+ info.rootExists = true;
78
+ for (const w of ws) {
79
+ if (!w.isDirectory()) continue;
80
+ let subs;
81
+ try { subs = readdirSync(join(root, w.name), { withFileTypes: true }); } catch { continue; }
82
+ for (const d of subs) {
83
+ if (!d.isDirectory()) continue;
84
+ info.sessionDirs += 1;
85
+ let names;
86
+ try { names = readdirSync(join(root, w.name, d.name)); } catch { continue; }
87
+ for (const n of names) {
88
+ if (!/\.jsonl(\.zst|\.zstd)?$/.test(n)) continue;
89
+ if (info.fileNames.length < 8 && !info.fileNames.includes(n)) info.fileNames.push(n);
90
+ if (!SESSION_FILE_RE.test(n)) continue;
91
+ try { if (statSync(join(root, w.name, d.name, n)).size === 0) info.emptyFiles += 1; } catch {}
92
+ }
93
+ }
94
+ }
95
+ return withNote(info);
96
+ }
97
+
98
+ function withNote(info) {
99
+ if (!info.rootExists) {
100
+ info.reason = 'no-sessions-root';
101
+ info.note = `未找到 DSH 会话目录:${info.root}(这台机器可能没跑过 DSH,或用 DSH_HOME 指到了别处)`;
102
+ } else if (info.sessionDirs === 0) {
103
+ info.reason = 'no-session-dirs';
104
+ info.note = `会话目录存在但没有任何会话子目录:${info.root}`;
105
+ } else if (info.emptyFiles > 0) {
106
+ info.reason = 'empty-session-files';
107
+ info.note = `${info.emptyFiles} 个会话文件是 0 字节(已跳过,可能是写盘未完成):${info.root}`;
108
+ } else if (info.fileNames.length) {
109
+ info.reason = 'no-session-files';
110
+ info.note = `未找到会话文件(可能是 DSH 会话格式版本变化);目录里见到的相关文件:${info.fileNames.join('、')}`;
111
+ } else {
112
+ info.reason = 'no-session-files';
113
+ info.note = `未找到会话文件(可能是 DSH 会话格式版本变化):${info.root}`;
114
+ }
115
+ return info;
116
+ }
117
+
118
+ function parseHeader(firstLine) {
119
+ try {
120
+ const j = JSON.parse(firstLine);
121
+ return {
122
+ deleg: Number(j.delegationDepth ?? 0),
123
+ origin: j.origin,
124
+ parent: !!j.parentSession,
125
+ id: j.id || '',
126
+ cwd: j.cwd || '',
127
+ preset: j.agentPreset || '',
128
+ };
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /** 对单个会话文件:判定顶层、抽取真实消息 → 概述行(不写库)。 */
135
+ export function overviewRowFromFile(file, ws, sid) {
136
+ let text;
137
+ try {
138
+ text = decodeAll(readFileSync(file));
139
+ } catch {
140
+ return { skip: 'failed', row: null };
141
+ }
142
+ // 顶层判定:从解码全文找 header 行(type=session)
143
+ let isChild = false;
144
+ for (const line of text.split('\n')) {
145
+ if (!line.trim()) continue;
146
+ if (line.includes('"type":"session"')) {
147
+ const h = parseHeader(line);
148
+ if (h) isChild = !!(h.parent || h.origin === 'subagent' || h.deleg > 0);
149
+ break;
150
+ }
151
+ if (!line.includes('"type"')) continue;
152
+ break;
153
+ }
154
+ if (isChild) return { skip: 'child', row: null };
155
+
156
+ let firstUser = '';
157
+ let nUser = 0;
158
+ let nAsst = 0;
159
+ let totalChars = 0;
160
+ let t0 = null;
161
+ let t1 = null;
162
+ for (const line of text.split('\n')) {
163
+ if (!line.includes('"type":"user/message"') && !line.includes('"type":"assistant/message"')) continue;
164
+ let ev;
165
+ try { ev = JSON.parse(line); } catch { continue; }
166
+ const kind = ev?.data?.source?.kind;
167
+ const t = Number(ev?.time);
168
+ if (t) { if (t0 === null || t < t0) t0 = t; if (t1 === null || t > t1) t1 = t; }
169
+ if (ev.type === 'user/message') {
170
+ if (kind !== 'user') continue; // 只留真人
171
+ const body = ev?.data?.content;
172
+ const u = Array.isArray(body)
173
+ ? body.filter((b) => b?.type !== 'reasoning' && b?.type !== 'tool-call').map((b) => b?.text || b?.content || '').join('\n')
174
+ : '';
175
+ if (!u) continue;
176
+ nUser += 1;
177
+ totalChars += u.length;
178
+ if (!firstUser) firstUser = u.trim();
179
+ } else {
180
+ const msg = ev?.data?.message;
181
+ const body = msg?.content;
182
+ const a = Array.isArray(body)
183
+ ? body.filter((b) => b?.type === 'text' || !b?.type).map((b) => b?.text || b?.content || '').join('\n')
184
+ : '';
185
+ if (!a) continue;
186
+ nAsst += 1;
187
+ totalChars += a.length;
188
+ }
189
+ }
190
+ if (!firstUser) return { skip: 'no-user', row: null };
191
+ const oneLine = firstUser.replace(/\s+/g, ' ').trim();
192
+ const weak = nUser < 1 || nUser + nAsst < 2 || totalChars < 40;
193
+ const trivial = nUser <= 2 && /^(hello|hi|test|你好|嗨|哈喽|在吗|还在吗|嗯|哈哈|测试|123)/i.test(oneLine);
194
+ if (weak || trivial) return { skip: 'weak', row: null };
195
+ const cat = categorizeTitle(oneLine);
196
+ return {
197
+ skip: null,
198
+ row: {
199
+ conv_id: sid,
200
+ source: 'dsh',
201
+ title: oneLine.slice(0, 46) || '(未命名会话)',
202
+ started_at: t0 ? new Date(t0).toISOString() : null,
203
+ updated_at: t1 ? new Date(t1).toISOString() : null,
204
+ domain_tags: [cat === 'knowledge' ? '知识' : cat === 'feeling' ? '生活' : '日常'],
205
+ category: cat,
206
+ keywords: keywordsFrom((oneLine + ' ' + firstUser).slice(0, 400)),
207
+ summary: `${oneLine.slice(0, 140)} — ${nUser + nAsst} 条消息`,
208
+ heat: 0,
209
+ importance: 0,
210
+ last_hit_at: null,
211
+ hit_count: 0,
212
+ overview_ok: 1,
213
+ origin: 'dsh-backfill-v1',
214
+ },
215
+ };
216
+ }
217
+
218
+ /**
219
+ * 全量扫描:枚举 → 解码 → 顶层/弱会话过滤 → (可选)写入 memory。
220
+ * @param {object} opts { memory?: MemoryStore(提供则写入), root?, limit? }
221
+ * @returns {{report, rows}} report 字段与 CLI 版一致;rows 为本次新增行。
222
+ */
223
+ export async function scanDshHistory({ memory = null, root, limit = 0 } = {}) {
224
+ // BUG-2:入口处**一次解析** root,扫描与归因共用同一个值。
225
+ // 旧写法把 `root`(可能是 undefined)直接交给 emptyScanInfo,而 api.js 的
226
+ // `/dsh/backfill` 只传 `{ memory, limit }` ⇒ 归因恒判 no-sessions-root、note 里
227
+ // 打印 undefined,并被 client 显示给用户。此处解析后,任何调用方(HTTP / CLI / 工具)
228
+ // 不传 root 都能拿到真实根目录。
229
+ const sessionsRoot = root ?? defaultSessionsRoot();
230
+ const jobs = candidateSessionFiles(sessionsRoot);
231
+ const report = { scanned: 0, topLevel: 0, decoded: 0, eligible: 0, created: 0, skippedExists: 0, skippedWeak: 0, failed: 0 };
232
+ // BUG-2:「0 候选」必须给出原因,否则和「这台机器没历史」在界面上长得一模一样
233
+ if (!jobs.length) Object.assign(report, emptyScanInfo(sessionsRoot));
234
+ const rows = [];
235
+ const seen = new Set();
236
+ if (memory) {
237
+ for (const r of memory.listOverviews({ source: 'dsh' })) seen.add(String(r.conv_id));
238
+ }
239
+ for (const job of jobs) {
240
+ report.scanned += 1;
241
+ const { skip, row } = overviewRowFromFile(job.f, job.ws, job.sid);
242
+ if (skip === 'failed') { report.failed += 1; continue; }
243
+ report.decoded += 1;
244
+ if (skip === 'child') continue;
245
+ report.topLevel += 1;
246
+ if (skip) { report.skippedWeak += 1; continue; }
247
+ if (seen.has(job.sid)) { report.skippedExists += 1; continue; }
248
+ report.eligible += 1;
249
+ rows.push(row);
250
+ if (limit > 0 && rows.length >= limit) break;
251
+ }
252
+ if (memory && rows.length) {
253
+ memory.db.exec('BEGIN');
254
+ try {
255
+ for (const r of rows) memory.upsertOverview(r);
256
+ memory.db.exec('COMMIT');
257
+ } catch (e) {
258
+ try { memory.db.exec('ROLLBACK'); } catch {}
259
+ throw e;
260
+ }
261
+ report.created = rows.length;
262
+ memory.kvSet('backfill.last', JSON.stringify({ at: new Date().toISOString(), ...report, inserted: rows.length }));
263
+ } else if (memory) {
264
+ memory.kvSet('backfill.last', JSON.stringify({ at: new Date().toISOString(), ...report, inserted: 0 }));
265
+ }
266
+ return { report, rows };
267
+ }
@@ -0,0 +1,10 @@
1
+ // dsh-ling host — 会话概述分类/类别词表(供概述器与导入共用)。
2
+ // v0 启发式;词表后续可在设置里校准。
3
+ const KNOW_RE = /物理|模拟|材料|合金|金属|固溶|热处理|退火|淬火|SLM|激光|3D|打印|工艺|实验|力学|运动|弹簧|电|磁|机械|轴承|齿轮|螺栓|公差|图纸|仿真|计算|推导|方程|函数|算法|代码|编程|程序|开发|设计|架构|编辑器|工具|软件|系统|虚拟机|显示|显卡|显示器|网络|协议|数据|数据库|安全|加密|AI|GPT|LLM|模型|HTML|CSS|插件|开源|文献|论文|综述|研究|分析|优化|参数|标准|工程|电路|噪声|声学|光学|流体|温度|硬度|刚度|强度|疲劳|失效|锈|腐蚀|焊接|铸造|粉末|显微|衍射|晶体|电池|半导体|传感器|嵌入式|单片机|遥控|信号|放大器|扬声器|摄像头|滚珠|丝杆|导轨|减速|电机|驱动|stm|arduino|树莓派|统计|概率|几何|代数|微积分|复变|傅里叶|拉普拉斯|微分方程|数值|离散|随机|目检|示波器|万用表|稳压|逆变|拓扑|算法/;
4
+ const FEEL_RE = /失眠|睡眠|睡不着|焦虑|烦躁|抑郁|emo|情绪|心情|感情|关系|朋友|家人|父母|孤独|迷茫|人生|困惑|害怕|恐惧|压力|疲惫|累|休息|放松|静心|呼吸|梦境|梦|倾诉|共鸣|温暖|鼓励|安慰|不安|内耗|自我|意义|幸福|遗憾|夜|月亮|散文|随想|杂感|闲聊|谈心|牢骚|吐槽|习惯|拖延|自律|动力|目标感|虚无|发呆|感性|告别|怀念|感谢|感动|爱|牵挂|想你了|晚安|早安|活着/;
5
+
6
+ export function categorizeTitle(title) {
7
+ if (FEEL_RE.test(title)) return 'feeling';
8
+ if (KNOW_RE.test(title)) return 'knowledge';
9
+ return 'daily';
10
+ }