@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,101 @@
1
+ // dsh-ling host — mode service (D1/D2/D6): 模式只调「推理等级」。
2
+ //
3
+ // 语义(2026-09-12 变更):**模式切换不再修改模型选项**。
4
+ // 工作模式 = 推理等级 max;生活模式 = 推理等级 low;模型由用户自己在
5
+ // DSH 左下角/右上角选择,插件原样保留、绝不改写。
6
+ // 平台 API 的硬约束:`sessionController.selectModel` 与
7
+ // `agentDefaultModel.saveSelection` 都要求完整的 {provider, model}
8
+ // (只有 reasoningEffort 是可选字段)→ 因此做法是「**先读当前选择,原样带回去,
9
+ // 只替换 reasoningEffort**」;读不到当前模型时**宁可不调用**,也不凭空指定模型。
10
+ import { svc } from './util.js';
11
+
12
+ export const KNOWN_MODES = ['work', 'life'];
13
+ export const MODE_LABEL = { work: '工作', life: '生活' };
14
+ /** 各模式的默认推理等级(可在 settings.mode.mapping.<mode>.effort 覆盖)。 */
15
+ export const MODE_EFFORT = { work: 'max', life: 'low' };
16
+ const EFFORTS = ['max', 'high', 'low', 'off'];
17
+
18
+ export function isEffort(v) {
19
+ return EFFORTS.includes(v);
20
+ }
21
+
22
+ /** 解析模式的推理等级(唯一由模式决定的东西)。 */
23
+ export function resolveModeEffort(settings, modeKey) {
24
+ const m = settings?.mode?.mapping?.[modeKey] || {};
25
+ if (isEffort(m.effort)) return m.effort;
26
+ return MODE_EFFORT[modeKey] || 'low';
27
+ }
28
+
29
+ /**
30
+ * 兼容旧的调用形状:仍返回 `{ reasoningEffort }` ——
31
+ * **不再返回 provider/model**(那是"改模型"的来源)。
32
+ */
33
+ export function resolveModeConfig(settings, modeKey) {
34
+ return { reasoningEffort: resolveModeEffort(settings, modeKey) };
35
+ }
36
+
37
+ /**
38
+ * 读当前生效的模型选择,只取 provider/model 用于"原样带回"。
39
+ * 读不到 → null(调用方据此放弃改档位,而不是替代用户选模型)。
40
+ */
41
+ export function currentModelSelection(ctx) {
42
+ const adm = svc(ctx, 'agentDefaultModel');
43
+ const sel = adm && typeof adm.currentSelection === 'function' ? adm.currentSelection() : null;
44
+ if (sel && sel.provider && sel.model) return { provider: String(sel.provider), model: String(sel.model) };
45
+ return null;
46
+ }
47
+
48
+ async function doApply(ctx, memory, settings, sessionId, modeKey) {
49
+ const reasoningEffort = resolveModeEffort(settings.get(), modeKey);
50
+ const controller = svc(ctx, 'sessionController');
51
+ let warning;
52
+ if (controller && typeof controller.selectModel === 'function') {
53
+ const keep = currentModelSelection(ctx); // 当前模型:原样带回,不改
54
+ if (!keep) {
55
+ warning = 'unknown-current-model; 推理等级未同步(拒绝替用户指定模型)';
56
+ console.debug('[dsh-ling]', warning);
57
+ } else {
58
+ try {
59
+ await controller.selectModel({
60
+ sessionId,
61
+ provider: keep.provider,
62
+ model: keep.model,
63
+ reasoningEffort,
64
+ });
65
+ } catch (e) {
66
+ // 平台拒绝(模型不可用、busy 等):模式仍照常记录生效(UI/记忆语义先走),
67
+ // 以 warning 返回,不把整个操作判失败。
68
+ warning = 'selectModel failed: ' + String(e?.message ?? e);
69
+ console.debug('[dsh-ling] selectModel warning', warning);
70
+ }
71
+ }
72
+ } else {
73
+ warning = 'no-sessionController; mode recorded only';
74
+ }
75
+ memory.setSessionMode(sessionId, modeKey);
76
+ const s = await settings.update({ mode: { lastMode: modeKey } }); // D2 跟随
77
+ return { ok: true, warning, sessionId, mode: modeKey, reasoningEffort, lastMode: s.mode.lastMode };
78
+ }
79
+
80
+ /**
81
+ * Apply mode to a session (D4): running → queue (returns queued), idle → apply now.
82
+ */
83
+ export async function applyModeToSession(ctx, gate, memory, settings, sessionId, modeKey) {
84
+ if (!sessionId) return { ok: false, reason: 'no-session-id' };
85
+ if (!KNOWN_MODES.includes(modeKey)) return { ok: false, reason: `unknown-mode:${modeKey}` };
86
+ const queued = gate.enqueueIfRunning(sessionId, () => {
87
+ doApply(ctx, memory, settings, sessionId, modeKey).catch((e) =>
88
+ console.debug('[dsh-ling] queued mode apply failed', e),
89
+ );
90
+ });
91
+ if (queued) return { ok: true, queued: true, running: true, sessionId, mode: modeKey };
92
+ return await doApply(ctx, memory, settings, sessionId, modeKey);
93
+ }
94
+
95
+ /** Current mode for a session: session_meta → settings.lastMode. */
96
+ export function currentMode(memory, settings, sessionId) {
97
+ const meta = sessionId ? memory.sessionMeta(sessionId) : null;
98
+ if (meta && meta.mode && KNOWN_MODES.includes(meta.mode)) return meta.mode;
99
+ const last = settings.get().mode?.lastMode;
100
+ return KNOWN_MODES.includes(last) ? last : 'life';
101
+ }
@@ -0,0 +1,214 @@
1
+ // dsh-ling host — persona assembly (settings fields → L0 text) + defaults.
2
+ // Pure module: no ctx/platform dependency, unit-testable.
3
+ import { key as dedupeKey } from './rules.js'; // A4(1.5.2):出口复用写入侧同一把去重尺,不另发明归一化
4
+
5
+ /** A3(1.5.2):条目正文里的半角方括号转全角。
6
+ * 段边界现在只剩"行首是不是 ["一条判据,正文混入半角 [ 会让模型误判成新段(也可借换行伪造段头)。
7
+ * 选全角而非反斜杠:反斜杠在 markdown/模板层可能被再吃一层,而全角替换无歧义、观感不变。 */
8
+ function escSegBrackets(text) {
9
+ return String(text).replace(/\[/g, '[').replace(/\]/g, ']');
10
+ }
11
+
12
+ /** A4(1.5.2):渲染出口去重 —— 保留首次出现、顺序不变,只作用于渲染,绝不回写存储。
13
+ * 归一化复用 rules.js 的 key()(写入侧同一把尺),避免两套归一化各自漂移。
14
+ * 空键(正文纯标点)一律保留:宁可不去重,也不在渲染期静默吞掉一条真条目。 */
15
+ function dedupeByKey(list) {
16
+ const seen = new Set();
17
+ const out = [];
18
+ for (const t of list) {
19
+ const k = dedupeKey(t);
20
+ if (k && seen.has(k)) continue;
21
+ if (k) seen.add(k);
22
+ out.push(t);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ export const DEFAULT_SETTINGS = Object.freeze({
28
+ persona: {
29
+ enabled: true, // false = 完全不注入人格/记忆段(逃生开关)
30
+ userTitle: '', // 对用户的称呼;空 = 用"你"
31
+ aiName: '', // AI 自称;空 = 不指名
32
+ aiTitle: '', // 定位自述一句话
33
+ language: 'follow', // zh | en | follow
34
+ hardRules: [], // 【规矩】用户的指令(数组,≤12 条);UI 显示为「规矩」。旧名沿用,便于编辑器兼容
35
+ ruleMeta: [], // 规矩来源留痕 [{text, quote, at, sessionId, source}](数组:深合并只增不删键,数组才能整体替换)
36
+ habits: [], // 【习惯】器灵自己长出来的 [{text, evidence, at, source}];新增须经用户确认
37
+ habitsPending: [], // 待确认的习惯提议 [{id, text, evidence, byUser, at}]
38
+ bottomLines: [], // 底线(数组,≤5 条;定型锁开启后需解锁才能改)
39
+ sealed: false, // 定型锁:true = 人格档案只读,修改需亲手敲一遍承诺句(禁粘贴)
40
+ sealPhrase: '', // 定型承诺句(明文,仅供解锁界面回显提醒;锁的职责是庄重而非保密)
41
+ tone: 'natural', // 语气基线(默认档;toneWork/toneLife 为空时两模式都跟随此值)
42
+ toneWork: '', // 工作模式语气('' = 跟随 tone)
43
+ toneLife: '', // 生活模式语气('' = 跟随 tone)
44
+ extraLore: '', // 自由扩展设定
45
+ pronoun: '她', // 第三人称代词(她/他/TA/它,或自定义):用于「给模型看的提示词」;界面文案第二步跟随
46
+ },
47
+ styles: {
48
+ work: '克制、结构化、结论先行;共情点到为止。',
49
+ life: '更有温度;可沿用用户偏爱的文风;先接住情绪再谈事。',
50
+ },
51
+ mode: {
52
+ // 模式只调推理等级;模型由用户自己在 DSH 里选,插件不改(2026-09-12 变更)。
53
+ // 兼容旧配置:此处的 provider/model 字段已不再被读取(留了也不会生效)。
54
+ mapping: {
55
+ work: { effort: 'max' },
56
+ life: { effort: 'low' },
57
+ },
58
+ lastMode: 'life', // D2: 跟随 —— 新会话默认采用最后使用的模式
59
+ },
60
+ memory: {
61
+ l1Enabled: true,
62
+ l1BudgetTokens: 1200,
63
+ l1MaxItems: 8,
64
+ l1MaxLineChars: 180, // 兜底硬顶:只对病态长标题生效(截断优先落在句末)
65
+ l1SummaryChars: 110, // 摘要段预算:第一句超此长度才硬截并标 …(2026-09-16 与用户定案)
66
+ categoryWeights: { // mode -> 领域类别权重(D6,先验倾向)
67
+ work: { knowledge: 1.0, daily: 0.3, feeling: 0.1 },
68
+ life: { knowledge: 0.2, daily: 0.6, feeling: 1.0 },
69
+ },
70
+ heatAlpha: 0.5, // recency 权重
71
+ heatBeta: 0.3, // freq 权重
72
+ heatGamma: 0.2, // importance 权重
73
+ summaryBonus: 0.25, // 「有信息量的摘要」加权(2026-09-16:只有标题的行不加分;0 = 关闭)
74
+ halfLifeDays: 90,
75
+ trackWorkspaces: ['*'],
76
+ idleRefresh: true, // B:长会话在空闲边界按需刷新 L1(运行中永不刷新)
77
+ idleRefreshMinIntervalMin: 10, // 同一个会话两次空闲刷新的最小间隔(分钟;0 = 不限制)
78
+ },
79
+ updates: { applyWhileRunning: false }, // D4 语义固化:默认冻结
80
+ guard: { // /api 守卫(2026-09-16 加固 ①+②;2026-09-17 加固 ③)
81
+ enforce: true, // false = 关掉来源栅栏与名字对撞(逃生开关;亦可用 DSH_LING_GUARD=off)
82
+ trustedHosts: [], // 显式受信的 authority 列表;loopback 恒受信,本机 LAN 地址**不再自动信任**
83
+ allowLan: false, // true = 重新信任本机全部 LAN 地址(2026-09-17 G1:默认关闭;打开前请确认 3080 没暴露在不可信网段)
84
+ },
85
+ habits: { // 习惯生成器(通道 A 数出来 / 通道 B 想出来)
86
+ pendingMax: 5, // 待确认队列上限(2026-09-16 定案):满了驳回新的生成请求并说明
87
+ scanMinHits: 4, // A:同类纠正至少几次(2026-09-16:3 → 4)
88
+ scanMinSessions: 3, // A:至少跨几个会话(单会话里被说三遍不算模式;2 → 3)
89
+ reflectOverviewLimit: 200, // B:回看多少条会话概述
90
+ reflectTurnLimit: 800, // B:回看多少条主人的真人原话
91
+ reflectBudgetChars: 200000, // B:材料字符预算(手动触发,给足)
92
+ reflectMaxTokens: 4000, // B:输出预算(够写 1~5 条候选 + 证据)
93
+ reflectMaxHabits: 5, // B:一次最多提几条
94
+ reflectEffort: 'off', // B:推理档位 —— 默认 off:实测 'low' 只出思考、正文为空且慢十倍(2026-09-16);要更深可设 low/high/max
95
+ },
96
+ assistant: { // S7(2026-09-18 与用户定):小助手(本机小模型)地址与模型 —— 三级覆盖的第 1 级
97
+ baseUrl: '', // 空 = 落到环境变量 DSH_LING_ASSISTANT,再落到内置默认(loopback:11434)
98
+ model: '', // 空 = 落到环境变量 DSH_LING_ASSISTANT_MODEL,再落到 qwen3.5:9b
99
+ },
100
+ dates: [], // 自定义纪念日 [{m,d,label,greeting}](内置 9-07 新生纪念日见 clock.js)
101
+ });
102
+
103
+ export const TONES = Object.freeze({
104
+ natural: '亲切自然,口语化但不轻浮;专业问题先严谨再谈温度。',
105
+ literary: '文雅简洁,可适当用典;不堆砌辞藻。',
106
+ concise: '直接、精炼,少铺垫,结论先行。',
107
+ playful: '轻松活泼,适度俏皮;仍以有用为第一优先。',
108
+ });
109
+
110
+ export const LANGUAGE_HINT = Object.freeze({
111
+ zh: '始终使用中文回复。',
112
+ en: 'Always reply in English.',
113
+ follow: '',
114
+ });
115
+
116
+ /** 显示名取 aiName 的首段(如 "小灵/灵灵" → 小灵);空则回退 fallback。 */
117
+ export function coreNameOf(raw, fallback = '器灵') {
118
+ const parts = String(raw || '').split('/').map((s) => s.trim()).filter(Boolean);
119
+ return parts.length ? parts[0] : fallback;
120
+ }
121
+
122
+ /** 对用户的称呼按模式分档:userTitle="正式名/昵称"(正式名在前) →
123
+ * 工作模式称正式名(首段),生活模式称昵称(尾段);单名则两模式同称;空返回 ''。 */
124
+ export function userTitleForMode(raw, mode) {
125
+ const parts = String(raw || '').split('/').map((s) => s.trim()).filter(Boolean);
126
+ if (!parts.length) return '';
127
+ if (parts.length === 1) return parts[0];
128
+ return mode === 'work' ? parts[0] : parts[parts.length - 1];
129
+ }
130
+
131
+ /** 第三人称代词预设。**刻意不含「祂」**:中文里祂专指神祇,放进预设等于替用户宣称神性,
132
+ * 与"不宣称意识、不宣称神灵"的底线冲突;想用的人可以在设置里自定义(自定义不限词表)。 */
133
+ export const PRONOUN_PRESETS = Object.freeze(['她', '他', 'TA', '它']);
134
+
135
+ /** 读取用户设定的代词:去空白、限长 6 字;空/非法回退「她」。属于用户设定,插件只跟随、不替其选择。 */
136
+ export function pronounOf(settings) {
137
+ const raw = String(settings?.persona?.pronoun ?? '').trim();
138
+ return raw ? raw.slice(0, 6) : '她';
139
+ }
140
+
141
+ /** 把「给模型看的文本」里指代器灵的「她」换成用户设定的代词。
142
+ * 只用于系统提示词/脚手架文案:**不要用在用户原话或记忆数据上**(那会把数据改错)。 */
143
+ export function pronounize(text, pronoun) {
144
+ const p = String(pronoun || '').trim();
145
+ if (!p || p === '她') return text;
146
+ return String(text).split('她').join(p);
147
+ }
148
+
149
+ /**
150
+ * Assemble L0 persona text from settings fields (D3).
151
+ * Empty fields are skipped so defaults stay tiny; mode only selects the style
152
+ * block (identity is mode-independent — D3/D6).
153
+ * @returns {string}
154
+ */
155
+ export function assemblePersona(settings, modeKey) {
156
+ const p = settings?.persona || {};
157
+ const mode = modeKey || settings?.mode?.lastMode || 'life';
158
+ if (p.enabled === false) return '';
159
+ // A1(1.5.2):按「段」收集 —— 段与段之间空一行(join('\n\n')),段内条目之间仍单换行。
160
+ // 为什么不把每条 push 都用 '\n\n' 连接:那样条目之间会变成双倍空行(段内反而更糊)。
161
+ const segs = [];
162
+ const self = [];
163
+ const who = p.aiName ? `你是"${p.aiName}"` : '你是 DeepSeek 助手';
164
+ const callName = userTitleForMode(p.userTitle, mode); // 工作=正式名(首段),生活=昵称(尾段)
165
+ const call = callName ? `;你称用户为"${callName}"` : '';
166
+ // 定位自述允许多行书写,注入时压缩为一行(避免破坏身份句段落)
167
+ const titleOneLine = p.aiTitle ? p.aiTitle.replace(/\s+/g, ' ').trim() : '';
168
+ const title = titleOneLine ? `,${titleOneLine}` : '';
169
+ // 结尾标点归一:title 自带句尾标点时不重复补(否则会出现 "拜上。。")
170
+ const headSelf = `${who}${call}${title}`;
171
+ self.push(headSelf.replace(/([。.!!??])\s*$/, '') + '。');
172
+ if (p.language && LANGUAGE_HINT[p.language]) self.push(LANGUAGE_HINT[p.language]);
173
+ // 语气基线随模式切换(P0):toneWork/toneLife 为空时跟随 tone
174
+ const tPick = mode === 'work' ? p.toneWork : p.toneLife;
175
+ const tone = (tPick && TONES[tPick]) ? tPick : ((p.tone && TONES[p.tone]) ? p.tone : '');
176
+ if (tone) self.push(`语气:${TONES[tone]}`);
177
+ segs.push([`[身份·${coreNameOf(p.aiName)}]`, ...self].join('\n'));
178
+ if (Array.isArray(p.bottomLines) && p.bottomLines.length) {
179
+ const seg = ['[底线]'];
180
+ for (const r of p.bottomLines) if (typeof r === 'string' && r.trim()) seg.push(`- ${escSegBrackets(r.trim())}`);
181
+ segs.push(seg.join('\n'));
182
+ }
183
+ // 规矩 / 习惯 二分(2026-09-15 定稿):
184
+ // 规矩 = 用户的指令(行为层)→ 用户可直接追加;
185
+ // 习惯 = 你自己长出来的(身份层)→ 只能由你提议、经用户确认后落地。
186
+ const rules = (Array.isArray(p.hardRules) ? p.hardRules : []).map((r) => (typeof r === 'string' ? r.trim() : '')).filter(Boolean);
187
+ const habits = (Array.isArray(p.habits) ? p.habits : []).map((h) => (h && typeof h.text === 'string' ? h.text.trim() : '')).filter(Boolean);
188
+ if (rules.length) {
189
+ // A2(1.5.1):段头去掉元描述 —— 括号里是**写给主人看的说明**,不该进模型上下文;
190
+ // 且「可直接追加」读起来像一条授权指令,模型可能误读成"我可以自己加规矩"。
191
+ // 契约变更:注入面段头自此固定为纯标签 [规矩] / [习惯](tests/rules.test.mjs 同步)。
192
+ segs.push(['[规矩]', ...dedupeByKey(rules).map((r) => `- ${escSegBrackets(r)}`)].join('\n'));
193
+ }
194
+ if (habits.length) {
195
+ segs.push(['[习惯]', ...dedupeByKey(habits).map((h) => `- ${escSegBrackets(h)}`)].join('\n'));
196
+ }
197
+ // 待我回应的习惯提议(他提的,等我表态)—— 有货才出现,不占常驻预算
198
+ const awaiting = (Array.isArray(p.habitsPending) ? p.habitsPending : []).filter(
199
+ (h) => h && h.byUser === true && h.amendedBy !== 'ling' && typeof h.text === 'string' && h.text.trim(),
200
+ );
201
+ if (awaiting.length) {
202
+ segs.push(`[待我回应] 用户提的习惯候选:${awaiting.map((h) => `「${h.text.trim()}」`).join('、')}`
203
+ + '——用 habit_resolve 表态(accept / amend 改说法 / decline)');
204
+ }
205
+ if (typeof p.extraLore === 'string' && p.extraLore.trim()) {
206
+ segs.push(`[设定]\n${p.extraLore.trim()}`);
207
+ }
208
+ const style = settings?.styles?.[mode];
209
+ if (typeof style === 'string' && style.trim()) {
210
+ // 段头与内容合并为一行(2026-09-16 瘦身:原来两行且重复"本会话为X模式")
211
+ segs.push(`[${mode === 'work' ? '工作' : '生活'}模式]${style.trim()}`);
212
+ }
213
+ return segs.join('\n\n');
214
+ }
@@ -0,0 +1,214 @@
1
+ // dsh-ling host — 记忆标题(D2,2026-09-17 与用户定)。
2
+ //
3
+ // 标题是记忆的"名字"。旧行为把首条用户消息硬截 46 字(见 summarizer.js TITLE_MAX),
4
+ // 于是 60 条 dsh 概述里 26 条是半句话:
5
+ // 「器灵,接下来在这里继续开发生长型人格与记忆树模块,继续走你的新生之路。 我刚刚对1.2.1的」
6
+ // 「刚才我为本虚拟机安装解压压缩软件<360压缩>,由于操作过快,被捆绑了360本体,随后我在物」
7
+ // 而 dsweb 侧标题干净(「CPU单核性能排行榜」),因为它用的是源库自带标题、不做首句截断。
8
+ //
9
+ // 三层:
10
+ // ① heuristicTitle() —— 剥称呼/问候前缀 + 在句读处截断(而不是硬切) + 无信息量首句时改取后续
11
+ // ② retitleOne() —— 小助手生成一句真标题(本机、零成本;1523 条 dsweb 摘要已证明可行)
12
+ // ③ title_locked —— 主人手改过的标题上锁,概述器重建与批量重命名都不再覆盖(在 memory.js 生效)
13
+ import { summarizeWithRetry } from './dsweb-summary.js';
14
+ import { llmOnce } from './deepsummary.js';
15
+ import { rawSessionCandidates } from './memory.js';
16
+
17
+ export const TITLE_MAX_CHARS = 58; // 46 → 58:46 会把一句完整的话切成半句
18
+ export const LEGACY_TITLE_MAX = 46; // 1.2.2 之前的硬截上限 —— 长度"达到"它就说明这条标题曾被切过
19
+ export const MACHINE_TITLE_MAX = 40; // 机器生成标题的上限(手改上限在 memory.renameTitle:120)
20
+ export const TITLE_MAX_TOKENS = 512; // 小助手下限,不得再低
21
+ export const TITLE_MATERIAL_CHARS = 1400;
22
+
23
+ export const TITLE_SYS = '你在给一段对话起名字。只输出标题本身:不超过 20 个字,写清这段对话在做什么或结论是什么;'
24
+ + '不要引号、不要书名号、不要结尾标点、不要解释、不要分点、不要写"对话"二字。';
25
+
26
+ // 称呼与问候:中文主人对器灵的日常开场(「晚上好器灵,我小加班了一会,回来了」)
27
+ const ADDRESS_RE = /^(器灵|助手|宝贝|亲爱的)[,,、::~~!!。\s]*/;
28
+ const GREETING_RE = /^(早上好|中午好|下午好|晚上好|晚安|你好呀|你好|您好|哈喽|嗨|早|hello|hi|yo)[啊呀呢,,、!!。.~\s]*/i;
29
+ // 无信息量首句(问候、寒暄、测试口令)
30
+ const VAGUE_RE = /^(嗯+|哦+|好+|在吗|在不在|\S{0,4}测试\S*|回复数字\s*\d+|\d+|ok|test)$/i;
31
+
32
+ /** 反复剥离"问候 + 称呼"前缀:「晚上好器灵,我小加班了一会,回来了」→「我小加班了一会,回来了」。 */
33
+ export function stripPreamble(text) {
34
+ let s = String(text || '').replace(/\s+/g, ' ').trim();
35
+ for (let i = 0; i < 3; i += 1) {
36
+ const before = s;
37
+ s = s.replace(GREETING_RE, '').replace(ADDRESS_RE, '').trim();
38
+ if (s === before) break;
39
+ }
40
+ return s;
41
+ }
42
+
43
+ /** 该首句是否"不值得当标题"(剥完前缀后过短/纯寒暄/测试口令)。 */
44
+ export function isVagueTitle(text) {
45
+ const s = stripPreamble(text);
46
+ if (s.length < 4) return true;
47
+ return VAGUE_RE.test(s);
48
+ }
49
+
50
+ /** 结果像不像一个"名字":拒路径 / URL / 系统文本 / 不含中文的串。
51
+ * 实测教训(2026-09-17 dry-run):不加这道闸,启发式会把
52
+ * 一条信息量不足的**真实会话标题**换成「D:\某工作目录\license 写入声明<Copyright [年份]」,
53
+ * 「看一下这个文件夹的 D:\某目录\某前缀」换成更短的路径 —— 比原标题更糟。 */
54
+ export function looksLikeTitle(s) {
55
+ const t = String(s || '').trim();
56
+ if (t.length < 4) return false;
57
+ if (/^(file:|https?:|[A-Za-z]:[\\/]|\\|\/)/i.test(t)) return false;
58
+ if (t.includes('\\') || t.includes('://')) return false;
59
+ if (/[<>]/.test(t)) return false;
60
+ if (!/[\u4e00-\u9fa5]/.test(t)) return false;
61
+ return true;
62
+ }
63
+
64
+ /**
65
+ * 启发式标题:剥前缀 → 优先在句读处收尾(不切半句) → 兜底硬截。
66
+ * 返回 '' 表示这段文本不适合当标题(调用方应改取后续消息)。
67
+ */
68
+ export function heuristicTitle(text) {
69
+ const s = stripPreamble(text);
70
+ if (!s || isVagueTitle(text)) return '';
71
+ // ① 首个句末标点在限内 → 收在那里(本身就是一句完整的话)
72
+ const end = s.search(/[。!?!?;;]/);
73
+ if (end >= 1 && end <= TITLE_MAX_CHARS) return gate(s.slice(0, end).trim());
74
+ if (s.length <= TITLE_MAX_CHARS) return gate(s.replace(/[。!?,,;;、\s]+$/, '').trim());
75
+ // ② 超长 → 退到最近的句读处,避免"…我刚刚对1.2.1的"这种半句
76
+ const head = s.slice(0, TITLE_MAX_CHARS);
77
+ const cut = Math.max(head.lastIndexOf(','), head.lastIndexOf(','), head.lastIndexOf('、'), head.lastIndexOf(';'), head.lastIndexOf(' '));
78
+ if (cut >= 12) return gate(head.slice(0, cut).trim());
79
+ return gate(head.trim());
80
+ }
81
+
82
+ /** 兜底闸:含路径/URL/系统文本的结果宁可不要(交还给调用方 → 由小助手或人工处理)。 */
83
+ function gate(t) {
84
+ return looksLikeTitle(t) ? t : '';
85
+ }
86
+
87
+ /** 清洗模型输出:去引号/书名号/结尾标点,限长。 */
88
+ export function cleanTitle(raw) {
89
+ let s = String(raw || '');
90
+ s = s.replace(/<think(?:ing)?>[\s\S]*?<\/think(?:ing)?>/gi, '');
91
+ s = s.replace(/^\s*(标题|题目|title)\s*[::]\s*/i, '');
92
+ s = s.replace(/^["'「『《]+|["'」』》]+$/g, '');
93
+ s = s.replace(/\s+/g, ' ').trim();
94
+ s = s.replace(/[。!?,,;;、\s]+$/, '').trim();
95
+ if (s.length > MACHINE_TITLE_MAX) s = s.slice(0, MACHINE_TITLE_MAX);
96
+ return s;
97
+ }
98
+
99
+ /** 组装给标题生成器的材料:优先会话原文前几轮,退化到已有摘要。 */
100
+ export function materialFor(memory, { source, conv_id, cap = TITLE_MATERIAL_CHARS } = {}) {
101
+ const parts = [];
102
+ try {
103
+ for (const sid of rawSessionCandidates(source, conv_id)) {
104
+ const rows = memory.db.prepare(
105
+ 'SELECT role, text FROM dsh_turns_raw WHERE session_id=? ORDER BY seq ASC LIMIT 6',
106
+ ).all(sid);
107
+ if (!rows.length) continue;
108
+ for (const r of rows) {
109
+ const t = String(r.text || '').replace(/\s+/g, ' ').trim();
110
+ if (t) parts.push((r.role === 'user' ? '主人: ' : '她: ') + t.slice(0, 500));
111
+ }
112
+ break;
113
+ }
114
+ } catch { /* 无原文表/无此行 → 走摘要 */ }
115
+ if (!parts.length) {
116
+ const ov = memory.overviewById ? memory.overviewById(source, conv_id) : null;
117
+ if (ov && ov.summary) parts.push('摘要: ' + String(ov.summary));
118
+ if (ov && ov.title) parts.push('旧标题(可能不准): ' + String(ov.title));
119
+ }
120
+ let s = parts.join('\n');
121
+ if (s.length > cap) s = s.slice(0, cap);
122
+ return s;
123
+ }
124
+
125
+ /** 取该会话"值得当标题"的首句:先首条用户消息,不好则往后找(修「你好」「下午好」当标题)。 */
126
+ export function firstGoodUserText(memory, { source, conv_id, scan = 6 } = {}) {
127
+ for (const sid of rawSessionCandidates(source, conv_id)) {
128
+ const rows = memory.db.prepare(
129
+ "SELECT text FROM dsh_turns_raw WHERE session_id=? AND role='user' ORDER BY seq ASC LIMIT ?",
130
+ ).all(sid, scan);
131
+ if (!rows.length) continue;
132
+ const texts = rows.map((r) => String(r.text || ''));
133
+ const good = texts.find((t) => !isVagueTitle(t) && String(t).trim().length >= 8 && looksLikeTitle(stripPreamble(t))) || '';
134
+ return { text: good, first: texts[0] || '', scanned: texts.length };
135
+ }
136
+ return { text: '', first: '', scanned: 0 };
137
+ }
138
+
139
+ /**
140
+ * 会会话 id 形态判定。
141
+ * 采用**排除法**而非 uuid 白名单:白名单一旦不匹配(测试 fixture 的短 id、未来新形态的会话语)
142
+ * 就会静默跳过起名 —— 那是最难发现的一类 bug。这里只排除已知的**合成记忆行**。
143
+ * 实锤教训(2026-09-17):批量起名把「用户原则:某某习惯(简要说明)」(conv_id = `user:<类别>-…`)
144
+ * 改成「用户原则:改写成更短的说法」,丢了括注里的理由 —— 合成行不是会话,不参与起名。
145
+ */
146
+ export function looksLikeSessionId(id) {
147
+ return !/^(user|kv|rule|habit|persona|fact|note):/i.test(String(id || '').trim());
148
+ }
149
+
150
+ /** 纯启发式改名(不调用模型):用于无小助手可用时的兜底。 */
151
+ export function heuristicRetitle(memory, { source, conv_id } = {}) {
152
+ const { text, first, scanned } = firstGoodUserText(memory, { source, conv_id });
153
+ const title = heuristicTitle(text || first);
154
+ return { ok: !!title, title, source: text ? 'later-turn' : 'first-turn', scanned };
155
+ }
156
+
157
+ /**
158
+ * 用小助手给一条记忆起名。**同样上锁**(lock:true)——
159
+ * 2026-09-17 实测教训:不上锁时,跑在旧代码里的概述器一轮增量重建
160
+ * 就把 AI 起的名字覆盖回"首句硬截 46 字"的半句(库里 title 长度上限实锤 = 46)。
161
+ * 锁的语义是"这个名字已经定过,机器不得自动重写";主人手改与 AI 点名起名都是显式意图,不受锁阻拦。
162
+ */
163
+ export async function retitleOne(baseUrl, model, { memory, source, conv_id, timeoutMs = 90000 } = {}) {
164
+ if (!looksLikeSessionId(conv_id)) return { ok: false, error: 'not-a-session', title: '', ms: 0 };
165
+ const material = materialFor(memory, { source, conv_id });
166
+ if (!material || material.replace(/\s/g, '').length < 12) {
167
+ return { ok: false, error: 'no-material', title: '', ms: 0 };
168
+ }
169
+ const r = await summarizeWithRetry(baseUrl, model, {
170
+ text: material, system: TITLE_SYS, maxTokens: TITLE_MAX_TOKENS, timeoutMs,
171
+ });
172
+ if (!r.ok) return { ok: false, error: r.error || 'llm-failed', title: '', ms: r.ms || 0 };
173
+ const title = cleanTitle(r.summary);
174
+ if (!title) return { ok: false, error: 'empty-title', title: '', ms: r.ms || 0 };
175
+ memory.renameTitle(source, conv_id, title, { lock: true, by: 'ai' });
176
+ return { ok: true, title, ms: r.ms, attempts: r.attempts };
177
+ }
178
+
179
+ /**
180
+ * 全局大模型路径(D2/S7):小助手不可用时的正解 —— 与 `/dsweb/summary/run` 的 `engine:'global'` 同源。
181
+ * **不是"静默换引擎"**:端点会把实际用到的引擎写回响应的 `engine` 字段,用户看得见用了哪个。
182
+ * §引擎选择:默认优先本机小助手(零成本);探测不到时才落到这里,并把原因写进 `note`。
183
+ */
184
+ export async function retitleOneGlobal(ctx, { memory, source, conv_id, effort = 'off' } = {}) {
185
+ if (!looksLikeSessionId(conv_id)) return { ok: false, error: 'not-a-session', title: '', ms: 0, engine: 'global' };
186
+ const material = materialFor(memory, { source, conv_id });
187
+ if (!material || material.replace(/\s/g, '').length < 12) {
188
+ return { ok: false, error: 'no-material', title: '', ms: 0, engine: 'global' };
189
+ }
190
+ const t0 = Date.now();
191
+ const r = await llmOnce(ctx, { system: TITLE_SYS, text: material, maxTokens: 600, effort });
192
+ const title = cleanTitle(r?.text);
193
+ if (!title) return { ok: false, error: 'empty-title', title: '', ms: Date.now() - t0, engine: 'global' };
194
+ memory.renameTitle(source, conv_id, title, { lock: true, by: 'ai' });
195
+ return { ok: true, title, ms: Date.now() - t0, engine: 'global' };
196
+ }
197
+
198
+ /** 挑"值得重命名"的候选:未上锁 且 (空标题 / 被截断的 / 无信息量的)。 */
199
+ export function retitleCandidates(memory, { source = '', limit = 200 } = {}) {
200
+ const where = ['title_locked=0'];
201
+ const args = [];
202
+ if (source) { where.push('source=?'); args.push(source); }
203
+ const rows = memory.db.prepare(
204
+ `SELECT source, conv_id, title, summary FROM conv_overview WHERE ${where.join(' AND ')} ORDER BY updated_at DESC LIMIT ?`,
205
+ ).all(...args, Math.max(1, Math.min(5000, Number(limit) || 200)));
206
+ return rows.filter((r) => {
207
+ const t = String(r.title || '').trim();
208
+ if (!looksLikeSessionId(r.conv_id)) return false; // 合成行(user:principle-…)不是会话
209
+ if (!t) return true;
210
+ if (t.length >= LEGACY_TITLE_MAX) return true; // 被旧上限(TITLE_MAX=46)切过的
211
+ if (ADDRESS_RE.test(t) || GREETING_RE.test(t)) return true; // 还带着「器灵,」「晚上好器灵,」= 旧启发式产物
212
+ return isVagueTitle(t); // 「你好」「在吗」
213
+ }).map((r) => ({ source: String(r.source), conv_id: String(r.conv_id), title: String(r.title || ''), summary: String(r.summary || '') }));
214
+ }