@nightpainters/dsh-ling 1.6.2 → 1.7.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.
package/lib/host/api.js CHANGED
@@ -9,7 +9,7 @@ import { sessionLineProbe, sessionLineState } from './session-line.js'; // E3(20
9
9
  import { selectL1 } from './l1.js';
10
10
  import { applySuggestionAsRule, dismissSuggestion } from './feedback.js';
11
11
  import { applyCorpusSuggestion, dismissCorpusSuggestion } from './suggestions.js';
12
- import { runDeepPass, rerunOne, candidateFor, deepSummarizeOne, llmOnce, importRawTargets } from './deepsummary.js';
12
+ import { runDeepPass, rerunOne, candidateFor, deepSummarizeOne, llmOnce, importRawTargets, resolveAuto, storedAuto } from './deepsummary.js';
13
13
  import { GENESIS_SYS, parseGenesisResult, buildGenesisSource, composeGenesisRows, filterNamePairs } from './genesis.js';
14
14
  import { scanIntoMemory } from './scan-dsweb.js';
15
15
  import { scanDshHistory } from './backfill.js';
@@ -21,6 +21,9 @@ import { logImport, listImportLog, formatLogLine, cleanTooBig, cleanTooBigNames,
21
21
  import { checkRequest, lanAddresses, sanitizePersonaPatch } from './guard.js';
22
22
  // 手术门(2026-09-30 深夜):本机判据 / 每启动票据 / 承诺句 / 哈希链留痕 —— 判据本体只在 surgery.js 一处。
23
23
  // ⚠️ 上面那行 guard.js 的 import **不许动形态**:tests/guard.test.mjs:139 逐字匹配它。
24
+ // `/auto` 的白名单(2026-10-16)因此**另起一行**:并进上面那行会把那条逐字匹配的护栏打红,
25
+ // 而它护的是"api.js 真的接上了守卫"这件事,不该为了少一行 import 去动它。
26
+ import { sanitizeAutoPatch } from './guard.js';
24
27
  import {
25
28
  gateSurgery, isLocalSurgeryRequest, readSurgeryTicket, rotateSurgeryTicket,
26
29
  listSurgeryEvents,
@@ -44,6 +47,9 @@ import {
44
47
  openSource, buildCandidates, conversationText, probeAssistant, summarizeWithRetry, cleanSummary,
45
48
  touchMemoryVersion, resolveAssistant, MIN_TURNS_DEFAULT, SUMMARY_SYS,
46
49
  } from './dsweb-summary.js';
50
+ // 1.7 图书馆导入通道的**模型段**(契约 §11.1):两档引擎(小助手 / 全局)的接线都归那个模块一处所有,
51
+ // 本文件只负责"把 body 里的引擎名翻成一个 hook + 一个如实的 label"。
52
+ import { IMPORT_ENGINES, makeImportModelHook } from './library-import-engine.js';
47
53
  import { TRUNK_ID } from './memory.js';
48
54
  import { bucketize, nameBucket } from './autotree.js';
49
55
  import { accessLogStatus } from '../audit/index.js';
@@ -377,7 +383,11 @@ export function readBody(req, maxBytes = BODY_MAX_BYTES, opts = {}) {
377
383
  }
378
384
 
379
385
  export function registerApi(ctx, deps, config) {
380
- const { gate, memory, settings } = deps;
386
+ // 1.7(2026-10-07):多一个**图书馆运行时**依赖(`library`)。它由主线实现并注入同一个实例
387
+ // (index.js 的 API / 全局工具 / agent 级工具三处收同一份 —— 见 1.7-integration-design §Configuration)。
388
+ // ⚠️ 它是**可选**的:未注入(老调用方 / 测试)时,/library/* 一律回 503 unavailable,
389
+ // 其余 40+ 端点**一个字都不变** —— "图书馆不可用只降级图书馆"这条是契约硬要求。
390
+ const { gate, memory, settings, library } = deps;
381
391
 
382
392
  // ── 手术门:票据**每启动轮换**(规格 §1.2)──────────────────────────────────────
383
393
  // 这一句就是"每启动"的落点:插件随宿主启动装配一次 ⇒ 覆盖写一枚新的 32 字节随机票,
@@ -389,6 +399,14 @@ export function registerApi(ctx, deps, config) {
389
399
  let disposed = false;
390
400
  let disposeRoutes = null;
391
401
  let timer = null;
402
+ const cleanup = () => {
403
+ disposed = true;
404
+ if (timer) { clearInterval(timer); timer = null; }
405
+ if (disposeRoutes) {
406
+ const release = disposeRoutes; disposeRoutes = null;
407
+ release();
408
+ }
409
+ };
392
410
 
393
411
  const tryMount = () => {
394
412
  if (disposed || disposeRoutes) return;
@@ -400,9 +418,11 @@ export function registerApi(ctx, deps, config) {
400
418
  ctx.inject?.(['webServer'], (child) => {
401
419
  scopeCtx = child;
402
420
  const s2 = child?.webServer;
403
- if (s2 && typeof s2.register === 'function' && !disposeRoutes) {
404
- disposeRoutes = mountRoutes(s2);
405
- memory.kvSet('api.registered', '1');
421
+ if (!disposed && s2 && typeof s2.register === 'function' && !disposeRoutes) {
422
+ try {
423
+ disposeRoutes = mountRoutes(s2);
424
+ memory.kvSet('api.registered', '1');
425
+ } catch (error) { cleanup(); throw error; }
406
426
  }
407
427
  });
408
428
  } catch (e) {
@@ -410,7 +430,7 @@ export function registerApi(ctx, deps, config) {
410
430
  }
411
431
  server = scopeCtx?.webServer ?? null;
412
432
  }
413
- if (server && typeof server.register === 'function') {
433
+ if (!disposed && !disposeRoutes && server && typeof server.register === 'function') {
414
434
  disposeRoutes = mountRoutes(server);
415
435
  memory.kvSet('api.registered', '1');
416
436
  }
@@ -418,6 +438,22 @@ export function registerApi(ctx, deps, config) {
418
438
 
419
439
  const mountRoutes = (server) => {
420
440
  const disposers = [];
441
+ const pendingLibraryChecks = new Set();
442
+ const releaseRoutes = () => {
443
+ for (const controller of pendingLibraryChecks) {
444
+ if (controller.signal.aborted) continue;
445
+ const error = new Error('插件卸载,本次核对被取消(不写核对留痕)。');
446
+ error.code = 'unloaded'; error.cancelled = true;
447
+ controller.abort(error);
448
+ }
449
+ pendingLibraryChecks.clear();
450
+ for (const dispose of disposers.splice(0).reverse()) {
451
+ try { if (typeof dispose === 'function') dispose(); } catch { /* continue releasing all routes */ }
452
+ }
453
+ };
454
+ // Registration may throw after some routes have mounted. Roll those back
455
+ // here: the caller has not yet received a disposer for this partial batch.
456
+ try {
421
457
  // 一键深摘(import 源)的运行状态:单实例串行,防并发重入
422
458
  const impRun = { running: false, total: 0, done: [], startedAt: 0, finishedAt: 0 };
423
459
  // 网页端库"补摘要"运行状态(与深摘同纪律:单实例串行 + 轮询进度)
@@ -461,7 +497,7 @@ export function registerApi(ctx, deps, config) {
461
497
  '/deep/run-import', '/deep/run', '/deep/one',
462
498
  '/dsweb/summary/run',
463
499
  '/feedback/apply', '/feedback/dismiss', '/suggestions/apply', '/suggestions/dismiss',
464
- // ── 1.5.3(2026-09-29 夜,尝生批;依据作者的审计台账《1.5.3 端点写面 · 未进 POST_ONLY》,该台账不随包公开)──
500
+ // ── 1.5.3(2026-09-29 夜,用户批;依据作者的审计台账《1.5.3 端点写面 · 未进 POST_ONLY》,该台账不随包公开)──
465
501
  // ⚠️ 版本号由主会话校正:本批**尚未发布**(`package.json` 仍是 `1.5.3-dev`)⇒ 归 **1.5.3**;
466
502
  // 原先这里写的 `1.5.4` 是修复代理想当然的版本号,仓库里从无 1.5.4 的计划。
467
503
  // 三条都是**形态上就不该由 GET 执行**的端点,补进同一处声明(仍守"一处维护、一眼看全"):
@@ -475,6 +511,47 @@ export function registerApi(ctx, deps, config) {
475
511
  // ── 1.5.3(2026-09-30 深夜,手术门)· 取票 = **发放凭据**的动作,形态上就不该由 GET 执行 ──
476
512
  // `/surgery/ticket` 只对**本机直连**(无代理头 **且** 带 UA)发一枚 32 字节票;**不缓存**(no-store)—— 票据是每启动轮换的。
477
513
  '/surgery/ticket',
514
+ // ── 1.7 图书馆(2026-10-07)· 四个写面 ────────────────────────────────────────────
515
+ // 与其余写面同一个取舍:集中在这张表里声明,而不是在 4 个 register 调用点各写一遍。
516
+ // 语义:create/revise/withdraw 会**改库**(新版本 / 留痕撤下),check 会**落一条核对留痕**
517
+ // (`library_check` 行)—— 四者都不是"读",故 GET 一律 405。
518
+ '/library/create', '/library/revise', '/library/withdraw', '/library/check',
519
+ // ── 1.7 图书馆 · 导入通道(2026-10-10)· 两条写面 ──────────────────────────────
520
+ // `analyze` 本身不写库,但它**收下整份原文**(可达 importTextMaxBytes = 1 MiB)、
521
+ // 且带模型段(能花全局模型额度)⇒ 形态上就不该由 GET 执行;
522
+ // `commit` 会**真落库**(逐条 create)⇒ 与其余四个写面同一类。
523
+ '/library/import/analyze', '/library/import/commit',
524
+ // ── 1.7 · 后台自动行为(2026-10-16)· 一条写面 ─────────────────────────────────
525
+ // `/auto` 会**改 settings.auto**(关掉自动深摘 / 反馈回路 ⇒ 直接改插件以后的行为),
526
+ // 与 /persona 同一类:写半边只许 POST ⇒ 进这张表(`GET` 拿不到写分支)。
527
+ // ⚠️ 它是**双方法路径**(GET 读 / POST 写),读半边由下面的 `DUAL_METHOD_READ_PATHS` 放行 ——
528
+ // 不这么做,这道闸会把读半边一起 405 掉,面板就永远读不到自己的开关。
529
+ '/auto',
530
+ ]);
531
+
532
+ // ── 双方法路径(2026-10-16):同一路径 GET 读 + POST 写,读半边单独放行 ────────────────
533
+ // 为什么要另立一张表:上面那道方法闸是**整条路径**级的(`POST_ONLY.has(sub) && method !== 'POST'`),
534
+ // 而 `/auto` 两个半边都要 —— GET 读生效值(面板的开/关与"这个值谁定的"从这来),POST 写 auto 子树。
535
+ // `/assistant/config` 是同一个形状,但它**不在** POST_ONLY 里(见 guard.test.mjs 的差集快照第 643 行);
536
+ // `/auto` 选择"进表 + 放行读",因为这样"改行为的写面只许 POST"这句话在声明里看得见。
537
+ // 纪律:handler 内部按 method 分支,GET 分支**一个字节都不写盘**;纯读端点不许塞进这里(纯读走 GET_ONLY)。
538
+ const DUAL_METHOD_READ_PATHS = new Set(['/auto']);
539
+ // ── 1.7 图书馆 · **只读面**(2026-10-07)────────────────────────────────────────────
540
+ // 这是本文件**第一张 GET_ONLY 表**(此前只有"写面不许 GET"这半边闸):
541
+ // 契约 §Integration Routes 明写"Reads allow GET only",1.7-integration-design §API and tools
542
+ // 同样要求"Reject unsupported methods"。为什么读面也要**反向**收紧:
543
+ // 宿主路由表的 `WebRoute` 没有 method 字段,凡是注册成 exact 的路径**任何方法都会进来**
544
+ // (与 POST_ONLY 那段注释同源)⇒ 不钉死 GET,`POST /library/search` 就能带着 body 走一条
545
+ // 谁也不认识的路;而读回执是**源资料**面,不该有"带体的写形状"的入口。
546
+ // 取 405(不是 403):它挡的是"方法不对",与身份无关 —— 故与 POST_ONLY 一样**先于**鉴权。
547
+ const GET_ONLY = new Set([
548
+ '/library/status', '/library/search', '/library/item', '/library/history', '/library/checks',
549
+ '/library/browse',
550
+ // ── B 档(2026-10-15)· 深层库(结论层)只读面 ────────────────────────────────
551
+ // 纯读(`listDeepItems` + 两个 COUNT,不写一个字节);写面**不走这条路** ——
552
+ // 落条与作废是经主人授权的动作,走工具 `deep_item`。反向收紧的理由同上:
553
+ // 宿主路由表没有 method 字段,不钉死 GET,`POST /deep/items` 就能带体走进来。
554
+ '/deep/items',
478
555
  ]);
479
556
  // ── 手术门下沉(2026-09-30 深夜;规格 §1.3「门下沉为一处」)──────────────────────────
480
557
  // 判据本体**只在** lib/host/surgery.js 的 requireSurgery() 里;这里只是**唯一**的调用点。
@@ -508,7 +585,10 @@ export function registerApi(ctx, deps, config) {
508
585
  // 方法校验**先于鉴权** —— 它挡的是"浏览器被诱导发出的同站请求",与身份无关。
509
586
  // 两种 url 形态都能处理:带前缀(`/api/dsh-ling/x`)被 replace 剥掉,不带的原样保留。
510
587
  const sub = String(req.url || '').split('?')[0].replace(/^\/api\/dsh-ling/, '');
511
- if (POST_ONLY.has(sub) && String(req.method || 'GET').toUpperCase() !== 'POST') {
588
+ const methodNow = String(req.method || 'GET').toUpperCase();
589
+ // 双方法路径(见 DUAL_METHOD_READ_PATHS 声明处):`GET` 是它的读半边,放行到 handler 的读分支。
590
+ if (POST_ONLY.has(sub) && methodNow !== 'POST'
591
+ && !(methodNow === 'GET' && DUAL_METHOD_READ_PATHS.has(sub))) {
512
592
  // ── C-14(rejection 误标)──────────────────────────────────────────────
513
593
  // 上面第 225 行就把这次请求交给访问日志了,但**判定点在这里**:只按状态码反推的
514
594
  // rejectionReason() 分不清"哪个栅栏拒的"。故判定完成后把真因挂到请求对象上;
@@ -519,6 +599,14 @@ export function registerApi(ctx, deps, config) {
519
599
  sendJson(res, 405, { ok: false, error: 'method-not-allowed', want: 'POST' });
520
600
  return;
521
601
  }
602
+ // ── 1.7 图书馆 · 只读面反向收紧(2026-10-07)────────────────────────────────────
603
+ // 与上面 POST_ONLY 同一个位置、同一个理由:方法闸**先于鉴权**、更先于 handler。
604
+ // 回执形状照抄 POST_ONLY 那一支的既有约定(`error`/`want`),只把 want 换成 'GET'。
605
+ if (GET_ONLY.has(sub) && String(req.method || 'GET').toUpperCase() !== 'GET') {
606
+ req.__dshLingRejection = 'method-not-allowed';
607
+ sendJson(res, 405, { ok: false, error: 'method-not-allowed', want: 'GET' });
608
+ return;
609
+ }
522
610
  const verdict = guardVerdict(req);
523
611
  if (!verdict.ok) {
524
612
  req.__dshLingRejection = verdict.reason; // C-14:同上,写日志发生在下面的 sendJson→res.end 之内
@@ -532,7 +620,7 @@ export function registerApi(ctx, deps, config) {
532
620
  // 但调用点必须落在"载荷已经解析出来"的地方(见下),因为请求流只能读一次。
533
621
  // 每个写端点的形状固定为:`const body = JSON.parse(…); if (!gateSurgery(req, res, sendJson, body, { settings, logFile: surgeryLog, endpoint: sub })) return;`
534
622
  // · 这一句就是全部接线:判据(本机 / 每启动票据 / 承诺句)与拒绝回应都在 surgery.js;
535
- // · 覆盖面 = 12 条写面,逐条清单与"改前是否过门"见 surgery.js 头部注释。
623
+ // · 覆盖面 = 14 条写面,逐条清单与"改前是否过门"见 surgery.js 头部注释。
536
624
  try {
537
625
  await handler(req, res);
538
626
  } catch (e) {
@@ -1132,7 +1220,7 @@ export function registerApi(ctx, deps, config) {
1132
1220
  sendJson(res, 200, { ok: true, total: list.length, log: list });
1133
1221
  }));
1134
1222
 
1135
- // POST /branch-log/pending —— 复盘守门(2026-09-22 尝生:「互相复盘不应该被我绕过」)。
1223
+ // POST /branch-log/pending —— 复盘守门(2026-09-22 用户:「互相复盘不应该被我绕过」)。
1136
1224
  // 返回"自器灵上次开口以来累积了多少笔结构改动还没交代";侧栏据此提示,
1137
1225
  // 并在未同步时**拦住"退出复盘模式"** —— 改完树必须让器灵有机会看到。
1138
1226
  // ⚠️ 进 `POST_ONLY` 的原因:它不是纯读 —— `memory.branchLogPending()` 在"水位键缺失 +
@@ -1161,12 +1249,12 @@ export function registerApi(ctx, deps, config) {
1161
1249
  // GET /conflicts?status=pending|confirmed|dismissed —— 待复盘矛盾(带两侧标题,复盘界面直接可读)
1162
1250
  disposers.push(register('/conflicts', async (req, res) => {
1163
1251
  const q = new URL(req.url, 'http://dsh.internal').searchParams;
1164
- // 2026-09-21:补两侧**摘要全文** —— 只给标题时人根本判不出"到底哪矛盾"(尝生实测反馈)。
1252
+ // 2026-09-21:补两侧**摘要全文** —— 只给标题时人根本判不出"到底哪矛盾"(用户实测反馈)。
1165
1253
  const ovOf = (src, cid) => {
1166
1254
  try { return memory.overviewById(String(src), String(cid)) || null; } catch { return null; }
1167
1255
  };
1168
1256
  // 2026-09-21:涉及归档会话的矛盾不进列表 —— 归档意味着"这条别再提了",
1169
- // 但已登记的旧记录不会自动消失,所以在这里按当前归档状态实时筛(尝生实测反馈:
1257
+ // 但已登记的旧记录不会自动消失,所以在这里按当前归档状态实时筛(用户实测反馈:
1170
1258
  // 扫描侧上一轮已排除归档,可已存在的记录仍会一直显示)。
1171
1259
  const archSet = memory.archivedConvIdSet();
1172
1260
  const isArch = (side) => side && side.source === 'dsh' && archSet.has(String(side.convId));
@@ -1201,7 +1289,7 @@ export function registerApi(ctx, deps, config) {
1201
1289
  sendJson(res, r.ok ? 200 : 400, r);
1202
1290
  }));
1203
1291
 
1204
- // ---- 一键生成记忆树(D9-b,2026-09-21 与尝生定案) ----
1292
+ // ---- 一键生成记忆树(D9-b,2026-09-21 与用户定案) ----
1205
1293
  // 两阶段:① 机械分段(零成本、只读) ② 小助手命名 ③ 主人审核后落库。
1206
1294
  // 不变量:不改写任何记忆内容 —— 只建枝/主脉 + 挂会话归属。
1207
1295
  const autoSeg = (gapDays, maxSize) => bucketize(memory.overviewIndex(), { gapDays, maxSize });
@@ -1305,7 +1393,7 @@ export function registerApi(ctx, deps, config) {
1305
1393
  const created = [];
1306
1394
  let assigned = 0;
1307
1395
  let skipped = 0;
1308
- // 零散合簇(尝生 2026-09-21 裁决):名字含「零散」的簇统一挂到同一个主脉「零散会话」下。
1396
+ // 零散合簇(用户 2026-09-21 裁决):名字含「零散」的簇统一挂到同一个主脉「零散会话」下。
1309
1397
  // 理由:历史网页端的一次性问答主题天然分散、不属于任何工作线;合并成一簇后可在
1310
1398
  // 复盘时展开细化(设计稿 §10.3)。主脉按名复用,不重复建。
1311
1399
  const ZERO_NAME = '零散会话';
@@ -1492,7 +1580,7 @@ export function registerApi(ctx, deps, config) {
1492
1580
  const text = '主脉名称:' + String(v.name || veinId) + '\n\n素材(共 ' + mat.length + ' 条):\n'
1493
1581
  + mat.map((m, i) => (i + 1) + '. ' + (m.title || m.convId) + ' —— ' + m.summary).join('\n');
1494
1582
  const t0 = Date.now();
1495
- // 全局大模型(2026-09-25 尝生:「你在给自己做整理,别让小助手代劳」)。
1583
+ // 全局大模型(2026-09-25 用户:「你在给自己做整理,别让小助手代劳」)。
1496
1584
  // 原先走本机小助手(summarizeWithRetry + /no_think),但"这几条枝讲的是不是一件事"
1497
1585
  // 是**判断**不是搬砖 —— 该用器灵自己的脑;小助手留给压缩/分类这类机械活。
1498
1586
  //
@@ -1801,7 +1889,7 @@ export function registerApi(ctx, deps, config) {
1801
1889
  const body = JSON.parse((await readBody(req)) || '{}');
1802
1890
  const limit = Math.max(10, Math.min(200, Number(body.limit) || 60));
1803
1891
  const threshold = Math.max(0.3, Math.min(0.95, Number(body.threshold) || 0.55));
1804
- // 2026-09-21:排除**已归档**会话 —— 封存的对话不该再被拉出来互相判矛盾(尝生实测反馈)。
1892
+ // 2026-09-21:排除**已归档**会话 —— 封存的对话不该再被拉出来互相判矛盾(用户实测反馈)。
1805
1893
  const archived = memory.archivedConvIdSet();
1806
1894
  const rows = memory.queryOverviews({ limit }).items
1807
1895
  .filter((r) => r.source === 'dsh' || r.source === 'import')
@@ -2083,7 +2171,7 @@ export function registerApi(ctx, deps, config) {
2083
2171
  // 上锁(sealed:true)需带新承诺句 → 覆盖哈希;撤锁清哈希;每次成功变更后存「结果档案」。
2084
2172
  disposers.push(register('/persona', async (req, res) => {
2085
2173
  const body = JSON.parse((await readBody(req)) || '{}');
2086
- // ── 手术门(第 1/12 条写面)────────────────────────────────────────────────
2174
+ // ── 手术门(第 1/14 条写面)────────────────────────────────────────────────
2087
2175
  // 载荷必须**在这里**交上去:请求流只能读一次(见 guard 里的接线说明)。
2088
2176
  // 只交 `unlock` 一项:门只读它(§1.3 判据 3)。把整份 patch 交上去 = 让留痕的"字段指纹"
2089
2177
  // 记下一串与判据无关的内容(越少记越好;要留痕的是"这次手术",不是"他改成了什么")。
@@ -2129,13 +2217,58 @@ export function registerApi(ctx, deps, config) {
2129
2217
  });
2130
2218
  }));
2131
2219
 
2220
+ // ---- 1.7(2026-10-16 与用户定):「后台行为」开关 —— 一个路径按 method 分派 ----
2221
+ // 缘起:自动深摘 / 启动探活 / 反馈回路一直**默认开着**,却既没文档也没面板开关。
2222
+ // 现在给出开关,但**默认值一个字节都不变**(见 persona.js 的 DEFAULT_SETTINGS.auto)。
2223
+ // GET /auto → 生效值(resolveAuto 的三级解析结果)+ 落盘的 auto 子树 + 插件配置旧键快照(只读,不写盘)
2224
+ // POST /auto { patch } → 过白名单收窄后 settings.update({ auto: clean }),回生效值 + 丢弃/夹取清单
2225
+ // 白名单纪律与 /persona 同源(G10 那次:`/`persona` 能持久化关掉守卫):`/auto` 收的是任意 body,
2226
+ // 而 settings 是**以后每次启动都读**的持久面 ⇒ 不收窄就等于开了一个"改守卫/人格/记忆"的写入口。
2227
+ disposers.push(register('/auto', async (req, res) => {
2228
+ // 插件配置一侧的**只读快照**(旧键名):面板要能说出"这个值是不是配置里定的"。
2229
+ // 四个键给 `null` 而不是省略 = "没写"(不是 false / 0)——免得前端把 undefined 看成设成了 0。
2230
+ const pc = (config && typeof config === 'object' && !Array.isArray(config)) ? config : {};
2231
+ const pluginConfig = {
2232
+ feedbackAuto: pc.feedbackAuto ?? null,
2233
+ deepSummaryAuto: pc.deepSummaryAuto ?? null,
2234
+ deepIntervalMin: pc.deepIntervalMin ?? null,
2235
+ deepMaxPerRun: pc.deepMaxPerRun ?? null,
2236
+ };
2237
+ if (String(req.method || 'GET').toUpperCase() === 'POST') {
2238
+ const body = JSON.parse((await readBody(req)) || '{}');
2239
+ // 形状照抄 /persona:既收 `{patch:{…}}` 也收裸 body(前端两种写法都有先例)。
2240
+ const raw = (body.patch && typeof body.patch === 'object') ? body.patch : body;
2241
+ const { clean, dropped, clamped } = sanitizeAutoPatch(raw);
2242
+ if (dropped.length) console.warn('[dsh-ling] /auto 丢弃白名单外的字段: %s', dropped.join(', '));
2243
+ if (!Object.keys(clean).length) {
2244
+ // 全是白名单外/类型不符 ⇒ **不落盘**,但如实回报丢在哪(clamp 与 dropped 一起给)。
2245
+ return sendJson(res, 400, { ok: false, error: 'nothing-to-update', dropped, clamped });
2246
+ }
2247
+ await settings.update({ auto: clean });
2248
+ return sendJson(res, 200, {
2249
+ ok: true,
2250
+ // 生效值重新解析一次(settings 实例已在 update 后自更新;source 会如实变成 'settings')。
2251
+ effective: resolveAuto(settings, pc),
2252
+ dropped, clamped,
2253
+ });
2254
+ }
2255
+ // `stored` = **落盘的** auto 子树(即用户真的写过的键;settings.json 只持久化用户补丁)。
2256
+ // 刻意不用 `settings.get().auto`:那个把代码默认值也合并了进来,五个键永远都在 ⇒ 看不出"用户设过什么"。
2257
+ return sendJson(res, 200, {
2258
+ ok: true,
2259
+ effective: resolveAuto(settings, pc),
2260
+ stored: storedAuto(settings),
2261
+ pluginConfig,
2262
+ });
2263
+ }));
2264
+
2132
2265
  // POST /persona/check { unlock } 客户端「解锁修改」先验钥(只读,不落盘)
2133
2266
  // 进 `POST_ONLY` 是**防御性**:今天 GET 下 body 恒为 `{}` ⇒ 只回"承诺句不一致"(无写),
2134
2267
  // 但 handler 读 body、注释也自称 POST ⇒ 形态已经是 POST 端点,补进去零成本。
2135
2268
  disposers.push(register('/persona/check', async (req, res) => {
2136
2269
  const body = JSON.parse((await readBody(req)) || '{}');
2137
2270
  const cur = settings.get();
2138
- // 手术门(第 2/12 条写面):**这一条本身不写** —— 它是"验句预言机"(在线猜句的入口)。
2271
+ // 手术门(第 2/14 条写面):**这一条本身不写** —— 它是"验句预言机"(在线猜句的入口)。
2139
2272
  // 门口径与别处**完全一致**:已定型 ⇒ 必须本机 + 带票 + 原句,否则连"这句对不对"都不告诉它
2140
2273
  // (改前它对任意调用者有求必应 ⇒ 猜句成功率只受网络延迟限制)。
2141
2274
  if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/check' })) return;
@@ -2167,7 +2300,7 @@ export function registerApi(ctx, deps, config) {
2167
2300
  // 无论是否定型,只允许更新 persona.aiTitle(定位自述)单字段;其余字段仍需手术/承诺句。
2168
2301
  disposers.push(register('/persona/self-summary', async (req, res) => {
2169
2302
  const body = JSON.parse((await readBody(req)) || '{}');
2170
- // 手术门(第 3/12 条写面):身份自述 aiTitle 属身份保护区。只交 unlock(见 /persona 处说明)。
2303
+ // 手术门(第 3/14 条写面):身份自述 aiTitle 属身份保护区。只交 unlock(见 /persona 处说明)。
2171
2304
  if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/self-summary' })) return;
2172
2305
  const text = String(body.text ?? '').trim();
2173
2306
  if (!text) return sendJson(res, 200, { ok: false, reason: 'empty', message: '内容为空' });
@@ -2191,7 +2324,7 @@ export function registerApi(ctx, deps, config) {
2191
2324
  // (习惯不能直达;这里的用户点击就是确认动作),重量前先归档。
2192
2325
  disposers.push(register('/persona/hint-adopt', async (req, res) => {
2193
2326
  const body = JSON.parse((await readBody(req)) || '{}');
2194
- // 手术门(第 4/12 条写面):一次点击即 propose+confirm 落一条习惯。只交 unlock。
2327
+ // 手术门(第 4/14 条写面):一次点击即 propose+confirm 落一条习惯。只交 unlock。
2195
2328
  if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/hint-adopt' })) return;
2196
2329
  const text = String(body.text ?? '').trim();
2197
2330
  if (text.length < 4 || text.length > 400) {
@@ -2231,7 +2364,7 @@ export function registerApi(ctx, deps, config) {
2231
2364
  // "去掉 quote"**不再等于**"来自面板":归因只由 ruleWriteAttribution() 一处判(见文件头)。
2232
2365
  disposers.push(register('/persona/rule', async (req, res) => {
2233
2366
  const body = JSON.parse((await readBody(req)) || '{}');
2234
- // 手术门(第 5+6/12 条写面:add 与 remove 同一端点,一次判过):
2367
+ // 手术门(第 5+6/14 条写面:add 与 remove 同一端点,一次判过):
2235
2368
  // 规矩是他的指令(quote 是"经同意"的凭据),但**删规矩能整批删光** ⇒ 一律过门。
2236
2369
  if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/rule' })) return;
2237
2370
  const action = String(body.action || 'add');
@@ -2273,7 +2406,7 @@ export function registerApi(ctx, deps, config) {
2273
2406
  // 习惯不直达:propose(任一方可提)/ confirm(对方点头)/ reject / remove。
2274
2407
  disposers.push(register('/persona/habit', async (req, res) => {
2275
2408
  const body = JSON.parse((await readBody(req)) || '{}');
2276
- // 手术门(第 7+8/12 条写面:propose/confirm/reject 与 remove 同一端点,一次判过)。
2409
+ // 手术门(第 7+8/14 条写面:propose/confirm/reject 与 remove 同一端点,一次判过)。
2277
2410
  // 改前只有 remove 过门 —— "加比删容易"正是侦察里指出的不对称,现在两条都过门。
2278
2411
  if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/habit' })) return;
2279
2412
  const action = String(body.action || 'propose');
@@ -2308,7 +2441,7 @@ export function registerApi(ctx, deps, config) {
2308
2441
  disposers.push(register('/persona/habits/scan', async (req, res) => {
2309
2442
  // ── 手术门(A-1,2026-10-05 夜;交办单 HANDOFF-机制面复核交办单 §A-1)──────────────────
2310
2443
  // ⚠️ 本端点**会写 `habitsPending`** ⇒ 它进的是**人格面** ⇒ 按 P-5 第 5 条必须过闸。
2311
- // 改前它是 12 条清单外的漏点:确认侧 `/persona/habit`(:2276)**过门**,而两条**产出候选**
2444
+ // 改前它是 14 条清单外的漏点:确认侧 `/persona/habit`(:2276)**过门**,而两条**产出候选**
2312
2445
  // 的写面(`scan` / `reflect`)不过门 —— 一条通路的两半只改了一半,正是
2313
2446
  // DESIGN §0.1 工程理念 9「两半同批」的反例(2026-10-05 机制面复核 A-1)。
2314
2447
  // 接线形状照抄 `/persona/habit`:必须先 `readBody` 再判门(请求流只能读一次,门要拿
@@ -2525,7 +2658,7 @@ export function registerApi(ctx, deps, config) {
2525
2658
  // 自性类成长通道:白名单字段(语气枚举 + 风格追加),锁定下亦可采纳;不可逆操作一律不做。
2526
2659
  disposers.push(register('/persona/grow', async (req, res) => {
2527
2660
  const body = JSON.parse((await readBody(req)) || '{}');
2528
- // 手术门(第 9/12 条写面):改语气 / 追加风格注 —— 旧注释自陈"锁定下亦可采纳",那正是缺口。
2661
+ // 手术门(第 9/14 条写面):改语气 / 追加风格注 —— 旧注释自陈"锁定下亦可采纳",那正是缺口。
2529
2662
  if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/grow' })) return;
2530
2663
  const patch = {};
2531
2664
  const toneIn = body.tone && typeof body.tone === 'object' ? body.tone : {};
@@ -2561,7 +2694,7 @@ export function registerApi(ctx, deps, config) {
2561
2694
  // POST /persona/rollback { ts, unlock? } 回滚到某历史存档(同样受定型门约束)
2562
2695
  disposers.push(register('/persona/rollback', async (req, res) => {
2563
2696
  const body = JSON.parse((await readBody(req)) || '{}');
2564
- // 手术门(第 10/12 条写面):整体回写 {persona,styles}(含 sealed/sealPhrase)——
2697
+ // 手术门(第 10/14 条写面):整体回写 {persona,styles}(含 sealed/sealPhrase)——
2565
2698
  // 回滚到定型前快照 = **撤锁**,故判据必须在"看存档是否存在"之前:格式不对的请求也不该探到存档。
2566
2699
  if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/rollback' })) return;
2567
2700
  const ts = Number(body.ts);
@@ -2938,7 +3071,7 @@ export function registerApi(ctx, deps, config) {
2938
3071
  const body = JSON.parse((await readBody(req)) || '{}');
2939
3072
  // ⚠️ **手术门补在这一条上**(2026-10-04 发布关④ 抓到):它**写人格**(诞生仪式生成的自我介绍
2940
3073
  // 会被主人采纳进 persona),却一直没有门 —— 而 README 与 `gateSurgery` 的注释都写着
2941
- // "**全部 12 条写面**已由手术门统一判过",实测全仓只有 11 处 `gateSurgery` 调用点。
3074
+ // "**全部 14 条写面**已由手术门统一判过",实测全仓只有 11 处 `gateSurgery` 调用点。
2942
3075
  // 补门后它与其余写面同级:本机 + 每启动票据 + 原句匹配。
2943
3076
  // ⚠️ 载荷只交 `unlock` 一项(与 `/persona` 同口径):门只读它,把整份 body 交上去
2944
3077
  // 会让留痕的"字段指纹"记下与判据无关的内容。
@@ -2992,7 +3125,7 @@ export function registerApi(ctx, deps, config) {
2992
3125
  // POST /feedback/apply { id }
2993
3126
  disposers.push(register('/feedback/apply', async (req, res) => {
2994
3127
  const body = JSON.parse((await readBody(req)) || '{}');
2995
- // 手术门(第 11/12 条写面):采纳建议 → 落习惯 / 改自称。改前只有身份两 kind 有门。
3128
+ // 手术门(第 11/14 条写面):采纳建议 → 落习惯 / 改自称。改前只有身份两 kind 有门。
2996
3129
  if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/feedback/apply' })) return;
2997
3130
  const r = await applySuggestionAsRule(memory, settings, body.id);
2998
3131
  if (!r.ok) return sendJson(res, 200, { ok: false, reason: r.reason });
@@ -3034,7 +3167,7 @@ export function registerApi(ctx, deps, config) {
3034
3167
  // POST /suggestions/apply { id }
3035
3168
  disposers.push(register('/suggestions/apply', async (req, res) => {
3036
3169
  const body = JSON.parse((await readBody(req)) || '{}');
3037
- // 手术门(第 13/12 条写面):采纳建议。改前**只有** kind∈{aiName,userTitle} 才验句,
3170
+ // 手术门(第 13/14 条写面):采纳建议。改前**只有** kind∈{aiName,userTitle} 才验句,
3038
3171
  // 惯例类(hardrule 等)可自由追加 ⇒ 现在整条端点统一过门(下面那段身份分支因此成了一道冗余的深防线,
3039
3172
  // 保留不动:判据更严的一侧永远不会把请求放过去)。
3040
3173
  if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/suggestions/apply' })) return;
@@ -3083,6 +3216,31 @@ export function registerApi(ctx, deps, config) {
3083
3216
  });
3084
3217
  }));
3085
3218
 
3219
+ // ── 深层库(结论层)· 只读面(B 档,2026-10-15)──────────────────────────────────
3220
+ // 为什么要有这一面:411 条结论此前**只有读的路、没有看得见的门** —— 面板里连"结论层"
3221
+ // 都没有,于是"我正在被注入什么"这件事只能靠 `recall` 间接感到(用户问"是不是没通道"
3222
+ // 的观感就来自这里)。写面**不在**这条路上:落条与作废走工具 `deep_item`(经他授权)。
3223
+ // 纯读:`listDeepItems` + 两个 COUNT,不写一个字节。
3224
+ // ⚠️ `all` 报**在役**条数(总数 − 已退役),与工具面同一口径 —— 别把退役的算进"有多少条结论"。
3225
+ // ⚠️ 行映射走 `memory.deepItemView`(单一来源):api.js 与 tools.js 报的是同一种东西,
3226
+ // 各写一份必然漂。
3227
+ disposers.push(register('/deep/items', async (req, res) => {
3228
+ const q = new URL(req.url, 'http://dsh.internal').searchParams;
3229
+ const kind = String(q.get('kind') || '').trim();
3230
+ const n = Number(q.get('limit'));
3231
+ const limit = Number.isFinite(n) && n > 0 ? Math.min(500, Math.trunc(n)) : 200;
3232
+ const includeSuperseded = q.get('includeSuperseded') === '1';
3233
+ const rows = memory.listDeepItems({ kind, limit, includeSuperseded });
3234
+ const retired = memory.deepRetiredCount();
3235
+ sendJson(res, 200, {
3236
+ ok: true,
3237
+ items: rows.map((r) => memory.deepItemView(r)),
3238
+ shown: rows.length,
3239
+ all: memory.deepItemCount() - retired,
3240
+ retired,
3241
+ });
3242
+ }));
3243
+
3086
3244
  // POST /deep/run { limit? }
3087
3245
  disposers.push(register('/deep/run', async (req, res) => {
3088
3246
  const body = JSON.parse((await readBody(req)) || '{}');
@@ -3135,7 +3293,7 @@ export function registerApi(ctx, deps, config) {
3135
3293
  const q = new URL(req.url, 'http://dsh.internal').searchParams;
3136
3294
  const text = await readBody(req);
3137
3295
  const parsed = JSON.parse(text || '{}');
3138
- // 手术门(第 14/12 条写面)⚠️ **前移到 importBundle 之前** —— 这是本批唯一一处"改前门在后面"的修正:
3296
+ // 手术门(第 14/14 条写面)⚠️ **前移到 importBundle 之前** —— 这是本批唯一一处"改前门在后面"的修正:
3139
3297
  // 改前:`memory.importBundle()`(下一行)先跑,门在它**之后** ⇒ 一个带着恶意 bundle 的请求
3140
3298
  // 即使人格回写被拒,**记忆也已经整体被换掉了**(DESIGN §3 该行备注"门拦不住记忆被换")。
3141
3299
  // 现在:已定型时,未过门(非本机 / 无票 / 原句不符)的请求**一个字节都不落库**。
@@ -3167,13 +3325,392 @@ export function registerApi(ctx, deps, config) {
3167
3325
  sendJson(res, 200, { ok: report.errors === 0, personaApplied, report });
3168
3326
  }));
3169
3327
 
3170
- return () => {
3171
- for (const d of disposers) {
3172
- try {
3173
- d();
3174
- } catch {}
3328
+ // ══ 1.7 图书馆 · HTTP 面(2026-10-07)══════════════════════════════════════════════
3329
+ // 九个端点:GET status/search/item/history/checks + POST create/revise/withdraw/check。
3330
+ // 三条纪律(契约 §Integration Response Decisions + 1.7-integration-design §API and tools):
3331
+ // ① 方法与鉴权都在上面那个 `guard` 里(POST_ONLY / GET_ONLY 两张表)—— 本块**不重判**;
3332
+ // ② 收下的载荷**先严格校验再动手**:未知/重复 query、未知 body 字段、非对象 body 一律 400,
3333
+ // 绝不"静默剥离"(与存储层"维护拒绝未知只读字段"同一条口径);
3334
+ // ③ 回执**如实透传**运行时的 canonical `{ok,reason,message}` —— 本层只做三件事:
3335
+ // 选 HTTP 状态码、给 check 补 `adoption_reason`、把不可用/未找到/哈希冲突翻成 503/404/409。
3336
+ // ⚠️ **不补** `total`(契约:search 回执里没有命中总数,连不可枚举别名都不许有)。
3337
+ const LIB_ITEM_MAX_FALLBACK = 65536; // = 契约 §Limits 的 itemMaxBytes 默认值
3338
+ /** 只多留一层**信封**余量:`{item}` / `{id,item,expectedHash}` 这些字段名本身的字节。
3339
+ * 为什么不干脆等号:`itemMaxBytes` 是**条目载荷**的闸(存储层自己还会再判一次字节),
3340
+ * 这里限的是**请求体** —— 两者只差一个对象外壳。取 8 KiB 足够任何信封,且不会把
3341
+ * "体量超限"从 413 放成"进库之后才回 400"(那样调用方拿到的是语义错,不是体量错)。 */
3342
+ const LIB_BODY_ENVELOPE_BYTES = 8192;
3343
+ const libraryBodyMax = () => {
3344
+ const n = library && library.limits && library.limits.itemMaxBytes;
3345
+ return (Number.isFinite(n) && n > 0 ? n : LIB_ITEM_MAX_FALLBACK) + LIB_BODY_ENVELOPE_BYTES;
3346
+ };
3347
+ /** 往响应写字之前的**最后一道闸**(与 `guard` 里 `res.destroyed` 那条同一条纪律):
3348
+ * 客户端断开的路上,响应对象可能已经死了 —— 再 `end()` 就是往死 socket 写。
3349
+ * `/library/check` 是**唯一**会出现"响应已死但 handler 还在跑"的端点(取源要时间),
3350
+ * 故本块的每一次发送都走这里,而不是直接 `sendJson`。 */
3351
+ const libSend = (res, status, body) => {
3352
+ if (res && (res.writableEnded || res.destroyed)) return;
3353
+ sendJson(res, status, body);
3354
+ };
3355
+ /** 运行时不在场 = 图书馆不可用(**不是**"零命中")—— 契约 §Store Surface 的硬要求。 */
3356
+ const libraryReady = () => !!library && typeof library === 'object';
3357
+ const sendLibraryUnavailable = (res) => libSend(res, 503, {
3358
+ ok: false, reason: 'unavailable',
3359
+ message: '图书馆运行时不可用:插件未注入 library 依赖(其余记忆功能不受影响)。',
3360
+ });
3361
+ /** reason(机器短码)→ HTTP 状态码。**表里没有的一律 400**:语义错由 reason 说,不假装成 5xx。
3362
+ * · 503:库关着 / 配置关掉 / 运行时不可用(构造失败)—— "服务不可用"三态。
3363
+ * ⚠️ 运行时(1.7 `library-runtime.js`)自己的短码是**带下划线**的两个:
3364
+ * `library_unavailable`(打不开库:构造失败/操作抛错)与 `library_disabled`(配置里关了)
3365
+ * —— 它们必须是 503,不能落到 400(否则"服务不可用"被显示成"你参数写错了")。
3366
+ * 老别名(closed / disabled / unavailable / library-unavailable)一并保留。
3367
+ * · 404:条目不存在(`not_found`);
3368
+ * · 409:两处**冲突**——① 修订/撤下的观测哈希与当前内容不符(`hash_mismatch`);
3369
+ * ② 该条目的同一次核对**正在飞**(`in_flight`)或并发额度用完(`concurrency_limit`)
3370
+ * —— 都不是参数错,重发/排队才有意义,故与哈希冲突同码;
3371
+ * · 503(2026-10-12 补):`model_failed` —— 导入通道的**模型段**没跑成(小助手不可达 / 回了
3372
+ * 没法解析的东西 / 全局模型调用抛错)。它**不是**参数错(请求本身完全合法),所以绝不能落
3373
+ * 400 让界面把"小助手没连上"显示成"你参数写错了";正确出路是修好模型来源或换一档重发,
3374
+ * 与上面那三态同属"服务这一趟不可用"。
3375
+ * ⚠️ **2026-10-14 D11 放行之后,analyze 不再发出这个 reason** —— 模型段失败改成
3376
+ * `200` + `stats.model_error`(显式标注的降级回执:规则段候选照给)。这条映射**保留**:
3377
+ * 一来别的路径/将来可能再用,二来删了会让"老客户端读到 `model_failed` 该按几号处理"没答案。
3378
+ * · 400:其余一切(`invalid_*` / `unknown_field` / `readonly_field` / 未知 reason)按语义错。 */
3379
+ const libraryHttpStatus = (reason) => {
3380
+ const r = String(reason || '');
3381
+ if (r === 'closed' || r === 'disabled' || r === 'unavailable' || r === 'library-unavailable'
3382
+ || r === 'library_unavailable' || r === 'library_disabled' || r === 'model_failed'
3383
+ || r === 'sqlite_unavailable') return 503;
3384
+ if (r === 'not_found' || r === 'not-found') return 404;
3385
+ if (r === 'hash_mismatch' || r === 'hash-mismatch' || r === 'in_flight' || r === 'concurrency_limit') return 409;
3386
+ return 400;
3387
+ };
3388
+ /** 如实发回执:ok ⇒ 200;失败 ⇒ 按 reason 选码,**原样**带 reason/message(不吞字段)。 */
3389
+ const sendLibrary = (res, r) => {
3390
+ if (!r || typeof r !== 'object') {
3391
+ return libSend(res, 500, { ok: false, reason: 'internal', message: '图书馆运行时没有返回回执。' });
3392
+ }
3393
+ if (r.ok) return libSend(res, 200, r);
3394
+ return libSend(res, libraryHttpStatus(r.reason), r);
3395
+ };
3396
+ const lib400 = (res, reason, message, extra) => libSend(res, 400, { ok: false, reason, message, ...(extra || {}) });
3397
+ /** 只读面的 query 严格校验:**未知键拒、重复键拒**(都 400,不静默取头一个)。
3398
+ * 为什么重复键也要拒:`URLSearchParams.get()` 只回**头一个** ⇒ `?id=a&id=b` 会被读成 `a`,
3399
+ * 调用方以为发的是 b、账却记在 a 名下。宁可回一个能看见原因的 400。 */
3400
+ const strictQuery = (url, allowed) => {
3401
+ const params = new URL(url || '/', 'http://dsh.internal').searchParams;
3402
+ for (const key of params.keys()) {
3403
+ if (!allowed.includes(key)) return { ok: false, reason: 'unknown-param', param: key };
3404
+ }
3405
+ const out = {};
3406
+ for (const key of allowed) {
3407
+ const all = params.getAll(key);
3408
+ if (all.length > 1) return { ok: false, reason: 'duplicate-param', param: key };
3409
+ if (all.length === 1) out[key] = all[0];
3410
+ }
3411
+ return { ok: true, params: out };
3412
+ };
3413
+ /** 写面的 body 严格校验:有界读(保留熔断)→ JSON 解析失败 400(**不是** 500)→ 必须是对象 →
3414
+ * 未知字段 400。**返回错误而不是抛**:抛出去会被 `guard` 的 catch 兜成 500
3415
+ * (既有 40 多个端点就是这个形状:`JSON.parse` 一抛,调用方看到的是"服务器内部错误")——
3416
+ * 契约要求这里必须能分辨"载荷不合法"(400)。 */
3417
+ const readLibraryBody = async (req, allowed, max = libraryBodyMax()) => {
3418
+ let text;
3419
+ try {
3420
+ text = await readBody(req, max);
3421
+ } catch (e) {
3422
+ // 体量/静默两条路:**rethrow** ⇒ 落回 `guard` 既有的 413 出口(一处覆盖全部端点)。
3423
+ if (e && e.code === 'BODY_TOO_LARGE') throw e;
3424
+ return { ok: false, reason: 'body-read-failed', message: String((e && e.message) || e) };
3175
3425
  }
3426
+ let body;
3427
+ try {
3428
+ body = JSON.parse(text || '{}');
3429
+ } catch (e) {
3430
+ return { ok: false, reason: 'bad-json', message: '请求体不是合法 JSON:' + String((e && e.message) || e).slice(0, 120) };
3431
+ }
3432
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
3433
+ return { ok: false, reason: 'bad-body', message: '请求体必须是一个 JSON 对象。' };
3434
+ }
3435
+ const unknown = Object.keys(body).filter((k) => !allowed.includes(k));
3436
+ if (unknown.length) {
3437
+ return { ok: false, reason: 'unknown-field', message: '请求体含未知字段:' + unknown.join(', ') };
3438
+ }
3439
+ return { ok: true, body };
3440
+ };
3441
+ /** 端点外壳:运行时不在场 ⇒ 503;异常 ⇒ 语义错(400)/ 不可用(503)/ 体量(413,rethrow)。 */
3442
+ const libraryRoute = (handler) => async (req, res) => {
3443
+ if (!libraryReady()) return sendLibraryUnavailable(res);
3444
+ try {
3445
+ await handler(req, res);
3446
+ } catch (e) {
3447
+ if (e && e.code === 'BODY_TOO_LARGE') throw e; // ⇒ guard 的 413 出口(既有约定)
3448
+ if (e && (e.code === 'library-unavailable' || e.code === 'LIBRARY_UNAVAILABLE')) {
3449
+ return sendJson(res, 503, { ok: false, reason: 'unavailable', message: String(e.message || '图书馆不可用') });
3450
+ }
3451
+ sendJson(res, 400, { ok: false, reason: 'bad-request', message: String((e && e.message) || e).slice(0, 200) });
3452
+ }
3453
+ };
3454
+ /** `id` 的**本地**必填判据(只管"在不在、是不是非空字符串" —— 形状判据归存储层)。 */
3455
+ const requireId = (params) => {
3456
+ const id = String((params && params.id != null) ? params.id : '').trim();
3457
+ return id ? { ok: true, id } : { ok: false, reason: 'id-required', message: 'id 必填。' };
3458
+ };
3459
+ /** 分页参数:能当整数就交整数,否则**原样**交出去让运行时自己拒(不在这里悄悄吞掉)。 */
3460
+ const pagingOf = (params) => {
3461
+ const out = {};
3462
+ for (const key of ['offset', 'limit']) {
3463
+ if (params[key] === undefined) continue;
3464
+ out[key] = /^-?\d+$/.test(params[key]) ? Number(params[key]) : params[key];
3465
+ }
3466
+ return out;
3467
+ };
3468
+ /** check 回执补 `adoption_reason`(契约:check 响应要能看出"这次证据有没有被采纳")。
3469
+ * 统一三值(与 tools.js 的 library_check **同一份**,两处都只有这几行):
3470
+ * · `adopted:true` ⇒ `'adopted'`;
3471
+ * · 有记录但没采纳 ⇒ 取存储层 `recordCheck` 的 reason(实测取值:`stale_or_inactive`);
3472
+ * · **整条没有记录**(取消 / in_flight / concurrency_limit / not_found)⇒ `'not_recorded'`
3473
+ * —— 如实说"没留下记录",不替它编具体原因。
3474
+ * ⚠️ 故意**不**用 `r.reason`:那是**本次核对**的 reason(如 `declared_comparison_mismatch`),
3475
+ * 与"证据有没有被采纳"是两回事 —— 混用会把"哈希陈旧"显示成"比较不符"。
3476
+ * ⚠️ 运行时自己给了**可用**的值(字符串)就原样保留;给 `null`(`library-runtime.js` 早先的
3477
+ * `record?.reason ?? null` 形状)视同"没给" ⇒ 仍由本层按上面三步补齐,避免调用方拿到 null。 */
3478
+ const withAdoptionReason = (r) => {
3479
+ if (!r || typeof r !== 'object') return r;
3480
+ if (typeof r.adoption_reason === 'string') return r; // 运行时已给出采用口径 ⇒ 不覆盖
3481
+ const record = (r.record && typeof r.record === 'object') ? r.record : null;
3482
+ const hasOutcome = r.ok === true || r.record !== undefined || r.recorded !== undefined;
3483
+ if (!hasOutcome) return r; // 没走到"核对结果"这一步:不硬贴采用口径
3484
+ const adoption_reason = r.adopted === true ? 'adopted' : String((record && record.reason) || 'not_recorded');
3485
+ return { ...r, adoption_reason };
3486
+ };
3487
+
3488
+ // ── 五个只读面 ────────────────────────────────────────────────────────────
3489
+ disposers.push(register('/library/status', libraryRoute(async (req, res) => {
3490
+ // `[]` = 这个端点**一个 query 都不收**:status 的回执由运行时自己决定,
3491
+ // 任何 query(尤其 `?enforce=0` / `?id=x` 这类"看着像开关"的键)都不许进来。
3492
+ const q = strictQuery(req.url, []);
3493
+ if (!q.ok) return lib400(res, q.reason, '查询参数 ' + q.param + ' 不被 /library/status 接受。', { param: q.param });
3494
+ sendLibrary(res, await library.status());
3495
+ })));
3496
+
3497
+ disposers.push(register('/library/search', libraryRoute(async (req, res) => {
3498
+ const q = strictQuery(req.url, ['q', 'offset', 'limit']);
3499
+ if (!q.ok) return lib400(res, q.reason, '查询参数 ' + q.param + ' 不被 /library/search 接受。', { param: q.param });
3500
+ const query = {};
3501
+ if (q.params.q !== undefined) query.q = q.params.q;
3502
+ Object.assign(query, pagingOf(q.params));
3503
+ // ⚠️ 不注入 includeInactive:它是**存储层内部**只读扩展(契约 §Integration Response Decisions),
3504
+ // 模型工具与公开检索面都不暴露 —— 这里连"透传"都不做(透传 = 暴露)。
3505
+ sendLibrary(res, await library.search(query));
3506
+ })));
3507
+
3508
+ // 浏览面(只读):**不搜也能翻** —— 按 类目/来源 分组列条目(含留痕行)+ 恒定全量的分组计数表。
3509
+ // 它是读面 ⇒ 进 `GET_ONLY`;query 只收两个分组维度 + 分页(与 /library/search 同款 strictQuery)。
3510
+ disposers.push(register('/library/browse', libraryRoute(async (req, res) => {
3511
+ const q = strictQuery(req.url, ['category', 'source_kind', 'offset', 'limit', 'fields']);
3512
+ if (!q.ok) return lib400(res, q.reason, '查询参数 ' + q.param + ' 不被 /library/browse 接受。', { param: q.param });
3513
+ const query = {};
3514
+ if (q.params.category !== undefined) query.category = q.params.category;
3515
+ if (q.params.source_kind !== undefined) query.source_kind = q.params.source_kind;
3516
+ if (q.params.fields !== undefined) query.fields = q.params.fields;
3517
+ Object.assign(query, pagingOf(q.params));
3518
+ sendLibrary(res, await library.browse(query));
3519
+ })));
3520
+
3521
+ disposers.push(register('/library/item', libraryRoute(async (req, res) => {
3522
+ const q = strictQuery(req.url, ['id']);
3523
+ if (!q.ok) return lib400(res, q.reason, '查询参数 ' + q.param + ' 不被 /library/item 接受。', { param: q.param });
3524
+ const id = requireId(q.params);
3525
+ if (!id.ok) return lib400(res, id.reason, id.message);
3526
+ sendLibrary(res, await library.get(id.id));
3527
+ })));
3528
+
3529
+ disposers.push(register('/library/history', libraryRoute(async (req, res) => {
3530
+ const q = strictQuery(req.url, ['id', 'offset', 'limit']);
3531
+ if (!q.ok) return lib400(res, q.reason, '查询参数 ' + q.param + ' 不被 /library/history 接受。', { param: q.param });
3532
+ const id = requireId(q.params);
3533
+ if (!id.ok) return lib400(res, id.reason, id.message);
3534
+ sendLibrary(res, await library.history(id.id, pagingOf(q.params)));
3535
+ })));
3536
+
3537
+ disposers.push(register('/library/checks', libraryRoute(async (req, res) => {
3538
+ const q = strictQuery(req.url, ['id', 'offset', 'limit']);
3539
+ if (!q.ok) return lib400(res, q.reason, '查询参数 ' + q.param + ' 不被 /library/checks 接受。', { param: q.param });
3540
+ const id = requireId(q.params);
3541
+ if (!id.ok) return lib400(res, id.reason, id.message);
3542
+ sendLibrary(res, await library.checks(id.id, pagingOf(q.params)));
3543
+ })));
3544
+
3545
+ // ── 四个写面(载荷逐一严格校验;不接 `gateSurgery` —— 图书馆不是人格/规矩面)──
3546
+ /** 写面的 query 必须**严格为空**:载荷只走请求体。
3547
+ * 为什么连"看着无害的" query 都拒:`URLSearchParams` 取的是**头一个**同名键 ⇒ 一旦将来某个
3548
+ * handler 顺手读了 query,`?id=a&id=b` 就会变成"调用方以为发的是 body、实际生效的是 query"
3549
+ * 这种账实不符;而且 query 还能绕开"body 字段白名单"的检查面。判据复用只读面那份 `strictQuery`,
3550
+ * 只是 allowed 恒为 `[]`(未知键拒、重复键拒,一律 400)。 */
3551
+ const writeQueryGuarded = (req, res, route) => {
3552
+ const q = strictQuery(req.url, []);
3553
+ if (q.ok) return false;
3554
+ lib400(res, q.reason, '写面 ' + route + ' 不接受 query 参数(' + q.reason + ':' + q.param + ');载荷请走请求体。', { param: q.param });
3555
+ return true;
3556
+ };
3557
+ disposers.push(register('/library/create', libraryRoute(async (req, res) => {
3558
+ if (writeQueryGuarded(req, res, '/library/create')) return;
3559
+ const b = await readLibraryBody(req, ['item']);
3560
+ if (!b.ok) return lib400(res, b.reason, b.message);
3561
+ const item = b.body.item;
3562
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
3563
+ return lib400(res, 'invalid_item', 'item 必须是条目对象。');
3564
+ }
3565
+ sendLibrary(res, await library.create(item));
3566
+ })));
3567
+
3568
+ disposers.push(register('/library/revise', libraryRoute(async (req, res) => {
3569
+ if (writeQueryGuarded(req, res, '/library/revise')) return;
3570
+ const b = await readLibraryBody(req, ['id', 'item', 'expectedHash']);
3571
+ if (!b.ok) return lib400(res, b.reason, b.message);
3572
+ const id = String(b.body.id ?? '').trim();
3573
+ if (!id) return lib400(res, 'id-required', 'id 必填。');
3574
+ const item = b.body.item;
3575
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return lib400(res, 'invalid_item', 'item 必须是条目对象。');
3576
+ if (typeof b.body.expectedHash !== 'string' || !b.body.expectedHash.trim()) {
3577
+ return lib400(res, 'hash-required', 'expectedHash 必填(修订必须带观测到的 content_hash)。');
3578
+ }
3579
+ sendLibrary(res, await library.revise(id, item, b.body.expectedHash));
3580
+ })));
3581
+
3582
+ disposers.push(register('/library/withdraw', libraryRoute(async (req, res) => {
3583
+ if (writeQueryGuarded(req, res, '/library/withdraw')) return;
3584
+ const b = await readLibraryBody(req, ['id', 'reason', 'expectedHash']);
3585
+ if (!b.ok) return lib400(res, b.reason, b.message);
3586
+ const id = String(b.body.id ?? '').trim();
3587
+ if (!id) return lib400(res, 'id-required', 'id 必填。');
3588
+ const reason = String(b.body.reason ?? '').trim();
3589
+ if (!reason) return lib400(res, 'reason-required', 'reason 必填(撤下要留痕)。');
3590
+ if (typeof b.body.expectedHash !== 'string' || !b.body.expectedHash.trim()) {
3591
+ return lib400(res, 'hash-required', 'expectedHash 必填(撤下必须带观测到的 content_hash)。');
3592
+ }
3593
+ sendLibrary(res, await library.withdraw(id, reason, b.body.expectedHash));
3594
+ })));
3595
+
3596
+ // check 是**唯一**的长活端点:它可能要取源(网络/磁盘)。两条取消来源都要接:
3597
+ // ① HTTP 客户端断开(`req.aborted` / `res.close`);② 插件卸载(disposer,见下面的 return)。
3598
+ // `AbortController` 由**本条请求**拥有;`finally` 里**必拆监听** —— 不拆的话 close 监听会
3599
+ // 一直挂在已结束的响应上(长跑进程里就是稳定泄漏)。
3600
+ // ⚠️ signal 的 reason 必须带 `cancelled:true`:checker 按这个标记区分"被取消"(不写库)
3601
+ // 与"取源失败"(写一条 unknown 留痕)—— 见 library-checker.js 的 signal.reason?.cancelled。
3602
+ disposers.push(register('/library/check', libraryRoute(async (req, res) => {
3603
+ if (writeQueryGuarded(req, res, '/library/check')) return;
3604
+ const b = await readLibraryBody(req, ['id']);
3605
+ if (!b.ok) return lib400(res, b.reason, b.message);
3606
+ const id = String(b.body.id ?? '').trim();
3607
+ if (!id) return lib400(res, 'id-required', 'id 必填。');
3608
+ const controller = new AbortController();
3609
+ const onDisconnect = () => {
3610
+ if (controller.signal.aborted) return;
3611
+ const err = new Error('客户端已断开,本次核对被取消(不写核对留痕)。');
3612
+ err.code = 'client-disconnected';
3613
+ err.cancelled = true;
3614
+ controller.abort(err);
3615
+ };
3616
+ pendingLibraryChecks.add(controller);
3617
+ req.on?.('aborted', onDisconnect);
3618
+ res.on?.('close', onDisconnect);
3619
+ try {
3620
+ sendLibrary(res, withAdoptionReason(await library.check(id, { signal: controller.signal })));
3621
+ } finally {
3622
+ req.off?.('aborted', onDisconnect);
3623
+ res.off?.('close', onDisconnect);
3624
+ pendingLibraryChecks.delete(controller);
3625
+ }
3626
+ })));
3627
+
3628
+ // ── 1.7 图书馆 · **导入通道**(2026-10-10;契约 §3,两阶段)────────────────────────
3629
+ /** 两条导入路由**各有各的读体上限**(不复用 itemMaxBytes 那把尺):
3630
+ * · `analyze` 收的是**整份源文本** ⇒ 尺 = `importTextMaxBytes`(默认 1 MiB);
3631
+ * · `commit` 收的是**人审后的整批** ⇒ 尺 = `importBatchMaxBytes`(默认 3 MiB)。
3632
+ * 为什么必须分开:`readBody` 的闸在 **handler 之前** —— 用 itemMaxBytes(64 KiB)那把尺读,
3633
+ * 一份合法的 1 MiB 表格会在还没人看它一眼时就被 413 顶回去(而且错误说的是"条目太大",
3634
+ * 与真实原因不符)。信封与其余图书馆端点同一份(+8 KiB 给 JSON 封套与转义余量)。
3635
+ * ⚠️ 读体上限 ≠ 语义上限:超了读体 ⇒ **413**(transport);批内条数/字节超 `importBatchMaxItems`
3636
+ * ⇒ 由 `commitImport` 回 `batch_too_large`,**400**(语义错,重发同样内容没用)。 */
3637
+ const libraryImportBodyMax = (key, fallback) => {
3638
+ const n = library && library.limits && library.limits[key];
3639
+ return (Number.isFinite(n) && n > 0 ? n : fallback) + LIB_BODY_ENVELOPE_BYTES;
3176
3640
  };
3641
+
3642
+ disposers.push(register('/library/import/analyze', libraryRoute(async (req, res) => {
3643
+ if (writeQueryGuarded(req, res, '/library/import/analyze')) return;
3644
+ const b = await readLibraryBody(req,
3645
+ ['kind', 'name', 'text', 'path', 'table', 'category', 'source_kind', 'as_of', 'model', 'offset'],
3646
+ libraryImportBodyMax('importTextMaxBytes', 1048576));
3647
+ if (!b.ok) return lib400(res, b.reason, b.message);
3648
+ const input = {
3649
+ kind: b.body.kind, name: b.body.name,
3650
+ text: b.body.text === undefined || b.body.text === null ? '' : b.body.text,
3651
+ };
3652
+ // SQLite 通道(路 A,D14):源在盘上 ⇒ 入参是 `path`(+`table`),不是 text。
3653
+ // ⚠️ 这两把只对 kind=sqlite 有意义:别的通道带上它 ⇒ **当场 400**,不静默丢
3654
+ // (静默丢参数正是复核 §11.6 A.3 点名的那条坑)。
3655
+ for (const key of ['path', 'table']) {
3656
+ const value = b.body[key];
3657
+ if (value === undefined || value === null || value === '') continue;
3658
+ if (typeof value !== 'string') {
3659
+ return lib400(res, `${key}-invalid`, `${key} 必须是字符串(实际 ${JSON.stringify(value)})`);
3660
+ }
3661
+ if (String(b.body.kind ?? '').trim() !== 'sqlite') {
3662
+ return lib400(res, 'param-not-applicable', `${key} 只对 kind=sqlite 有意义(kind=${JSON.stringify(b.body.kind)})`);
3663
+ }
3664
+ input[key] = value;
3665
+ }
3666
+ // 默认值走 opts(与 §4 的"面板给今天作默认、但可改"一致):不给就由 analyzeImport 用自己的默认。
3667
+ const opts = {};
3668
+ for (const [from, to] of [['category', 'category'], ['source_kind', 'source_kind'], ['as_of', 'today']]) {
3669
+ if (typeof b.body[from] === 'string' && b.body[from].trim() !== '') opts[to] = b.body[from].trim();
3670
+ }
3671
+ // 分段/翻页(D12):`offset` = 从第几个内容单元开始(CSV 一行 / 文本一段)。
3672
+ // ⚠️ 值非法 ⇒ **当场 400**,不静默当 0 —— 把"你要的第 2 页"悄悄换成第 1 页是最坏的一种回执。
3673
+ if (b.body.offset !== undefined && b.body.offset !== null) {
3674
+ const off = typeof b.body.offset === 'number' ? b.body.offset : NaN;
3675
+ if (!Number.isInteger(off) || off < 0) {
3676
+ return lib400(res, 'offset-invalid', `offset 必须是 >= 0 的整数(实际 ${JSON.stringify(b.body.offset)})`);
3677
+ }
3678
+ if (off > 0) opts.offset = off;
3679
+ }
3680
+ // 模型段(2026-10-12,契约 §11.1):引擎名 ∈ IMPORT_ENGINES,两档复用**既有**调用路径:
3681
+ // `assistant` = 本机小助手(零成本) / `global` = 当前全局大模型(会花额度)。
3682
+ // ⚠️ **不给 `model` 就是不调模型**:只走规则段 —— 默认不烧额度、不让人等(显式才花)。
3683
+ // ⚠️ 值不合法 ⇒ **当场 400**,不静默回落成"没选模型"(那会让调用方以为自己在用小助手)。
3684
+ const engine = typeof b.body.model === 'string' ? b.body.model.trim() : '';
3685
+ if (engine !== '') {
3686
+ if (!IMPORT_ENGINES.includes(engine)) {
3687
+ return lib400(res, 'model-invalid', `model 只能是 ${IMPORT_ENGINES.join(' / ')}(实际 ${engine})`);
3688
+ }
3689
+ const built = makeImportModelHook({ engine, ctx, settings });
3690
+ opts.model = built.hook;
3691
+ opts.modelLabel = built.label; // 回执如实写明"这一趟是哪个模型跑的"(契约 §3)
3692
+ }
3693
+ sendLibrary(res, await library.analyzeImport(input, opts));
3694
+ })));
3695
+
3696
+ disposers.push(register('/library/import/commit', libraryRoute(async (req, res) => {
3697
+ if (writeQueryGuarded(req, res, '/library/import/commit')) return;
3698
+ const b = await readLibraryBody(req, ['source_hash', 'items'],
3699
+ libraryImportBodyMax('importBatchMaxBytes', 3145728));
3700
+ if (!b.ok) return lib400(res, b.reason, b.message);
3701
+ // 两处**本地**必填判据(只管"在不在、是不是数组")—— 形状与限额判据归导入层,两套**不合并**。
3702
+ if (typeof b.body.source_hash !== 'string' || b.body.source_hash.trim() === '') {
3703
+ return lib400(res, 'hash-required', 'source_hash 必填(幂等键的前半截由它定)。');
3704
+ }
3705
+ if (!Array.isArray(b.body.items)) return lib400(res, 'items-required', 'items 必须是数组。');
3706
+ sendLibrary(res, await library.commitImport({ source_hash: b.body.source_hash, items: b.body.items }));
3707
+ })));
3708
+
3709
+ return releaseRoutes;
3710
+ } catch (error) {
3711
+ releaseRoutes();
3712
+ throw error;
3713
+ }
3177
3714
  };
3178
3715
 
3179
3716
  const reqSessionId = (url) => {
@@ -3181,32 +3718,28 @@ export function registerApi(ctx, deps, config) {
3181
3718
  return q.get('sessionId') || q.get('sid') || null;
3182
3719
  };
3183
3720
 
3184
- tryMount();
3185
- if (!disposeRoutes) {
3186
- // 服务启动顺序兜底:每 3s 重试,成功或插件卸载即停
3187
- timer = setInterval(() => {
3188
- if (disposed || disposeRoutes) {
3189
- clearInterval(timer);
3190
- timer = null;
3191
- return;
3192
- }
3193
- tryMount();
3194
- }, 3000);
3195
- }
3196
-
3197
- return () => {
3198
- disposed = true;
3199
- if (timer) {
3200
- clearInterval(timer);
3201
- timer = null;
3202
- }
3203
- if (disposeRoutes) {
3204
- try {
3205
- disposeRoutes();
3206
- } catch {}
3207
- disposeRoutes = null;
3721
+ try {
3722
+ tryMount();
3723
+ if (!disposeRoutes) {
3724
+ // 服务启动顺序兜底:每 3s 重试,成功或插件卸载即停
3725
+ timer = setInterval(() => {
3726
+ if (disposed || disposeRoutes) {
3727
+ clearInterval(timer);
3728
+ timer = null;
3729
+ return;
3730
+ }
3731
+ try { tryMount(); }
3732
+ catch (error) {
3733
+ cleanup();
3734
+ console.error('[dsh-ling] delayed API mount failed', error);
3735
+ }
3736
+ }, 3000);
3208
3737
  }
3209
- };
3738
+ return cleanup;
3739
+ } catch (error) {
3740
+ cleanup();
3741
+ throw error;
3742
+ }
3210
3743
  }
3211
3744
 
3212
3745
  function allSnapshotSessionIds(gate) {