@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,2584 @@
1
+ // dsh-ling host — loopback HTTP gateway /api/dsh-ling/*
2
+ // webServer acquisition is robust to boot order: direct svc → ctx.inject
3
+ // scope → periodic retry until mounted (routes disappear if unavailable).
4
+ import { svc, pick, isoDate } from './util.js';
5
+ import { assemblePersona, pronounOf, pronounize } from './persona.js';
6
+ import { applyModeToSession, currentMode, currentModelSelection, MODE_LABEL, resolveModeConfig } from './mode.js';
7
+ import { invalidateSession, refreshStats } from './inject.js';
8
+ import { selectL1 } from './l1.js';
9
+ import { applySuggestionAsRule, dismissSuggestion } from './feedback.js';
10
+ import { applyCorpusSuggestion, dismissCorpusSuggestion } from './suggestions.js';
11
+ import { runDeepPass, rerunOne, candidateFor, deepSummarizeOne, llmOnce, importRawTargets } from './deepsummary.js';
12
+ import { GENESIS_SYS, parseGenesisResult, buildGenesisSource, composeGenesisRows, filterNamePairs } from './genesis.js';
13
+ import { scanIntoMemory } from './scan-dsweb.js';
14
+ import { scanDshHistory } from './backfill.js';
15
+ import { applyImportItems } from './import-file.js';
16
+ import { sealOk, KEY_MIN } from './seal.js';
17
+ import { timeAnchorDate, timeAnchorLive } from './clock.js';
18
+ import { TONE_ADVICE_SYS, TONE_SET, sampleToneRows, parseToneAdvice, appendStyleNote } from './tone-advice.js';
19
+ import { logImport, listImportLog, formatLogLine } from './import-log.js';
20
+ import { checkRequest, lanAddresses, sanitizePersonaPatch } from './guard.js';
21
+ import {
22
+ mapBranchList, convExistsIn,
23
+ opBranchCreate, opBranchDelete, opBranchReparent, opBranchRename, opBranchWeight,
24
+ opVeinLink, opVeinUnlink, opAssignConv, opConflictResolve,
25
+ opBranchMembers, opSnapshotTree, opRestoreTree, opDeleteSnapshot,
26
+ } from './tree-ops.js';
27
+ import { retitleOne, retitleOneGlobal, heuristicRetitle } from './retitle.js';
28
+ import {
29
+ addRule, removeRule, proposeHabit, resolveHabit, removeHabit, amendHabit, habitsOf, rulesView, RULE_MAX_CHARS,
30
+ habitsPendingOf, pendingMaxOf,
31
+ } from './rules.js';
32
+ import {
33
+ scanCorrections, evidenceOf, SCAN_DEFAULTS, summarizeScan,
34
+ HABIT_REFLECT_SYS, buildReflectMaterial, parseReflect, REFLECT_DEFAULTS, reflectWithRetry,
35
+ } from './habit-gen.js';
36
+ import {
37
+ openSource, buildCandidates, conversationText, probeAssistant, summarizeWithRetry, cleanSummary,
38
+ touchMemoryVersion, resolveAssistant, MIN_TURNS_DEFAULT, SUMMARY_SYS,
39
+ } from './dsweb-summary.js';
40
+ import { TRUNK_ID } from './memory.js';
41
+ import { bucketize, nameBucket } from './autotree.js';
42
+
43
+ // (旧)只校验 cookie 名形状的守卫已废弃 —— 见 guard.js:来源栅栏 + 名字对撞(2026-09-16 加固 ①+②)
44
+ // 网页端聊天记录库的默认路径:私有路径不写死在代码里 —— 由 UI 传入,或用 DSH_LING_DSWEB_DB 环境变量覆盖。
45
+ const DSWEB_DB_DEFAULT = process.env.DSH_LING_DSWEB_DB || '';
46
+ const MSG_KEY = '定型门:档案已定型 — 请在「解锁修改」弹窗里亲手敲一遍你的承诺句(输入框禁粘贴;若旧档案无明文记录,输入一句新的 ≥' + KEY_MIN + ' 字承诺即被采纳)。';
47
+
48
+ function sendJson(res, status, body) {
49
+ const payload = JSON.stringify(body ?? {});
50
+ res.statusCode = status;
51
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
52
+ res.setHeader('Content-Length', Buffer.byteLength(payload));
53
+ res.end(payload);
54
+ }
55
+
56
+ /** 记忆来源白名单(D2/D8,2026-09-17):旧代码写死 `body.source === 'dsh' ? 'dsh' : 'dsweb'`,
57
+ * 会把 source='import' 的置顶/删除误打到 dsweb 源上。
58
+ * 'vein' = 主脉提炼产物(2026-09-25 补)。它是库内第 5 个来源,漏掉它的后果:
59
+ * ① 记忆中心的来源筛选栏没有它的计数入口,主人看不到"主脉提炼"这一类;
60
+ * ② /memories/pin|delete 会拒收它 ⇒ 主人无法置顶或删除这类记忆。 */
61
+ const MEM_SOURCES = ['dsh', 'dsweb', 'import', 'vein'];
62
+
63
+ /** ⚠️ **写操作命中 0 行的"静默失败"**(审计 B#3,2026-09-22 修):
64
+ * 底层 `renameBranch` / `setBranchWeight` / `reparentBranch` 用的都是 `UPDATE ... WHERE id=?`
65
+ * —— id 不存在时 SQLite 命中 0 行、`changes=0`,可方法仍回 `{ok:true}`。端点据此写了
66
+ * 「已改名」的 `branch_log` 留痕:用户看到"成功",库里的行却纹丝未动(留痕本身还是假的)。
67
+ * `memory.js` 的这几个 UPDATE 目前**不回传 `changes`**,故在端点层做**前置存在性检查**
68
+ * (与 POST_ONLY 白名单同为"值不值得改 memory.js"的取舍:这边零回归面)。
69
+ * 断边可直接用底层返回的 `removed` 计数(`unlinkVein` 已回传)。
70
+ */
71
+ /** 树操作的收发助手(Part G G1):`tree-ops` 的 result 里 `status` 是**权威状态码**,
72
+ * 这里只负责"照发",并把 `status` 从响应体里剥掉 —— 保证搬到 tree-ops 前后**响应体逐字不变**。 */
73
+ function sendOp(res, r) {
74
+ const { status, ...payload } = r || {};
75
+ sendJson(res, Number.isFinite(status) ? status : (r && r.ok ? 200 : 400), payload);
76
+ }
77
+
78
+ /** ⚠️ **批处理端点的单实例锁 + 总时长上限**(审计 B#6,2026-09-22 修):
79
+ * `/conflicts/judge` 与 `/tree/autobuild/name` 都要逐条调模型(单条 10~90 秒),旧实现
80
+ * ① **没有单实例锁** —— 前端循环调用时两批并发,同一批矛盾被判两次、同一簇被命名两次;
81
+ * ② **没有整体超时** —— 小助手卡住时一个请求能把连接和批处理永久挂住,且无从取消。
82
+ * 修法:模块级"正在运行"标志(同类请求再来直接回 `{ok:false, reason:'busy'}`)+
83
+ * 整批 deadline(到点**停止再发新的一条**并回报已完成数 —— 在飞的那一条仍会收尾,
84
+ * 所以超时后的整体耗时上限是"总上限 + 单条最坏耗时",不会无限拖)。
85
+ * 状态放**模块级**而非 `mountRoutes` 内:路由重挂(webServer 晚到/重试)不该把锁清掉。
86
+ *
87
+ * ⚠️ 第三个消费者 `/vein/distill`(E2 / C-06,1.5.1 红蓝对抗 —— **同一条缺陷的两端**;
88
+ * 收口方式:**只修服务端这一端**,客户端那半是"决定不做",理由见下):
89
+ * 红队 E 从界面看:「✦ 提炼主脉」可重入(`doDistill` 读了忙标志 `lingBusyNow()` 却
90
+ * **从不置位** ⇒ 连点两次发两次请求);红队 C 从服务端看:这个端点**没有单实例锁**
91
+ * ⇒ 同一主脉并发提炼 = 模型调两次 + 候选表落两条几乎一样的行。
92
+ * **只从服务端这一端修**(1.5.2 定案):本处**复用下面这套锁**(不另发明)—— 锁在任何 await
93
+ * 之前抢占、finally 释放;重复请求直接回 `{ok:false, reason:'busy'}`(前端已有对应中文提示)。
94
+ * 客户端那半**决定不做**(前序曾把它记成"另一端、另一批改动"):`lingBusySet` 是
95
+ * scan / judge / gather **共用**的一把锁,而这里的 BATCH_RUN 是**按 kind 分锁** ⇒ 前端单方面
96
+ * 置位会把三个操作互相挡死(提炼最坏 180s + 在飞单条 ~90s ≈ 270s > `LING_BUSY_JUDGE_MS`
97
+ * 240s 的窗口),而且它**没有任何可见状态**(无渲染分支)⇒ 只会多出一把"看不见却挡人
98
+ * 5 分钟"的锁。正确性由**本锁**闭合:前端 `doDistill` 对 `lingBusyNow()` **只读不写**
99
+ * (同处注释见 lib/client.js)。真要视觉状态,应另做 per-op 忙标志,不要复用这把共用锁。
100
+ */
101
+ const BATCH_RUN = {
102
+ judge: { running: false, startedAt: 0, finishedAt: 0, done: 0, total: 0, timedOut: false },
103
+ name: { running: false, startedAt: 0, finishedAt: 0, done: 0, total: 0, timedOut: false },
104
+ distill: { running: false, startedAt: 0, finishedAt: 0, done: 0, total: 0, timedOut: false },
105
+ };
106
+ /** 整批总时长上限(命名长、判定短;仍远小于浏览器/网关的挂死容忍时间)。
107
+ * `distill` 的语义与另两个略有不同:它不是"一批 N 条",而是"一次提炼里最多 3 次尝试"
108
+ * (DISTILL_TRIES)—— 到点即**不再开始下一次尝试**,在飞的那一次仍会收尾。
109
+ * 180s 的取法:与客户端忙锁的"无进展容忍上限"同量级(client.js 的 `LING_BUSY_MS`),
110
+ * 且远小于网关挂死容忍时间;单次尝试最坏 ~60s ⇒ 总耗时上界 ≈ 240s。 */
111
+ const BATCH_MS = { judge: 120000, name: 300000, distill: 180000 };
112
+
113
+ /** 抢占一次单实例运行权:已在跑则回 null,调用方据此回 busy。 */
114
+ function batchEnter(kind) {
115
+ const st = BATCH_RUN[kind];
116
+ if (!st || st.running) return null;
117
+ st.running = true;
118
+ st.startedAt = Date.now();
119
+ st.finishedAt = 0;
120
+ st.done = 0;
121
+ st.total = 0;
122
+ st.timedOut = false;
123
+ return st;
124
+ }
125
+
126
+ /** 释放运行权(必须走 finally —— 抛异常时更不能把锁永久留下)。 */
127
+ function batchLeave(kind) {
128
+ const st = BATCH_RUN[kind];
129
+ if (!st) return;
130
+ st.running = false;
131
+ st.finishedAt = Date.now();
132
+ }
133
+
134
+ /** 整批 deadline 判据:到点即**不再开始新的一条**。 */
135
+ const batchTimeout = (kind, t0) => (Date.now() - t0) >= (BATCH_MS[kind] || 120000);
136
+
137
+ function readBody(req, maxBytes = 4 * 1024 * 1024) {
138
+ return new Promise((resolve, reject) => {
139
+ const chunks = [];
140
+ let size = 0;
141
+ req.on('data', (c) => {
142
+ size += c.length;
143
+ if (size > maxBytes) {
144
+ reject(new Error('单次请求体过大(超过 ' + Math.round(maxBytes / 1024 / 1024) + 'MB 上限),请拆分后重试'));
145
+ req.destroy();
146
+ return;
147
+ }
148
+ chunks.push(c);
149
+ });
150
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
151
+ req.on('error', reject);
152
+ });
153
+ }
154
+
155
+ export function registerApi(ctx, deps, config) {
156
+ const { gate, memory, settings } = deps;
157
+
158
+ // ---- 获取 webServer:直取 → inject 子作用域 → 定时重试 ----
159
+ let disposed = false;
160
+ let disposeRoutes = null;
161
+ let timer = null;
162
+
163
+ const tryMount = () => {
164
+ if (disposed || disposeRoutes) return;
165
+ let server = svc(ctx, 'webServer');
166
+ let scopeCtx = null;
167
+ if (!server || typeof server.register !== 'function') {
168
+ // ctx.inject(['webServer'], cb) 在服务可用时立刻回调子 ctx
169
+ try {
170
+ ctx.inject?.(['webServer'], (child) => {
171
+ scopeCtx = child;
172
+ const s2 = child?.webServer;
173
+ if (s2 && typeof s2.register === 'function' && !disposeRoutes) {
174
+ disposeRoutes = mountRoutes(s2);
175
+ memory.kvSet('api.registered', '1');
176
+ }
177
+ });
178
+ } catch (e) {
179
+ console.debug('[dsh-ling] ctx.inject webServer failed', e);
180
+ }
181
+ server = scopeCtx?.webServer ?? null;
182
+ }
183
+ if (server && typeof server.register === 'function') {
184
+ disposeRoutes = mountRoutes(server);
185
+ memory.kvSet('api.registered', '1');
186
+ }
187
+ };
188
+
189
+ const mountRoutes = (server) => {
190
+ const disposers = [];
191
+ // 一键深摘(import 源)的运行状态:单实例串行,防并发重入
192
+ const impRun = { running: false, total: 0, done: [], startedAt: 0, finishedAt: 0 };
193
+ // 网页端库"补摘要"运行状态(与深摘同纪律:单实例串行 + 轮询进度)
194
+ const dswebRun = {
195
+ running: false, total: 0, done: [], ok: 0, fail: 0, current: '', engine: '',
196
+ startedAt: 0, finishedAt: 0, lastError: null, db: '',
197
+ };
198
+ // 守卫(2026-09-16 加固 ①+②,2026-09-17 加固 ③):来源栅栏(loopback / **显式** trustedHosts
199
+ // + 非 cross-site + Origin 同源)+ 会话 cookie **名字对撞**(期望名由 Host 派生,与 DSH 同法)。
200
+ // ③(G1)= 本机 LAN 地址不再自动信任:settings.guard.allowLan 默认 false,要开局域网访问须显式打开。
201
+ // 逃生开关:DSH_LING_GUARD=off 或 settings.guard.enforce=false(误判时不会把人锁在门外)。
202
+ const guardVerdict = (req) => {
203
+ const g = (settings.get().guard && typeof settings.get().guard === 'object') ? settings.get().guard : {};
204
+ if (process.env.DSH_LING_GUARD === 'off' || g.enforce === false) return { ok: true, reason: 'disabled' };
205
+ const extra = Array.isArray(g.trustedHosts) ? g.trustedHosts : [];
206
+ return checkRequest(req.headers, { trustedHosts: extra, allowLan: g.allowLan === true });
207
+ };
208
+ // ⚠️ **HTTP 方法校验**(审计 B#1/P0,2026-09-22 修):
209
+ // 宿主契约 `dsh-host-webserver` 的 `WebRoute` **没有 method 字段**,`guard` 也从不读 `req.method`
210
+ // ⇒ 旧实现下**任何 GET 都能执行写端点**。raw socket 实测:`GET /branches/gather` 返回 200
211
+ // **且真的改了树**(审计期间已实际发生一次)。
212
+ // 这让"本机任意网页里一个 `<img src="http://127.0.0.1:3080/api/dsh-ling/branches/gather">`"
213
+ // 就能静默改数据 —— 守卫只拒 `sec-fetch-site: cross-site`,而 `<img>` 不带 Origin、
214
+ // cookie 又是 host cookie,同站子资源会照常带上(同站 CSRF 面)。
215
+ // 修法**集中在这里声明**,而不是改 72 处 `register` 调用:一处维护、一眼看全、零回归面。
216
+ const POST_ONLY = new Set([
217
+ '/branches/create', '/branches/reparent', '/branches/rename', '/branches/weight', '/branches/delete',
218
+ '/veins/link', '/veins/unlink',
219
+ '/conflicts/record', '/conflicts/resolve', '/conflicts/judge', '/conflicts/scan',
220
+ '/tree/autobuild/name', '/tree/autobuild/apply',
221
+ '/branches/gather', '/tree/snapshot', '/tree/restore', '/tree/snapshot/delete',
222
+ '/branch/assign',
223
+ '/vein/distill', '/vein/suggestion/resolve',
224
+ '/memories/pin', '/memories/delete', '/memories/rename', '/memories/retitle',
225
+ '/assistant/test', '/mode/toggle',
226
+ '/persona', '/persona/self-summary', '/persona/hint-adopt', '/persona/rule', '/persona/habit',
227
+ '/persona/habits/scan', '/persona/habits/reflect', '/persona/tone-advice', '/persona/grow',
228
+ '/persona/rollback', '/persona/genesis',
229
+ '/memory/refresh', '/dsweb/scan', '/dsh/backfill',
230
+ '/import/file/batch', '/import/log', '/import',
231
+ '/deep/run-import', '/deep/run', '/deep/one',
232
+ '/dsweb/summary/run',
233
+ '/feedback/apply', '/feedback/dismiss', '/suggestions/apply', '/suggestions/dismiss',
234
+ ]);
235
+ const guard = (handler) => async (req, res) => {
236
+ // ── E4 覆盖缺口(C-02,1.5.1 红蓝对抗)──────────────────────────────────
237
+ // 器灵的端点在宿主路由表里注册为 **exact** 路由,先于 core 的 `/api` **前缀**路由命中
238
+ // ⇒ core 里那两行钩子**永远看不到器灵自己的流量**(实测:受控发一条
239
+ // `GET /api/dsh-ling/health` 拿到 403,而当天日志**新增 0 行**;两天日志里
240
+ // `/api/dsh-ling` 命中总数也是 0)⇒ E4 对"谁调用了器灵"这个问题一直是空白。
241
+ // 修法**不**再往 node_modules 塞第三条补丁(DSH 一升级就被吃掉,见内部踩坑记录 B25/B30),
242
+ // 而是**让器灵自己记自己** —— 入口就在下面这一个 guard 上:零 core 依赖、升级不会漂,
243
+ // 而且方法校验(405)与鉴权拒绝(403)都在它后面,所以**拒绝也会被记下来**。
244
+ try {
245
+ if (!req.__dshLingLogged && typeof globalThis.__dshAccessLogObserve === 'function') {
246
+ req.__dshLingLogged = true; // 万一将来 core 侧也覆盖到这里,不会记两遍
247
+ globalThis.__dshAccessLogObserve(req, res);
248
+ }
249
+ } catch { /* 记日志失败绝不影响请求本身 */ }
250
+ // 方法校验**先于鉴权** —— 它挡的是"浏览器被诱导发出的同站请求",与身份无关。
251
+ // 两种 url 形态都能处理:带前缀(`/api/dsh-ling/x`)被 replace 剥掉,不带的原样保留。
252
+ const sub = String(req.url || '').split('?')[0].replace(/^\/api\/dsh-ling/, '');
253
+ if (POST_ONLY.has(sub) && String(req.method || 'GET').toUpperCase() !== 'POST') {
254
+ // ── C-14(rejection 误标)──────────────────────────────────────────────
255
+ // 上面第 225 行就把这次请求交给访问日志了,但**判定点在这里**:只按状态码反推的
256
+ // rejectionReason() 分不清"哪个栅栏拒的"。故判定完成后把真因挂到请求对象上;
257
+ // 时序是安全的:日志行由 observe() 补丁过的 res.end() **同步**写出(access-log.js 的
258
+ // `patchedEnd`:先 `finish()` 写行、再 `origEnd.apply`),而 sendJson 里才调 res.end
259
+ // ⇒ 本行必早于写出(不写行号,免得漂移;认这两个函数名)。
260
+ req.__dshLingRejection = 'method-not-allowed';
261
+ sendJson(res, 405, { ok: false, error: 'method-not-allowed', want: 'POST' });
262
+ return;
263
+ }
264
+ const verdict = guardVerdict(req);
265
+ if (!verdict.ok) {
266
+ req.__dshLingRejection = verdict.reason; // C-14:同上,写日志发生在下面的 sendJson→res.end 之内
267
+ console.info('[dsh-ling] guard rejected: %s (host=%s site=%s origin=%s)',
268
+ verdict.reason, req.headers.host, req.headers['sec-fetch-site'], req.headers.origin);
269
+ sendJson(res, 403, { ok: false, error: 'forbidden', reason: verdict.reason });
270
+ return;
271
+ }
272
+ try {
273
+ await handler(req, res);
274
+ } catch (e) {
275
+ sendJson(res, 500, { ok: false, error: String(e?.message ?? e) });
276
+ }
277
+ };
278
+ // 供状态/自检使用:本机 LAN 地址(G1 之后**不再**自动受信,仅用于提示"要不要显式打开 allowLan")
279
+ const guardInfo = () => ({
280
+ lan: lanAddresses(),
281
+ enabled: process.env.DSH_LING_GUARD !== 'off',
282
+ allowLan: settings.get().guard?.allowLan === true,
283
+ });
284
+ const register = (path, handler) =>
285
+ server.register({ kind: 'exact', path: '/api/dsh-ling' + path, handler: guard(handler) });
286
+
287
+ /** 批量失效所有已定稿快照(规矩/习惯写入后调用;运行中的会话只标记 stale,空闲边界再生效)。 */
288
+ const invalidateAllSummaries = () =>
289
+ allSnapshotSessionIds(gate).map((sid) => ({ sessionId: sid, result: invalidateSession(gate, memory, settings, sid) }));
290
+
291
+ /** L0 预览 = 人格段 + 时间锚(便于用户在状态页看到"现在")。
292
+ * 2026-09-23 时间锚拆分:system 侧只留 [今天](日期,一天变一次),
293
+ * 时段与间隔移到 context();状态页是给人看的,故两侧拼起来保持信息完整。 */
294
+ const l0WithClock = (s, mode) => {
295
+ const base = assemblePersona(s, mode);
296
+ const anchor = [timeAnchorDate(memory, settings), timeAnchorLive(memory, settings)]
297
+ .filter(Boolean).join('\n');
298
+ return anchor ? (base ? base + '\n\n' + anchor : anchor) : base;
299
+ };
300
+
301
+ const personaInfo = (s) => ({
302
+ enabled: s?.persona?.enabled !== false,
303
+ userTitle: s?.persona?.userTitle ?? '',
304
+ aiName: s?.persona?.aiName ?? '',
305
+ aiTitle: s?.persona?.aiTitle ?? '',
306
+ tone: s?.persona?.tone ?? 'natural',
307
+ toneWork: s?.persona?.toneWork ?? '',
308
+ toneLife: s?.persona?.toneLife ?? '',
309
+ language: s?.persona?.language ?? 'follow',
310
+ hardRules: Array.isArray(s?.persona?.hardRules) ? s.persona.hardRules : [],
311
+ // 规矩/习惯(2026-09-15 二分):人格中心要看到它们,否则会显示"还没有习惯"(数据其实在,只是没被这个白名单带出来)
312
+ habits: Array.isArray(s?.persona?.habits) ? s.persona.habits : [],
313
+ habitsPending: Array.isArray(s?.persona?.habitsPending) ? s.persona.habitsPending : [],
314
+ ruleMeta: Array.isArray(s?.persona?.ruleMeta) ? s.persona.ruleMeta : [],
315
+ bottomLines: Array.isArray(s?.persona?.bottomLines) ? s.persona.bottomLines : [],
316
+ sealed: s?.persona?.sealed === true,
317
+ hasSeal: !!s?.persona?.sealPhrase,
318
+ sealPhrase: s?.persona?.sealPhrase ?? '', // 明文回显用(锁只造庄重,不保密)
319
+ extraLore: s?.persona?.extraLore ?? '',
320
+ pronoun: s?.persona?.pronoun || '她', // 第三人称代词(用户设定;预设 她/他/TA/它,可自定义)
321
+ stylesWork: s?.styles?.work ?? '',
322
+ stylesLife: s?.styles?.life ?? '',
323
+ });
324
+
325
+ const touchesPersona = (patch) =>
326
+ (patch && typeof patch === 'object') &&
327
+ ((patch.persona && typeof patch.persona === 'object' && Object.keys(patch.persona).length) ||
328
+ (patch.styles && typeof patch.styles === 'object' && Object.keys(patch.styles).length));
329
+ const clampBottomLines = (patch) => {
330
+ const bl = patch?.persona?.bottomLines;
331
+ if (Array.isArray(bl) && bl.length > 5) {
332
+ patch.persona.bottomLines = bl.slice(0, 5); // 底线上限 5 条(host 侧硬保险)
333
+ patch._bottomCapped = true;
334
+ }
335
+ return patch;
336
+ };
337
+ /**
338
+ * 存档一份人格档案快照(语义 = 「那次操作之后的档案」,用户直觉即所见;回滚前
339
+ * 额外用 kind='pre-rollback' 留一份当前档案便于反悔)。与最新一份完全相同则跳过。
340
+ * 上限 30 份(hist/log 同步修剪)。
341
+ */
342
+ const archivePersona = async (s, kind = 'result') => {
343
+ const at = Date.now();
344
+ const p = s.persona || {};
345
+ const rows = memory.kvList('persona.hist.');
346
+ if (rows.length) {
347
+ const latest = rows[rows.length - 1]; // kvList 升序,最后一行最新
348
+ try {
349
+ const lv = JSON.parse(String(latest.value));
350
+ if (lv && JSON.stringify(lv.persona || null) === JSON.stringify(p) &&
351
+ JSON.stringify(lv.styles || null) === JSON.stringify(s.styles || {})) {
352
+ return null; // 内容无变化(连点保存等),不重复留档
353
+ }
354
+ } catch {}
355
+ }
356
+ const histKey = `persona.hist.${at}`;
357
+ await memory.kvSet(histKey, JSON.stringify({ persona: p, styles: s.styles || {}, kind }));
358
+ await memory.kvSet(`persona.log.${at}`, JSON.stringify({
359
+ at, kind,
360
+ keys: Object.keys(p),
361
+ sample: JSON.stringify(p).slice(0, 60),
362
+ }));
363
+ const hists = memory.kvList('persona.hist.').map((r) => String(r.key)).sort();
364
+ const logs = memory.kvList('persona.log.').map((r) => String(r.key)).sort();
365
+ while (hists.length > 30) {
366
+ const victim = hists.shift();
367
+ await memory.kvDel(victim);
368
+ }
369
+ while (logs.length > 30) {
370
+ const victim = logs.shift();
371
+ await memory.kvDel(victim);
372
+ }
373
+ return histKey;
374
+ };
375
+
376
+ /** GET 参数预览覆写:合成一份临时 settings(不落盘)供 L0 实时预览。 */
377
+ const previewOverrides = (q) => {
378
+ if (!q) return null;
379
+ const pp = q.get('previewPersona');
380
+ const ps = q.get('previewStyles');
381
+ if (!pp && !ps) return null;
382
+ const tmp = structuredClone(settings.get());
383
+ if (pp) {
384
+ try {
385
+ const patch = JSON.parse(pp);
386
+ if (patch && typeof patch === 'object') tmp.persona = { ...(tmp.persona || {}), ...patch };
387
+ } catch {}
388
+ }
389
+ if (ps) {
390
+ try {
391
+ const patch = JSON.parse(ps);
392
+ if (patch && typeof patch === 'object') tmp.styles = { ...(tmp.styles || {}), ...patch };
393
+ } catch {}
394
+ }
395
+ return tmp;
396
+ };
397
+
398
+ const stateForGlobal = (q) => {
399
+ const base = settings.get();
400
+ const tmp = previewOverrides(q) || base;
401
+ const mode = base.mode?.lastMode === 'work' ? 'work' : 'life';
402
+ const all = memory.listOverviews({ onlyOk: true });
403
+ const wantPreview = q && q.get('l0Preview') === '1';
404
+ return {
405
+ ok: true,
406
+ plugin: 'dsh-ling',
407
+ sessionId: null,
408
+ scope: 'global',
409
+ mode,
410
+ modeLabel: MODE_LABEL[mode],
411
+ running: false,
412
+ pending: 0,
413
+ snapshot: null,
414
+ l0PreviewText: wantPreview ? l0WithClock(tmp, mode) : undefined,
415
+ persona: personaInfo(base),
416
+ modeMapping: base.mode?.mapping ?? {},
417
+ lastMode: mode,
418
+ memory: {
419
+ overviews: all.length,
420
+ rawTurnSessions: rawTurnSessionCount(memory),
421
+ fingerprint: memory.fingerprint(),
422
+ },
423
+ updates: base.updates ?? {},
424
+ };
425
+ };
426
+
427
+ const stateFor = (sessionId, { l0Preview = false, q } = {}) => {
428
+ const base = settings.get();
429
+ const tmp = q ? (previewOverrides(q) || base) : base;
430
+ const mode = currentMode(memory, settings, sessionId);
431
+ const snap = gate.snapshotOf(sessionId);
432
+ const all = memory.listOverviews({ onlyOk: true });
433
+ const l1 = base.memory?.l1Enabled === false ? null : selectL1(memory, {
434
+ mode,
435
+ sessionId, // D9-a 血缘加权:按该会话所属枝给记忆定档
436
+ categoryWeights: base.memory?.categoryWeights,
437
+ maxItems: base.memory?.l1MaxItems,
438
+ budgetChars: Math.max(200, Math.round((base.memory?.l1BudgetTokens ?? 1200) * 1.4)),
439
+ });
440
+ return {
441
+ ok: true,
442
+ plugin: 'dsh-ling',
443
+ sessionId,
444
+ mode,
445
+ modeLabel: MODE_LABEL[mode] ?? mode,
446
+ running: gate.isRunning(sessionId),
447
+ pending: gate.pendingCount(sessionId),
448
+ snapshot: snap
449
+ ? { exists: true, builtAt: snap.builtAt ?? null, mode: snap.mode ?? null, stale: !!snap.stale, chars: (snap.text || '').length }
450
+ : { exists: false },
451
+ idleRefresh: refreshStats(memory, sessionId), // B:空闲边界刷新留痕(次数 + 最近时间)
452
+ l0PreviewText: l0Preview ? l0WithClock(tmp, mode) : undefined,
453
+ persona: personaInfo(base),
454
+ modeMapping: base.mode?.mapping ?? {},
455
+ lastMode: base.mode?.lastMode ?? 'life',
456
+ memory: {
457
+ overviews: all.length,
458
+ rawTurnSessions: rawTurnSessionCount(memory),
459
+ fingerprint: memory.fingerprint(),
460
+ l1: l1 ? {
461
+ items: l1.items.map((i) => ({
462
+ conv_id: i.conv_id, title: i.title, date: isoDate(i.updated_at || i.started_at),
463
+ category: i.category, score: i.score, line: i.line,
464
+ })),
465
+ totalChars: l1.totalChars,
466
+ dropped: l1.dropped,
467
+ } : null,
468
+ },
469
+ updates: base.updates ?? {},
470
+ };
471
+ };
472
+
473
+ // GET /health
474
+ disposers.push(register('/health', async (_req, res) => {
475
+ sendJson(res, 200, { ok: true, plugin: 'dsh-ling', version: config.version });
476
+ }));
477
+
478
+ // ---- 记忆中心 ----
479
+ const memSourceOf = (v) => (MEM_SOURCES.includes(String(v)) ? String(v) : '');
480
+
481
+ // GET /memories/sources —— 各来源条数(D8:筛选栏显示「历史网页端 (1523)」,一眼看到库里有什么)
482
+ // + 各枝条数(D9-a 记忆分枝:枝名与计数一次带回,省一个往返)
483
+ disposers.push(register('/memories/sources', async (req, res) => {
484
+ const bc = memory.branchCounts();
485
+ const byId = new Map(memory.listBranches().map((b) => [b.id, b]));
486
+ const list = Object.keys(bc.byBranch || {}).map((id) => {
487
+ const b = byId.get(id) || {};
488
+ return {
489
+ id,
490
+ name: String(b.name || (id === TRUNK_ID ? '主干' : id)),
491
+ kind: String(b.kind || (id === TRUNK_ID ? 'trunk' : 'branch')),
492
+ memories: Number(bc.byBranch[id] || 0),
493
+ };
494
+ });
495
+ list.sort((a, b) => (a.id === TRUNK_ID ? -1 : b.id === TRUNK_ID ? 1 : b.memories - a.memories));
496
+ sendJson(res, 200, {
497
+ ok: true,
498
+ ...memory.sourceCounts(),
499
+ trunk: TRUNK_ID,
500
+ branch: { total: bc.total, byBranch: bc.byBranch, list },
501
+ });
502
+ }));
503
+
504
+ /** 与 `branchMembers()` **同一谓词**的 per-枝计数(审计 A#6,2026-09-22):
505
+ * 界面上的「N 个会话」此前用 `sessionBranchMap()` 全量聚合 —— 那算的是"**挂在**这条枝下的
506
+ * 会话数",而展开列表(`branchMembers`)只列"**真的有内容**的"(有概述标题 OR 有原文轮次);
507
+ * 于是主干显示 76、点开只有 15 条,数字与列表对不上。
508
+ * 数据层的 `memory.branchMemberCounts()` 用同一谓词算计数,这里**优先采用**它。
509
+ * ⚠️ 该方法与 api.js 是**跨文件软链**:此刻可能尚未落地 ⇒ 必须 `typeof` 防御,
510
+ * 取不到就退回旧的 `sessionBranchMap` 口径(**降级,不是报错**)。
511
+ * 返回结构做了两种兼容(`mc[id]` 扁平 与 `mc.members[id]` 嵌套)—— 未知形状不该让端点崩。
512
+ * (定义在 `/branches`、`/tree` 两个用点之前:同文件内少一次"往上找定义"。) */
513
+ const memberCountOf = () => {
514
+ try {
515
+ if (typeof memory.branchMemberCounts !== 'function') return null;
516
+ const raw = memory.branchMemberCounts();
517
+ // ⚠️ 数据层实测返回 { members: Map, sessions: Map, rows: Map, byMembers: 普通对象 }
518
+ // (memory.js:1089)。**Map 用 Object.keys() 取不到任何键** —— 若直接读 raw.members,
519
+ // 会解析出空表、于是所有枝的计数被"清零"(比退回旧口径更糟,A#6 等于反向修坏)。
520
+ // 故:优先 byMembers(普通对象) → 退 raw.members(并在是 Map 时转一次) → 退 raw 本身;
521
+ // 且**解析结果为空时按"取不到"处理**(return null),让调用方退回 session_meta 口径,
522
+ // 而不是把界面上的会话数全显示成 0。
523
+ let src = (raw && typeof raw === 'object') ? raw.byMembers : null;
524
+ if (!src || typeof src !== 'object') {
525
+ src = (raw && typeof raw === 'object') ? raw.members : null;
526
+ if (src instanceof Map) src = Object.fromEntries(src);
527
+ }
528
+ if (!src || typeof src !== 'object') src = (raw && typeof raw === 'object') ? raw : null;
529
+ if (!src || typeof src !== 'object') return null;
530
+ if (src instanceof Map) src = Object.fromEntries(src);
531
+ const out = {};
532
+ for (const k of Object.keys(src)) {
533
+ const n = Number(src[k]);
534
+ if (Number.isFinite(n)) out[String(k)] = n;
535
+ }
536
+ return Object.keys(out).length ? out : null;
537
+ } catch {
538
+ return null; // 计数失败退回旧口径,不影响端点
539
+ }
540
+ };
541
+
542
+ // GET /branches —— 枝列表(记忆分枝 D9-a,只读):每条枝带自己的会话数与记忆数。
543
+ // 主干恒在列表首位;枝按建立时间排。界面用它渲染「枝」下拉与枝名标记。
544
+ disposers.push(register('/branches', async (req, res) => {
545
+ const counts = memory.branchCounts().byBranch || {};
546
+ // A#6:与 /tree 同一口径 —— 优先用"同一谓词"的成员计数(取不到则退回 session_meta 计数)。
547
+ const mc = memberCountOf();
548
+ let sessCount = mc;
549
+ if (!sessCount) {
550
+ const sess = memory.sessionBranchMap();
551
+ sessCount = {};
552
+ for (const bid of sess.values()) sessCount[bid] = (sessCount[bid] || 0) + 1;
553
+ }
554
+ const list = memory.listBranches().map((b) => ({
555
+ ...b,
556
+ memories: Number(counts[b.id] || 0),
557
+ sessions: Number(sessCount[b.id] || 0),
558
+ }));
559
+ list.sort((a, b) => (a.id === TRUNK_ID ? -1 : b.id === TRUNK_ID ? 1 : String(a.createdAt || '').localeCompare(String(b.createdAt || ''))));
560
+ sendJson(res, 200, { ok: true, trunk: TRUNK_ID, sessionsBasis: mc ? 'members' : 'session-meta', branches: list });
561
+ }));
562
+
563
+ // ---- 记忆树:复盘驱动的树操作(D9-b,2026-09-19) ----
564
+ //
565
+ // 全部操作**不改写任何记忆内容**(设计稿 §4 核心不变量):分枝/并脉/连边只增加"关系"。
566
+ // 复盘界面用 /tree 一次拿全量结构,用下面几个 POST 落地主人与器灵的归类决定。
567
+
568
+ const treeCounts = () => {
569
+ const bc = memory.branchCounts();
570
+ const byBranch = bc.byBranch || {};
571
+ // A#6:优先用"同一谓词"的成员计数;该方法不存在/抛错 → 退回 session_meta 计数(软链降级)
572
+ const mc = memberCountOf();
573
+ if (mc) return { total: bc.total, byBranch, sessCount: mc, memberCounts: true };
574
+ const sess = memory.sessionBranchMap();
575
+ const sessCount = {};
576
+ for (const bid of sess.values()) sessCount[bid] = (sessCount[bid] || 0) + 1;
577
+ return { total: bc.total, byBranch, sessCount, memberCounts: false };
578
+ };
579
+ // sessions 用"同一谓词"的成员计数(A#6):数字与「展开」列表里的条数从此一致。
580
+ const treeNode = (n, byBranch, sessCount) => ({
581
+ id: n.id,
582
+ name: n.name,
583
+ kind: n.kind,
584
+ parentId: n.parentId,
585
+ forkAt: n.forkAt,
586
+ nameLocked: n.nameLocked,
587
+ weightScale: n.weightScale,
588
+ visibility: n.visibility,
589
+ status: n.status,
590
+ createdAt: n.createdAt,
591
+ memories: Number(byBranch[n.id] || 0),
592
+ sessions: Number(sessCount[n.id] || 0),
593
+ children: (n.children || []).map((c) => treeNode(c, byBranch, sessCount)),
594
+ });
595
+
596
+ // GET /tree —— 整树(枝/主脉嵌套 + 横向连边 + 计数),复盘界面一次渲染
597
+ disposers.push(register('/tree', async (_req, res) => {
598
+ const t = memory.branchTree();
599
+ const { byBranch, sessCount, total, memberCounts } = treeCounts();
600
+ sendJson(res, 200, {
601
+ ok: true,
602
+ trunk: TRUNK_ID,
603
+ total,
604
+ memories: Number(total || 0),
605
+ // A#6:告诉界面这次 sessions 用的是哪种口径(同一谓词 / 旧的 session_meta 计数降级)
606
+ sessionsBasis: memberCounts ? 'members' : 'session-meta',
607
+ roots: (t.roots || []).map((n) => treeNode(n, byBranch, sessCount)),
608
+ links: t.links || [],
609
+ });
610
+ }));
611
+
612
+ // POST /branches/create { name, kind, parentId } —— 手建枝或主脉(kind='vein')
613
+ disposers.push(register('/branches/create', async (req, res) => {
614
+ const body = JSON.parse((await readBody(req)) || '{}');
615
+ sendOp(res, opBranchCreate(memory, { ...body, actor: 'user' }));
616
+ }));
617
+
618
+ // POST /branches/delete { id, force } —— **删枝**(审计 B#4 补的能力)
619
+ // 规则:trunk 不可删;非空枝(有子枝/归属/连边)默认拒绝并回报影响面,`force:true` 才动手
620
+ // —— force 时归属回退到主干,不留悬空引用。
621
+ disposers.push(register('/branches/delete', async (req, res) => {
622
+ const body = JSON.parse((await readBody(req)) || '{}');
623
+ sendOp(res, opBranchDelete(memory, { id: body.id, force: body.force, actor: 'user' }));
624
+ }));
625
+
626
+ // POST /branches/reparent { id, parentId, name } —— **并脉**:把枝/主干挂到主脉下(内容零变化)
627
+ // 新主脉可当场建(name 非空且 parentId 尚不存在时先建 vein)。
628
+ disposers.push(register('/branches/reparent', async (req, res) => {
629
+ const body = JSON.parse((await readBody(req)) || '{}');
630
+ sendOp(res, opBranchReparent(memory, { ...body, actor: 'user' }));
631
+ }));
632
+
633
+ // POST /branches/rename { id, name, lock }
634
+ disposers.push(register('/branches/rename', async (req, res) => {
635
+ const body = JSON.parse((await readBody(req)) || '{}');
636
+ sendOp(res, opBranchRename(memory, { ...body, actor: 'user' }));
637
+ }));
638
+
639
+ // POST /branches/weight { id, weightScale } —— 复盘时调枝系数(平时只读,设计稿 §3.3)
640
+ disposers.push(register('/branches/weight', async (req, res) => {
641
+ const body = JSON.parse((await readBody(req)) || '{}');
642
+ sendOp(res, opBranchWeight(memory, { ...body, actor: 'user' }));
643
+ }));
644
+
645
+ // GET /branch-log?limit=N —— 复盘留痕(改了什么、为什么;只记结构操作,不记记忆内容)
646
+ disposers.push(register('/branch-log', async (req, res) => {
647
+ const q = new URL(req.url, 'http://dsh.internal').searchParams;
648
+ const names = new Map(memory.listBranches().map((b) => [b.id, b.name || b.id]));
649
+ const list = memory.listBranchLog({ limit: q.get('limit') || 100 })
650
+ .map((e) => ({ ...e, branchName: names.get(e.branchId) || e.branchId }));
651
+ sendJson(res, 200, { ok: true, total: list.length, log: list });
652
+ }));
653
+
654
+ // GET /branch-log/pending —— 复盘守门(2026-09-22 用户:「互相复盘不应该被我绕过」)。
655
+ // 返回"自器灵上次开口以来累积了多少笔结构改动还没交代";侧栏据此提示,
656
+ // 并在未同步时**拦住"退出复盘模式"** —— 改完树必须让器灵有机会看到。
657
+ disposers.push(register('/branch-log/pending', async (req, res) => {
658
+ let r = { watermark: 0, maxId: 0, pending: 0 };
659
+ try {
660
+ if (typeof memory.branchLogPending === 'function') r = memory.branchLogPending();
661
+ } catch { /* 降级为 0,不阻断界面 */ }
662
+ sendJson(res, 200, { ok: true, ...r });
663
+ }));
664
+
665
+ // POST /veins/link { from, to, kind, note } / POST /veins/unlink { from, to }
666
+ disposers.push(register('/veins/link', async (req, res) => {
667
+ const body = JSON.parse((await readBody(req)) || '{}');
668
+ sendOp(res, opVeinLink(memory, { ...body, actor: 'user' }));
669
+ }));
670
+ disposers.push(register('/veins/unlink', async (req, res) => {
671
+ const body = JSON.parse((await readBody(req)) || '{}');
672
+ sendOp(res, opVeinUnlink(memory, { ...body, actor: 'user' }));
673
+ }));
674
+
675
+ // ---- 矛盾标记层(D9-b):检出但**不改写内容**;未复盘 → 以最新为准 ----
676
+
677
+ // GET /conflicts?status=pending|confirmed|dismissed —— 待复盘矛盾(带两侧标题,复盘界面直接可读)
678
+ disposers.push(register('/conflicts', async (req, res) => {
679
+ const q = new URL(req.url, 'http://dsh.internal').searchParams;
680
+ // 2026-09-21:补两侧**摘要全文** —— 只给标题时人根本判不出"到底哪矛盾"(用户实测反馈)。
681
+ const ovOf = (src, cid) => {
682
+ try { return memory.overviewById(String(src), String(cid)) || null; } catch { return null; }
683
+ };
684
+ // 2026-09-21:涉及归档会话的矛盾不进列表 —— 归档意味着"这条别再提了",
685
+ // 但已登记的旧记录不会自动消失,所以在这里按当前归档状态实时筛(用户实测反馈:
686
+ // 扫描侧上一轮已排除归档,可已存在的记录仍会一直显示)。
687
+ const archSet = memory.archivedConvIdSet();
688
+ const isArch = (side) => side && side.source === 'dsh' && archSet.has(String(side.convId));
689
+ const list = memory.listConflicts({ status: q.get('status') || '', limit: q.get('limit') || 100 })
690
+ .filter((c) => !isArch(c.a) && !isArch(c.b))
691
+ .map((c) => {
692
+ const oa = ovOf(c.a.source, c.a.convId);
693
+ const ob = ovOf(c.b.source, c.b.convId);
694
+ return {
695
+ ...c,
696
+ aTitle: (oa && oa.title) || memory.overviewTitleOf(c.a.convId) || c.a.convId,
697
+ bTitle: (ob && ob.title) || memory.overviewTitleOf(c.b.convId) || c.b.convId,
698
+ aSummary: String((oa && oa.summary) || ''),
699
+ bSummary: String((ob && ob.summary) || ''),
700
+ };
701
+ });
702
+ sendJson(res, 200, { ok: true, total: list.length, conflicts: list });
703
+ }));
704
+
705
+ // POST /conflicts/record { a:{source,conv_id}, b:{...}, kind, reason } —— 手工/LLM 检出登记
706
+ disposers.push(register('/conflicts/record', async (req, res) => {
707
+ const body = JSON.parse((await readBody(req)) || '{}');
708
+ const a = body.a || {};
709
+ const b = body.b || {};
710
+ const r = memory.recordConflict({
711
+ aSource: a.source, aConvId: a.conv_id || a.convId,
712
+ bSource: b.source, bConvId: b.conv_id || b.convId,
713
+ kind: String(body.kind || 'contradict'),
714
+ detectedBy: String(body.detectedBy || 'user'),
715
+ reason: String(body.reason || ''),
716
+ });
717
+ sendJson(res, r.ok ? 200 : 400, r);
718
+ }));
719
+
720
+ // ---- 一键生成记忆树(D9-b,2026-09-21 与用户定案) ----
721
+ // 两阶段:① 机械分段(零成本、只读) ② 小助手命名 ③ 主人审核后落库。
722
+ // 不变量:不改写任何记忆内容 —— 只建枝/主脉 + 挂会话归属。
723
+ const autoSeg = (gapDays, maxSize) => bucketize(memory.overviewIndex(), { gapDays, maxSize });
724
+
725
+ // GET /tree/autobuild/preview?gapDays=14&maxSize=40 —— 机械分段(只读,不调模型)
726
+ disposers.push(register('/tree/autobuild/preview', async (req, res) => {
727
+ const u = new URL(String(req.url || '/'), 'http://x');
728
+ const gapDays = Math.max(1, Math.min(365, Number(u.searchParams.get('gapDays')) || 14));
729
+ const maxSize = Math.max(5, Math.min(200, Number(u.searchParams.get('maxSize')) || 40));
730
+ const buckets = autoSeg(gapDays, maxSize);
731
+ const bySource = {};
732
+ for (const b of buckets) bySource[b.source] = (bySource[b.source] || 0) + 1;
733
+ sendJson(res, 200, {
734
+ ok: true,
735
+ gapDays,
736
+ maxSize,
737
+ total: buckets.length,
738
+ bySource,
739
+ buckets: buckets.map((b) => ({
740
+ id: b.id, source: b.source, count: b.count, from: b.from, to: b.to,
741
+ sample: b.items.slice(0, 16).map((it) => it.title || it.convId),
742
+ })),
743
+ });
744
+ }));
745
+
746
+ // POST /tree/autobuild/name { bucketIds?, gapDays?, maxSize?, limit? } —— 给指定簇起名(批量,每次 limit 个)
747
+ disposers.push(register('/tree/autobuild/name', async (req, res) => {
748
+ const body = JSON.parse((await readBody(req)) || '{}');
749
+ // 审计 B#6:单实例锁 —— 命名批处理逐条调模型,重复入队既浪费引擎额度,
750
+ // 也会让同一簇拿到两份互相覆盖的名字。锁在**任何 await 之前**抢占(同 tick 原子)。
751
+ const run = batchEnter('name');
752
+ if (!run) {
753
+ return sendJson(res, 200, {
754
+ ok: false, reason: 'busy',
755
+ hint: '上一批命名还在跑;请等它结束再点(重复提交会让同一簇被命名两次)',
756
+ });
757
+ }
758
+ try {
759
+ const gapDays = Math.max(1, Math.min(365, Number(body.gapDays) || 14));
760
+ const maxSize = Math.max(5, Math.min(200, Number(body.maxSize) || 40));
761
+ const limit = Math.max(1, Math.min(20, Number(body.limit) || 5));
762
+ const asst = resolveAssistant(settings);
763
+ const probe = await probeAssistant(asst.baseUrl, { timeoutMs: 5000 });
764
+ if (!probe.ok) {
765
+ return sendJson(res, 200, { ok: false, reason: 'assistant-unavailable', hint: '命名需要模型;地址可在「接入历史」里配置' });
766
+ }
767
+ const all = autoSeg(gapDays, maxSize);
768
+ const want = Array.isArray(body.bucketIds) && body.bucketIds.length
769
+ ? new Set(body.bucketIds.map((x) => String(x))) : null;
770
+ const candidates = (want ? all.filter((b) => want.has(b.id)) : all).slice(0, limit);
771
+ // 并发命名:小助手单条命名可能要 10~20 秒,串行 5 条会超过浏览器/网关的容忍时间而被断开
772
+ // (前端表现为 fetch reject → "命名失败(网关)")。并发把总耗时压到 ≈ 最慢的那一条。
773
+ // 审计 B#6 补的总时长上限:整批起跑前先按 deadline 卡一道 —— 若**本身就在超时前**
774
+ // 发起,fork 出来的这一批必然都在 deadline 内开工(不会出现"半批已发、半批被拦")。
775
+ const t0 = Date.now();
776
+ const targets = batchTimeout('name', t0) ? [] : candidates;
777
+ run.total = candidates.length;
778
+ run.done = 0;
779
+ const out = (await Promise.allSettled(targets.map(async (b) => {
780
+ const r = await nameBucket(asst.baseUrl, asst.model, b, { call: summarizeWithRetry });
781
+ run.done += 1;
782
+ return {
783
+ bucketId: b.id, source: b.source, count: b.count, from: b.from, to: b.to,
784
+ ok: !!r.ok, name: r.name || '', note: r.note || '', error: r.error || null,
785
+ };
786
+ }))).filter((x) => x.status === 'fulfilled').map((x) => x.value);
787
+ const timedOut = candidates.length > targets.length;
788
+ run.timedOut = timedOut;
789
+ sendJson(res, 200, {
790
+ ok: true,
791
+ named: out.filter((x) => x.ok).length,
792
+ failed: out.filter((x) => !x.ok).length,
793
+ dropped: candidates.length - targets.length, // 因总时长上限**未开工**的簇数
794
+ timedOut,
795
+ engine: String((asst && asst.model) || ''),
796
+ ms: Date.now() - t0,
797
+ items: out,
798
+ });
799
+ } finally {
800
+ batchLeave('name');
801
+ }
802
+ }));
803
+
804
+ // POST /tree/autobuild/apply { plan:[{bucketId,mode:'branch'|'vein',name,parentId}] } —— 落库(只建结构)
805
+ disposers.push(register('/tree/autobuild/apply', async (req, res) => {
806
+ const body = JSON.parse((await readBody(req)) || '{}');
807
+ const gapDays = Math.max(1, Math.min(365, Number(body.gapDays) || 14));
808
+ const maxSize = Math.max(5, Math.min(200, Number(body.maxSize) || 40));
809
+ const plan = Array.isArray(body.plan) ? body.plan.slice(0, 200) : [];
810
+ if (!plan.length) return sendJson(res, 200, { ok: false, reason: 'empty-plan' });
811
+ // ⚠️ 审计 B#10:plan 里的 `parentId` / `mode` 此前**零校验** ——
812
+ // ① 未知 parentId(已删的枝、前端缓存的旧 id、伪造的裸 uuid)被 `createBranch` 原样塞进
813
+ // `parent_id`,`branchTree()` 里找不到父节点 ⇒ **被当成根节点**,凭空多出一棵"野树";
814
+ // ② 同一个 bucketId 在 plan 里出现两次会**重复建枝**(同一批会话被挂两遍,归属互相覆盖)。
815
+ // `mode` 无需白名单:非 'vein' 一律走 branch(旧行为已是这个语义,保持兼容)。
816
+ const branchMap = mapBranchList(memory.listBranches());
817
+ const byId = new Map(autoSeg(gapDays, maxSize).map((b) => [b.id, b]));
818
+ const seenBucket = new Set();
819
+ let badParent = 0;
820
+ let duplicate = 0;
821
+ const created = [];
822
+ let assigned = 0;
823
+ let skipped = 0;
824
+ // 零散合簇(用户 2026-09-21 裁决):名字含「零散」的簇统一挂到同一个主脉「零散会话」下。
825
+ // 理由:历史网页端的一次性问答主题天然分散、不属于任何工作线;合并成一簇后可在
826
+ // 复盘时展开细化(设计稿 §10.3)。主脉按名复用,不重复建。
827
+ const ZERO_NAME = '零散会话';
828
+ let zeroVeinId = null;
829
+ const zeroVeinOf = () => {
830
+ if (zeroVeinId) return zeroVeinId;
831
+ const exist = memory.listBranches().find((x) => x.kind === 'vein' && x.name === ZERO_NAME);
832
+ zeroVeinId = exist ? exist.id : memory.createVein({ name: ZERO_NAME });
833
+ if (!exist) memory.logBranch(zeroVeinId, 'autobuild', { after: ZERO_NAME, note: '零散会话合簇(自动创建)' });
834
+ return zeroVeinId;
835
+ };
836
+ for (const p of plan) {
837
+ const key = String(p.bucketId || '');
838
+ const b = byId.get(key);
839
+ const name = String(p.name || '').trim().slice(0, 60);
840
+ if (!b || !name) { skipped += 1; continue; }
841
+ // 去重(审计 B#10②):一个簇只落一条枝 —— 同批里第二次出现直接跳过,不重复建、不重复挂。
842
+ if (seenBucket.has(key)) { duplicate += 1; skipped += 1; continue; }
843
+ const wantParent = String(p.parentId || '');
844
+ let parentId;
845
+ if (/零散/.test(name) && !wantParent) {
846
+ parentId = zeroVeinOf(); // 零散簇的既有裁决优先(见上)
847
+ } else if (wantParent && wantParent !== TRUNK_ID && !branchMap.has(wantParent)) {
848
+ // 未知父(审计 B#10①):不落"父不存在的枝" —— 那在 branchTree() 里会被当成根节点。
849
+ // 与 memory.reparentBranch 的 `no-parent` 纪律一致:跳过并计数,由调用方决定是否用 trunk 重试。
850
+ badParent += 1;
851
+ skipped += 1;
852
+ continue;
853
+ } else {
854
+ parentId = wantParent || TRUNK_ID;
855
+ }
856
+ const mode = String(p.mode || 'branch');
857
+ const bid = mode === 'vein'
858
+ ? memory.createVein({ name, parentId })
859
+ : memory.createBranch({ name, parentId, kind: 'branch' });
860
+ seenBucket.add(key);
861
+ // 归属两条链(方案 A,2026-09-21):
862
+ // dsh 会话 → session_meta(D9-a 既有链路,零变化);
863
+ // 其余源(dsweb/import)→ conv_branch 覆盖层 —— 它们不是 DSH 会话,推不出枝。
864
+ let n = 0;
865
+ for (const it of b.items) {
866
+ if (it.source === 'dsh') memory.setSessionBranch(it.convId, bid);
867
+ else memory.setConvBranch(it.source, it.convId, bid);
868
+ n += 1;
869
+ assigned += 1;
870
+ }
871
+ memory.logBranch(bid, 'autobuild', { after: name, note: '一键生成:' + b.source + ' ' + b.count + ' 条(挂上 ' + n + ')' });
872
+ created.push({ id: bid, name, mode, parentId, source: b.source, count: b.count, assigned: n });
873
+ }
874
+ sendJson(res, 200, {
875
+ ok: true,
876
+ created,
877
+ assigned,
878
+ skipped,
879
+ // 审计 B#10:把"为什么少建了"分类回报,否则调用方只看到 created 比 plan 短,无从判断
880
+ // 是自己发了重复项、还是引用了已删的父枝。
881
+ duplicate,
882
+ badParent,
883
+ zeroVein: zeroVeinId,
884
+ note: '归属已写入 ' + assigned + ' 条(DSH 会话走 session_meta,历史网页端/导入条目走 conv_branch 覆盖层)'
885
+ + (zeroVeinId ? ';零散簇已合并到主脉「' + ZERO_NAME + '」' : '')
886
+ + (duplicate ? ';已忽略 ' + duplicate + ' 条重复 bucketId' : '')
887
+ + (badParent ? ';已跳过 ' + badParent + ' 条父枝不存在的项(未知 parentId 不落枝)' : ''),
888
+ });
889
+ }));
890
+
891
+ // POST /branches/gather { pattern='零散', veinName='零散会话' }
892
+ // —— 把**已存在**的枝按名字归并到同一个主脉下(幂等,可重复跑)。
893
+ // 为什么需要它:一键生成树在旧逻辑下已经建出多个「零散记录」枝,`/tree/autobuild/apply`
894
+ // 的合簇只对**新应用**生效,追不回存量;本端点负责把存量收进主脉。
895
+ // 同时它也是复盘期的批量归类原语:把名字含某关键词的枝一次归到一个主脉。
896
+ disposers.push(register('/branches/gather', async (req, res) => {
897
+ const body = JSON.parse((await readBody(req)) || '{}');
898
+ const pattern = String(body.pattern || '零散').trim().slice(0, 30);
899
+ const veinName = String(body.veinName || '零散会话').trim().slice(0, 60);
900
+ if (!pattern) return sendJson(res, 200, { ok: false, reason: 'empty-pattern' });
901
+ const esc = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
902
+ const re = new RegExp(esc);
903
+ let vein = memory.listBranches().find((x) => x.kind === 'vein' && x.name === veinName);
904
+ let veinCreated = false;
905
+ if (!vein) {
906
+ const vid = memory.createVein({ name: veinName });
907
+ memory.logBranch(vid, 'gather', { after: veinName, note: '归并目标主脉(自动创建)' });
908
+ vein = { id: vid, name: veinName };
909
+ veinCreated = true;
910
+ }
911
+ const moved = [];
912
+ for (const b of memory.listBranches()) {
913
+ if (b.id === vein.id || b.kind === 'vein') continue;
914
+ if (!re.test(String(b.name || ''))) continue;
915
+ if (String(b.parentId || '') === String(vein.id)) continue; // 已就位,幂等跳过
916
+ const r = memory.reparentBranch(b.id, vein.id);
917
+ if (r && r.ok) {
918
+ memory.logBranch(b.id, 'gather', {
919
+ before: String(b.parentId || 'trunk'),
920
+ after: String(vein.id),
921
+ note: '归并到主脉「' + veinName + '」(匹配 ' + pattern + ')',
922
+ });
923
+ moved.push({ id: b.id, name: String(b.name || '') });
924
+ }
925
+ }
926
+ sendJson(res, 200, {
927
+ ok: true, vein: vein.id, veinName, veinCreated,
928
+ moved: moved.length, movedList: moved.slice(0, 60),
929
+ note: moved.length ? ('已把 ' + moved.length + ' 条含「' + pattern + '」的枝归并到主脉「' + veinName + '」')
930
+ : (veinCreated ? ('已建主脉「' + veinName + '」,但没有匹配的枝') : '没有需要归并的枝(已就位)'),
931
+ });
932
+ }));
933
+
934
+ // ---- 树的保存 / 恢复(R1,2026-09-22)+ 会话级展开(R2) ----
935
+
936
+ /** 从 req.url 取查询参数(不依赖 URL 类,与既有 GET 端点同风格)。 */
937
+ const qArg = (url, key) => {
938
+ const m = new RegExp('[?&]' + key + '=([^&]*)').exec(String(url || ''));
939
+ if (!m) return '';
940
+ // 审计 B#7:畸形百分号编码(如 `?limit=%E0%A4%A`)会让 decodeURIComponent 抛 URIError
941
+ // —— 旧实现直接冒到 guard 的 catch,变成 500;这里退化为原样返回(端点自己会做数值校验)。
942
+ try {
943
+ return decodeURIComponent(m[1]);
944
+ } catch {
945
+ return String(m[1] || '');
946
+ }
947
+ };
948
+
949
+ // GET /tree/snapshots?limit=N —— 快照列表(不带快照体 —— 体可能很大)
950
+ disposers.push(register('/tree/snapshots', async (req, res) => {
951
+ const limit = Number(qArg(req.url, 'limit') || 50);
952
+ sendJson(res, 200, { ok: true, snapshots: memory.listTreeSnapshots({ limit }) });
953
+ }));
954
+
955
+ // ---- 主脉提炼(R7,2026-09-23) ----
956
+ // 设计稿 §5:主脉装的是"从多条枝里看出来的共性",不是任何子枝的内容。
957
+ // 三条硬约束:① 走审核队列(不直接进记忆) ② 标注派生来源 ③ 与并脉解耦(并脉只建结构)。
958
+ const DISTILL_SYS = '你在帮主人整理长期记忆树。下面给你一条「主脉」及其子枝上的若干条记忆摘要。'
959
+ + '请只输出一段 60~160 字的中文结论,指出这些记忆里**共同的**规律、方法或结论 —— '
960
+ + '不是逐条复述,也不要把标题罗列一遍。若它们之间确实没有明显共性,就直说「暂未看出共性」。'
961
+ + '不要 markdown,不要编号,不要引号。';
962
+
963
+ // GET /vein/suggestions?status=new&veinId=&limit=N —— 提炼候选(审核队列)
964
+ disposers.push(register('/vein/suggestions', async (req, res) => {
965
+ const status = String(qArg(req.url, 'status') || 'new');
966
+ const veinId = String(qArg(req.url, 'veinId') || '');
967
+ const limit = Number(qArg(req.url, 'limit') || 50);
968
+ sendJson(res, 200, { ok: true, suggestions: memory.listVeinSuggestions({ status, veinId, limit }) });
969
+ }));
970
+
971
+ // POST /vein/distill { veinId, limit, chars } —— 取子枝概述 → 模型提炼 → 存候选队列
972
+ disposers.push(register('/vein/distill', async (req, res) => {
973
+ // ── C-06(1.5.1 红蓝对抗,E2 是它的界面那一端):**单实例锁** ────────────────
974
+ // 锁必须在 `readBody` **之前**抢:`batchEnter` 是同步的(同 tick 原子),而 `await readBody`
975
+ // 就是本处理器的第一个让出点 —— 放到它后面,两个并发请求都能先读完 body、
976
+ // 再一起冲进模型调用,锁等于没上(与 `/tree/autobuild/name` 的同一条纪律)。
977
+ // 复用的是本文件已有的那一套(BATCH_RUN / batchEnter / batchLeave / batchTimeout,
978
+ // 见顶部注释与 `/conflicts/judge`、`/tree/autobuild/name` 两处用法),不另发明:
979
+ // 这样 `busy` 的 reason 文案与响应形态与客户端既有处理完全一致。
980
+ const run = batchEnter('distill');
981
+ if (!run) {
982
+ return sendJson(res, 200, {
983
+ ok: false, reason: 'busy',
984
+ hint: '上一条主脉的提炼还在跑(模型调用要几十秒);等它出结果再提炼 —— 重复提交会产出重复候选',
985
+ });
986
+ }
987
+ let timedOut = false;
988
+ // 下面这一整段的缩进**保持原样不重排**:本批只加"锁 + deadline"两件事,
989
+ // 便于逐行对照原实现审查(锁定/释放的边界一眼可见)。
990
+ try {
991
+ const body = JSON.parse((await readBody(req)) || '{}');
992
+ const veinId = String(body.veinId || '').trim();
993
+ if (!veinId) { sendJson(res, 400, { ok: false, reason: 'no-vein' }); return; }
994
+ const v = memory.listBranches().find((b) => String(b.id) === veinId);
995
+ if (!v) { sendJson(res, 404, { ok: false, reason: 'vein-not-found' }); return; }
996
+ if (String(v.kind || '') !== 'vein') {
997
+ sendJson(res, 400, { ok: false, reason: 'not-a-vein', hint: '只有主脉才能提炼;普通枝请先并到主脉下' });
998
+ return;
999
+ }
1000
+ const mat = memory.veinMaterial(veinId, { limit: body.limit, chars: body.chars });
1001
+ if (!mat.length) {
1002
+ sendJson(res, 200, {
1003
+ ok: false, reason: 'no-material',
1004
+ hint: '这条主脉下还没有带摘要的记忆 —— 先把枝并进来,或多聊几句让概述器生成摘要',
1005
+ });
1006
+ return;
1007
+ }
1008
+ const text = '主脉名称:' + String(v.name || veinId) + '\n\n素材(共 ' + mat.length + ' 条):\n'
1009
+ + mat.map((m, i) => (i + 1) + '. ' + (m.title || m.convId) + ' —— ' + m.summary).join('\n');
1010
+ const t0 = Date.now();
1011
+ // 全局大模型(2026-09-25 用户:「你在给自己做整理,别让小助手代劳」)。
1012
+ // 原先走本机小助手(summarizeWithRetry + /no_think),但"这几条枝讲的是不是一件事"
1013
+ // 是**判断**不是搬砖 —— 该用器灵自己的脑;小助手留给压缩/分类这类机械活。
1014
+ //
1015
+ // ⚠️ 换通路时漏改判据,盘上实际踩过(2026-09-25 深夜,现象是"模型调用失败"):
1016
+ // `llmOnce` 返回 `{ text, chunkLog, target }` —— **既没有 `ok` 也没有 `summary`**。
1017
+ // 旧判据 `!r.ok` 因此恒为真 ⇒ 每次提炼都判失败(error 回退成 'empty'),而模型其实答了;
1018
+ // 末尾 `String(r.summary)` 还会把回执 text 写成字面量 "undefined"。
1019
+ // 教训:**换通路必须连返回形态与判据一起改**(与 A4 同一次改动的残留半截)。
1020
+ // 空回复是真实存在的(llmOnce 内部不重试)—— 照 habit-gen.reflectWithRetry 的成例分档重试。
1021
+ const DISTILL_TRIES = [
1022
+ { effort: 'off', maxTokens: 1200 },
1023
+ { effort: 'low', maxTokens: 2400 },
1024
+ { effort: 'off', maxTokens: 2400, cap: 12000 },
1025
+ ];
1026
+ const attempts = [];
1027
+ let distilled = '';
1028
+ let lastErr = '';
1029
+ run.total = DISTILL_TRIES.length;
1030
+ for (const att of DISTILL_TRIES) {
1031
+ // C-06 的后半句"无总超时":整批 deadline —— 到点**不再开始下一次尝试**
1032
+ // (在飞的那一次仍会收尾 ⇒ 总耗时上界 = 总上限 + 单次最坏耗时,与 judge/name 同口径)。
1033
+ if (batchTimeout('distill', t0)) { timedOut = true; break; }
1034
+ const material = att.cap ? text.slice(0, att.cap) : text;
1035
+ try {
1036
+ const rr = await llmOnce(ctx, { system: DISTILL_SYS, text: material, maxTokens: att.maxTokens, effort: att.effort });
1037
+ const got = String((rr && rr.text) || '').trim();
1038
+ attempts.push({ effort: att.effort, maxTokens: att.maxTokens, chars: material.length, ok: !!got, len: got.length });
1039
+ if (got) { distilled = got; break; }
1040
+ } catch (e) {
1041
+ lastErr = String(e?.message ?? e);
1042
+ attempts.push({ effort: att.effort, maxTokens: att.maxTokens, chars: material.length, ok: false, error: lastErr });
1043
+ }
1044
+ }
1045
+ run.done = attempts.length;
1046
+ run.timedOut = timedOut;
1047
+ if (!distilled) {
1048
+ // 超时截断与"模型答了空"要分开报:前者重试/稍后再试有用,后者重试无用。
1049
+ sendJson(res, 200, {
1050
+ ok: false, reason: timedOut ? 'timeout' : 'llm-failed',
1051
+ error: timedOut ? 'deadline' : (lastErr || 'empty'),
1052
+ timedOut, attempts, ms: Date.now() - t0,
1053
+ });
1054
+ return;
1055
+ }
1056
+ const saved = memory.addVeinSuggestion({
1057
+ veinId,
1058
+ text: distilled,
1059
+ sources: mat.map((m) => m.source + '/' + m.convId),
1060
+ model: 'global',
1061
+ note: '素材 ' + mat.length + ' 条',
1062
+ });
1063
+ if (saved.ok) {
1064
+ memory.logBranch(veinId, 'distill', { after: '候选 #' + saved.id, note: '提炼出候选(待审核),素材 ' + mat.length + ' 条' });
1065
+ }
1066
+ sendJson(res, 200, {
1067
+ ok: !!saved.ok, id: saved.id, text: distilled,
1068
+ materials: mat.length, ms: Date.now() - t0, reason: saved.reason, attempts, timedOut,
1069
+ });
1070
+ } finally {
1071
+ // 释放必须走 finally(审计 B#6 的纪律:抛异常时更不能把锁永久留下)。早退的每一条
1072
+ // (no-vein / vein-not-found / not-a-vein / no-material / 模型失败)都会经过这里。
1073
+ batchLeave('distill');
1074
+ }
1075
+ }));
1076
+
1077
+ // POST /vein/suggestion/resolve { id, action, text, note } —— 采纳(写入主脉记忆)/驳回
1078
+ /** 候选行的 `vein_id`(只读;A-06 与 A-12 两个前置判据都要先知道"这条候选指向哪条主脉")。
1079
+ * 为什么不走 `listVeinSuggestions`:它只有 status/veinId/limit 三个筛、默认 50 条上限,
1080
+ * 按 id 精确定位要翻页,反而更脆。直读 `memory.db` 在本文件有先例(见 `/dsh/backfill`)。 */
1081
+ const sugVeinOf = (sid) => {
1082
+ try {
1083
+ const row = memory.db.prepare('SELECT vein_id FROM vein_suggestions WHERE id=?').get(sid);
1084
+ return row ? String(row.vein_id || '') : '';
1085
+ } catch { return ''; }
1086
+ };
1087
+ /** E9:失败原因 → 中文。与客户端 `delSnapshot` 的既有分工一致(见 client.js 的
1088
+ * `SNAP_DEL_REASON` / `snapDelReason`:后端带 message 就用它,前端只在自己那张表里兜底)。 */
1089
+ const VEI_SUG_MSG = {
1090
+ 'bad-id': '候选编号不对',
1091
+ 'not-found': '这条候选不存在(可能已经处理过了)',
1092
+ 'bad-action': '动作只认「采纳」或「驳回」',
1093
+ 'empty-text': '候选正文是空的 —— 没有可写入的内容',
1094
+ 'no-vein': '这条候选没记录所属主脉,结论无处安放',
1095
+ 'vein-not-found': '这条候选所属的主脉已被删除 —— 结论无处安放(请驳回它,或对现存的主脉重新提炼)',
1096
+ 'archive-failed': '旧版主脉记忆归档失败 —— 为免覆盖后回不去,本次采纳已中止',
1097
+ 'db': '记忆库写入错误',
1098
+ };
1099
+ disposers.push(register('/vein/suggestion/resolve', async (req, res) => {
1100
+ const body = JSON.parse((await readBody(req)) || '{}');
1101
+ const sid = Number(body.id);
1102
+ const act = String(body.action || '').trim().toLowerCase();
1103
+ // ── A-06(1.5.1 红蓝对抗):采纳的判据落在**真实存在性**上 ────────────────────
1104
+ // 候选指向的主脉被删之后,`resolveVeinSuggestion` 照旧会写 conv_overview,并回
1105
+ // `ok:true` + `branchId=<已删枝>` —— 因为底层 `assignConv`(memory.js 的 `assignConv`)
1106
+ // 把 `setConvBranch` 的 `{ok:false, reason:'no-branch'}` **吞掉了**(它只 try/catch 了读归属
1107
+ // 那半句,写入的返回值根本没看)⇒「归属已写」是句谎话,那条主脉记忆实际落回主干。
1108
+ // 判据放在端点层(与上面 B#3 那几处存在性检查同一取舍:零回归面、不动 memory.js):
1109
+ // 采纳前先确认候选所属的主脉**此刻还在 branch 表里**,不在就如实拒收、**一个字节都不写库**。
1110
+ const veinId = Number.isFinite(sid) ? sugVeinOf(sid) : '';
1111
+ let veinAlive = true;
1112
+ if (act === 'accept' && veinId) {
1113
+ veinAlive = memory.listBranches().some((b) => String(b.id) === veinId);
1114
+ if (!veinAlive) {
1115
+ return sendJson(res, 200, {
1116
+ ok: false, reason: 'vein-not-found', veinId,
1117
+ message: VEI_SUG_MSG['vein-not-found'],
1118
+ hint: '候选悬空(主脉已删)',
1119
+ });
1120
+ }
1121
+ }
1122
+ // ── A-12(1.5.1 红蓝对抗):先记下"这条主脉记忆的名字是否已经定过" ──────────────
1123
+ // 「锁」的确切语义(DESIGN.md:title_locked「锁住的是自动重写,**不锁主人**」):
1124
+ // `title_locked=1` 只让 `upsertOverview` 不动 title,summary 照样被覆盖 ⇒ 主人手改过名字的
1125
+ // 那条主脉记忆,下次采纳会把**正文**换掉、名字留着 —— 名字是对的,内容却换了人写,而回执里
1126
+ // 只有一个 `replaced`。这把锁**不该由端点扩权去挡主人**(采纳本就是主人的显式动作,
1127
+ // 前面还有解锁门),该做的是**把这件事说出来**:动手前读一次定名状态,动手后如实回报。
1128
+ let naming = null;
1129
+ if (act === 'accept' && veinId && veinAlive) {
1130
+ try {
1131
+ const prev = memory.overviewById('vein', veinId);
1132
+ if (prev && prev.title_locked) {
1133
+ naming = { title: String(prev.title || ''), titleBy: String(prev.title_by || ''), locked: true };
1134
+ }
1135
+ } catch { /* 读不到就当没定名:不因此拒绝采纳 */ }
1136
+ }
1137
+ const r = memory.resolveVeinSuggestion(sid, { action: body.action, text: body.text, note: body.note });
1138
+ if (!r.ok) {
1139
+ // E9:把英文内部码也译一份中文出去。`ok` / `reason` **逐字不动**(前端可能仍在读 reason,
1140
+ // 兼容优先),只**增**一个 message;底层已带 message 的(如 archive-failed)优先用它。
1141
+ sendJson(res, 200, { ...r, message: r.message || VEI_SUG_MSG[r.reason] || ('操作失败:' + String(r.reason || '未知')) });
1142
+ return;
1143
+ }
1144
+ // A-01(1.5.1):「覆盖旧版主脉记忆」必须在**总变动日志**里留痕 —— 否则主人只看得到
1145
+ // "采纳了一条",不知道旧的被换掉了、也不知道旧版去了哪个归档目录。
1146
+ const accepted = r.status === 'accepted';
1147
+ memory.logBranch(String(r.veinId || ''), 'vein-adopt', {
1148
+ after: accepted ? (r.replaced ? '已采纳(覆盖旧版)' : '已采纳') : '已驳回',
1149
+ note: accepted && r.replaced
1150
+ ? ('覆盖了上一版主脉记忆(' + r.replaced.prevChars + ' 字);旧版已归档 ' + r.replaced.dir)
1151
+ : '主脉提炼候选',
1152
+ });
1153
+ // ── A-06 的后半:归属**真的**落库了吗(不信任底层回执,自己看一遍) ────────────
1154
+ // 前置门之后仍复核一次:改动小、代价低,而它挡的正是"回执说写了、库里没有"这类谎话。
1155
+ if (accepted && String(memory.branchOfConv('vein', String(r.veinId || '')) || '') !== String(r.veinId || '')) {
1156
+ sendJson(res, 200, {
1157
+ ...r, branchId: '', assigned: false,
1158
+ message: '记忆已写入,但没能挂到主脉下(主脉可能刚被删除)—— 它现在按主干显示',
1159
+ });
1160
+ return;
1161
+ }
1162
+ // ── E10(1.5.1 红蓝对抗):「覆盖了上一版」与「名字已定过还被换正文」这两件事此前
1163
+ // **前端零提示**(A-01 已把 replaced 回传、也留了痕,但界面上看不出来)。
1164
+ // 后端把话说全,前端照 `message` 显示(前端那半见另一批改动)。
1165
+ const out = { ...r };
1166
+ if (accepted) {
1167
+ out.assigned = true;
1168
+ if (naming) {
1169
+ out.naming = naming;
1170
+ out.message = '已采纳 —— 主脉记忆的正文换成了新提炼的结论;名字「' + naming.title + '」'
1171
+ + (naming.titleBy === 'user' ? '是你手改过的' : '已由机器定过') + ',按约定保持不动'
1172
+ + (r.replaced ? ';旧版正文已归档:' + r.replaced.dir : '');
1173
+ } else if (r.replaced) {
1174
+ out.message = '已采纳 —— **覆盖了上一版主脉记忆**(旧版已归档:' + r.replaced.dir + '),不是新增一条';
1175
+ }
1176
+ }
1177
+ sendJson(res, 200, out);
1178
+ }));
1179
+
1180
+ // POST /tree/snapshot { name, note } —— 存一份当前树结构(只存结构,不存记忆内容)
1181
+ // ⚠️ 审计 B#5:单份快照约 217KB 且**此前无任何条数上限** ⇒ 反复调用可让 DB 无限膨胀。
1182
+ // 加软上限:到顶时裁掉**最旧的一条**。
1183
+ // ⚠️ 审计 A#9 的交互(2026-09-22):`deleteTreeSnapshot` 现在**拒绝删掉最后一份
1184
+ // `kind='auto'` 的快照**(那是撤回上次树恢复的唯一退路)。旧清理只盯"最旧一条",
1185
+ // 若它恰好是最后一份 auto,删除被拒 —— 而这里为了"清理失败不该挡住保存"包着 try/catch,
1186
+ // 于是**静默失效**、快照继续增长、软上限形同虚设。
1187
+ // 修法:清理时**优先裁非 auto 的最旧一条**;没有可裁的(全是 auto 备份)就
1188
+ // **跳过本轮** —— 宁可暂时超上限,也不去动那条唯一的退路。
1189
+ const MAX_TREE_SNAPSHOTS = 60;
1190
+ disposers.push(register('/tree/snapshot', async (req, res) => {
1191
+ const body = JSON.parse((await readBody(req)) || '{}');
1192
+ sendOp(res, opSnapshotTree(memory, { ...body, actor: 'user' }, { maxSnapshots: MAX_TREE_SNAPSHOTS }));
1193
+ }));
1194
+
1195
+ // POST /tree/restore { id } —— 恢复树结构;恢复前**自动存一份当前状态**(返回 backupId)
1196
+ disposers.push(register('/tree/restore', async (req, res) => {
1197
+ const body = JSON.parse((await readBody(req)) || '{}');
1198
+ sendOp(res, opRestoreTree(memory, { ...body, actor: 'user' }));
1199
+ }));
1200
+
1201
+ // POST /tree/snapshot/delete { id }
1202
+ disposers.push(register('/tree/snapshot/delete', async (req, res) => {
1203
+ const body = JSON.parse((await readBody(req)) || '{}');
1204
+ sendOp(res, opDeleteSnapshot(memory, { id: body.id, actor: 'user' }));
1205
+ }));
1206
+
1207
+ // GET /branch/members?id=<branchId>&limit=N —— 会话级展开(R2):该枝下的会话/条目
1208
+ disposers.push(register('/branch/members', async (req, res) => {
1209
+ const id = String(qArg(req.url, 'id') || TRUNK_ID);
1210
+ const limit = Number(qArg(req.url, 'limit') || 300);
1211
+ sendOp(res, opBranchMembers(memory, { id, limit }));
1212
+ }));
1213
+
1214
+ // POST /branch/assign { source, convId, branchId } —— 拖动会话/条目改归属(R2/R3)
1215
+ disposers.push(register('/branch/assign', async (req, res) => {
1216
+ const body = JSON.parse((await readBody(req)) || '{}');
1217
+ sendOp(res, opAssignConv(memory, { ...body, actor: 'user' }));
1218
+ }));
1219
+
1220
+ // POST /conflicts/resolve { id, winner:'a'|'b'|null, status:'confirmed'|'dismissed' }
1221
+ disposers.push(register('/conflicts/resolve', async (req, res) => {
1222
+ const body = JSON.parse((await readBody(req)) || '{}');
1223
+ sendOp(res, opConflictResolve(memory, { ...body, actor: 'user' }));
1224
+ }));
1225
+
1226
+ // POST /conflicts/judge { limit } —— **复盘环节**:让模型读两侧摘要全文,判定这条候选到底是什么。
1227
+ // 启发式(2-gram)只能找"措辞重复";判"同题反结论"必须读懂意思 ⇒ 这一步只能交给模型。
1228
+ // 分批(默认 5 条,上限 20),前端循环调用并显示进度 —— 避免一个长任务把 HTTP 连接拖死。
1229
+ // unrelated → 直接驳回(清洗假阳性);duplicate / contradict → 回写性质,留给主人裁定。
1230
+ const JUDGE_SYS = '你在帮主人整理长期记忆。下面给你两条记忆的摘要(A 和 B)。'
1231
+ + '请判断它们的关系,只回一个词:\n'
1232
+ + '- unrelated:讲的是不同的事(哪怕用词相近)\n'
1233
+ + '- duplicate:讲的是同一件事\n'
1234
+ + '- contradict:讲的是同一件事但结论相反\n'
1235
+ + '只回那一个词,不要解释,不要标点。';
1236
+ const parseVerdict = (s) => {
1237
+ const t = String(s || '').toLowerCase();
1238
+ if (t.includes('contradict')) return 'contradict';
1239
+ if (t.includes('duplicate')) return 'duplicate';
1240
+ if (t.includes('unrelated')) return 'unrelated';
1241
+ return 'unknown';
1242
+ };
1243
+ disposers.push(register('/conflicts/judge', async (req, res) => {
1244
+ const body = JSON.parse((await readBody(req)) || '{}');
1245
+ const limit = Math.max(1, Math.min(20, Number(body.limit) || 5));
1246
+ // 审计 B#6:单实例锁(同 name 批处理)—— 前端"循环调用直到 left=0"时,
1247
+ // 上一批还在跑就再发一批,会让同一条矛盾被判定两次(`setConflictKind` 后写覆盖先写)。
1248
+ const run = batchEnter('judge');
1249
+ if (!run) {
1250
+ return sendJson(res, 200, {
1251
+ ok: false, reason: 'busy',
1252
+ hint: '上一批判定还在跑;请等它返回结果里的 left 变成 0 再收尾',
1253
+ });
1254
+ }
1255
+ try {
1256
+ const asst = resolveAssistant(settings);
1257
+ const probe = await probeAssistant(asst.baseUrl, { timeoutMs: 5000 });
1258
+ if (!probe.ok) return sendJson(res, 200, { ok: false, reason: 'assistant-unavailable', hint: '小助手不可用时可跳过模型判定,直接人工裁定' });
1259
+ const pend = memory.listConflicts({ status: 'pending', limit: 200 });
1260
+ const t0 = Date.now();
1261
+ const done = [];
1262
+ let left = 0;
1263
+ let timedOut = false;
1264
+ run.total = pend.length;
1265
+ run.done = 0;
1266
+ for (const c of pend) {
1267
+ if (done.filter((d) => !d.skip).length >= limit) { left += 1; continue; }
1268
+ // 审计 B#6:整批总时长上限 —— 到点**不再开始新的一条**,直接把剩余数回报出去
1269
+ // (在飞的那一条会收尾,所以总耗时上界 = 总上限 + 单条最坏耗时,不会无限挂)。
1270
+ if (batchTimeout('judge', t0)) { timedOut = true; left += 1; continue; }
1271
+ const pickOv = (src, cid) => { try { return memory.overviewById(String(src), String(cid)) || null; } catch { return null; } };
1272
+ const oa = pickOv(c.a.source, c.a.convId);
1273
+ const ob = pickOv(c.b.source, c.b.convId);
1274
+ const ta = String((oa && oa.summary) || '').trim().slice(0, 700);
1275
+ const tb = String((ob && ob.summary) || '').trim().slice(0, 700);
1276
+ if (!ta || !tb) { done.push({ id: c.id, skip: 'no-summary' }); continue; }
1277
+ let verdict = 'unknown';
1278
+ let why = '';
1279
+ try {
1280
+ const r = await summarizeWithRetry(asst.baseUrl, asst.model, {
1281
+ text: 'A:\n' + ta + '\n\nB:\n' + tb,
1282
+ system: JUDGE_SYS,
1283
+ });
1284
+ verdict = parseVerdict(r && r.summary);
1285
+ why = String((r && r.summary) || '').slice(0, 200);
1286
+ } catch (e) {
1287
+ verdict = 'unknown';
1288
+ why = String(e?.message ?? e).slice(0, 120);
1289
+ }
1290
+ // 保守:只对明确判定动作,unknown 一律不动(宁可留给人工,也不误驳回)。
1291
+ if (verdict === 'unrelated') memory.resolveConflict(c.id, { status: 'dismissed' });
1292
+ else if (verdict === 'duplicate' || verdict === 'contradict') {
1293
+ memory.setConflictKind(c.id, { kind: verdict, detectedBy: 'llm', reason: why });
1294
+ }
1295
+ done.push({ id: c.id, verdict });
1296
+ run.done += 1;
1297
+ }
1298
+ run.timedOut = timedOut;
1299
+ sendJson(res, 200, {
1300
+ ok: true,
1301
+ judged: done.filter((d) => !d.skip).length,
1302
+ skipped: done.filter((d) => d.skip).length,
1303
+ left,
1304
+ timedOut,
1305
+ engine: String((asst && asst.model) || ''),
1306
+ items: done,
1307
+ });
1308
+ } finally {
1309
+ batchLeave('judge');
1310
+ }
1311
+ }));
1312
+
1313
+ // POST /conflicts/scan { limit, threshold } —— 启发式检出**重复**(duplicate)
1314
+ // 范围刻意收窄:只扫最近的 dsh/import 概述(不含 dsweb 海量短会话),两两比 2-gram Jaccard。
1315
+ // 语义矛盾(反结论)检出需要 LLM 判读,不在本端点假装能做 —— 它产出的是待复盘候选。
1316
+ disposers.push(register('/conflicts/scan', async (req, res) => {
1317
+ const body = JSON.parse((await readBody(req)) || '{}');
1318
+ const limit = Math.max(10, Math.min(200, Number(body.limit) || 60));
1319
+ const threshold = Math.max(0.3, Math.min(0.95, Number(body.threshold) || 0.55));
1320
+ // 2026-09-21:排除**已归档**会话 —— 封存的对话不该再被拉出来互相判矛盾(用户实测反馈)。
1321
+ const archived = memory.archivedConvIdSet();
1322
+ const rows = memory.queryOverviews({ limit }).items
1323
+ .filter((r) => r.source === 'dsh' || r.source === 'import')
1324
+ .filter((r) => !archived.has(String(r.conv_id)))
1325
+ .filter((r) => String(r.summary || '').trim().length >= 20);
1326
+ const grams = (s) => {
1327
+ const t = String(s).replace(/[\s\p{P}]+/gu, '');
1328
+ const g = new Set();
1329
+ for (let i = 0; i + 2 <= t.length; i++) g.add(t.slice(i, i + 2));
1330
+ return g;
1331
+ };
1332
+ const sets = rows.map((r) => grams(r.summary));
1333
+ const found = [];
1334
+ for (let i = 0; i < rows.length; i++) {
1335
+ for (let j = i + 1; j < rows.length; j++) {
1336
+ const A = sets[i]; const B = sets[j];
1337
+ if (!A.size || !B.size) continue;
1338
+ let inter = 0;
1339
+ for (const g of A) if (B.has(g)) inter++;
1340
+ const jac = inter / (A.size + B.size - inter);
1341
+ if (jac >= threshold) {
1342
+ const rec = memory.recordConflict({
1343
+ aSource: rows[i].source, aConvId: rows[i].conv_id,
1344
+ bSource: rows[j].source, bConvId: rows[j].conv_id,
1345
+ kind: 'duplicate', detectedBy: 'heuristic',
1346
+ score: Number(jac.toFixed(3)),
1347
+ reason: `2-gram Jaccard ${jac.toFixed(3)} ≥ ${threshold}`,
1348
+ });
1349
+ found.push({ a: rows[i].conv_id, b: rows[j].conv_id, score: Number(jac.toFixed(3)), id: rec.id, reopened: !!rec.reopened });
1350
+ }
1351
+ }
1352
+ }
1353
+ sendJson(res, 200, { ok: true, scanned: rows.length, threshold, found: found.length, pairs: found.slice(0, 50) });
1354
+ }));
1355
+
1356
+ // GET /memories?source=&category=&q=&sort=&limit=&offset=
1357
+ disposers.push(register('/memories', async (req, res) => {
1358
+ const q = new URL(req.url, 'http://dsh.internal').searchParams;
1359
+ const r = memory.queryOverviews({
1360
+ source: q.get('source') || undefined,
1361
+ category: q.get('category') || undefined,
1362
+ q: q.get('q') || undefined,
1363
+ sort: q.get('sort') || 'updated',
1364
+ branch: q.get('branch') || undefined,
1365
+ limit: q.get('limit') || undefined,
1366
+ offset: q.get('offset') || undefined,
1367
+ });
1368
+ sendJson(res, 200, { ok: true, ...r });
1369
+ }));
1370
+
1371
+ // POST /memories/pin { source, conv_id, pin }
1372
+ disposers.push(register('/memories/pin', async (req, res) => {
1373
+ const body = JSON.parse((await readBody(req)) || '{}');
1374
+ const src = memSourceOf(body.source);
1375
+ if (!src) return sendJson(res, 400, { ok: false, error: 'bad-source' });
1376
+ if (!body.conv_id) return sendJson(res, 400, { ok: false, error: 'conv_id required' });
1377
+ if (!memory.overviewById(src, body.conv_id)) return sendJson(res, 404, { ok: false, error: 'not-found' });
1378
+ memory.setImportance(src, body.conv_id, body.pin === false ? 0 : 1);
1379
+ sendJson(res, 200, { ok: true, source: src, conv_id: body.conv_id, pinned: body.pin !== false });
1380
+ }));
1381
+
1382
+ // POST /memories/delete { source, conv_id }
1383
+ disposers.push(register('/memories/delete', async (req, res) => {
1384
+ const body = JSON.parse((await readBody(req)) || '{}');
1385
+ const src = memSourceOf(body.source);
1386
+ if (!src) return sendJson(res, 400, { ok: false, error: 'bad-source' });
1387
+ if (!body.conv_id) return sendJson(res, 400, { ok: false, error: 'conv_id required' });
1388
+ const existed = !!memory.overviewById(src, body.conv_id);
1389
+ if (existed) memory.deleteOverview(src, body.conv_id);
1390
+ sendJson(res, 200, { ok: true, existed });
1391
+ }));
1392
+
1393
+ // POST /memories/rename { source, conv_id, title, lock? }
1394
+ // 主人手改标题(D2):默认上锁(title_locked=1) —— 概述器重建、批量重命名此后都不再覆盖。
1395
+ disposers.push(register('/memories/rename', async (req, res) => {
1396
+ const body = JSON.parse((await readBody(req)) || '{}');
1397
+ const src = memSourceOf(body.source);
1398
+ if (!src) return sendJson(res, 400, { ok: false, error: 'bad-source' });
1399
+ if (!body.conv_id) return sendJson(res, 400, { ok: false, error: 'conv_id required' });
1400
+ if (!memory.overviewById(src, body.conv_id)) return sendJson(res, 404, { ok: false, error: 'not-found' });
1401
+ const r = memory.renameTitle(src, body.conv_id, body.title, { lock: body.lock !== false });
1402
+ if (!r.ok) return sendJson(res, 400, { ok: false, error: r.reason || 'rename-failed' });
1403
+ touchMemoryVersion(memory);
1404
+ sendJson(res, 200, { ok: true, source: src, conv_id: body.conv_id, title: r.title, locked: r.locked });
1405
+ }));
1406
+
1407
+ // POST /memories/retitle { source, conv_id, baseUrl?, model?, heuristic? }
1408
+ // 用小助手给一条记忆起名(D2)。**机器起的名字同样上锁** —— 2026-09-17 实测教训:
1409
+ // 不上锁时,跑在旧代码里的概述器一轮增量重建就把 AI 起的名字覆盖回"首句硬截 46 字"的半句。
1410
+ // 已上锁的行**允许**再次起名(用户点按钮=显式意图),结果仍是"定过的名字"。
1411
+ disposers.push(register('/memories/retitle', async (req, res) => {
1412
+ const body = JSON.parse((await readBody(req)) || '{}');
1413
+ const src = memSourceOf(body.source);
1414
+ if (!src) return sendJson(res, 400, { ok: false, error: 'bad-source' });
1415
+ if (!body.conv_id) return sendJson(res, 400, { ok: false, error: 'conv_id required' });
1416
+ const ov = memory.overviewById(src, body.conv_id);
1417
+ if (!ov) return sendJson(res, 404, { ok: false, error: 'not-found' });
1418
+ const t0 = Date.now();
1419
+ // heuristic:true 走纯启发式(不调模型) —— 小助手不可用时的兜底,也是测试用的稳定路径
1420
+ if (body.heuristic === true) {
1421
+ const h = heuristicRetitle(memory, { source: src, conv_id: body.conv_id });
1422
+ if (h.ok) {
1423
+ memory.renameTitle(src, body.conv_id, h.title, { lock: true, by: 'ai' });
1424
+ touchMemoryVersion(memory);
1425
+ }
1426
+ return sendJson(res, h.ok ? 200 : 502, {
1427
+ ok: h.ok, source: src, conv_id: body.conv_id, title: h.title || '',
1428
+ engine: 'heuristic', error: h.error || null, ms: Date.now() - t0,
1429
+ });
1430
+ }
1431
+ // 引擎(D2/S7):默认 auto —— 优先本机小助手(零成本),探测不到才落全局大模型;
1432
+ // 实际用了哪个写回响应(engine/note),不做"静默换引擎"。
1433
+ const want = body.engine === 'global' ? 'global' : body.engine === 'assistant' ? 'assistant' : 'auto';
1434
+ const asst = resolveAssistant(settings); // S7:settings → 环境变量 → 内置默认(loopback)
1435
+ let engine = want;
1436
+ let note = '';
1437
+ if (want === 'auto') {
1438
+ const probe = await probeAssistant(String(body.baseUrl || asst.baseUrl));
1439
+ engine = probe.ok ? 'assistant' : 'global';
1440
+ note = probe.ok ? '' : '本机小助手不可用(' + probe.reason + ' @ ' + asst.baseUrl + ') → 本次改用当前全局大模型';
1441
+ }
1442
+ const r = engine === 'global'
1443
+ ? await retitleOneGlobal(ctx, { memory, source: src, conv_id: body.conv_id })
1444
+ : await retitleOne(String(body.baseUrl || asst.baseUrl), String(body.model || asst.model), {
1445
+ memory, source: src, conv_id: body.conv_id,
1446
+ });
1447
+ if (r.ok) touchMemoryVersion(memory);
1448
+ sendJson(res, r.ok ? 200 : 502, {
1449
+ ok: r.ok, source: src, conv_id: body.conv_id, title: r.title || '',
1450
+ engine, from: r.source || engine, note: note || undefined,
1451
+ error: r.error || null, ms: r.ms || (Date.now() - t0),
1452
+ });
1453
+ }));
1454
+
1455
+ // ---- S7(2026-09-18 与用户定):小助手(本机小模型)地址 —— 三级覆盖 ----
1456
+ // 一个路径按 method 分派(平台的 exact 路由表不允许同 path 注册两次):
1457
+ // GET /assistant/config → 生效值 + **来源**(settings/env/default) + 各级候选值(不做网络探测,秒回)
1458
+ // POST /assistant/config { baseUrl?, model?, clear? } → 只写这两个键(不开放任意设置写入)
1459
+ // 真实探测在 /assistant/test。
1460
+ disposers.push(register('/assistant/config', async (req, res) => {
1461
+ if (String(req.method || 'GET').toUpperCase() === 'POST') {
1462
+ const body = JSON.parse((await readBody(req)) || '{}');
1463
+ if (body.clear === true) {
1464
+ await settings.update({ assistant: { baseUrl: '', model: '' } });
1465
+ } else {
1466
+ const patch = {};
1467
+ if (body.baseUrl !== undefined) {
1468
+ const v = String(body.baseUrl || '').trim();
1469
+ if (v && !/^https?:\/\//i.test(v)) {
1470
+ return sendJson(res, 400, {
1471
+ ok: false, error: 'bad-url',
1472
+ message: '地址需以 http:// 或 https:// 开头(留空 = 回到环境变量 / 内置默认)',
1473
+ });
1474
+ }
1475
+ patch.baseUrl = v.slice(0, 300);
1476
+ }
1477
+ if (body.model !== undefined) patch.model = String(body.model || '').trim().slice(0, 120);
1478
+ if (!Object.keys(patch).length) return sendJson(res, 400, { ok: false, error: 'nothing-to-update' });
1479
+ await settings.update({ assistant: patch });
1480
+ }
1481
+ const after = resolveAssistant(settings);
1482
+ return sendJson(res, 200, { ok: true, baseUrl: after.baseUrl, model: after.model, source: after.source });
1483
+ }
1484
+ const asst = resolveAssistant(settings);
1485
+ return sendJson(res, 200, {
1486
+ ok: true,
1487
+ baseUrl: asst.baseUrl, model: asst.model, source: asst.source,
1488
+ configured: asst.configured, env: asst.env, defaults: asst.defaults,
1489
+ });
1490
+ }));
1491
+
1492
+ // POST /assistant/test { baseUrl?, model? } —— 探活(不改配置);UI 的「测试连通」用
1493
+ disposers.push(register('/assistant/test', async (req, res) => {
1494
+ const body = JSON.parse((await readBody(req)) || '{}');
1495
+ const asst = resolveAssistant(settings);
1496
+ const baseUrl = String(body.baseUrl || asst.baseUrl).trim();
1497
+ const probe = await probeAssistant(baseUrl, { timeoutMs: 5000 });
1498
+ sendJson(res, 200, {
1499
+ ok: probe.ok, baseUrl, model: String(body.model || asst.model),
1500
+ reason: probe.reason || null, models: probe.models || [],
1501
+ // 填错地址最常见的两种:忘了 /v1、把它当成文件夹路径 —— 按形态给不同的提示
1502
+ hint: probe.ok ? null : (String(baseUrl).includes('/v1')
1503
+ ? '确认小助手(Ollama)已启动,且该地址从本机可达 —— 要带 http:// 前缀与端口(默认 11434)。'
1504
+ : '这个地址看起来少了 /v1 结尾(Ollama 的 OpenAI 兼容端点在 /v1/models,而 /models 不存在)。'
1505
+ + '单机自用填 http://127.0.0.1:11434/v1;小助手在别的机器上就把 127.0.0.1 换成那台的 IP。'),
1506
+ });
1507
+ }));
1508
+
1509
+ // POST /mode/toggle { sessionId?, mode?, defaultOnly? }
1510
+ disposers.push(register('/mode/toggle', async (req, res) => {
1511
+ const body = JSON.parse((await readBody(req)) || '{}');
1512
+ const wantDefault = body.defaultOnly === true;
1513
+ const sessionId = wantDefault ? null : (body.sessionId || gate.recentSessionId());
1514
+ if (!sessionId) {
1515
+ // 全局默认路径(无会话或显式 defaultOnly):
1516
+ // ① 平台默认模型(agent-default-model)→ 只同步**推理等级**;模型原样保留
1517
+ // ② 自有 lastMode(D2 跟随源)
1518
+ const cur = settings.get().mode?.lastMode === 'work' ? 'work' : 'life';
1519
+ const next = body.mode && ['work', 'life'].includes(body.mode) ? body.mode : (cur === 'work' ? 'life' : 'work');
1520
+ const cfg = resolveModeConfig(settings.get(), next); // 只含 reasoningEffort
1521
+ let warning;
1522
+ const adm = svc(ctx, 'agentDefaultModel');
1523
+ if (adm && typeof adm.saveSelection === 'function') {
1524
+ const keep = currentModelSelection(ctx); // 当前模型:原样带回,不改
1525
+ if (!keep) {
1526
+ warning = 'unknown-current-model; 默认推理等级未同步(拒绝替用户指定模型)';
1527
+ console.debug('[dsh-ling]', warning);
1528
+ } else {
1529
+ try {
1530
+ // saveSelection 需要完整选择(provider/model 必填)→ 只替换 reasoningEffort
1531
+ await adm.saveSelection({ provider: keep.provider, model: keep.model, reasoningEffort: cfg.reasoningEffort });
1532
+ } catch (e) {
1533
+ warning = 'default-model sync failed: ' + String(e?.message ?? e);
1534
+ console.debug('[dsh-ling] saveSelection warning', warning);
1535
+ }
1536
+ }
1537
+ } else {
1538
+ warning = 'agentDefaultModel unavailable; new-session effort not synced';
1539
+ console.debug('[dsh-ling]', warning);
1540
+ }
1541
+ const s2 = await settings.update({ mode: { lastMode: next } });
1542
+ // 修法 A 补完:空会话(无真人输入)语义上"跟随默认",默认变了 → 它们的快照必须失效;
1543
+ // 有内容的旧会话保持自己的模式(不动)。running 的走 stale,空闲则立即重建。
1544
+ const refreshed = [];
1545
+ for (const sid of allSnapshotSessionIds(gate)) {
1546
+ if (memory.hasUserTurns(sid)) continue;
1547
+ const r2 = invalidateSession(gate, memory, settings, sid);
1548
+ refreshed.push({ sessionId: sid, ...r2 });
1549
+ }
1550
+ return sendJson(res, 200, {
1551
+ ok: true, mode: next, applied: 'default-only', sessionId: null,
1552
+ lastMode: s2.mode.lastMode, platformDefaultSynced: !warning, warning,
1553
+ emptySessionsRefreshed: refreshed.length,
1554
+ });
1555
+ }
1556
+ const cur = currentMode(memory, settings, sessionId);
1557
+ const next = body.mode && ['work', 'life'].includes(body.mode) ? body.mode : (cur === 'work' ? 'life' : 'work');
1558
+ const r = await applyModeToSession(ctx, gate, memory, settings, sessionId, next);
1559
+ if (r.ok && !r.queued) invalidateSession(gate, memory, settings, sessionId);
1560
+ else if (r.ok && r.queued) gate.markSnapStale(sessionId); // 运行中:排队;空闲后重建会读新模式
1561
+ sendJson(res, 200, { ok: r.ok, ...r, running: gate.isRunning(sessionId) });
1562
+ }));
1563
+
1564
+ // GET /state?sessionId=&scope=global&l0Preview=1&previewPersona=&previewStyles=
1565
+ disposers.push(register('/state', async (req, res) => {
1566
+ const q = new URL(req.url, 'http://dsh.internal').searchParams;
1567
+ if (q.get('scope') === 'global') return sendJson(res, 200, stateForGlobal(q));
1568
+ const sessionId = reqSessionId(req.url) || gate.recentSessionId();
1569
+ if (!sessionId) return sendJson(res, 200, stateForGlobal(q));
1570
+ sendJson(res, 200, stateFor(sessionId, { l0Preview: q.get('l0Preview') === '1', q }));
1571
+ }));
1572
+
1573
+ // POST /persona 字段补丁 → settings + 全局失效(按冻结门)
1574
+ // 定型门(承诺句版):sealed 后动 persona/styles 需带 unlock=定型时亲手写下的句子(哈希比对);
1575
+ // 上锁(sealed:true)需带新承诺句 → 覆盖哈希;撤锁清哈希;每次成功变更后存「结果档案」。
1576
+ disposers.push(register('/persona', async (req, res) => {
1577
+ const body = JSON.parse((await readBody(req)) || '{}');
1578
+ const raw = clampBottomLines(body.patch && typeof body.patch === 'object' ? body.patch : body);
1579
+ // 2026-09-17 加固④(G10):白名单收窄 —— 补丁只能落在 persona / styles 两棵子树内。
1580
+ // 此前 body 被原样深合并进 settings.json,`{"guard":{"enforce":false}}` 可持久化关停本守卫。
1581
+ const { clean: patch, dropped } = sanitizePersonaPatch(raw);
1582
+ if (dropped.length) console.warn('[dsh-ling] /persona 丢弃白名单外的字段: %s', dropped.join(', '));
1583
+ const cur = settings.get();
1584
+ const wantLock = patch.persona?.sealed === true;
1585
+ const wasSealed = cur.persona?.sealed === true;
1586
+ const doUnseal = patch.persona?.sealed === false;
1587
+ if (touchesPersona(patch)) {
1588
+ if (wantLock) {
1589
+ const k = String(body.unlock || '').trim();
1590
+ if (k.length < KEY_MIN) return sendJson(res, 200, { ok: false, reason: 'sealed', message: '上锁需先亲手写下一句 ≥' + KEY_MIN + ' 字的承诺句(明文保存,仅供解锁界面回显提醒)。' });
1591
+ patch.persona.sealPhrase = k; // 覆盖旧承诺
1592
+ } else if (wasSealed) {
1593
+ const g = sealOk(cur.persona, body.unlock);
1594
+ if (!g.pass) return sendJson(res, 200, { ok: false, reason: 'sealed', message: MSG_KEY });
1595
+ if (g.adoptKey) patch.persona.sealPhrase = g.adoptKey; // 旧档案无明文:采纳为承诺句
1596
+ if (doUnseal) patch.persona.sealPhrase = ''; // 永久撤锁 → 清句
1597
+ }
1598
+ }
1599
+ const s2 = await settings.update(patch);
1600
+ const histKey = touchesPersona(patch) ? await archivePersona(s2, 'result') : null;
1601
+ const affected = allSnapshotSessionIds(gate).map((sid) => ({
1602
+ sessionId: sid,
1603
+ result: invalidateSession(gate, memory, settings, sid),
1604
+ }));
1605
+ const mode = s2.mode?.lastMode === 'work' ? 'work' : 'life';
1606
+ sendJson(res, 200, {
1607
+ ok: true,
1608
+ affected,
1609
+ lastMode: s2.mode?.lastMode,
1610
+ sealed: s2.persona?.sealed === true,
1611
+ hasSeal: !!s2.persona?.sealPhrase,
1612
+ bottomCapped: raw._bottomCapped === true,
1613
+ ignored: dropped.length ? dropped : undefined,
1614
+ histKey,
1615
+ l0PreviewText: l0WithClock(s2, mode),
1616
+ });
1617
+ }));
1618
+
1619
+ // POST /persona/check { unlock } 客户端「解锁修改」先验钥(只读,不落盘)
1620
+ disposers.push(register('/persona/check', async (req, res) => {
1621
+ const body = JSON.parse((await readBody(req)) || '{}');
1622
+ const cur = settings.get();
1623
+ if (cur.persona?.sealed !== true) {
1624
+ return sendJson(res, 200, { ok: true, valid: true, sealed: false, message: '档案未定型' });
1625
+ }
1626
+ const g = sealOk(cur.persona, body.unlock);
1627
+ sendJson(res, 200, {
1628
+ ok: true, valid: g.pass, sealed: true, adopt: !!g.adoptKey,
1629
+ message: g.pass ? (g.adoptKey ? '旧档案无明文:这句话将被采纳为定型承诺句' : '承诺句一致') : MSG_KEY,
1630
+ });
1631
+ }));
1632
+
1633
+ // GET /persona/history 人格档案存档(每次保存后的结果;回滚前另有 pre-rollback 档)
1634
+ disposers.push(register('/persona/history', async (_req, res) => {
1635
+ const items = memory.kvList('persona.hist.').map((r) => {
1636
+ const k = String(r.key);
1637
+ let v = null;
1638
+ try { v = JSON.parse(String(r.value)); } catch {}
1639
+ return { ts: Number(k.slice('persona.hist.'.length)) || null, persona: v?.persona ?? null, styles: v?.styles ?? null, kind: v?.kind || 'result' };
1640
+ }).sort((a, b) => (b.ts || 0) - (a.ts || 0)).slice(0, 30);
1641
+ sendJson(res, 200, { ok: true, items });
1642
+ }));
1643
+
1644
+ // POST /persona/self-summary { text } 自我总结成长通道(自由生长的一部分):
1645
+ // 无论是否定型,只允许更新 persona.aiTitle(定位自述)单字段;其余字段仍需手术/承诺句。
1646
+ disposers.push(register('/persona/self-summary', async (req, res) => {
1647
+ const body = JSON.parse((await readBody(req)) || '{}');
1648
+ const text = String(body.text ?? '').trim();
1649
+ if (!text) return sendJson(res, 200, { ok: false, reason: 'empty', message: '内容为空' });
1650
+ if (text.length > 500) return sendJson(res, 200, { ok: false, reason: 'long', message: '自述过长(≤500 字)' });
1651
+ const cur = settings.get();
1652
+ const histKey = await archivePersona(cur, 'result');
1653
+ const s2 = await settings.update({ persona: { aiTitle: text } });
1654
+ const affected = allSnapshotSessionIds(gate).map((sid) => ({
1655
+ sessionId: sid,
1656
+ result: invalidateSession(gate, memory, settings, sid),
1657
+ }));
1658
+ const mode = s2.mode?.lastMode === 'work' ? 'work' : 'life';
1659
+ sendJson(res, 200, {
1660
+ ok: true, affected, histKey, chars: text.length,
1661
+ l0PreviewText: l0WithClock(s2, mode),
1662
+ });
1663
+ }));
1664
+
1665
+ // POST /persona/hint-adopt { text } 诞生建议(语气建议/相处观察)采纳:
1666
+ // 属"成长回路"—— 建议中心里按下那一下 = 确认,故走「提议 → 确认」状态机落到**习惯**
1667
+ // (习惯不能直达;这里的用户点击就是确认动作),重量前先归档。
1668
+ disposers.push(register('/persona/hint-adopt', async (req, res) => {
1669
+ const body = JSON.parse((await readBody(req)) || '{}');
1670
+ const text = String(body.text ?? '').trim();
1671
+ if (text.length < 4 || text.length > 400) {
1672
+ return sendJson(res, 200, { ok: false, reason: 'bad', message: '内容需在 4~400 字之间' });
1673
+ }
1674
+ const existing = habitsOf(settings).some((h) => String(h.text).replace(/\s+/g, '') === text.replace(/\s+/g, ''));
1675
+ if (existing) {
1676
+ return sendJson(res, 200, { ok: true, exists: true, habits: habitsOf(settings).length, message: '这条已经是习惯里的了' });
1677
+ }
1678
+ const histKey = await archivePersona(settings.get(), 'result');
1679
+ const prop = await proposeHabit({ settings, habit: text, evidence: '建议中心采纳', byUser: true });
1680
+ if (!prop.ok) return sendJson(res, 200, { ok: false, reason: prop.reason, message: '未记入:' + prop.reason });
1681
+ const done = await resolveHabit({ settings, id: prop.id, action: 'confirm' });
1682
+ const affected = allSnapshotSessionIds(gate).map((sid) => ({
1683
+ sessionId: sid,
1684
+ result: invalidateSession(gate, memory, settings, sid),
1685
+ }));
1686
+ const s2 = settings.get();
1687
+ sendJson(res, 200, {
1688
+ ok: done.ok, affected, histKey,
1689
+ habits: habitsOf(settings).length,
1690
+ warning: done.warning,
1691
+ l0PreviewText: l0WithClock(s2, s2.mode?.lastMode === 'work' ? 'work' : 'life'),
1692
+ });
1693
+ }));
1694
+
1695
+ // ---- 规矩 / 习惯(2026-09-15 定稿:指令直达,人格不直达)----
1696
+ // GET /persona/rules 规矩 + 习惯 + 待确认提议(面板用)
1697
+ disposers.push(register('/persona/rules', async (_req, res) => {
1698
+ sendJson(res, 200, { ok: true, ...rulesView(settings) });
1699
+ }));
1700
+
1701
+ // POST /persona/rule { action:'add'|'remove', rule, quote? }
1702
+ // 规矩直达:add 必须带原话(quote);remove 只需正文。
1703
+ disposers.push(register('/persona/rule', async (req, res) => {
1704
+ const body = JSON.parse((await readBody(req)) || '{}');
1705
+ const action = String(body.action || 'add');
1706
+ if (action === 'remove') {
1707
+ const r = await removeRule({ settings, rule: body.rule });
1708
+ return sendJson(res, 200, r.ok
1709
+ ? { ok: true, ...r, affected: invalidateAllSummaries() }
1710
+ : { ok: false, reason: r.reason, message: '没有这条规矩' });
1711
+ }
1712
+ if (action !== 'add') return sendJson(res, 200, { ok: false, reason: 'bad-action' });
1713
+ const r = await addRule({
1714
+ settings, rule: body.rule, quote: body.quote, sessionId: String(body.sessionId || 'panel'),
1715
+ source: body.quote ? 'session' : 'panel', // 面板直接写入:用户自己敲的就是同意
1716
+ });
1717
+ const message = !r.ok
1718
+ ? {
1719
+ 'no-quote': '规矩必须附上原话(你说的那一句)才能写入 —— 这是"经同意"的凭据。',
1720
+ 'empty-rule': '规矩内容为空。',
1721
+ 'too-long': `规矩太长(上限 ${RULE_MAX_CHARS} 字),请压缩成一条短句。`,
1722
+ duplicate: '已有同义规矩,未重复写入。',
1723
+ }[r.reason] || ('未写入:' + r.reason)
1724
+ : undefined;
1725
+ sendJson(res, 200, { ok: r.ok, ...r, message, affected: r.ok ? invalidateAllSummaries() : [] });
1726
+ }));
1727
+
1728
+ // POST /persona/habit { action:'propose'|'confirm'|'reject'|'remove', habit?, evidence?, id? }
1729
+ // 习惯不直达:propose(任一方可提)/ confirm(对方点头)/ reject / remove。
1730
+ disposers.push(register('/persona/habit', async (req, res) => {
1731
+ const body = JSON.parse((await readBody(req)) || '{}');
1732
+ const action = String(body.action || 'propose');
1733
+ if (action === 'propose') {
1734
+ const r = await proposeHabit({
1735
+ settings, habit: body.habit, evidence: body.evidence, byUser: body.byUser !== false,
1736
+ });
1737
+ return sendJson(res, 200, {
1738
+ ok: r.ok, ...r,
1739
+ message: r.ok
1740
+ ? '已作为候选记下,确认后才成为习惯'
1741
+ : (r.reason === 'pending-full'
1742
+ ? `待确认的习惯已经有 ${r.pending} 条(上限 ${r.limit})—— 先处理完这些,再看新的。`
1743
+ : ('未记录:' + r.reason)),
1744
+ });
1745
+ }
1746
+ if (action === 'confirm' || action === 'reject') {
1747
+ const r = await resolveHabit({ settings, id: body.id, action });
1748
+ return sendJson(res, 200, { ok: r.ok, ...r, affected: r.ok ? invalidateAllSummaries() : [] });
1749
+ }
1750
+ if (action === 'remove') {
1751
+ // 习惯属于她:删改属"对她做手术",定型状态下需先解锁(承诺句);提议/认可/不认可仍免手术。
1752
+ const cur = settings.get();
1753
+ const gate = cur.persona?.sealed === true ? sealOk(cur.persona, body.unlock) : { pass: true, adoptKey: null };
1754
+ if (!gate.pass) {
1755
+ return sendJson(res, 200, {
1756
+ ok: false, reason: 'sealed',
1757
+ message: pronounize('习惯是她长出来的 —— 删改需走手术门:请在人格中心「🔓 对人格做手术…」解锁后再试。', pronounOf(settings.get())) + MSG_KEY,
1758
+ });
1759
+ }
1760
+ const r = await removeHabit({ settings, habit: body.habit });
1761
+ return sendJson(res, 200, { ok: r.ok, ...r, affected: r.ok ? invalidateAllSummaries() : [] });
1762
+ }
1763
+ sendJson(res, 200, { ok: false, reason: 'bad-action' });
1764
+ }));
1765
+
1766
+ // POST /persona/habits/scan 通道 A:零模型,扫"跨会话重复出现的纠正"→ 落成习惯候选(待确认)
1767
+ // 阈值:同类纠正 ≥4 次 且 跨 ≥3 个会话(单次会话里被说三遍不算模式;口径与变更记录见 habit-gen.js 的 SCAN_DEFAULTS)
1768
+ disposers.push(register('/persona/habits/scan', async (_req, res) => {
1769
+ try {
1770
+ // 上限(2026-09-16 定案):待确认满了就**明确驳回**,并说明为什么 —— 不排队、不静默丢弃。
1771
+ const capS = pendingMaxOf(settings);
1772
+ const pendS = habitsPendingOf(settings).length;
1773
+ if (pendS >= capS) {
1774
+ return sendJson(res, 200, {
1775
+ ok: false, reason: 'pending-full', pending: pendS, limit: capS,
1776
+ message: `待确认的习惯已经有 ${pendS} 条(上限 ${capS})—— 先把手上的处理完(认可 / 先不要),再看新的。`,
1777
+ });
1778
+ }
1779
+ const cands = scanCorrections(memory);
1780
+ if (!cands.length) {
1781
+ return sendJson(res, 200, {
1782
+ ok: true, found: 0, proposed: 0, skipped: [],
1783
+ message: `没发现"跨会话重复"的纠正信号(阈值:同类 ≥${SCAN_DEFAULTS.minHits} 次且跨 ≥${SCAN_DEFAULTS.minSessions} 个会话)。`,
1784
+ });
1785
+ }
1786
+ const proposed = [];
1787
+ const skipped = [];
1788
+ for (const c of cands) {
1789
+ const r = await proposeHabit({ settings, habit: c.rule, evidence: evidenceOf(c), byUser: false });
1790
+ if (r.ok) proposed.push({ id: r.id, text: c.rule, hits: c.hits, sessions: c.sessions, evidence: evidenceOf(c) });
1791
+ else skipped.push({ text: c.rule, reason: r.reason, hits: c.hits });
1792
+ }
1793
+ sendJson(res, 200, {
1794
+ ok: true, found: cands.length, proposed: proposed.length, skipped, items: proposed,
1795
+ message: proposed.length
1796
+ ? `从纠正记录里长出 ${proposed.length} 条习惯候选(等你确认)`
1797
+ : '候选都已经在习惯里或待确认列表里了',
1798
+ });
1799
+ } catch (e) {
1800
+ sendJson(res, 200, { ok: false, reason: 'scan', message: '扫描失败:' + String(e?.message ?? e).slice(0, 160) });
1801
+ }
1802
+ }));
1803
+
1804
+ // POST /persona/habits/reflect 通道 B:让她"回想最近的相处" → 1~5 条习惯候选(一次 LLM,只提议不落盘)
1805
+ // 手动触发,材料给足:主人真人原话 + 记忆概述 + 纠正统计 + 她现状(默认预算 20 万字符,可用 settings.habits.reflect* 覆盖)
1806
+ disposers.push(register('/persona/habits/reflect', async (_req, res) => {
1807
+ try {
1808
+ // 上限(2026-09-16 定案):与 scan 同一条规则 —— 满了明确驳回,不排队、不静默丢弃。
1809
+ const capR = pendingMaxOf(settings);
1810
+ const pendR = habitsPendingOf(settings).length;
1811
+ if (pendR >= capR) {
1812
+ return sendJson(res, 200, {
1813
+ ok: false, reason: 'pending-full', pending: pendR, limit: capR,
1814
+ message: `待确认的习惯已经有 ${pendR} 条(上限 ${capR})—— 先把手上的处理完(认可 / 先不要),再让她看新的。`,
1815
+ });
1816
+ }
1817
+ const hs = settings.get()?.habits || {};
1818
+ // 注意:scanCorrections 用的是"展开覆盖"语义,传 undefined 会把默认值顶掉 —— 只在有值时传
1819
+ const scanOpts = {};
1820
+ if (hs.scanMinHits != null) scanOpts.minHits = Number(hs.scanMinHits) || SCAN_DEFAULTS.minHits;
1821
+ if (hs.scanMinSessions != null) scanOpts.minSessions = Number(hs.scanMinSessions) || SCAN_DEFAULTS.minSessions;
1822
+ const scan = summarizeScan(scanCorrections(memory, scanOpts));
1823
+ // 轮询游标:每次读上一轮没读过的那一段;读满一圈回到最新(kv 持久化,跨会话累计)
1824
+ let cursor = null;
1825
+ try { cursor = JSON.parse(memory.kvGet('habits.reflect.cursor') || 'null') || null; } catch { cursor = null; }
1826
+ const { text: material, stats, nextCursor } = buildReflectMaterial(memory, {
1827
+ scan,
1828
+ rules: settings.get()?.persona?.hardRules || [],
1829
+ habits: settings.get()?.persona?.habits || [],
1830
+ overviewLimit: hs.reflectOverviewLimit,
1831
+ turnLimit: hs.reflectTurnLimit,
1832
+ budgetChars: hs.reflectBudgetChars,
1833
+ cursor,
1834
+ });
1835
+ try { if (nextCursor) memory.kvSet('habits.reflect.cursor', JSON.stringify(nextCursor)); } catch { /* 游标没存上只影响下一轮,不影响本次 */ }
1836
+ const cov = stats?.coverage ? ` ${stats.coverage}` : '';
1837
+ if (!material) {
1838
+ return sendJson(res, 200, {
1839
+ ok: false, reason: 'empty',
1840
+ message: pronounize('没有可回想的材料 —— 先接入一些历史,她才有相处的痕迹可读。', pronounOf(settings.get())),
1841
+ });
1842
+ }
1843
+ const eff = hs.reflectEffort || REFLECT_DEFAULTS.effort;
1844
+ const maxTok = Number(hs.reflectMaxTokens) || REFLECT_DEFAULTS.maxTokens;
1845
+ const { text, attempts, retried } = await reflectWithRetry(
1846
+ (o) => llmOnce(ctx, o),
1847
+ { system: pronounize(HABIT_REFLECT_SYS, pronounOf(settings.get())), material, maxTokens: maxTok, effort: eff },
1848
+ );
1849
+ const cands = parseReflect(text, { max: Number(hs.reflectMaxHabits) || REFLECT_DEFAULTS.maxHabits });
1850
+ // 每次运行都留一条运行记录(不只是空回复时):否则"跑了但没结果"这类问题只能靠猜
1851
+ // —— 2026-09-16 实测:上一版只在空回复时写 kv,结果连"跑没跑、读到什么"都查不到。
1852
+ try {
1853
+ const first = attempts[0] || {};
1854
+ memory.kvSet('habits.reflect.last', JSON.stringify({
1855
+ at: new Date().toISOString(),
1856
+ round: cursor?.round ?? 1,
1857
+ materialChars: material.length,
1858
+ maxTokens: maxTok,
1859
+ effort: eff,
1860
+ retried: !!retried,
1861
+ textLen: String(text || '').length,
1862
+ empty: !String(text || '').trim(),
1863
+ candidates: cands.length,
1864
+ target: first.target ? `${first.target.provider}/${first.target.model}` : null,
1865
+ attempts: (attempts || []).map((a) => ({
1866
+ tag: a.tag, effort: a.effort, chars: a.chars, ms: a.ms, ok: a.ok, len: a.len,
1867
+ chunkTypes: a.chunkTypes, error: a.error ? String(a.error).slice(0, 200) : undefined,
1868
+ })),
1869
+ coverage: stats?.coverage || null,
1870
+ }));
1871
+ } catch { /* 运行记录没写上不影响主流程 */ }
1872
+ if (!cands.length) {
1873
+ const empty = !String(text || '').trim();
1874
+ const first = attempts[0] || {};
1875
+ if (empty) {
1876
+ // 空回复必须留下"为什么":否则只能靠猜(旧版就是这样瞒了一整天)
1877
+ try {
1878
+ memory.kvSet('habits.reflect.debug', JSON.stringify({
1879
+ at: new Date().toISOString(), materialChars: material.length, maxTokens: maxTok, effort: eff, attempts, stats,
1880
+ }));
1881
+ } catch { /* 诊断没写上不影响主流程 */ }
1882
+ }
1883
+ const tgt = first.target ? `${first.target.provider}/${first.target.model}` : '(未知目标模型)';
1884
+ const chunks = Array.isArray(first.chunkTypes) && first.chunkTypes.length ? first.chunkTypes.join(',') : '(无 chunk)';
1885
+ return sendJson(res, 200, {
1886
+ ok: true, found: 0, proposed: 0, skipped: [], items: [], material: stats, retried,
1887
+ message: pronounize(`她读完${cov},觉得还不足以长出一条习惯(宁缺勿滥)。`, pronounOf(settings.get())) +
1888
+ (empty
1889
+ ? `(模型没有返回内容:目标 ${tgt} · 材料 ${material.length} 字 · ${maxTok} token · chunk=[${chunks}]` +
1890
+ `${retried ? ' · 已用 off 档 + 裁短材料重试一次仍空' : ''};诊断已存 kv habits.reflect.debug —— ` +
1891
+ '多半是材料超出当前模型上下文,或思考档吃掉了预算:可调小 reflectBudgetChars 或提高 reflectMaxTokens)'
1892
+ : ''),
1893
+ });
1894
+ }
1895
+ const proposed = [];
1896
+ const skipped = [];
1897
+ for (const c of cands) {
1898
+ const r = await proposeHabit({ settings, habit: c.text, evidence: c.evidence || '她回想最近的相处时归纳', byUser: false });
1899
+ if (r.ok) proposed.push({ id: r.id, text: c.text, evidence: c.evidence });
1900
+ else skipped.push({ text: c.text, reason: r.reason });
1901
+ }
1902
+ sendJson(res, 200, {
1903
+ ok: true, found: cands.length, proposed: proposed.length, skipped, items: proposed, material: stats,
1904
+ message: proposed.length
1905
+ ? pronounize(`她回想${cov},提出 ${proposed.length} 条习惯候选(等你确认)`, pronounOf(settings.get()))
1906
+ : '候选都已经在习惯里或待确认列表里了',
1907
+ });
1908
+ } catch (e) {
1909
+ sendJson(res, 200, { ok: false, reason: 'reflect', message: '回想失败:' + String(e?.message ?? e).slice(0, 160) });
1910
+ }
1911
+ }));
1912
+
1913
+ // ---- 语气 P1(自性类 = 自由生长,锁定下免手术)----
1914
+ // POST /persona/tone-advice 按记忆分类(工作侧知识 / 生活侧情感)归纳两档语气候选(只读,不落盘)
1915
+ disposers.push(register('/persona/tone-advice', async (_req, res) => {
1916
+ try {
1917
+ const pnTone = pronounOf(settings.get());
1918
+ const workSide = sampleToneRows(memory, { side: 'work', limit: 40, pronoun: pnTone });
1919
+ const lifeSide = sampleToneRows(memory, { side: 'life', limit: 40, pronoun: pnTone });
1920
+ const srcWork = buildGenesisSource(workSide.rows, { cap: 9000 });
1921
+ const srcLife = buildGenesisSource(lifeSide.rows, { cap: 9000 });
1922
+ if (!srcWork && !srcLife) {
1923
+ return sendJson(res, 200, { ok: false, reason: 'empty', message: pronounize('记忆库还是空的——先接入历史,她才有可观察的素材。', pronounOf(settings.get())) });
1924
+ }
1925
+ const material = '【工作侧材料(知识/技术为主)】\n' + (srcWork || '(无)') +
1926
+ '\n\n【生活侧材料(情感/日常为主)】\n' + (srcLife || '(无)');
1927
+ const { text } = await llmOnce(ctx, {
1928
+ system: pronounize(TONE_ADVICE_SYS, pronounOf(settings.get())),
1929
+ text: material,
1930
+ maxTokens: 1000,
1931
+ effort: 'low',
1932
+ });
1933
+ let result = parseToneAdvice(text);
1934
+ let raw = text;
1935
+ if (!result.work.tone && !result.life.tone) {
1936
+ // 一次强化重试:明确只输出 JSON、tone 用小写英文枚举
1937
+ const retry = await llmOnce(ctx, {
1938
+ system: '只输出一个 JSON 对象,不要 markdown 围栏、不要解释。格式:{"work":{"tone":"natural|literary|concise|playful","note":"…","evidence":"…"},"life":{…}}。tone 必须是小写英文枚举之一,禁止中文。',
1939
+ text: material,
1940
+ maxTokens: 1000,
1941
+ effort: 'off',
1942
+ });
1943
+ raw = retry.text;
1944
+ result = parseToneAdvice(retry.text);
1945
+ }
1946
+ if (!result.work.tone && !result.life.tone) {
1947
+ // 留下原始输出,便于定位(可在同一位置读 kv 查看)
1948
+ memory.kvSet('tone.advice.debug', JSON.stringify({
1949
+ at: new Date().toISOString(),
1950
+ raw: String(raw || '').slice(0, 1200),
1951
+ sampled: { work: workSide.sampled, life: lifeSide.sampled },
1952
+ }));
1953
+ return sendJson(res, 200, {
1954
+ ok: false, reason: 'parse',
1955
+ message: '模型没给出可用的语气建议(原始输出已留存)。再试一次,或看看这次它说了什么:' + String(raw || '').slice(0, 120),
1956
+ });
1957
+ }
1958
+ memory.kvSet('tone.advice.last', JSON.stringify({ at: new Date().toISOString(), result, sampled: { work: workSide.sampled, life: lifeSide.sampled } }));
1959
+ sendJson(res, 200, {
1960
+ ok: true, result,
1961
+ sampled: { work: workSide.sampled, life: lifeSide.sampled, workPool: workSide.pool, lifePool: lifeSide.pool },
1962
+ });
1963
+ } catch (e) {
1964
+ sendJson(res, 200, { ok: false, reason: 'llm', message: '归纳失败:' + String(e?.message ?? e).slice(0, 160) });
1965
+ }
1966
+ }));
1967
+
1968
+ // POST /persona/grow { tone?: {work?,life?,default?}, styleAppend?: {work?,life?} }
1969
+ // 自性类成长通道:白名单字段(语气枚举 + 风格追加),锁定下亦可采纳;不可逆操作一律不做。
1970
+ disposers.push(register('/persona/grow', async (req, res) => {
1971
+ const body = JSON.parse((await readBody(req)) || '{}');
1972
+ const patch = {};
1973
+ const toneIn = body.tone && typeof body.tone === 'object' ? body.tone : {};
1974
+ if (toneIn.default && TONE_SET.includes(String(toneIn.default))) patch.tone = String(toneIn.default);
1975
+ if (toneIn.work && TONE_SET.includes(String(toneIn.work))) patch.toneWork = String(toneIn.work);
1976
+ if (toneIn.life && TONE_SET.includes(String(toneIn.life))) patch.toneLife = String(toneIn.life);
1977
+ const styleIn = body.styleAppend && typeof body.styleAppend === 'object' ? body.styleAppend : {};
1978
+ const cur = settings.get();
1979
+ const styles = { ...(cur.styles || {}) };
1980
+ let appended = false;
1981
+ for (const side of ['work', 'life']) {
1982
+ const note = String(styleIn[side] ?? '').trim();
1983
+ if (!note) continue;
1984
+ const next = appendStyleNote(styles[side], note);
1985
+ if (next !== styles[side]) { styles[side] = next; appended = true; }
1986
+ }
1987
+ if (!Object.keys(patch).length && !appended) {
1988
+ return sendJson(res, 200, { ok: false, reason: 'noop', message: '没有可应用的有效更改(语气需为四档之一,风格注只追加不覆盖)' });
1989
+ }
1990
+ const histKey = await archivePersona(cur, 'result');
1991
+ const s2 = await settings.update(appended ? { persona: patch, styles } : { persona: patch });
1992
+ const affected = allSnapshotSessionIds(gate).map((sid) => ({
1993
+ sessionId: sid,
1994
+ result: invalidateSession(gate, memory, settings, sid),
1995
+ }));
1996
+ sendJson(res, 200, {
1997
+ ok: true, affected, histKey, appended,
1998
+ tone: { tone: s2.persona?.tone, toneWork: s2.persona?.toneWork, toneLife: s2.persona?.toneLife },
1999
+ l0PreviewText: l0WithClock(s2, s2.mode?.lastMode === 'work' ? 'work' : 'life'),
2000
+ });
2001
+ }));
2002
+
2003
+ // POST /persona/rollback { ts, unlock? } 回滚到某历史存档(同样受定型门约束)
2004
+ disposers.push(register('/persona/rollback', async (req, res) => {
2005
+ const body = JSON.parse((await readBody(req)) || '{}');
2006
+ const ts = Number(body.ts);
2007
+ const raw = ts ? memory.kvGet('persona.hist.' + ts) : null;
2008
+ if (!raw) return sendJson(res, 200, { ok: false, reason: 'notfound', message: '存档不存在' });
2009
+ let snap = null;
2010
+ try { snap = JSON.parse(String(raw)); } catch {}
2011
+ if (!snap || !snap.persona) return sendJson(res, 200, { ok: false, reason: 'bad', message: '存档内容损坏' });
2012
+ const cur = settings.get();
2013
+ const g = cur.persona?.sealed === true ? sealOk(cur.persona, body.unlock) : { pass: true, adoptKey: null };
2014
+ if (!g.pass) return sendJson(res, 200, { ok: false, reason: 'sealed', message: MSG_KEY });
2015
+ await archivePersona(cur, 'pre-rollback'); // 回滚前再留一份当前档案,可再反悔
2016
+ const patch2 = { persona: { ...snap.persona }, styles: { ...(snap.styles || {}) } };
2017
+ if (g.adoptKey) patch2.persona.sealPhrase = g.adoptKey; // 旧档案无明文:采纳当前键入句子
2018
+ const s2 = await settings.update(patch2);
2019
+ const affected = allSnapshotSessionIds(gate).map((sid) => ({
2020
+ sessionId: sid,
2021
+ result: invalidateSession(gate, memory, settings, sid),
2022
+ }));
2023
+ const mode = s2.mode?.lastMode === 'work' ? 'work' : 'life';
2024
+ sendJson(res, 200, {
2025
+ ok: true, affected, sealed: s2.persona?.sealed === true,
2026
+ l0PreviewText: l0WithClock(s2, mode),
2027
+ });
2028
+ }));
2029
+
2030
+ // POST /memory/refresh { sessionId? }
2031
+ disposers.push(register('/memory/refresh', async (req, res) => {
2032
+ const body = JSON.parse((await readBody(req)) || '{}');
2033
+ const sessionId = body.sessionId || null;
2034
+ if (sessionId) {
2035
+ return sendJson(res, 200, { ok: true, ...invalidateSession(gate, memory, settings, sessionId) });
2036
+ }
2037
+ const affected = allSnapshotSessionIds(gate).map((sid) => ({ sessionId: sid, result: invalidateSession(gate, memory, settings, sid) }));
2038
+ sendJson(res, 200, { ok: true, affected });
2039
+ }));
2040
+
2041
+ // GET /import/history 导入记录(三条入口共用:文件导入 / 本机 DSH 扫描 / 网页端库扫描)
2042
+ disposers.push(register('/import/history', async (_req, res) => {
2043
+ const items = listImportLog(memory, { limit: 50 }).map((it) => ({ ...it, line: formatLogLine(it) }));
2044
+ sendJson(res, 200, { ok: true, items, keep: 50 });
2045
+ }));
2046
+
2047
+ // GET /persona/draft 基于当前档案(惯例/底线/设定/风格)起草「定位自述」候选。
2048
+ // 只读操作,不受定型门约束(不落盘;采用仍需常规保存流程)。
2049
+ disposers.push(register('/persona/draft', async (_req, res) => {
2050
+ const s = settings.get();
2051
+ const p = s.persona || {};
2052
+ const parts = [];
2053
+ const toneName = { natural: '自然亲切', literary: '文雅', concise: '简洁直接', playful: '活泼俏皮' }[p.tone] || p.tone || '自然';
2054
+ parts.push('自称:' + (p.aiName || '(未设置)') + ' | 对用户称呼:' + (p.userTitle || '(用你)'));
2055
+ parts.push('语气:' + toneName + ' | 语言:' + (p.language === 'zh' ? '中文' : p.language === 'en' ? 'English' : '跟随用户'));
2056
+ if (Array.isArray(p.bottomLines) && p.bottomLines.length) parts.push('底线:\n' + p.bottomLines.map((x) => '- ' + x).join('\n'));
2057
+ if (Array.isArray(p.hardRules) && p.hardRules.length) parts.push('规矩(用户的指令):\n' + p.hardRules.map((x) => '- ' + x).join('\n'));
2058
+ if (Array.isArray(p.habits) && p.habits.length) parts.push('习惯(她长出来的):\n' + p.habits.map((x) => '- ' + (x?.text || x)).join('\n'));
2059
+ if (typeof p.extraLore === 'string' && p.extraLore.trim()) parts.push('扩展设定/身世:\n' + p.extraLore.trim().slice(0, 1200));
2060
+ parts.push('工作模式风格:' + (s.styles?.work || '(空)'));
2061
+ parts.push('生活模式风格:' + (s.styles?.life || '(空)'));
2062
+ if (!parts.length) return sendJson(res, 200, { ok: false, reason: 'empty', message: '档案为空,先填一些内容再起草' });
2063
+ try {
2064
+ const { text } = await llmOnce(ctx, {
2065
+ system: '你是人格档案文案起草助手。依据档案要素起草「定位自述」:说清 AI 是谁、什么气质、与用户是什么关系,自然、克制、不空泛、不堆砌辞藻,与档案语气一致,每段不超过 200 字。输出 1 到 3 个候选,每行一个:不带序号、不加引号、不写解释。',
2066
+ text: parts.join('\n'),
2067
+ maxTokens: 600,
2068
+ effort: 'low',
2069
+ });
2070
+ const candidates = String(text || '')
2071
+ .split('\n').map((l) => l.trim().replace(/^[\s\-•·*]*\d*[.、))]\s*/, '').replace(/^["「『]|["」』]$/g, '').trim())
2072
+ .filter((l) => l.length >= 4 && l.length <= 200)
2073
+ .filter((v, i, a) => a.indexOf(v) === i)
2074
+ .slice(0, 3);
2075
+ if (!candidates.length && String(text || '').trim().length <= 220) candidates.push(String(text).trim());
2076
+ if (!candidates.length) return sendJson(res, 200, { ok: false, reason: 'empty-reply', message: '模型没有给出可用候选,再试一次' });
2077
+ sendJson(res, 200, { ok: true, candidates });
2078
+ } catch (e) {
2079
+ sendJson(res, 200, { ok: false, reason: 'llm', message: '起草失败:' + String(e?.message ?? e).slice(0, 160) });
2080
+ }
2081
+ }));
2082
+
2083
+ // POST /dsweb/scan { db?, limit? } 网页端历史库(聊天记录 .db)增量扫描入库
2084
+ disposers.push(register('/dsweb/scan', async (req, res) => {
2085
+ const body = JSON.parse((await readBody(req)) || '{}');
2086
+ const dbPath = String(body.db || DSWEB_DB_DEFAULT || '').trim();
2087
+ if (!dbPath) {
2088
+ return sendJson(res, 200, {
2089
+ ok: false, reason: 'no-db',
2090
+ message: '请先填写网页端聊天记录库的完整路径(本插件不预设私有路径;也可用 DSH_LING_DSWEB_DB 环境变量指定)',
2091
+ });
2092
+ }
2093
+ try {
2094
+ const r = scanIntoMemory(memory, dbPath, { limit: body.limit ? Number(body.limit) : 0 });
2095
+ const affected = allSnapshotSessionIds(gate).map((sid) => ({
2096
+ sessionId: sid,
2097
+ result: invalidateSession(gate, memory, settings, sid),
2098
+ }));
2099
+ logImport(memory, {
2100
+ kind: 'dsweb', name: String(dbPath).split(/[\\/]/).pop(),
2101
+ seen: r.seen, newRows: r.added, refreshed: r.refreshed,
2102
+ });
2103
+ sendJson(res, 200, { ok: true, db: dbPath, ...r, affected: affected.length });
2104
+ } catch (e) {
2105
+ sendJson(res, 200, { ok: false, reason: 'scan', message: '扫描失败:' + String(e?.message ?? e).slice(0, 200) });
2106
+ }
2107
+ }));
2108
+
2109
+ // POST /dsh/backfill { limit? } 通道 A:DSH 存量会话一键扫描入库(幂等;已存在跳过)
2110
+ disposers.push(register('/dsh/backfill', async (req, res) => {
2111
+ const body = JSON.parse((await readBody(req)) || '{}');
2112
+ try {
2113
+ const { report } = await scanDshHistory({ memory, limit: body.limit ? Number(body.limit) : 0 });
2114
+ const total = memory.db.prepare("SELECT COUNT(*) n FROM conv_overview WHERE source='dsh'").get().n;
2115
+ logImport(memory, { kind: 'dsh', name: '本机会话', seen: report.scanned, newRows: report.created });
2116
+ sendJson(res, 200, { ok: true, ...report, totalDsh: Number(total) });
2117
+ } catch (e) {
2118
+ sendJson(res, 200, { ok: false, reason: 'backfill', message: '扫描失败:' + String(e?.message ?? e).slice(0, 200) });
2119
+ }
2120
+ }));
2121
+
2122
+ // POST /import/file/batch { items, file?, runId?, at? } 通道 B:会话契约文件导入(每批 ≤500;客户端分页)
2123
+ // file/runId/at 仅用于记账:同一次导入(同 runId)的多批累加进**一条**账,at = 导入开始时刻
2124
+ disposers.push(register('/import/file/batch', async (req, res) => {
2125
+ const body = JSON.parse((await readBody(req)) || '{}');
2126
+ const items = Array.isArray(body.items) ? body.items.slice(0, 500) : [];
2127
+ if (!items.length) return sendJson(res, 200, { ok: false, reason: 'empty', message: '本批没有条目' });
2128
+ try {
2129
+ const r = await applyImportItems(memory, items);
2130
+ logImport(memory, {
2131
+ kind: 'file', name: body.file, runId: body.runId, at: body.at,
2132
+ accepted: r.accepted, newRows: r.newRows, refreshed: r.refreshed,
2133
+ upgraded: r.upgraded, folded: r.folded, removedImport: r.removedImport,
2134
+ degraded: r.degraded, rejected: (r.rejected || []).length,
2135
+ });
2136
+ sendJson(res, 200, { ok: true, ...r });
2137
+ } catch (e) {
2138
+ sendJson(res, 200, { ok: false, reason: 'import', message: '导入失败:' + String(e?.message ?? e).slice(0, 200) });
2139
+ }
2140
+ }));
2141
+
2142
+ // POST /import/log { runId, at?, file?, errors? } 记账补充:客户端上报**失败批**(成功批由上面自己记账)
2143
+ disposers.push(register('/import/log', async (req, res) => {
2144
+ const body = JSON.parse((await readBody(req)) || '{}');
2145
+ const runId = String(body.runId || '').slice(0, 64);
2146
+ if (!runId) return sendJson(res, 200, { ok: false, reason: 'runId', message: '缺少 runId' });
2147
+ const key = logImport(memory, {
2148
+ kind: 'file', name: body.file, runId, at: body.at,
2149
+ errors: Math.max(1, Number(body.errors) || 1),
2150
+ });
2151
+ sendJson(res, 200, { ok: true, key });
2152
+ }));
2153
+
2154
+ // ---- 诞生仪式:一键深摘(import 源)+ 人格初稿 ----
2155
+ // POST /deep/run-import 对"文件导入"会话中**尚未读过**的批量深摘(后台串行;GET 轮询状态;
2156
+ // 已 done/skip 的跳过 —— 重复点击推进的是未读部分,不会重读)
2157
+ disposers.push(register('/deep/run-import', async (req, res) => {
2158
+ if (impRun.running) {
2159
+ return sendJson(res, 200, { ok: true, busy: true, total: impRun.total, finished: impRun.done.length });
2160
+ }
2161
+ const targets = importRawTargets(memory, { limit: 200, excludeProcessed: true }); // 一次跑完所有未读(含快速通过的短会话)
2162
+ if (!targets.length) {
2163
+ return sendJson(res, 200, { ok: false, reason: 'none', message: '已全部读过(可换新导入的历史再来)' });
2164
+ }
2165
+ impRun.running = true;
2166
+ impRun.total = targets.length;
2167
+ impRun.done = [];
2168
+ impRun.startedAt = Date.now();
2169
+ impRun.finishedAt = 0;
2170
+ (async () => {
2171
+ for (const t of targets) {
2172
+ let r;
2173
+ try {
2174
+ r = await deepSummarizeOne(ctx, memory, candidateFor(memory, t.session_id));
2175
+ impRun.done.push({ id: t.session_id.slice(0, 8), ok: !!r.ok, chars: r.chars || null, reason: r.reason || null });
2176
+ } catch (e) {
2177
+ impRun.done.push({ id: t.session_id.slice(0, 8), ok: false, reason: 'err:' + String(e?.message ?? e).slice(0, 120) });
2178
+ }
2179
+ }
2180
+ impRun.running = false;
2181
+ impRun.finishedAt = Date.now();
2182
+ })();
2183
+ sendJson(res, 200, { ok: true, started: true, total: targets.length });
2184
+ }));
2185
+
2186
+ // GET /deep/run-status 一键深摘进度
2187
+ disposers.push(register('/deep/run-status', async (_req, res) => {
2188
+ sendJson(res, 200, {
2189
+ ok: true, running: impRun.running, total: impRun.total,
2190
+ done: impRun.done, startedAt: impRun.startedAt, finishedAt: impRun.finishedAt,
2191
+ });
2192
+ }));
2193
+
2194
+ // ---- 网页端历史(dsweb)补摘要:候选预览 / 后台串行执行 / 进度 ----
2195
+ // 为什么要有 UI:CLI 只适合本机;给别人用时,引擎选择必须是**显式动作** —— 绝不静默花用户的钱。
2196
+ const dswebDbOf = (v) => String(v || DSWEB_DB_DEFAULT || '').trim();
2197
+ const dswebQuery = (req) => new URL(String(req.url || '/'), 'http://127.0.0.1').searchParams;
2198
+
2199
+ // GET /dsweb/summary/preview?db=&minTurns=&all=1&onlyHit=1&limit=
2200
+ disposers.push(register('/dsweb/summary/preview', async (req, res) => {
2201
+ const q = dswebQuery(req);
2202
+ const db = dswebDbOf(q.get('db'));
2203
+ if (!db) {
2204
+ return sendJson(res, 200, {
2205
+ ok: false, reason: 'no-db',
2206
+ message: '请先填写网页端聊天记录库(.db)的完整路径(也可用 DSH_LING_DSWEB_DB 环境变量指定)',
2207
+ });
2208
+ }
2209
+ const minTurns = Number(q.get('minTurns') || MIN_TURNS_DEFAULT) || MIN_TURNS_DEFAULT;
2210
+ const includeShort = q.get('all') === '1';
2211
+ const onlyHit = q.get('onlyHit') === '1';
2212
+ const limit = Math.max(1, Math.min(Number(q.get('limit') || 20) || 20, 200));
2213
+ let src;
2214
+ try { src = openSource(db); } catch (e) {
2215
+ return sendJson(res, 200, { ok: false, reason: 'db', message: '打不开源库:' + String(e?.message ?? e).slice(0, 160) });
2216
+ }
2217
+ try {
2218
+ const list = buildCandidates(memory, src, { minTurns, includeShort, onlyHit });
2219
+ const asst = resolveAssistant(settings);
2220
+ const assistant = await probeAssistant(asst.baseUrl);
2221
+ sendJson(res, 200, {
2222
+ ok: true, db, total: list.length, minTurns, includeShort, onlyHit,
2223
+ assistant: { ok: assistant.ok, reason: assistant.reason, models: assistant.models, base: asst.baseUrl, model: asst.model, source: asst.source },
2224
+ items: list.slice(0, limit),
2225
+ });
2226
+ } catch (e) {
2227
+ sendJson(res, 200, { ok: false, reason: 'preview', message: String(e?.message ?? e).slice(0, 200) });
2228
+ } finally { try { src.close(); } catch { /* ignore */ } }
2229
+ }));
2230
+
2231
+ // POST /dsweb/summary/run { db, engine:'assistant'|'global', limit?, minTurns?, all?, onlyHit?, baseUrl?, model? }
2232
+ disposers.push(register('/dsweb/summary/run', async (req, res) => {
2233
+ if (dswebRun.running) {
2234
+ return sendJson(res, 200, { ok: true, busy: true, total: dswebRun.total, finished: dswebRun.done.length });
2235
+ }
2236
+ const body = JSON.parse((await readBody(req)) || '{}');
2237
+ const db = dswebDbOf(body.db);
2238
+ if (!db) return sendJson(res, 200, { ok: false, reason: 'no-db', message: '请先填写网页端聊天记录库路径' });
2239
+ const engine = body.engine === 'global' ? 'global' : 'assistant';
2240
+ const asst = resolveAssistant(settings);
2241
+ const baseUrl = String(body.baseUrl || asst.baseUrl);
2242
+ const model = String(body.model || asst.model);
2243
+ if (engine === 'assistant') {
2244
+ const probe = await probeAssistant(baseUrl);
2245
+ if (!probe.ok) {
2246
+ return sendJson(res, 200, {
2247
+ ok: false, reason: 'no-assistant',
2248
+ message: '本机小助手探测失败(' + probe.reason + ' @ ' + baseUrl + ')—— 我们不会替你静默换引擎;请确认它在线,或**显式**改选「当前全局大模型」。',
2249
+ });
2250
+ }
2251
+ }
2252
+ let src;
2253
+ try { src = openSource(db); } catch (e) {
2254
+ return sendJson(res, 200, { ok: false, reason: 'db', message: '打不开源库:' + String(e?.message ?? e).slice(0, 160) });
2255
+ }
2256
+ const minTurns = Number(body.minTurns || MIN_TURNS_DEFAULT) || MIN_TURNS_DEFAULT;
2257
+ const includeShort = !!body.all;
2258
+ const onlyHit = !!body.onlyHit;
2259
+ const cap = Math.max(0, Math.min(Number(body.limit || 0) || 0, 2000));
2260
+ let list;
2261
+ try {
2262
+ list = buildCandidates(memory, src, { minTurns, includeShort, onlyHit });
2263
+ } catch (e) {
2264
+ try { src.close(); } catch { /* ignore */ }
2265
+ return sendJson(res, 200, { ok: false, reason: 'candidates', message: String(e?.message ?? e).slice(0, 200) });
2266
+ }
2267
+ if (cap) list = list.slice(0, cap);
2268
+ if (!list.length) {
2269
+ try { src.close(); } catch { /* ignore */ }
2270
+ return sendJson(res, 200, { ok: false, reason: 'none', message: '没有待补摘要的候选(可能都已补过,或轮次数不足)' });
2271
+ }
2272
+ dswebRun.running = true; dswebRun.total = list.length; dswebRun.done = [];
2273
+ dswebRun.ok = 0; dswebRun.fail = 0; dswebRun.current = ''; dswebRun.engine = engine;
2274
+ dswebRun.startedAt = Date.now(); dswebRun.finishedAt = 0; dswebRun.lastError = null; dswebRun.db = db;
2275
+ (async () => {
2276
+ try {
2277
+ for (const it of list) {
2278
+ dswebRun.current = String(it.title || it.conv_id).slice(0, 40);
2279
+ const short = String(it.conv_id).slice(0, 8);
2280
+ try {
2281
+ const text = conversationText(src, it.conv_id);
2282
+ if (!text) { dswebRun.fail += 1; dswebRun.done.push({ id: short, ok: false, reason: 'no-text' }); continue; }
2283
+ let summary = '';
2284
+ let reason = null;
2285
+ if (engine === 'assistant') {
2286
+ const r = await summarizeWithRetry(baseUrl, model, { text, system: SUMMARY_SYS });
2287
+ summary = r.ok ? String(r.summary || '') : '';
2288
+ reason = r.ok ? null : (r.error || 'failed');
2289
+ } else {
2290
+ const r = await llmOnce(ctx, { system: SUMMARY_SYS, text, maxTokens: 600, effort: 'off' });
2291
+ summary = cleanSummary(r?.text);
2292
+ reason = summary ? null : 'empty';
2293
+ }
2294
+ if (!summary) { dswebRun.fail += 1; dswebRun.lastError = reason; dswebRun.done.push({ id: short, ok: false, reason }); continue; }
2295
+ const ov = memory.overviewById('dsweb', it.conv_id);
2296
+ if (ov) memory.upsertOverview({ ...ov, summary });
2297
+ dswebRun.ok += 1; dswebRun.done.push({ id: short, ok: true, chars: summary.length });
2298
+ } catch (e) {
2299
+ dswebRun.fail += 1;
2300
+ dswebRun.lastError = String(e?.message ?? e).slice(0, 120);
2301
+ dswebRun.done.push({ id: short, ok: false, reason: 'err' });
2302
+ }
2303
+ }
2304
+ } finally {
2305
+ touchMemoryVersion(memory); // 补摘要改了记忆内容 → 长会话空闲时才会追平
2306
+ try { src.close(); } catch { /* ignore */ }
2307
+ dswebRun.running = false; dswebRun.finishedAt = Date.now(); dswebRun.current = '';
2308
+ }
2309
+ })();
2310
+ sendJson(res, 200, { ok: true, started: true, total: list.length, engine });
2311
+ }));
2312
+
2313
+ // GET /dsweb/summary/status 补摘要进度(前端 2 秒轮询)
2314
+ disposers.push(register('/dsweb/summary/status', async (_req, res) => {
2315
+ sendJson(res, 200, {
2316
+ ok: true, running: dswebRun.running, total: dswebRun.total, finished: dswebRun.done.length,
2317
+ okCount: dswebRun.ok, failCount: dswebRun.fail, current: dswebRun.current, engine: dswebRun.engine,
2318
+ startedAt: dswebRun.startedAt, finishedAt: dswebRun.finishedAt, lastError: dswebRun.lastError,
2319
+ tail: dswebRun.done.slice(-5),
2320
+ });
2321
+ }));
2322
+
2323
+ // POST /persona/genesis { scope?: 'import'|'all' }
2324
+ // 合成"她对自己的第一次介绍"。原料:scope='import'=刚请进来的历史;'all'=全部记忆
2325
+ // (生活/情感优先加权抽样)。每行三级取用:深摘 → 非占位概述 → 原文片段/线索。
2326
+ disposers.push(register('/persona/genesis', async (req, res) => {
2327
+ const body = JSON.parse((await readBody(req)) || '{}');
2328
+ const scope = body.scope === 'all' ? 'all' : 'import';
2329
+ const pn = pronounOf(settings.get());
2330
+ const comp = composeGenesisRows(memory, { scope, pronoun: pn });
2331
+ const sourceText = buildGenesisSource(comp.rows);
2332
+ if (!sourceText) {
2333
+ return sendJson(res, 200, { ok: false, reason: 'empty', message: pronounize('还没有可读的历史——先在「接入历史」把一段对话请进来,她才能开始认识自己。', pronounOf(settings.get())) });
2334
+ }
2335
+ try {
2336
+ const { text } = await llmOnce(ctx, {
2337
+ system: pronounize(GENESIS_SYS, pn),
2338
+ text: pronounize(`请阅读以下"她与用户的共同历史材料"(${scope === 'all' ? '来自全部记忆,已按生活/情感优先抽样 ' + comp.sampled + ' 条' : '来自刚导入的一段历史 ' + comp.sampled + ' 条'}),完成自我介绍:\n\n`, pn) + sourceText,
2339
+ maxTokens: 1600,
2340
+ effort: 'low',
2341
+ });
2342
+ const result = parseGenesisResult(text);
2343
+ if (!result.self_intros.length && !result.name_pairs.length) {
2344
+ return sendJson(res, 200, { ok: false, reason: 'parse', message: '模型输出无法解读,再试一次' });
2345
+ }
2346
+ // 名字必须属于"她":剔除用户称谓/称呼(userTitle 成分 + 常见称谓词)
2347
+ const userTitle = settings.get().persona?.userTitle || '';
2348
+ result.name_pairs = filterNamePairs(result.name_pairs, userTitle);
2349
+ memory.kvSet('genesis.last', JSON.stringify({ at: new Date().toISOString(), scope, result }));
2350
+ sendJson(res, 200, { ok: true, scope, sampled: comp.sampled, result });
2351
+ } catch (e) {
2352
+ sendJson(res, 200, { ok: false, reason: 'llm', message: '诞生仪式失败:' + String(e?.message ?? e).slice(0, 160) });
2353
+ }
2354
+ }));
2355
+
2356
+ // ---- 成长回路(修订建议队列,人审闭环) ----
2357
+ // GET /feedback
2358
+ disposers.push(register('/feedback', async (_req, res) => {
2359
+ const items = memory.listFeedback({ status: 'new' }).map((it) => ({
2360
+ id: it.id, created_at: it.created_at, session_id: it.session_id,
2361
+ note: it.note, rating: it.rating,
2362
+ }));
2363
+ sendJson(res, 200, {
2364
+ ok: true,
2365
+ items,
2366
+ stats: {
2367
+ new: items.length,
2368
+ applied: memory.countFeedback('applied'),
2369
+ dismissed: memory.countFeedback('dismissed'),
2370
+ },
2371
+ });
2372
+ }));
2373
+
2374
+ // POST /feedback/apply { id }
2375
+ disposers.push(register('/feedback/apply', async (req, res) => {
2376
+ const body = JSON.parse((await readBody(req)) || '{}');
2377
+ const r = await applySuggestionAsRule(memory, settings, body.id);
2378
+ if (!r.ok) return sendJson(res, 200, { ok: false, reason: r.reason });
2379
+ await archivePersona(settings.get(), 'result'); // 惯例成长也留档
2380
+ const affected = allSnapshotSessionIds(gate).map((sid) => ({
2381
+ sessionId: sid,
2382
+ result: invalidateSession(gate, memory, settings, sid),
2383
+ }));
2384
+ sendJson(res, 200, {
2385
+ ok: true, id: r.id, hardRules: r.hardRules,
2386
+ affected: affected.length,
2387
+ l0PreviewText: l0WithClock(settings.get(), settings.get().mode?.lastMode === 'work' ? 'work' : 'life'),
2388
+ });
2389
+ }));
2390
+
2391
+ // POST /feedback/dismiss { id }
2392
+ disposers.push(register('/feedback/dismiss', async (req, res) => {
2393
+ const body = JSON.parse((await readBody(req)) || '{}');
2394
+ sendJson(res, 200, dismissSuggestion(memory, body.id));
2395
+ }));
2396
+
2397
+ // ---- 语料提炼建议(人格档案) ----
2398
+ // GET /suggestions
2399
+ disposers.push(register('/suggestions', async (_req, res) => {
2400
+ const items = memory.listPersonaSuggestions({ status: 'new' }).map((it) => ({
2401
+ id: it.id, kind: it.kind, value: it.value, note: it.note, evidence: it.evidence,
2402
+ }));
2403
+ sendJson(res, 200, {
2404
+ ok: true,
2405
+ items,
2406
+ stats: {
2407
+ new: items.length,
2408
+ adopted: memory.countPersonaSuggestion('adopted'),
2409
+ dismissed: memory.countPersonaSuggestion('dismissed'),
2410
+ },
2411
+ });
2412
+ }));
2413
+
2414
+ // POST /suggestions/apply { id }
2415
+ disposers.push(register('/suggestions/apply', async (req, res) => {
2416
+ const body = JSON.parse((await readBody(req)) || '{}');
2417
+ const row = memory.getPersonaSuggestion(Number(body.id));
2418
+ const kind = row?.kind ?? '';
2419
+ // 定型门:自称/称呼属身份保护区,采纳需钥匙;惯例类建议(成长回路)可自由追加
2420
+ if (kind === 'aiName' || kind === 'userTitle') {
2421
+ const cur = settings.get();
2422
+ const g = cur.persona?.sealed === true ? sealOk(cur.persona, body.unlock) : { pass: true, adoptKey: null };
2423
+ if (!g.pass) {
2424
+ return sendJson(res, 200, { ok: false, reason: 'sealed', message: '人格已定型 — 采纳「' + (kind === 'aiName' ? '自称' : '称呼') + '」需先输入承诺句(' + MSG_KEY + ')。惯例类建议仍可直接采纳。' });
2425
+ }
2426
+ if (g.adoptKey) await settings.update({ persona: { sealPhrase: g.adoptKey } });
2427
+ }
2428
+ const r = await applyCorpusSuggestion(memory, settings, body.id);
2429
+ if (!r.ok) return sendJson(res, 200, { ok: false, reason: r.reason });
2430
+ await archivePersona(settings.get(), 'result'); // 建议采纳后的档案留档
2431
+ const affected = allSnapshotSessionIds(gate).map((sid) => ({
2432
+ sessionId: sid,
2433
+ result: invalidateSession(gate, memory, settings, sid),
2434
+ }));
2435
+ sendJson(res, 200, {
2436
+ ok: true, id: r.id, kind: r.kind, kindName: r.kindName, value: r.value,
2437
+ affected: affected.length,
2438
+ l0PreviewText: l0WithClock(settings.get(), settings.get().mode?.lastMode === 'work' ? 'work' : 'life'),
2439
+ });
2440
+ }));
2441
+
2442
+ // POST /suggestions/dismiss { id }
2443
+ disposers.push(register('/suggestions/dismiss', async (req, res) => {
2444
+ const body = JSON.parse((await readBody(req)) || '{}');
2445
+ sendJson(res, 200, dismissCorpusSuggestion(memory, body.id));
2446
+ }));
2447
+
2448
+ // ---- LLM 深摘要 ----
2449
+ // GET /deep/status
2450
+ disposers.push(register('/deep/status', async (_req, res) => {
2451
+ const probeRaw = memory.kvGet('llm.probe');
2452
+ const lastRaw = memory.kvGet('deep.last');
2453
+ const doneRow = memory.db.prepare("SELECT COUNT(*) n FROM kv WHERE key LIKE 'deep:%' AND value LIKE 'done:%'").get();
2454
+ sendJson(res, 200, {
2455
+ ok: true,
2456
+ probe: probeRaw ? JSON.parse(probeRaw) : null,
2457
+ last: lastRaw ? JSON.parse(lastRaw) : null,
2458
+ done: doneRow ? Number(doneRow.n) : 0,
2459
+ });
2460
+ }));
2461
+
2462
+ // POST /deep/run { limit? }
2463
+ disposers.push(register('/deep/run', async (req, res) => {
2464
+ const body = JSON.parse((await readBody(req)) || '{}');
2465
+ const r = await runDeepPass(ctx, memory, { limit: body.limit ? Number(body.limit) : undefined });
2466
+ sendJson(res, 200, { ok: r.ok, reason: r.reason, candidates: r.candidates, done: r.done });
2467
+ }));
2468
+
2469
+ // POST /deep/one { sessionId } — 对单个会话强制重深摘(自动过 skip/done)
2470
+ disposers.push(register('/deep/one', async (req, res) => {
2471
+ const body = JSON.parse((await readBody(req)) || '{}');
2472
+ const sid = body.sessionId;
2473
+ if (!sid) return sendJson(res, 400, { ok: false, error: 'sessionId required' });
2474
+ rerunOne(memory, sid);
2475
+ const cand = candidateFor(memory, sid);
2476
+ if (!cand) {
2477
+ return sendJson(res, 200, { ok: false, reason: 'no-candidate', message: '该会话没有可深摘的内容' });
2478
+ }
2479
+ const r = await deepSummarizeOne(ctx, memory, cand);
2480
+ let message;
2481
+ if (!r.ok) {
2482
+ if (r.reason === 'below-min') message = '会话较短,未达深摘门槛(短会话无需深摘;它的标题与片段已可检索),是正常现象';
2483
+ else if (r.reason === 'no-overview') message = '该会话没有可回写深摘的档案行';
2484
+ }
2485
+ sendJson(res, 200, { ok: r.ok, sessionId: sid, reason: r.reason || null, message, chars: r.chars || null });
2486
+ }));
2487
+
2488
+ // GET /export
2489
+ disposers.push(register('/export', async (req, res) => {
2490
+ const q = new URL(req.url, 'http://dsh.internal').searchParams;
2491
+ const bundle = memory.exportBundle({ includeRaw: q.get('includeRaw') === '1' });
2492
+ const s = settings.get();
2493
+ // 人格/风格/最后模式随包附载(默认不在合并时覆盖目标)
2494
+ bundle.persona = {
2495
+ persona: s.persona || {},
2496
+ styles: s.styles || {},
2497
+ lastMode: s.mode?.lastMode || 'life',
2498
+ };
2499
+ const payload = JSON.stringify(bundle, null, 2);
2500
+ res.statusCode = 200;
2501
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
2502
+ res.setHeader('Content-Disposition', `attachment; filename="dsh-ling-memory-${new Date().toISOString().slice(0, 10)}.dshling.json"`);
2503
+ res.end(payload);
2504
+ }));
2505
+
2506
+ // POST /import body = bundle(JSON) 或 { bundle, persona?: true };?overwrite=1
2507
+ disposers.push(register('/import', async (req, res) => {
2508
+ const q = new URL(req.url, 'http://dsh.internal').searchParams;
2509
+ const text = await readBody(req);
2510
+ const parsed = JSON.parse(text || '{}');
2511
+ const bundle = parsed.bundle || parsed;
2512
+ const report = memory.importBundle(bundle, { overwrite: q.get('overwrite') === '1' });
2513
+ // 人格默认不覆盖;仅当显式 persona:true(如备份恢复)才合并(外来条目不污染本地人格)
2514
+ let personaApplied = false;
2515
+ if (parsed.persona === true && bundle.persona) {
2516
+ const cur = settings.get();
2517
+ // 定型门:已定型时整体覆盖人格需钥匙
2518
+ const g = cur.persona?.sealed === true ? sealOk(cur.persona, parsed.unlock) : { pass: true, adoptKey: null };
2519
+ if (!g.pass) {
2520
+ return sendJson(res, 200, { ok: false, reason: 'sealed', message: MSG_KEY + '只并入记忆/建议则不受影响。', report });
2521
+ }
2522
+ const p = bundle.persona.persona || {};
2523
+ const st = bundle.persona.styles || {};
2524
+ if (g.adoptKey) p.sealPhrase = g.adoptKey;
2525
+ const { clean } = sanitizePersonaPatch({ persona: p, styles: st }); // 加固④:外来 bundle 同样过白名单
2526
+ await settings.update(clean);
2527
+ await archivePersona(settings.get(), 'result'); // 备份覆盖后档案留档
2528
+ personaApplied = true;
2529
+ for (const sid of allSnapshotSessionIds(gate)) {
2530
+ invalidateSession(gate, memory, settings, sid);
2531
+ }
2532
+ }
2533
+ sendJson(res, 200, { ok: report.errors === 0, personaApplied, report });
2534
+ }));
2535
+
2536
+ return () => {
2537
+ for (const d of disposers) {
2538
+ try {
2539
+ d();
2540
+ } catch {}
2541
+ }
2542
+ };
2543
+ };
2544
+
2545
+ const reqSessionId = (url) => {
2546
+ const q = new URL(url || '/', 'http://dsh.internal').searchParams;
2547
+ return q.get('sessionId') || q.get('sid') || null;
2548
+ };
2549
+
2550
+ tryMount();
2551
+ if (!disposeRoutes) {
2552
+ // 服务启动顺序兜底:每 3s 重试,成功或插件卸载即停
2553
+ timer = setInterval(() => {
2554
+ if (disposed || disposeRoutes) {
2555
+ clearInterval(timer);
2556
+ timer = null;
2557
+ return;
2558
+ }
2559
+ tryMount();
2560
+ }, 3000);
2561
+ }
2562
+
2563
+ return () => {
2564
+ disposed = true;
2565
+ if (timer) {
2566
+ clearInterval(timer);
2567
+ timer = null;
2568
+ }
2569
+ if (disposeRoutes) {
2570
+ try {
2571
+ disposeRoutes();
2572
+ } catch {}
2573
+ disposeRoutes = null;
2574
+ }
2575
+ };
2576
+ }
2577
+
2578
+ function allSnapshotSessionIds(gate) {
2579
+ return gate.snapshotIds();
2580
+ }
2581
+
2582
+ function rawTurnSessionCount(memory) {
2583
+ return memory.rawSessionCount();
2584
+ }