@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,273 @@
1
+ // dsh-ling host — 习惯候选生成器(通道 A:零模型数纠正;通道 B:一次 LLM 读记忆回想)
2
+ //
3
+ // 为什么需要它(2026-09-16 与用户定稿):规矩/习惯拆开之后,"习惯"必须由器灵长出来,
4
+ // 但当时只有"会话内即时提议"这一条路 —— 没有任何东西会回头读我们一年多的相处。
5
+ // - **通道 A(数出来的)**:纯本地规则扫描,零模型、确定性、可单测;
6
+ // - **通道 B(想出来的)**:一次 LLM 调用,读最近的记忆概述 + A 的统计,归纳 1~3 条候选。
7
+ // 两者都只到"提议"为止 —— 落成习惯必须经用户确认(见 rules.proposeHabit / resolveHabit)。
8
+ //
9
+ // 判定口径(通道 A,**跨会话才叫模式**):
10
+ // - 同一类纠正(太啰嗦/太文言/太虚/太冷/格式…)累计 ≥4 次,且**分布在 ≥3 个不同会话**(阈值变更记录见下方 SCAN_DEFAULTS);
11
+ // - 候选文本直接取自 corpus-suggest 里那套纠正桶的规则文案(与语料提炼共用一份口径,避免两套标准)。
12
+ //
13
+ // 本模块只读库、零副作用(不写库、不发消息),便于在接口层决定要不要落成提议。
14
+
15
+ import { CORRECTION_BUCKETS } from './corpus-suggest.js';
16
+ import { extractJson } from './genesis.js';
17
+ import { isoDate } from './util.js';
18
+
19
+ export const SCAN_DEFAULTS = Object.freeze({
20
+ minHits: 4, // 同类纠正至少出现几次(2026-09-16 用户定案:3 → 4)
21
+ minSessions: 3, // 至少跨几个会话(单次会话里被说三遍不算"模式";2 → 3)
22
+ maxTurns: 20000, // 最多回看多少条真人轮次(防大库拖慢空闲边界)
23
+ sampleLimit: 3, // 每条候选带几段原话摘录
24
+ });
25
+
26
+ const clean = (s) => String(s ?? '').replace(/\s+/g, ' ').trim();
27
+
28
+ /**
29
+ * 扫描纠正信号(纯读)。
30
+ * @param {{db:{prepare:Function}}} memory MemoryStore 实例
31
+ * @returns {Array<{key:string, rule:string, hits:number, sessions:number, samples:string[]}>}
32
+ */
33
+ export function scanCorrections(memory, opts = {}) {
34
+ const { minHits, minSessions, maxTurns, sampleLimit } = { ...SCAN_DEFAULTS, ...opts };
35
+ let rows = [];
36
+ try {
37
+ rows = memory.db.prepare(
38
+ // G4:习惯只能从「主人自己的会话」长出来 —— 排除导入命名空间(别人的对话)
39
+ "SELECT session_id, text FROM dsh_turns_raw WHERE role='user' AND session_id NOT LIKE 'import:%' ORDER BY seq DESC LIMIT ?",
40
+ ).all(maxTurns);
41
+ } catch {
42
+ return []; // 库结构异常时静默退化(生成器不能拖累别处)
43
+ }
44
+
45
+ const buckets = new Map(CORRECTION_BUCKETS.map((b) => [b.key, {
46
+ key: b.key, rule: b.rule, hits: 0, sessions: new Set(), samples: [],
47
+ }]));
48
+
49
+ for (const r of rows) {
50
+ const text = clean(r.text);
51
+ if (!text) continue;
52
+ for (const b of CORRECTION_BUCKETS) {
53
+ if (!b.re.test(text)) continue;
54
+ const item = buckets.get(b.key);
55
+ item.hits += 1;
56
+ item.sessions.add(String(r.session_id ?? ''));
57
+ if (item.samples.length < sampleLimit) item.samples.push(text.slice(0, 80));
58
+ }
59
+ }
60
+
61
+ return [...buckets.values()]
62
+ .filter((b) => b.hits >= minHits && b.sessions.size >= minSessions)
63
+ .map((b) => ({ key: b.key, rule: b.rule, hits: b.hits, sessions: b.sessions.size, samples: b.samples }))
64
+ .sort((a, b) => (b.hits - a.hits) || (b.sessions - a.sessions));
65
+ }
66
+
67
+ /** 人读证据串(≤300 字,写进习惯提议的 evidence 字段)。 */
68
+ export function evidenceOf(cand) {
69
+ const samples = (cand.samples || []).map((s) => `「${s}」`).join(' / ');
70
+ return clean(`跨 ${cand.sessions} 个会话、共 ${cand.hits} 次纠正:${samples}`).slice(0, 300);
71
+ }
72
+
73
+ /** 给面板/接口的人读摘要(不落库)。 */
74
+ export function summarizeScan(candidates) {
75
+ return candidates.map((c) => ({
76
+ key: c.key, text: c.rule, hits: c.hits, sessions: c.sessions, evidence: evidenceOf(c),
77
+ }));
78
+ }
79
+
80
+ // ── 通道 B:「让她回想最近的相处」 ───────────────────────────────────────────
81
+ // 与通道 A 的分工:A 是"数出来的"(零模型、确定性),B 是"想出来的"(一次 LLM,读记忆概述)。
82
+ // B 同样只产出**提议**,落地仍须用户确认。
83
+
84
+ export const REFLECT_DEFAULTS = Object.freeze({
85
+ overviewLimit: 200, // 回看多少条记忆概述(手动触发,不必省)
86
+ turnLimit: 800, // 回看多少条主人的真人原话
87
+ budgetChars: 200000, // 材料字符预算(远小于 1M 上下文,留足模型思考空间)
88
+ maxTokens: 4000, // 输出预算:够写 1~5 条候选 + 各自的证据
89
+ maxHabits: 5, // 一次最多提几条
90
+ effort: 'off', // 归纳任务:实测 'low' 只出思考、正文为空且慢十倍(2026-09-16);要更深可在设置里提高
91
+ });
92
+
93
+ export const HABIT_REFLECT_SYS = `你是器灵的"习惯观察员"。材料是她与主人最近的相处:主人的真人原话、她读过的会话概述、主人反复纠正过的地方,以及她现在的规矩与习惯。
94
+ 任务:判断她是否值得新增 1~5 条「习惯」—— 即她**自己长出来的行为倾向**(语气、表达方式、做事节奏、提及记忆的方式),
95
+ 而不是主人给她的指令(指令属于「规矩」,不由你提议)。
96
+
97
+ 规则:
98
+ 1. 只在材料里确有迹象时才提;迹象不足就返回空数组 —— **宁缺勿滥**;证据越具体,越要提;
99
+ 2. 每条 ≤40 字,写成"她自己的倾向"(例:先给结论再展开;夜里说话更轻;不确定就直说不确定);
100
+ 3. 每条必须给出证据:依据材料里的哪些原话/哪些重复出现的迹象;
101
+ 4. 不要提"称呼、名字、底线、安全"相关内容 —— 那些属于契约,需要主人亲自解锁,不属于习惯;
102
+ 5. 不要提泛泛的美德(如"要更努力"),要具体到行为;
103
+ 6. 不要重复她已有的规矩或习惯。
104
+
105
+ 只输出 JSON,不要解释:{"habits":[{"text":"…","evidence":"…"}]}`;
106
+
107
+ /**
108
+ * 组装"回想"材料(纯读,不落库)。手动触发的调用,材料要给足 —— 预算默认 20 万字符(远小于 1M 上下文)。
109
+ * 优先级:她现在的规矩/习惯 → 主人反复纠正处 → 主人的真人原话 → 她读过的概述;超预算时从尾部截断。
110
+ * @returns {{ text: string, stats: {turns:number, overviews:number, corrections:number, chars:number} }}
111
+ */
112
+ export function buildReflectMaterial(memory, opts = {}) {
113
+ const {
114
+ overviewLimit = REFLECT_DEFAULTS.overviewLimit,
115
+ turnLimit = REFLECT_DEFAULTS.turnLimit,
116
+ budgetChars = REFLECT_DEFAULTS.budgetChars,
117
+ scan = [], rules = [], habits = [],
118
+ cursor = null,
119
+ } = opts;
120
+ const textOf = (x) => clean(typeof x === 'string' ? x : x?.text);
121
+ const blocks = [];
122
+ const stats = {
123
+ turns: 0, overviews: 0, corrections: 0, chars: 0,
124
+ round: Math.max(1, Number(cursor?.round) || 1),
125
+ turnOffset: 0, turnTotal: 0, ovOffset: 0, ovTotal: 0, wrapped: false,
126
+ };
127
+ let nextTurnOffset = 0;
128
+ let nextOvOffset = 0;
129
+
130
+ // ① 她现在的规矩与习惯 —— 让模型知道"已经有了什么",避免重复提议
131
+ const curRules = (rules || []).map(textOf).filter(Boolean);
132
+ const curHabits = (habits || []).map(textOf).filter(Boolean);
133
+ if (curRules.length || curHabits.length) {
134
+ const lines = [];
135
+ if (curRules.length) lines.push('规矩(主人的指令):' + curRules.join(' / '));
136
+ if (curHabits.length) lines.push('已有习惯:' + curHabits.join(' / '));
137
+ blocks.push({ title: '【她现在的规矩与习惯(不要重复提议)】', lines });
138
+ }
139
+
140
+ // ② 主人反复纠正过的地方
141
+ const cands = Array.isArray(scan) ? scan : [];
142
+ if (cands.length) {
143
+ stats.corrections = cands.length;
144
+ blocks.push({
145
+ title: '【主人反复纠正过的地方(本地统计:跨会话计数)】',
146
+ lines: cands.map((c) => `- ${c.rule || c.text || c.key}(出现在 ${c.sessions} 个会话 / 共 ${c.hits} 次)`),
147
+ });
148
+ }
149
+
150
+ // ③ 主人说过的话(真人原话;分轮读取 —— 每轮读上一轮没读过的那一段)
151
+ try {
152
+ const total = Number(memory?.db?.prepare?.("SELECT COUNT(*) n FROM dsh_turns_raw WHERE role='user' AND session_id NOT LIKE 'import:%'").get()?.n) || 0;
153
+ stats.turnTotal = total;
154
+ let off = Math.max(0, Number(cursor?.turnOffset) || 0);
155
+ if (total > 0 && off >= total) { off = 0; stats.wrapped = true; }
156
+ const rows = total > 0
157
+ ? (memory.db.prepare("SELECT ts, text FROM dsh_turns_raw WHERE role='user' AND session_id NOT LIKE 'import:%' ORDER BY rowid DESC LIMIT ? OFFSET ?").all(turnLimit, off) || [])
158
+ : [];
159
+ const lines = rows
160
+ .map((r) => { const t = clean(r?.text); return t ? `- (${isoDate(r?.ts) || '?'}) ${t.slice(0, 140)}` : ''; })
161
+ .filter(Boolean)
162
+ .reverse();
163
+ stats.turns = lines.length;
164
+ stats.turnOffset = off;
165
+ nextTurnOffset = (total > 0 && off + turnLimit >= total) ? 0 : off + turnLimit;
166
+ if (lines.length) blocks.push({ title: '【主人最近说过的话(真人原话,旧→新)】', lines });
167
+ } catch { /* 库不可读时跳过该块 */ }
168
+
169
+ // ④ 她读过的会话概述(同样分轮读取)
170
+ try {
171
+ const all = typeof memory?.listOverviews === 'function' ? memory.listOverviews({ onlyOk: true }) : [];
172
+ const sorted = [...all].sort((a, b) => String(b?.updated_at || b?.started_at || '').localeCompare(String(a?.updated_at || a?.started_at || '')));
173
+ stats.ovTotal = sorted.length;
174
+ let off = Math.max(0, Number(cursor?.ovOffset) || 0);
175
+ if (sorted.length > 0 && off >= sorted.length) { off = 0; stats.wrapped = true; }
176
+ stats.ovOffset = off;
177
+ nextOvOffset = (sorted.length > 0 && off + overviewLimit >= sorted.length) ? 0 : off + overviewLimit;
178
+ const lines = sorted
179
+ .slice(off, off + overviewLimit)
180
+ .map((r) => {
181
+ const date = String(r?.updated_at || r?.started_at || '').slice(0, 10);
182
+ const sum = clean(r?.summary).slice(0, 120);
183
+ const title = clean(r?.title).slice(0, 50);
184
+ return `- [${r?.category || '?'}] (${date}) ${title}${sum && sum !== title ? ':' + sum : ''}`;
185
+ })
186
+ .filter((l) => l.length > 12);
187
+ stats.overviews = lines.length;
188
+ if (lines.length) blocks.push({ title: '【她读过的会话概述(新→旧)】', lines });
189
+ } catch { /* 同上 */ }
190
+
191
+ // 按预算拼装(超预算从块尾截断)
192
+ const out = [];
193
+ let used = 0;
194
+ for (const b of blocks) {
195
+ const head = b.title + '\n';
196
+ if (used + head.length >= budgetChars) break;
197
+ const kept = [];
198
+ let size = 0;
199
+ for (const line of b.lines) {
200
+ if (used + head.length + size + line.length + 1 > budgetChars) break;
201
+ kept.push(line);
202
+ size += line.length + 1;
203
+ }
204
+ if (!kept.length) continue;
205
+ const text = head + kept.join('\n');
206
+ out.push(text);
207
+ used += text.length + 2;
208
+ }
209
+ stats.chars = used;
210
+ // 本轮覆盖情况(人读;接口与面板共用同一份措辞)
211
+ const rng = (start, count, total) => (total ? `第 ${start + 1}–${start + count} 条 / 共 ${total} 条` : '无');
212
+ stats.coverage = `第 ${stats.round} 轮 · 你的原话 ${rng(stats.turnOffset, stats.turns, stats.turnTotal)} · 会话概述 ${rng(stats.ovOffset, stats.overviews, stats.ovTotal)}` +
213
+ (stats.wrapped ? ' · 已读完一圈,回到最新' : '');
214
+ const nextCursor = { round: stats.round + 1, turnOffset: nextTurnOffset, ovOffset: nextOvOffset };
215
+ if (!out.length) return { text: '', stats, nextCursor }; // 无任何材料 → 空串(接口据此提示,不要只给一行统计)
216
+ const header = `(回想材料 · ${stats.coverage})`;
217
+ const text = (header + '\n\n' + out.join('\n\n') +
218
+ `\n\n(材料统计:真人原话 ${stats.turns} 条 · 概述 ${stats.overviews} 条 · 纠正类型 ${stats.corrections} 类 · 共约 ${stats.chars} 字)`).trim();
219
+ return { text, stats, nextCursor };
220
+ }
221
+
222
+ /** 回想调用的重试策略(复刻 probeLLM 的既有经验:思考档可能把预算吃光 → 空回复)。 */
223
+ export const REFLECT_RETRY = Object.freeze({
224
+ retryEffort: 'off', // 不思考,保证有可见输出
225
+ retryMaterialChars: 80000, // 重试时裁短材料,兼顾上下文较小的模型
226
+ });
227
+
228
+ /**
229
+ * 发起"回想"调用;若空回复(或抛错),用 off 档 + 裁短材料再试一次。
230
+ * 传入 llmOnce 而不是 ctx,便于单测注入假实现。
231
+ * @returns {Promise<{text:string, attempts:Array, retried:boolean}>}
232
+ */
233
+ export async function reflectWithRetry(llmOnceFn, { system, material, maxTokens, effort }) {
234
+ const attempts = [];
235
+ const run = async (eff, mat, tag) => {
236
+ const t0 = Date.now();
237
+ try {
238
+ const r = await llmOnceFn({ system, text: mat, maxTokens, effort: eff });
239
+ const text = String(r?.text ?? '');
240
+ attempts.push({
241
+ tag, effort: eff, maxTokens, chars: mat.length, ok: text.trim().length > 0,
242
+ len: text.length, chunkTypes: r?.chunkLog || null, target: r?.target || null, ms: Date.now() - t0,
243
+ });
244
+ return text;
245
+ } catch (e) {
246
+ attempts.push({ tag, effort: eff, maxTokens, chars: mat.length, ok: false, error: String(e?.message ?? e).slice(0, 200), ms: Date.now() - t0 });
247
+ return '';
248
+ }
249
+ };
250
+ let text = await run(effort, material, 'first');
251
+ if (!text.trim()) {
252
+ const short = material.slice(0, REFLECT_RETRY.retryMaterialChars);
253
+ text = await run(REFLECT_RETRY.retryEffort, short, 'retry-off');
254
+ }
255
+ return { text, attempts, retried: attempts.length > 1 };
256
+ }
257
+
258
+ /** 解析"回想"结果(容错:围栏剥离、中/英字段、超长丢弃、去重)。 */
259
+ export function parseReflect(text, { max = REFLECT_DEFAULTS.maxHabits, maxChars = 40 } = {}) {
260
+ const obj = extractJson(String(text || ''));
261
+ const arr = Array.isArray(obj?.habits) ? obj.habits : (Array.isArray(obj) ? obj : []);
262
+ const out = [];
263
+ for (const it of arr) {
264
+ const t = clean(it?.text ?? it?.habit).slice(0, 200);
265
+ if (!t || t.length > maxChars) continue;
266
+ const ev = clean(it?.evidence ?? it?.why).slice(0, 300);
267
+ if (out.some((x) => x.text === t)) continue;
268
+ out.push({ text: t, evidence: ev });
269
+ if (out.length >= max) break;
270
+ }
271
+ return out;
272
+ }
273
+
@@ -0,0 +1,291 @@
1
+ // dsh-ling host — 会话契约文件导入(ACCESS-DESIGN §1/§3)。
2
+ // 统一搬运格式:JSON {sessions:[…]}/数组 或 JSONL 逐行一会话;两档深度:
3
+ // 完整档 messages[{role,text,at}] / 轻量档 summary+keywords(仅标题+时间也可)。
4
+ // source 恒为 'import'(与 dsweb/dsh 区分),conv_id 建议含平台命名空间(如 "webchat:123")。
5
+ // 2026-09-08:适配 DeepSeek 官方「导出所有历史会话」conversations.json(ChatGPT 风格 mapping 树:
6
+ // 会话 {id,title,inserted_at,updated_at,mapping},消息在 mapping 节点的 message.fragments,
7
+ // REQUEST=用户 / RESPONSE=助手 / THINK/工具/FILE 忽略);同 uuid 命中 dsweb 旧域时自动折叠归并。
8
+ import { sha256Text } from './util.js';
9
+ import { heuristicTitle, TITLE_MAX_CHARS } from './retitle.js';
10
+ import { rawSessionId, rawSessionCandidates } from './memory.js';
11
+ import { categorizeTitle } from './classify.js';
12
+ import { keywordsFrom } from './inject-common.js';
13
+
14
+ export const IMPORT_SOURCE = 'import';
15
+ export const CAT_NAME = { knowledge: '知识', daily: '日常', feeling: '生活' };
16
+
17
+ function str(v) { return typeof v === 'string' ? v.trim() : ''; }
18
+
19
+ /** 时间值清洗:ISO 字符串可能带 6 位微秒与偏移(如 .412000+08:00),截为 3 位毫秒再解析 */
20
+ function cleanTimeStr(v) {
21
+ if (typeof v !== 'string') return v;
22
+ return v.replace(/(\.\d{3})\d+(?=(?:Z|[+-]\d{2}:?\d{2})?$)/, '$1');
23
+ }
24
+ /** epoch 归一:ChatGPT 的 create_time 是**秒**(浮点),DeepSeek 的是毫秒。
25
+ * S5(2026-09-17):旧实现一律当毫秒,于是 ChatGPT 导出的时间全部落到 1970 年。 */
26
+ function normEpoch(n) {
27
+ const x = Number(n);
28
+ if (!Number.isFinite(x) || x <= 0) return null;
29
+ if (x > 1e11) return x; // 毫秒(13 位)
30
+ if (x > 1e8) return x * 1000; // 秒(10 位)→ 毫秒
31
+ return null;
32
+ }
33
+ function isoTime(v) {
34
+ if (v == null || v === '') return null;
35
+ const ms = typeof v === 'number' ? normEpoch(v) : null;
36
+ const t = ms != null ? new Date(ms) : new Date(cleanTimeStr(String(v)));
37
+ return Number.isNaN(t.getTime()) ? null : t.toISOString();
38
+ }
39
+ function toMs(v) {
40
+ if (v == null || v === '') return null;
41
+ if (typeof v === 'number') return normEpoch(v);
42
+ const t = Date.parse(cleanTimeStr(String(v)));
43
+ if (Number.isFinite(t)) return t;
44
+ return normEpoch(Number(v));
45
+ }
46
+
47
+ /** 从 mapping 节点取一轮(role,text)。兼容两种官方导出形态:
48
+ * ① DeepSeek:fragments[{type:'REQUEST'|'RESPONSE', content}] —— REQUEST=主人 / RESPONSE=器灵
49
+ * ② ChatGPT :author.role('user'|'assistant') + content.parts[] */
50
+ function turnOfNode(node) {
51
+ const msg = node && typeof node.message === 'object' && node.message ? node.message : null;
52
+ if (!msg) return { role: '', text: '', ts: null };
53
+ const ts = toMs(msg.inserted_at ?? msg.insertedAt ?? msg.create_time ?? msg.createTime ?? msg.time);
54
+ const frags = Array.isArray(msg.fragments) ? msg.fragments : [];
55
+ let role = '';
56
+ const texts = [];
57
+ for (const fr of frags) {
58
+ if (!fr || typeof fr !== 'object') continue;
59
+ if (fr.type === 'REQUEST') role = 'user';
60
+ else if (fr.type === 'RESPONSE') role = 'assistant';
61
+ if (typeof fr.content === 'string' && fr.content.trim() && (fr.type === 'REQUEST' || fr.type === 'RESPONSE')) {
62
+ texts.push(fr.content.trim());
63
+ }
64
+ }
65
+ if (role && texts.length) return { role, text: texts.join('\n'), ts };
66
+ const ar = String((msg.author && msg.author.role) || '').toLowerCase();
67
+ const role2 = ar === 'user' ? 'user' : (ar === 'assistant' ? 'assistant' : '');
68
+ if (role2) {
69
+ const parts = Array.isArray(msg.content && msg.content.parts) ? msg.content.parts : [];
70
+ const text2 = parts.filter((p) => typeof p === 'string' && p.trim()).join('\n').trim();
71
+ if (text2) return { role: role2, text: text2, ts };
72
+ }
73
+ return { role: '', text: '', ts };
74
+ }
75
+
76
+ /**
77
+ * 官方导出:mapping 树 → 有序轮次(REQUEST→user / RESPONSE→assistant;
78
+ * THINK/FILE/SEARCH/TOOL_* 跳过;节点可混合多个 fragment)。
79
+ *
80
+ * S5(2026-09-17 导入诚实化):旧实现只走 `walk('root')`,而 **ChatGPT 的 mapping 根本没有 root 键**
81
+ * (根是 parent 为 null 的随机 uuid 节点)⇒ 真正的 ChatGPT 导出解析出 0 轮却仍报"导入完成"。
82
+ * 现在三级取链:
83
+ * ① current_node 沿 parent 回溯(主对话链;ChatGPT 有重生成分支,回溯才不会把分支全算进来)
84
+ * ② root + children 深度优先(DeepSeek 形态)
85
+ * ③ 兜底:全节点按时间排序(结构异形时至少不丢内容)
86
+ * @returns [{seq,role,ts,text}]
87
+ */
88
+ export function turnsFromOfficialMapping(mapping, { currentNode = '' } = {}) {
89
+ const map = mapping && typeof mapping === 'object' ? mapping : {};
90
+ const ids = Object.keys(map);
91
+ const order = [];
92
+ const seen = new Set();
93
+ const push = (id) => {
94
+ const k = String(id);
95
+ if (!k || !map[k] || seen.has(k)) return;
96
+ seen.add(k);
97
+ order.push(k);
98
+ };
99
+ // ① 主链回溯
100
+ const cur = str(currentNode) || str(map.current_node) || str(map.currentNode);
101
+ if (cur && map[cur]) {
102
+ const chain = [];
103
+ let id = cur;
104
+ const guard = new Set();
105
+ while (id && map[id] && !guard.has(id)) {
106
+ guard.add(id);
107
+ chain.push(id);
108
+ id = String(map[id].parent == null ? '' : map[id].parent);
109
+ }
110
+ chain.reverse().forEach(push);
111
+ }
112
+ // ② root + children DFS
113
+ if (!order.length && map.root) {
114
+ const walk = (id) => {
115
+ push(id);
116
+ const node = map[id];
117
+ if (node && Array.isArray(node.children)) node.children.forEach(walk);
118
+ };
119
+ walk('root');
120
+ }
121
+ // ③ 兜底:整表按时间排序
122
+ if (!order.length) {
123
+ const rows = ids.map((id) => ({ id, ts: turnOfNode(map[id]).ts }));
124
+ rows.sort((a, b) => (Number(a.ts || 0) - Number(b.ts || 0)));
125
+ rows.forEach((r) => push(r.id));
126
+ }
127
+ const turns = [];
128
+ let seq = 0;
129
+ for (const id of order) {
130
+ const t = turnOfNode(map[id]);
131
+ if (!t.role || !t.text) continue;
132
+ seq += 1;
133
+ turns.push({ seq, role: t.role, ts: t.ts, text: t.text });
134
+ }
135
+ return turns;
136
+ }
137
+
138
+ /** 规范化一个契约对象 → {ok, row, rawTurns?, degraded?, reason?} */
139
+ export function normalizeImportItem(raw) {
140
+ if (!raw || typeof raw !== 'object') return { ok: false, reason: '非对象条目' };
141
+ const s = raw;
142
+ const title = str(s.title) || str(s.name);
143
+ // S5:补 ChatGPT 原生字段名(create_time/update_time/conversation_id) —— 它们此前不在别名链里,
144
+ // 于是真正的 ChatGPT 导出连时间都拿不到(叠加"秒被当毫秒"→ 落 1970)。
145
+ const started = isoTime(s.inserted_at ?? s.startedAt ?? s.started_at ?? s.insertedAt ?? s.startTime ?? s.time ?? s.create_time ?? s.createTime);
146
+ const updated = isoTime(s.updated_at ?? s.updatedAt ?? s.update_time ?? s.updateTime) || started;
147
+ if (!title && !started && !s.mapping) return { ok: false, reason: '缺标题与时间(至少要有一项)' };
148
+
149
+ // messages(完整档);官方导出走 mapping 树
150
+ const rawTurns = [];
151
+ const hasMapping = s.mapping && typeof s.mapping === 'object';
152
+ if (hasMapping) {
153
+ rawTurns.push(...turnsFromOfficialMapping(s.mapping, { currentNode: str(s.current_node ?? s.currentNode) }));
154
+ } else if (Array.isArray(s.messages) && s.messages.length) {
155
+ let seq = 0;
156
+ for (const m of s.messages) {
157
+ if (!m || typeof m !== 'object') continue;
158
+ const role = m.role === 'assistant' || m.role === 'bot' || m.role === 'ai' ? 'assistant'
159
+ : m.role === 'user' || m.role === 'human' ? 'user' : '';
160
+ const text = str(m.text ?? m.content);
161
+ if (!role || !text) continue;
162
+ seq += 1;
163
+ rawTurns.push({ seq, role, ts: toMs(m.at ?? m.ts ?? m.time), text });
164
+ }
165
+ }
166
+
167
+ const category = s.category === 'knowledge' || s.category === 'daily' || s.category === 'feeling'
168
+ ? s.category : categorizeTitle(title || (rawTurns.length ? rawTurns[0].text.slice(0, 80) : ''));
169
+ const firstUserText = rawTurns.length ? (rawTurns.find((t) => t.role === 'user')?.text || '') : '';
170
+ // D2(2026-09-17):与 DSH 会话侧共用同一套启发式(剥称呼/问候前缀、句读处收尾),不再硬截 46 字
171
+ const rawOne = (title || firstUserText).replace(/\s+/g, ' ').trim();
172
+ const oneLine = heuristicTitle(rawOne) || rawOne.slice(0, TITLE_MAX_CHARS);
173
+ const finalTitle = oneLine || '(未命名会话)';
174
+ const summary0 = rawTurns.length
175
+ ? (str(s.summary) || `${oneLine.slice(0, 140)} — ${rawTurns.length} 条消息`)
176
+ : str(s.summary);
177
+ const summary = summary0 || `${oneLine.slice(0, 140)}(轻量条目,摘要待深摘补充)`;
178
+ if (!rawTurns.length && !summary && !title) return { ok: false, reason: '既无消息也无摘要,无法成档' };
179
+ // S5(2026-09-17 导入诚实化):「说有内容、却解析不出任何轮次」一律标记 degraded,
180
+ // 且必须覆盖 mapping 分支 —— 旧判定只看 messages 数组,于是 mapping 树解析 0 轮
181
+ // (真正的 ChatGPT 导出 / 导出格式变更)被当成成功,界面显示"✓ 导入完成"而原文一条没进。
182
+ const claimed = (Array.isArray(s.messages) && s.messages.length > 0) || hasMapping;
183
+ const degraded = claimed && !rawTurns.length;
184
+ const degradedReason = degraded
185
+ ? (hasMapping ? 'mapping 树解析出 0 轮(导出格式未识别)' : 'messages 里没有可用的 user/assistant 文本')
186
+ : '';
187
+ const givenKw = Array.isArray(s.keywords) ? s.keywords.map((k) => str(k)).filter(Boolean).slice(0, 20) : [];
188
+ const keywords = givenKw.length
189
+ ? givenKw
190
+ : keywordsFrom((finalTitle + ' ' + (str(s.summary) || firstUserText)).slice(0, 400));
191
+
192
+ let id = str(s.id ?? s.convId ?? s.sessionId ?? s.conversation_id ?? s.conversationId);
193
+ if (!id) {
194
+ id = 'auto:' + sha256Text(String(finalTitle) + '|' + String(started || '')).slice(0, 16);
195
+ }
196
+ const row = {
197
+ conv_id: id,
198
+ source: IMPORT_SOURCE,
199
+ title: finalTitle,
200
+ started_at: started,
201
+ updated_at: updated,
202
+ domain_tags: [CAT_NAME[category] || '日常'],
203
+ category,
204
+ keywords,
205
+ summary,
206
+ heat: 0,
207
+ importance: 0,
208
+ last_hit_at: null,
209
+ hit_count: 0,
210
+ overview_ok: 1,
211
+ origin: 'import-file-v1',
212
+ };
213
+ return { ok: true, row, rawTurns, degraded, degradedReason };
214
+ }
215
+
216
+ /**
217
+ * 落库一批(≤500/批,幂等):
218
+ * - 折叠:同 conv_id 命中 dsweb 旧域(官方导出与历史抓取同源)→ 元数据与原文并入 dsweb,
219
+ * 并移除本域残留副本(避免 L1 双份召回);
220
+ * - 否则:新行全量 upsert;已存在只刷新 标题/时间/类别/关键词(不动 summary/importance/置顶/热度);
221
+ * - 完整档 raw 轮次按 (session_id,seq) 覆盖写,重复导入天然幂等。
222
+ * @returns {{ok, newRows, refreshed, folded, removedImport, accepted, degraded, rejected}}
223
+ */
224
+ export async function applyImportItems(memory, items) {
225
+ const rejected = [];
226
+ const stat = { ok: true, accepted: 0, newRows: 0, refreshed: 0, upgraded: 0, folded: 0, removedImport: 0, degraded: 0, degradedReasons: {} };
227
+ const list = Array.isArray(items) ? items : [];
228
+ for (let i = 0; i < list.length; i++) {
229
+ const n = normalizeImportItem(list[i]);
230
+ if (!n.ok) { rejected.push({ i, reason: n.reason }); continue; }
231
+ stat.accepted += 1;
232
+ if (n.degraded) {
233
+ stat.degraded += 1;
234
+ const why = n.degradedReason || '未识别';
235
+ stat.degradedReasons[why] = (Number(stat.degradedReasons[why]) || 0) + 1;
236
+ }
237
+ const id = n.row.conv_id;
238
+ const dswebRow = memory.overviewById('dsweb', id);
239
+ if (dswebRow) {
240
+ // 官方导出/历史抓取同源:并入 dsweb 域(不动其 summary/importance;raw 落到 dsweb conv)
241
+ memory.db.prepare(
242
+ `UPDATE conv_overview SET title=?, started_at=?, updated_at=?, domain_tags=?, category=?, keywords=?, origin=?
243
+ WHERE source='dsweb' AND conv_id=?`,
244
+ ).run(
245
+ n.row.title,
246
+ n.row.started_at ?? dswebRow.started_at,
247
+ n.row.updated_at ?? dswebRow.updated_at,
248
+ JSON.stringify(n.row.domain_tags), n.row.category, JSON.stringify(n.row.keywords),
249
+ n.row.origin, id,
250
+ );
251
+ const impDup = memory.overviewById(IMPORT_SOURCE, id);
252
+ if (impDup) {
253
+ memory.db.prepare("DELETE FROM conv_overview WHERE source=? AND conv_id=?").run(IMPORT_SOURCE, id);
254
+ stat.removedImport += 1;
255
+ }
256
+ stat.folded += 1;
257
+ // 折叠分支的原文归 dsweb 域(裸 id):它本就与 dsweb 抓取同源,G4 的 import 前缀不适用
258
+ for (const t of n.rawTurns) {
259
+ memory.appendRawTurn(id, { seq: t.seq, role: t.role, ts: t.ts, model: null, text: t.text });
260
+ }
261
+ continue;
262
+ }
263
+ const existed = memory.overviewById(IMPORT_SOURCE, id);
264
+ if (existed) {
265
+ memory.db.prepare(
266
+ `UPDATE conv_overview SET title=?, started_at=?, updated_at=?, domain_tags=?, category=?, keywords=?,
267
+ overview_ok=1, origin=? WHERE source=? AND conv_id=?`,
268
+ ).run(
269
+ n.row.title,
270
+ n.row.started_at ?? existed.started_at,
271
+ n.row.updated_at ?? existed.updated_at,
272
+ JSON.stringify(n.row.domain_tags), n.row.category, JSON.stringify(n.row.keywords),
273
+ n.row.origin, IMPORT_SOURCE, id,
274
+ );
275
+ stat.refreshed += 1;
276
+ // 轻量行升级为完整档:本次带来原文而原行没有 raw → 记为「补全原文」
277
+ // (G4:原文落在 import 命名空间,旧数据是裸 id —— 候选链任一有行即算已有原文)
278
+ if (n.rawTurns.length && !rawSessionCandidates(IMPORT_SOURCE, id).some((k) => memory.rawTurnCount(k) > 0)) stat.upgraded += 1;
279
+ } else {
280
+ memory.upsertOverview(n.row);
281
+ stat.newRows += 1;
282
+ }
283
+ // G4(2026-09-17):导入原文落 'import:<id>' 命名空间 —— 与 DSH 会话原文物理隔离。
284
+ // 否则概述器(GROUP BY session_id)会把它当成 DSH 会话重建(跨源双份),同 id 时还会互相覆盖原文。
285
+ const rawId = rawSessionId(IMPORT_SOURCE, id);
286
+ for (const t of n.rawTurns) {
287
+ memory.appendRawTurn(rawId, { seq: t.seq, role: t.role, ts: t.ts, model: null, text: t.text });
288
+ }
289
+ }
290
+ return { ok: true, ...stat, rejected };
291
+ }