@nightpainters/dsh-ling 1.6.1 → 1.7.0
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/CHANGELOG.md +207 -41
- package/DESIGN.md +121 -29
- package/PHILOSOPHY.md +184 -0
- package/README.md +112 -21
- package/cordis.patch.yml +32 -2
- package/lib/client.js +2520 -55
- package/lib/host/agent-tools.js +286 -0
- package/lib/host/api.js +612 -61
- package/lib/host/archive.js +3 -3
- package/lib/host/autotree.js +2 -2
- package/lib/host/clock-skill.js +1 -1
- package/lib/host/config.js +38 -0
- package/lib/host/deep-index-skill.js +9 -0
- package/lib/host/deepsummary.js +75 -2
- package/lib/host/dsweb-summary.js +6 -3
- package/lib/host/guard.js +53 -0
- package/lib/host/inject.js +12 -2
- package/lib/host/l1.js +59 -10
- package/lib/host/library-checker.js +552 -0
- package/lib/host/library-import-engine.js +211 -0
- package/lib/host/library-import.js +911 -0
- package/lib/host/library-runtime.js +199 -0
- package/lib/host/library-sqlite.js +344 -0
- package/lib/host/library-store.js +1714 -0
- package/lib/host/memory.js +89 -30
- package/lib/host/persona.js +13 -3
- package/lib/host/rawlog.js +10 -5
- package/lib/host/recall-search.js +192 -17
- package/lib/host/snapshot.js +2 -1
- package/lib/host/surgery.js +7 -2
- package/lib/host/tools.js +506 -31
- package/lib/host/util.js +1 -1
- package/lib/index.js +120 -81
- package/package.json +11 -5
- package/tools/deep-import-docs.mjs +1210 -0
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
|
-
|
|
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
|
-
|
|
405
|
-
|
|
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
|
|
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,10 +511,53 @@ 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() 里;这里只是**唯一**的调用点。
|
|
481
|
-
// 覆盖面 = 全部
|
|
558
|
+
// 覆盖面 = 全部 14 条能改人格/规矩/习惯的写面(逐条清单见 surgery.js 头部注释):挂在 guard(handler)
|
|
559
|
+
// ⚠️ 条数 2026-10-05 夜由 12 订正为 14(B-5):清单逐条列了 14 行,而这里与 surgery.js:15 一直写"12"
|
|
560
|
+
// ⇒ 三处不一致的根源是"没人拿清单行数当唯一真值"。现口径:**以清单行数为准**,改清单须同批改这两处。
|
|
482
561
|
// 上就"一眼看全、零回归面",与 POST_ONLY / 413 出口同一个取舍 —— 而不是在 49 个 register 调用点各写一遍。
|
|
483
562
|
// 安全的位置:本调用在 POST_ONLY(405)与 guardVerdict(403)**之后** ⇒ 被挡下的请求绝不会走到 handler。
|
|
484
563
|
// ⚠️ 承诺句必须**惰性**取:多数写端点的载荷由 `await readBody(req)` 消费,而请求流**只能读一次** ——
|
|
@@ -506,7 +585,10 @@ export function registerApi(ctx, deps, config) {
|
|
|
506
585
|
// 方法校验**先于鉴权** —— 它挡的是"浏览器被诱导发出的同站请求",与身份无关。
|
|
507
586
|
// 两种 url 形态都能处理:带前缀(`/api/dsh-ling/x`)被 replace 剥掉,不带的原样保留。
|
|
508
587
|
const sub = String(req.url || '').split('?')[0].replace(/^\/api\/dsh-ling/, '');
|
|
509
|
-
|
|
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))) {
|
|
510
592
|
// ── C-14(rejection 误标)──────────────────────────────────────────────
|
|
511
593
|
// 上面第 225 行就把这次请求交给访问日志了,但**判定点在这里**:只按状态码反推的
|
|
512
594
|
// rejectionReason() 分不清"哪个栅栏拒的"。故判定完成后把真因挂到请求对象上;
|
|
@@ -517,6 +599,14 @@ export function registerApi(ctx, deps, config) {
|
|
|
517
599
|
sendJson(res, 405, { ok: false, error: 'method-not-allowed', want: 'POST' });
|
|
518
600
|
return;
|
|
519
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
|
+
}
|
|
520
610
|
const verdict = guardVerdict(req);
|
|
521
611
|
if (!verdict.ok) {
|
|
522
612
|
req.__dshLingRejection = verdict.reason; // C-14:同上,写日志发生在下面的 sendJson→res.end 之内
|
|
@@ -530,7 +620,7 @@ export function registerApi(ctx, deps, config) {
|
|
|
530
620
|
// 但调用点必须落在"载荷已经解析出来"的地方(见下),因为请求流只能读一次。
|
|
531
621
|
// 每个写端点的形状固定为:`const body = JSON.parse(…); if (!gateSurgery(req, res, sendJson, body, { settings, logFile: surgeryLog, endpoint: sub })) return;`
|
|
532
622
|
// · 这一句就是全部接线:判据(本机 / 每启动票据 / 承诺句)与拒绝回应都在 surgery.js;
|
|
533
|
-
// · 覆盖面 =
|
|
623
|
+
// · 覆盖面 = 14 条写面,逐条清单与"改前是否过门"见 surgery.js 头部注释。
|
|
534
624
|
try {
|
|
535
625
|
await handler(req, res);
|
|
536
626
|
} catch (e) {
|
|
@@ -1130,7 +1220,7 @@ export function registerApi(ctx, deps, config) {
|
|
|
1130
1220
|
sendJson(res, 200, { ok: true, total: list.length, log: list });
|
|
1131
1221
|
}));
|
|
1132
1222
|
|
|
1133
|
-
// POST /branch-log/pending —— 复盘守门(2026-09-22
|
|
1223
|
+
// POST /branch-log/pending —— 复盘守门(2026-09-22 用户:「互相复盘不应该被我绕过」)。
|
|
1134
1224
|
// 返回"自器灵上次开口以来累积了多少笔结构改动还没交代";侧栏据此提示,
|
|
1135
1225
|
// 并在未同步时**拦住"退出复盘模式"** —— 改完树必须让器灵有机会看到。
|
|
1136
1226
|
// ⚠️ 进 `POST_ONLY` 的原因:它不是纯读 —— `memory.branchLogPending()` 在"水位键缺失 +
|
|
@@ -1159,12 +1249,12 @@ export function registerApi(ctx, deps, config) {
|
|
|
1159
1249
|
// GET /conflicts?status=pending|confirmed|dismissed —— 待复盘矛盾(带两侧标题,复盘界面直接可读)
|
|
1160
1250
|
disposers.push(register('/conflicts', async (req, res) => {
|
|
1161
1251
|
const q = new URL(req.url, 'http://dsh.internal').searchParams;
|
|
1162
|
-
// 2026-09-21:补两侧**摘要全文** —— 只给标题时人根本判不出"到底哪矛盾"(
|
|
1252
|
+
// 2026-09-21:补两侧**摘要全文** —— 只给标题时人根本判不出"到底哪矛盾"(用户实测反馈)。
|
|
1163
1253
|
const ovOf = (src, cid) => {
|
|
1164
1254
|
try { return memory.overviewById(String(src), String(cid)) || null; } catch { return null; }
|
|
1165
1255
|
};
|
|
1166
1256
|
// 2026-09-21:涉及归档会话的矛盾不进列表 —— 归档意味着"这条别再提了",
|
|
1167
|
-
// 但已登记的旧记录不会自动消失,所以在这里按当前归档状态实时筛(
|
|
1257
|
+
// 但已登记的旧记录不会自动消失,所以在这里按当前归档状态实时筛(用户实测反馈:
|
|
1168
1258
|
// 扫描侧上一轮已排除归档,可已存在的记录仍会一直显示)。
|
|
1169
1259
|
const archSet = memory.archivedConvIdSet();
|
|
1170
1260
|
const isArch = (side) => side && side.source === 'dsh' && archSet.has(String(side.convId));
|
|
@@ -1199,7 +1289,7 @@ export function registerApi(ctx, deps, config) {
|
|
|
1199
1289
|
sendJson(res, r.ok ? 200 : 400, r);
|
|
1200
1290
|
}));
|
|
1201
1291
|
|
|
1202
|
-
// ---- 一键生成记忆树(D9-b,2026-09-21
|
|
1292
|
+
// ---- 一键生成记忆树(D9-b,2026-09-21 与用户定案) ----
|
|
1203
1293
|
// 两阶段:① 机械分段(零成本、只读) ② 小助手命名 ③ 主人审核后落库。
|
|
1204
1294
|
// 不变量:不改写任何记忆内容 —— 只建枝/主脉 + 挂会话归属。
|
|
1205
1295
|
const autoSeg = (gapDays, maxSize) => bucketize(memory.overviewIndex(), { gapDays, maxSize });
|
|
@@ -1303,7 +1393,7 @@ export function registerApi(ctx, deps, config) {
|
|
|
1303
1393
|
const created = [];
|
|
1304
1394
|
let assigned = 0;
|
|
1305
1395
|
let skipped = 0;
|
|
1306
|
-
// 零散合簇(
|
|
1396
|
+
// 零散合簇(用户 2026-09-21 裁决):名字含「零散」的簇统一挂到同一个主脉「零散会话」下。
|
|
1307
1397
|
// 理由:历史网页端的一次性问答主题天然分散、不属于任何工作线;合并成一簇后可在
|
|
1308
1398
|
// 复盘时展开细化(设计稿 §10.3)。主脉按名复用,不重复建。
|
|
1309
1399
|
const ZERO_NAME = '零散会话';
|
|
@@ -1490,7 +1580,7 @@ export function registerApi(ctx, deps, config) {
|
|
|
1490
1580
|
const text = '主脉名称:' + String(v.name || veinId) + '\n\n素材(共 ' + mat.length + ' 条):\n'
|
|
1491
1581
|
+ mat.map((m, i) => (i + 1) + '. ' + (m.title || m.convId) + ' —— ' + m.summary).join('\n');
|
|
1492
1582
|
const t0 = Date.now();
|
|
1493
|
-
// 全局大模型(2026-09-25
|
|
1583
|
+
// 全局大模型(2026-09-25 用户:「你在给自己做整理,别让小助手代劳」)。
|
|
1494
1584
|
// 原先走本机小助手(summarizeWithRetry + /no_think),但"这几条枝讲的是不是一件事"
|
|
1495
1585
|
// 是**判断**不是搬砖 —— 该用器灵自己的脑;小助手留给压缩/分类这类机械活。
|
|
1496
1586
|
//
|
|
@@ -1799,7 +1889,7 @@ export function registerApi(ctx, deps, config) {
|
|
|
1799
1889
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
1800
1890
|
const limit = Math.max(10, Math.min(200, Number(body.limit) || 60));
|
|
1801
1891
|
const threshold = Math.max(0.3, Math.min(0.95, Number(body.threshold) || 0.55));
|
|
1802
|
-
// 2026-09-21:排除**已归档**会话 —— 封存的对话不该再被拉出来互相判矛盾(
|
|
1892
|
+
// 2026-09-21:排除**已归档**会话 —— 封存的对话不该再被拉出来互相判矛盾(用户实测反馈)。
|
|
1803
1893
|
const archived = memory.archivedConvIdSet();
|
|
1804
1894
|
const rows = memory.queryOverviews({ limit }).items
|
|
1805
1895
|
.filter((r) => r.source === 'dsh' || r.source === 'import')
|
|
@@ -2081,7 +2171,7 @@ export function registerApi(ctx, deps, config) {
|
|
|
2081
2171
|
// 上锁(sealed:true)需带新承诺句 → 覆盖哈希;撤锁清哈希;每次成功变更后存「结果档案」。
|
|
2082
2172
|
disposers.push(register('/persona', async (req, res) => {
|
|
2083
2173
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2084
|
-
// ── 手术门(第 1/
|
|
2174
|
+
// ── 手术门(第 1/14 条写面)────────────────────────────────────────────────
|
|
2085
2175
|
// 载荷必须**在这里**交上去:请求流只能读一次(见 guard 里的接线说明)。
|
|
2086
2176
|
// 只交 `unlock` 一项:门只读它(§1.3 判据 3)。把整份 patch 交上去 = 让留痕的"字段指纹"
|
|
2087
2177
|
// 记下一串与判据无关的内容(越少记越好;要留痕的是"这次手术",不是"他改成了什么")。
|
|
@@ -2127,13 +2217,58 @@ export function registerApi(ctx, deps, config) {
|
|
|
2127
2217
|
});
|
|
2128
2218
|
}));
|
|
2129
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
|
+
|
|
2130
2265
|
// POST /persona/check { unlock } 客户端「解锁修改」先验钥(只读,不落盘)
|
|
2131
2266
|
// 进 `POST_ONLY` 是**防御性**:今天 GET 下 body 恒为 `{}` ⇒ 只回"承诺句不一致"(无写),
|
|
2132
2267
|
// 但 handler 读 body、注释也自称 POST ⇒ 形态已经是 POST 端点,补进去零成本。
|
|
2133
2268
|
disposers.push(register('/persona/check', async (req, res) => {
|
|
2134
2269
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2135
2270
|
const cur = settings.get();
|
|
2136
|
-
// 手术门(第 2/
|
|
2271
|
+
// 手术门(第 2/14 条写面):**这一条本身不写** —— 它是"验句预言机"(在线猜句的入口)。
|
|
2137
2272
|
// 门口径与别处**完全一致**:已定型 ⇒ 必须本机 + 带票 + 原句,否则连"这句对不对"都不告诉它
|
|
2138
2273
|
// (改前它对任意调用者有求必应 ⇒ 猜句成功率只受网络延迟限制)。
|
|
2139
2274
|
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/check' })) return;
|
|
@@ -2165,7 +2300,7 @@ export function registerApi(ctx, deps, config) {
|
|
|
2165
2300
|
// 无论是否定型,只允许更新 persona.aiTitle(定位自述)单字段;其余字段仍需手术/承诺句。
|
|
2166
2301
|
disposers.push(register('/persona/self-summary', async (req, res) => {
|
|
2167
2302
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2168
|
-
// 手术门(第 3/
|
|
2303
|
+
// 手术门(第 3/14 条写面):身份自述 aiTitle 属身份保护区。只交 unlock(见 /persona 处说明)。
|
|
2169
2304
|
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/self-summary' })) return;
|
|
2170
2305
|
const text = String(body.text ?? '').trim();
|
|
2171
2306
|
if (!text) return sendJson(res, 200, { ok: false, reason: 'empty', message: '内容为空' });
|
|
@@ -2189,7 +2324,7 @@ export function registerApi(ctx, deps, config) {
|
|
|
2189
2324
|
// (习惯不能直达;这里的用户点击就是确认动作),重量前先归档。
|
|
2190
2325
|
disposers.push(register('/persona/hint-adopt', async (req, res) => {
|
|
2191
2326
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2192
|
-
// 手术门(第 4/
|
|
2327
|
+
// 手术门(第 4/14 条写面):一次点击即 propose+confirm 落一条习惯。只交 unlock。
|
|
2193
2328
|
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/hint-adopt' })) return;
|
|
2194
2329
|
const text = String(body.text ?? '').trim();
|
|
2195
2330
|
if (text.length < 4 || text.length > 400) {
|
|
@@ -2229,7 +2364,7 @@ export function registerApi(ctx, deps, config) {
|
|
|
2229
2364
|
// "去掉 quote"**不再等于**"来自面板":归因只由 ruleWriteAttribution() 一处判(见文件头)。
|
|
2230
2365
|
disposers.push(register('/persona/rule', async (req, res) => {
|
|
2231
2366
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2232
|
-
// 手术门(第 5+6/
|
|
2367
|
+
// 手术门(第 5+6/14 条写面:add 与 remove 同一端点,一次判过):
|
|
2233
2368
|
// 规矩是他的指令(quote 是"经同意"的凭据),但**删规矩能整批删光** ⇒ 一律过门。
|
|
2234
2369
|
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/rule' })) return;
|
|
2235
2370
|
const action = String(body.action || 'add');
|
|
@@ -2271,7 +2406,7 @@ export function registerApi(ctx, deps, config) {
|
|
|
2271
2406
|
// 习惯不直达:propose(任一方可提)/ confirm(对方点头)/ reject / remove。
|
|
2272
2407
|
disposers.push(register('/persona/habit', async (req, res) => {
|
|
2273
2408
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2274
|
-
// 手术门(第 7+8/
|
|
2409
|
+
// 手术门(第 7+8/14 条写面:propose/confirm/reject 与 remove 同一端点,一次判过)。
|
|
2275
2410
|
// 改前只有 remove 过门 —— "加比删容易"正是侦察里指出的不对称,现在两条都过门。
|
|
2276
2411
|
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/habit' })) return;
|
|
2277
2412
|
const action = String(body.action || 'propose');
|
|
@@ -2303,7 +2438,17 @@ export function registerApi(ctx, deps, config) {
|
|
|
2303
2438
|
|
|
2304
2439
|
// POST /persona/habits/scan 通道 A:零模型,扫"跨会话重复出现的纠正"→ 落成习惯候选(待确认)
|
|
2305
2440
|
// 阈值:同类纠正 ≥4 次 且 跨 ≥3 个会话(单次会话里被说三遍不算模式;口径与变更记录见 habit-gen.js 的 SCAN_DEFAULTS)
|
|
2306
|
-
disposers.push(register('/persona/habits/scan', async (
|
|
2441
|
+
disposers.push(register('/persona/habits/scan', async (req, res) => {
|
|
2442
|
+
// ── 手术门(A-1,2026-10-05 夜;交办单 HANDOFF-机制面复核交办单 §A-1)──────────────────
|
|
2443
|
+
// ⚠️ 本端点**会写 `habitsPending`** ⇒ 它进的是**人格面** ⇒ 按 P-5 第 5 条必须过闸。
|
|
2444
|
+
// 改前它是 14 条清单外的漏点:确认侧 `/persona/habit`(:2276)**过门**,而两条**产出候选**
|
|
2445
|
+
// 的写面(`scan` / `reflect`)不过门 —— 一条通路的两半只改了一半,正是
|
|
2446
|
+
// DESIGN §0.1 工程理念 9「两半同批」的反例(2026-10-05 机制面复核 A-1)。
|
|
2447
|
+
// 接线形状照抄 `/persona/habit`:必须先 `readBody` 再判门(请求流只能读一次,门要拿
|
|
2448
|
+
// `unlock` 原句);空 body 用 `|| '{}'` 兜住(面板按钮不带 body)。
|
|
2449
|
+
// 判据本体仍**只在** `surgery.js` 的 `requireSurgery()` 一处,这里只接线、不重判。
|
|
2450
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2451
|
+
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/habits/scan' })) return;
|
|
2307
2452
|
try {
|
|
2308
2453
|
// 上限(2026-09-16 定案):待确认满了就**明确驳回**,并说明为什么 —— 不排队、不静默丢弃。
|
|
2309
2454
|
const capS = pendingMaxOf(settings);
|
|
@@ -2341,7 +2486,13 @@ export function registerApi(ctx, deps, config) {
|
|
|
2341
2486
|
|
|
2342
2487
|
// POST /persona/habits/reflect 通道 B:让她"回想最近的相处" → 1~5 条习惯候选(一次 LLM,只提议不落盘)
|
|
2343
2488
|
// 手动触发,材料给足:主人真人原话 + 记忆概述 + 纠正统计 + 她现状(默认预算 20 万字符,可用 settings.habits.reflect* 覆盖)
|
|
2344
|
-
disposers.push(register('/persona/habits/reflect', async (
|
|
2489
|
+
disposers.push(register('/persona/habits/reflect', async (req, res) => {
|
|
2490
|
+
// ── 手术门(A-1,同 scan;两处必须同批改 —— 它们共享 `proposeHabit` 这一半)──────────
|
|
2491
|
+
// `reflect` 的候选经 `parseReflect` → `proposeHabit`(:2436)落进 `habitsPending` ⇒ 同属写面。
|
|
2492
|
+
// 注意它另有 kv 写入(`habits.reflect.cursor` / `.last` / `.debug`)—— 那些是**运行记录**,
|
|
2493
|
+
// 不是人格面内容 ⇒ 单独看它们不构成"必须过门"的理由;过门的理由是候选落进人格面。
|
|
2494
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2495
|
+
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/habits/reflect' })) return;
|
|
2345
2496
|
try {
|
|
2346
2497
|
// 上限(2026-09-16 定案):与 scan 同一条规则 —— 满了明确驳回,不排队、不静默丢弃。
|
|
2347
2498
|
const capR = pendingMaxOf(settings);
|
|
@@ -2507,7 +2658,7 @@ export function registerApi(ctx, deps, config) {
|
|
|
2507
2658
|
// 自性类成长通道:白名单字段(语气枚举 + 风格追加),锁定下亦可采纳;不可逆操作一律不做。
|
|
2508
2659
|
disposers.push(register('/persona/grow', async (req, res) => {
|
|
2509
2660
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2510
|
-
// 手术门(第 9/
|
|
2661
|
+
// 手术门(第 9/14 条写面):改语气 / 追加风格注 —— 旧注释自陈"锁定下亦可采纳",那正是缺口。
|
|
2511
2662
|
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/grow' })) return;
|
|
2512
2663
|
const patch = {};
|
|
2513
2664
|
const toneIn = body.tone && typeof body.tone === 'object' ? body.tone : {};
|
|
@@ -2543,7 +2694,7 @@ export function registerApi(ctx, deps, config) {
|
|
|
2543
2694
|
// POST /persona/rollback { ts, unlock? } 回滚到某历史存档(同样受定型门约束)
|
|
2544
2695
|
disposers.push(register('/persona/rollback', async (req, res) => {
|
|
2545
2696
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2546
|
-
// 手术门(第 10/
|
|
2697
|
+
// 手术门(第 10/14 条写面):整体回写 {persona,styles}(含 sealed/sealPhrase)——
|
|
2547
2698
|
// 回滚到定型前快照 = **撤锁**,故判据必须在"看存档是否存在"之前:格式不对的请求也不该探到存档。
|
|
2548
2699
|
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/rollback' })) return;
|
|
2549
2700
|
const ts = Number(body.ts);
|
|
@@ -2920,7 +3071,7 @@ export function registerApi(ctx, deps, config) {
|
|
|
2920
3071
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2921
3072
|
// ⚠️ **手术门补在这一条上**(2026-10-04 发布关④ 抓到):它**写人格**(诞生仪式生成的自我介绍
|
|
2922
3073
|
// 会被主人采纳进 persona),却一直没有门 —— 而 README 与 `gateSurgery` 的注释都写着
|
|
2923
|
-
// "**全部
|
|
3074
|
+
// "**全部 14 条写面**已由手术门统一判过",实测全仓只有 11 处 `gateSurgery` 调用点。
|
|
2924
3075
|
// 补门后它与其余写面同级:本机 + 每启动票据 + 原句匹配。
|
|
2925
3076
|
// ⚠️ 载荷只交 `unlock` 一项(与 `/persona` 同口径):门只读它,把整份 body 交上去
|
|
2926
3077
|
// 会让留痕的"字段指纹"记下与判据无关的内容。
|
|
@@ -2974,7 +3125,7 @@ export function registerApi(ctx, deps, config) {
|
|
|
2974
3125
|
// POST /feedback/apply { id }
|
|
2975
3126
|
disposers.push(register('/feedback/apply', async (req, res) => {
|
|
2976
3127
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2977
|
-
// 手术门(第 11/
|
|
3128
|
+
// 手术门(第 11/14 条写面):采纳建议 → 落习惯 / 改自称。改前只有身份两 kind 有门。
|
|
2978
3129
|
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/feedback/apply' })) return;
|
|
2979
3130
|
const r = await applySuggestionAsRule(memory, settings, body.id);
|
|
2980
3131
|
if (!r.ok) return sendJson(res, 200, { ok: false, reason: r.reason });
|
|
@@ -3016,7 +3167,7 @@ export function registerApi(ctx, deps, config) {
|
|
|
3016
3167
|
// POST /suggestions/apply { id }
|
|
3017
3168
|
disposers.push(register('/suggestions/apply', async (req, res) => {
|
|
3018
3169
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
3019
|
-
// 手术门(第 13/
|
|
3170
|
+
// 手术门(第 13/14 条写面):采纳建议。改前**只有** kind∈{aiName,userTitle} 才验句,
|
|
3020
3171
|
// 惯例类(hardrule 等)可自由追加 ⇒ 现在整条端点统一过门(下面那段身份分支因此成了一道冗余的深防线,
|
|
3021
3172
|
// 保留不动:判据更严的一侧永远不会把请求放过去)。
|
|
3022
3173
|
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/suggestions/apply' })) return;
|
|
@@ -3065,6 +3216,31 @@ export function registerApi(ctx, deps, config) {
|
|
|
3065
3216
|
});
|
|
3066
3217
|
}));
|
|
3067
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
|
+
|
|
3068
3244
|
// POST /deep/run { limit? }
|
|
3069
3245
|
disposers.push(register('/deep/run', async (req, res) => {
|
|
3070
3246
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
@@ -3117,7 +3293,7 @@ export function registerApi(ctx, deps, config) {
|
|
|
3117
3293
|
const q = new URL(req.url, 'http://dsh.internal').searchParams;
|
|
3118
3294
|
const text = await readBody(req);
|
|
3119
3295
|
const parsed = JSON.parse(text || '{}');
|
|
3120
|
-
// 手术门(第 14/
|
|
3296
|
+
// 手术门(第 14/14 条写面)⚠️ **前移到 importBundle 之前** —— 这是本批唯一一处"改前门在后面"的修正:
|
|
3121
3297
|
// 改前:`memory.importBundle()`(下一行)先跑,门在它**之后** ⇒ 一个带着恶意 bundle 的请求
|
|
3122
3298
|
// 即使人格回写被拒,**记忆也已经整体被换掉了**(DESIGN §3 该行备注"门拦不住记忆被换")。
|
|
3123
3299
|
// 现在:已定型时,未过门(非本机 / 无票 / 原句不符)的请求**一个字节都不落库**。
|
|
@@ -3149,13 +3325,392 @@ export function registerApi(ctx, deps, config) {
|
|
|
3149
3325
|
sendJson(res, 200, { ok: report.errors === 0, personaApplied, report });
|
|
3150
3326
|
}));
|
|
3151
3327
|
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
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) };
|
|
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];
|
|
3157
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 };
|
|
3158
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;
|
|
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
|
+
}
|
|
3159
3714
|
};
|
|
3160
3715
|
|
|
3161
3716
|
const reqSessionId = (url) => {
|
|
@@ -3163,32 +3718,28 @@ export function registerApi(ctx, deps, config) {
|
|
|
3163
3718
|
return q.get('sessionId') || q.get('sid') || null;
|
|
3164
3719
|
};
|
|
3165
3720
|
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
clearInterval(timer);
|
|
3183
|
-
timer = null;
|
|
3184
|
-
}
|
|
3185
|
-
if (disposeRoutes) {
|
|
3186
|
-
try {
|
|
3187
|
-
disposeRoutes();
|
|
3188
|
-
} catch {}
|
|
3189
|
-
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);
|
|
3190
3737
|
}
|
|
3191
|
-
|
|
3738
|
+
return cleanup;
|
|
3739
|
+
} catch (error) {
|
|
3740
|
+
cleanup();
|
|
3741
|
+
throw error;
|
|
3742
|
+
}
|
|
3192
3743
|
}
|
|
3193
3744
|
|
|
3194
3745
|
function allSnapshotSessionIds(gate) {
|