@modusensus/dsh-mneme 0.9.4 → 0.9.5
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/README.md +53 -10
- package/bin/cli.mjs +215 -2
- package/docs/CONFIGURATION.md +4 -2
- package/docs/DAEMON.md +9 -2
- package/docs/DISTILL.md +66 -4
- package/docs/SHARE.md +552 -0
- package/docs/SLEEP.md +1 -1
- package/docs/STORAGE.md +1 -1
- package/lib/api-standalone.js +286 -8
- package/lib/api.js +195 -1
- package/lib/client.js +783 -30
- package/lib/document.js +15 -2
- package/lib/dream/decisions.js +88 -3
- package/lib/dream/sleep.js +63 -13
- package/lib/dream.js +65 -9
- package/lib/lang.js +23 -6
- package/lib/memory-source.js +9 -0
- package/lib/organize.js +5 -1
- package/lib/pipeline.js +62 -1
- package/lib/provenance-http.js +126 -0
- package/lib/serve.js +13 -0
- package/lib/service.js +67 -4
- package/lib/share-http.js +281 -0
- package/lib/share-import.js +234 -0
- package/lib/share.js +335 -0
- package/lib/store.js +272 -11
- package/lib/summarize.js +60 -11
- package/package.json +1 -1
- package/src/api-standalone.js +286 -8
- package/src/api.js +195 -1
- package/src/document.js +15 -2
- package/src/dream/decisions.js +88 -3
- package/src/dream/sleep.js +63 -13
- package/src/dream.js +65 -9
- package/src/lang.js +23 -6
- package/src/memory-source.js +9 -0
- package/src/organize.js +5 -1
- package/src/pipeline.js +62 -1
- package/src/provenance-http.js +126 -0
- package/src/serve.js +13 -0
- package/src/service.js +67 -4
- package/src/share-http.js +281 -0
- package/src/share-import.js +234 -0
- package/src/share.js +335 -0
- package/src/store.js +272 -11
- package/src/summarize.js +60 -11
- package/test/api.test.js +370 -2
- package/test/audit.test.js +2 -2
- package/test/cli-receive.test.js +390 -0
- package/test/cli-share.test.js +437 -0
- package/test/client-share.test.js +225 -0
- package/test/client.test.js +139 -0
- package/test/dedupe-candidate-window.test.js +180 -0
- package/test/dream-candidate-hybrid.test.js +2 -2
- package/test/dream-fence.test.js +164 -0
- package/test/dream.test.js +87 -5
- package/test/helpers/dream-mock.js +6 -2
- package/test/llm-message-source.test.js +1 -1
- package/test/mint-source.test.js +1 -1
- package/test/pipeline-r1r2.test.js +129 -0
- package/test/policy-epoch.test.js +2 -2
- package/test/provenance-http.test.js +184 -0
- package/test/readme-claims.test.js +45 -0
- package/test/reasoning-effort.test.js +10 -10
- package/test/scope-candidates.test.js +7 -4
- package/test/scope-state.test.js +130 -1
- package/test/serve.test.js +62 -0
- package/test/share-foreign-rows.test.js +144 -0
- package/test/share-http.test.js +144 -0
- package/test/share-import.test.js +577 -0
- package/test/share-imported-guard.test.js +311 -0
- package/test/share-imported-identity.test.js +111 -0
- package/test/share-pack.test.js +511 -0
- package/test/share-receive-http.test.js +756 -0
- package/test/share-routes.test.js +337 -0
- package/test/share-store.test.js +133 -0
- package/test/share-validate.test.js +101 -0
- package/test/standalone-api.test.js +524 -5
- package/test/summarize-parse-diagnostics.test.js +75 -0
- package/test/summarize.test.js +15 -2
package/lib/api-standalone.js
CHANGED
|
@@ -15,10 +15,13 @@
|
|
|
15
15
|
// the operator's explicit responsibility (documented in README).
|
|
16
16
|
import { createServer } from "node:http";
|
|
17
17
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
18
|
+
import { homedir } from "node:os";
|
|
18
19
|
import { TYPES } from "./store.js";
|
|
19
20
|
import { bootstrapFromDirectory } from "./bootstrap.js";
|
|
20
21
|
import { PACKAGE_VERSION } from "./version-check.js";
|
|
21
22
|
import { parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.js";
|
|
23
|
+
import { parseShareBody, shareReply, parseReceiveBody, receiveReply, parseUndoBody, undoReply } from "./share-http.js";
|
|
24
|
+
import { parseProvenanceQuery, provenanceReply } from "./provenance-http.js";
|
|
22
25
|
|
|
23
26
|
const DEFAULT_PORT = 8790;
|
|
24
27
|
const DEFAULT_HOST = "127.0.0.1";
|
|
@@ -30,6 +33,46 @@ const MAX_REQUEST_BODY_BYTES = 4 * 1024 * 1024;
|
|
|
30
33
|
const MAX_PAGE_LIMIT = 1000;
|
|
31
34
|
const MAX_SEARCH_TOP_K = 200;
|
|
32
35
|
|
|
36
|
+
/**
|
|
37
|
+
* /context 降级原因枚举(#383,issue #508 评审后定稿)——**日志读数**的唯一真源。
|
|
38
|
+
* 它不进响应体:响应只多 `tier` 一个键,具体原因走 daemon stderr 那行 warn。
|
|
39
|
+
*
|
|
40
|
+
* 成例是「修法不同就分开」,于是六值互不吞并:
|
|
41
|
+
* embed-unavailable embedder 对象在,上面没有 embedSingle / embed → 修适配器
|
|
42
|
+
* embed-empty 接口返回了,但向量是空数组 / 非数组 → 修接口
|
|
43
|
+
* embed-timeout 接口挂起,被 embedTimeoutMs 护栏按缺失处理 → 修端点响应
|
|
44
|
+
* embed-error 接口抛错(HTTP 4xx/5xx、网络、解析失败……)→ 查端点
|
|
45
|
+
* embed-dimension 接口成功了,但库里没有一条维度相符的活跃向量 → 重建索引
|
|
46
|
+
* no-embedder 压根没装配 embedder(daemon `--embed off` / lightMode /
|
|
47
|
+
* 宿主未装配)→ 把嵌入装上或开起来。这是**预期态,不是故障**
|
|
48
|
+
* 最后一个是 #508 Minor 的修正:此前这条路不写 diag.reason,靠日志行的兜底表达式
|
|
49
|
+
* 临时拼一个值出来,结果那个值不在任何文档声明的枚举里,按枚举解析日志的运维程序
|
|
50
|
+
* 会整条漏掉。现在它在决策点赋值,与其余五值并列同源。
|
|
51
|
+
*
|
|
52
|
+
* 不并进 embed-unavailable 是有意的:那两个读数今天共用「语义路没通」的结果,但
|
|
53
|
+
* 主体与动作都不同——一个是适配器坏了(异常),一个是语义路没开(设计内常态,
|
|
54
|
+
* src/index.js 的 lightMode 注释与 src/serve.js 的 `embedder ?? semantic?.embedder
|
|
55
|
+
* ?? null` 都明写它)。并过去会让按 embed-unavailable 告警的面板对每个轻量模式
|
|
56
|
+
* 安装报警。用词则与宿主面 /api/dsh-mneme/semantic 的 reason 对齐(src/api.js),
|
|
57
|
+
* 同一个物理条件在两个面叫同一个名字,运维 grep 时不会分裂。
|
|
58
|
+
*
|
|
59
|
+
* **两套同名枚举的分界**:src/api.js 那套是 `no-embedder / not-configured /
|
|
60
|
+
* initializing / null`,免鉴权状态面板消费,test/api.test.js 锁着。两套同名不同源,
|
|
61
|
+
* 这里只借词、不改那边;清扫文档时不要顺手统一(会打红那条测试)。
|
|
62
|
+
*
|
|
63
|
+
* 这一个常量同时是代码写入方、两份文档(README 外部 API 段 / docs/DAEMON.md §2)与
|
|
64
|
+
* 契约测试的同一来源:新增降级路径必须在这里加值并同步那两处,否则
|
|
65
|
+
* test/standalone-api.test.js 的枚举契约测试会红。
|
|
66
|
+
*/
|
|
67
|
+
export const CONTEXT_DEGRADE_REASONS = Object.freeze([
|
|
68
|
+
"embed-unavailable",
|
|
69
|
+
"embed-empty",
|
|
70
|
+
"embed-timeout",
|
|
71
|
+
"embed-error",
|
|
72
|
+
"embed-dimension",
|
|
73
|
+
"no-embedder"
|
|
74
|
+
]);
|
|
75
|
+
|
|
33
76
|
/**
|
|
34
77
|
* Listen with automatic EADDRINUSE recovery. Tries the configured port, then
|
|
35
78
|
* the next MAX_PORT_ATTEMPTS-1 ports, and finally falls back to port 0 so the
|
|
@@ -226,16 +269,29 @@ async function handlePutBody(res, service, logger, id, text) {
|
|
|
226
269
|
* /context 的查询嵌入:best-effort,embedder 接口宽容与 searchMemories 同款
|
|
227
270
|
* (embedSingle 优先,兼容 embed-only 的 OpenAI 兼容客户端,issue #10)。
|
|
228
271
|
* 调用方负责 catch——嵌入失败降级规则档,绝不因此 500。
|
|
272
|
+
*
|
|
273
|
+
* `diag`(optional,issue #383):拿不到向量时把原因写进去,供 daemon 侧日志枚举
|
|
274
|
+
* 降级原因。枚举是上面那个 CONTEXT_DEGRADE_REASONS(六值,互不吞并),本函数只负
|
|
275
|
+
* 责其中两个「没拿到向量」的值:无可用接口 → embed-unavailable,空/非数组 →
|
|
276
|
+
* embed-empty。其余由超时护栏、查询嵌入 .catch 与维度判据写入。不传则行为与既往
|
|
277
|
+
* 逐字节一致。
|
|
229
278
|
*/
|
|
230
|
-
async function embedQueryVector(embedder, q) {
|
|
279
|
+
async function embedQueryVector(embedder, q, diag) {
|
|
231
280
|
const embedSingle = typeof embedder.embedSingle === "function"
|
|
232
281
|
? embedder.embedSingle.bind(embedder)
|
|
233
282
|
: typeof embedder.embed === "function"
|
|
234
283
|
? embedder.embed.bind(embedder)
|
|
235
284
|
: null;
|
|
236
|
-
if (!embedSingle)
|
|
285
|
+
if (!embedSingle) {
|
|
286
|
+
if (diag) diag.reason = "embed-unavailable";
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
237
289
|
const vector = await embedSingle(q);
|
|
238
|
-
|
|
290
|
+
if (!Array.isArray(vector) || !vector.length) {
|
|
291
|
+
if (diag) diag.reason = "embed-empty";
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
return vector;
|
|
239
295
|
}
|
|
240
296
|
|
|
241
297
|
/**
|
|
@@ -244,11 +300,17 @@ async function embedQueryVector(embedder, q) {
|
|
|
244
300
|
* 按嵌入缺失处理,降级规则档。输掉的 embed promise 无法取消、自行结束后被丢弃,
|
|
245
301
|
* 无害;挂起的主要成本是一个泄漏的挂起任务,可接受。嵌入先完成时清掉定时器
|
|
246
302
|
* (CodeRabbit 同轮 Minor:别让每个请求都留一枚要等满超时的计时器)。
|
|
303
|
+
*
|
|
304
|
+
* `diag`(optional,issue #383):超时写 `embed-timeout`,与上面几个原因同一套枚举
|
|
305
|
+
* (CONTEXT_DEGRADE_REASONS)。
|
|
247
306
|
*/
|
|
248
|
-
function withEmbedTimeout(promise, timeoutMs) {
|
|
307
|
+
function withEmbedTimeout(promise, timeoutMs, diag) {
|
|
249
308
|
let timer;
|
|
250
309
|
const timeout = new Promise((resolve) => {
|
|
251
|
-
timer = setTimeout(() =>
|
|
310
|
+
timer = setTimeout(() => {
|
|
311
|
+
if (diag) diag.reason = "embed-timeout";
|
|
312
|
+
resolve(undefined);
|
|
313
|
+
}, timeoutMs);
|
|
252
314
|
});
|
|
253
315
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
254
316
|
}
|
|
@@ -281,6 +343,11 @@ function scopeFromSearchParams(url) {
|
|
|
281
343
|
* - embedTimeoutMs: /context query-embed guard (default 3000) — a hung
|
|
282
344
|
* embedder must not hold the HTTP connection; timeout degrades to the rule
|
|
283
345
|
* tier. Factory option (like strictPort), not a user config key.
|
|
346
|
+
* - config: 缺省 `{}` 对 /memory/provenance 意味着**功能关着**——该读出口的门读
|
|
347
|
+
* config.distillRecords.enabled(判据见 src/provenance-http.js),而它返回的是
|
|
348
|
+
* 蒸馏窗口的逐字原文,所以「不传即关」是故意的 fail-closed 方向(reclaim-http
|
|
349
|
+
* 「没接线如实报」同族)。将来新增第三个构造点忘传 config,这个端点会安静地
|
|
350
|
+
* 回 409 而不是把原文读出去——那是想要的行为,不要修成默认开。
|
|
284
351
|
* Returns { server, port, host, token, ready }: `port` is the effective bound
|
|
285
352
|
* port (updated to the OS-assigned one after `ready` resolves when asked to
|
|
286
353
|
* bind port 0), `ready` resolves once listening and rejects if the bind fails.
|
|
@@ -414,6 +481,32 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
|
|
|
414
481
|
return;
|
|
415
482
|
}
|
|
416
483
|
|
|
484
|
+
// --- GET /memory/provenance: 记忆级溯源读出口(#399 写、#421 读)--------
|
|
485
|
+
// 与宿主内 /api/dsh-mneme/memory/provenance 同一份实现口径,就在
|
|
486
|
+
// src/provenance-http.js 里写一份(#411 的教训:两张皮整段复制必然漂移)。
|
|
487
|
+
// 一个链接一个窗口(区间 + 区间内原文行,按 seq 升序);没有链接的记忆返回
|
|
488
|
+
// 空 windows——「不是蒸馏来的」是有效答案,不是错误。
|
|
489
|
+
//
|
|
490
|
+
// 这张面本来就要 Bearer token(除 /health 外无例外),所以不涉及宿主侧那次
|
|
491
|
+
// open → token 的归类调整;daemon 与宿主共用 service,口径天然一致。
|
|
492
|
+
//
|
|
493
|
+
// distillRecords.enabled 门(#509 评审整改):config 原样转交给共用模块,
|
|
494
|
+
// 判据只在那边算一次(isDistillRecordsEnabled,与写侧 src/summarize.js:988
|
|
495
|
+
// 同源,本文件不重写)。config 缺省 {} → 判据为假 → 409,错误码取共用模块导出的
|
|
496
|
+
// DISTILL_RECORDS_DISABLED:「不传即关」对这个返回逐字原文的端点是正确的失败
|
|
497
|
+
// 方向,别当成 bug 修掉。阶梯全貌(401 → 400 → 409 → 503 → 读)见
|
|
498
|
+
// provenance-http.js 文件头。
|
|
499
|
+
if (req.method === "GET" && pathname === "/memory/provenance") {
|
|
500
|
+
const parsed = parseProvenanceQuery(url.searchParams.get("id"));
|
|
501
|
+
if (!parsed.ok) {
|
|
502
|
+
sendJson(res, parsed.status, { error: parsed.error });
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const { status, body } = provenanceReply({ service, id: parsed.id, config, warn: (m) => logger?.warn?.(m) });
|
|
506
|
+
sendJson(res, status, body);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
417
510
|
// --- GET/PUT/DELETE /memories/:id --------------------------------------
|
|
418
511
|
const idMatch = pathname.match(/^\/memories\/([^/]+)$/);
|
|
419
512
|
if (idMatch) {
|
|
@@ -581,6 +674,84 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
|
|
|
581
674
|
return;
|
|
582
675
|
}
|
|
583
676
|
|
|
677
|
+
// --- POST /share: 分享包(spec §4)--------------------------------------
|
|
678
|
+
// 判据一条都不在这里:哪些行进包、超没超限、抛错回什么码,全在 share-http.js
|
|
679
|
+
// 那份共用实现里(#411 的口径——两张皮只许差在鉴权与路径)。本侧独有的只有
|
|
680
|
+
// 鉴权(Bearer,除 GET /health 外没有例外)与「不出 Content-Disposition」:
|
|
681
|
+
// 下面的 sendJson 连 headers 参数都没有,CLI 拿 JSON 自己落盘。
|
|
682
|
+
//
|
|
683
|
+
// GET 回 405 而不是掉到末尾的 404,与面板那侧一致。这**故意**与上面的
|
|
684
|
+
// /maintenance/reclaim(GET → 404)不同:状态码差一次就是第三处差别,而两条
|
|
685
|
+
// 路由的「两张皮」约束比这份文件里邻居的写法更硬。
|
|
686
|
+
//
|
|
687
|
+
// .catch 不是装饰:shareReply 现在自己兜得住,但这条链上 readBody 之前之后的
|
|
688
|
+
// 任何一处抛错都没人接——本文件那个同步 try/catch 在 .then 的回调里已经退栈,
|
|
689
|
+
// 漏出去就是未处理拒绝、客户端一个字节都没收到(#410)。
|
|
690
|
+
if (pathname === "/share") {
|
|
691
|
+
if (req.method !== "POST") {
|
|
692
|
+
sendJson(res, 405, { error: "method-not-allowed" });
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
void readBody(req, res).then((text) => {
|
|
696
|
+
if (text === null) return; // readBody 已经答过 413,再写一次就是双写响应
|
|
697
|
+
const parsed = parseShareBody(text);
|
|
698
|
+
if (!parsed.ok) {
|
|
699
|
+
sendJson(res, 400, { error: parsed.error });
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const out = shareReply({
|
|
703
|
+
service,
|
|
704
|
+
packageVersion: PACKAGE_VERSION,
|
|
705
|
+
homeDir: homedir(),
|
|
706
|
+
options: parsed.options,
|
|
707
|
+
warn: (msg) => logger?.warn?.(msg)
|
|
708
|
+
});
|
|
709
|
+
sendJson(res, out.status, out.body);
|
|
710
|
+
}).catch(() => sendJson(res, 500, { error: "internal" }));
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// --- POST /receive + POST /receive/undo: 分享包入库与整包退出(车道 C)-----
|
|
715
|
+
// 判据一条都不在这里:包体合格不合格住在 share.js 的闸门(由 service 的
|
|
716
|
+
// importSharePack 调),错误码 → 状态的映射与 dry_run 那一格住在 src/share-http.js
|
|
717
|
+
// 这份共用实现里——#411 的口径是两张皮只许差在鉴权与路径(面板那半见
|
|
718
|
+
// /api/dsh-mneme/receive)。本面独有的只有两件事:鉴权是这里唯一的 Bearer 一道门
|
|
719
|
+
// (没有 policy 名可用,除 GET /health 外人人都要带),以及 sendJson 连 headers
|
|
720
|
+
// 参数都没有,所以不出 Content-Disposition。
|
|
721
|
+
//
|
|
722
|
+
// 非 POST 一律 405,与面板逐字同款:这**故意**与上面的 /maintenance/reclaim 不同
|
|
723
|
+
// (那边掉到末尾 404)。状态码差一次就是第三处差别,而每条 receive 都还有个面板
|
|
724
|
+
// 双胞胎,一致性比照抄本文件的邻居更硬。
|
|
725
|
+
//
|
|
726
|
+
// void + .catch,不留外层 try 兜底:本文件那个 try 在 .then 的回调里早已退栈,
|
|
727
|
+
// 回调里抛出去就是没人接的 rejection、客户端一个字节都收不到(#410)。共用层
|
|
728
|
+
// 从不抛(连 warn 自己坏都被守卫吞掉),所以这里能被走到的只剩面自己写答复那一步。
|
|
729
|
+
// readBody 超限时会自己答 413 并把 text 解析成 null:那句 return 是必需的,少了
|
|
730
|
+
// 它就是在已发出的响应上再写一次。
|
|
731
|
+
if (pathname === "/receive" || pathname === "/receive/undo") {
|
|
732
|
+
const isUndo = pathname === "/receive/undo";
|
|
733
|
+
if (req.method !== "POST") {
|
|
734
|
+
sendJson(res, 405, { error: "method-not-allowed" });
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
void readBody(req, res).then((text) => {
|
|
738
|
+
if (text === null) return; // readBody 已经答过 413,再写一次就是双写响应
|
|
739
|
+
const parsed = isUndo ? parseUndoBody(text) : parseReceiveBody(text);
|
|
740
|
+
// 两个解析器都回 {ok:true,…}|{ok:false,error},所以一处判断就够。别把它写成
|
|
741
|
+
// 「有 error 就 400」:那会把本面缺路径时该落的 404 与真正的坏 body 混成一种
|
|
742
|
+
// 答复,排查时看不出是哪一层没接上。
|
|
743
|
+
if (!parsed.ok) {
|
|
744
|
+
sendJson(res, 400, { error: parsed.error });
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
const out = isUndo
|
|
748
|
+
? undoReply({ service, packId: parsed.packId, dryRun: parsed.dryRun, warn: (msg) => logger?.warn?.(msg) })
|
|
749
|
+
: receiveReply({ service, pack: parsed.pack, dryRun: parsed.dryRun, warn: (msg) => logger?.warn?.(msg) });
|
|
750
|
+
sendJson(res, out.status, out.body);
|
|
751
|
+
}).catch(() => sendJson(res, 500, { error: "internal" }));
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
|
|
584
755
|
// --- POST /bootstrap: cold-start memories from repo files (#220) -------
|
|
585
756
|
// 显式收 dir(插件没有工作区根目录概念,不猜路径);确定性解析零 LLM;
|
|
586
757
|
// 幂等骑 saveWithDedupe 的 (type, title, scope) 去重 + _overwrite 刷新。
|
|
@@ -671,16 +842,123 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
|
|
|
671
842
|
const thresholdRaw = Number(url.searchParams.get("threshold") ?? 3);
|
|
672
843
|
const threshold = Number.isFinite(thresholdRaw) ? thresholdRaw : 3;
|
|
673
844
|
const scope = scopeFromSearchParams(url);
|
|
674
|
-
|
|
675
|
-
|
|
845
|
+
// #383:降级原因记账用。embedQueryVector / withEmbedTimeout 只负责「拿不到
|
|
846
|
+
// 向量时返回 undefined」,原因(无可用接口 / 空向量 / 超时 / 抛错 / 维度不符 /
|
|
847
|
+
// 没装配 embedder)在这里回收,供 daemon 侧日志枚举——响应体只多 tier 一个
|
|
848
|
+
// 键,不加第二个字段。
|
|
849
|
+
const diag = {};
|
|
850
|
+
// 记账的唯一决策点:q 非空但没装配 embedder。#508 Minor 的成因正是这条路不写
|
|
851
|
+
// reason,靠日志行的兜底表达式临时拼一个值(no-embedder),而那个值不在文档
|
|
852
|
+
// 声明的枚举里;同一表达式里还躺着第二个越界字面量 embed-failed。兜底表达式的
|
|
853
|
+
// 问题不只是越界:它把「默认值」编码在两处,且 embedder 存在时那个 embed-error
|
|
854
|
+
// 分支一次都走不到(10 种降级场景实测:只要 embedder 为真,embedQueryVector /
|
|
855
|
+
// withEmbedTimeout / 下面的 .catch 三者必居其一地写好原因),却会把未来任何新增
|
|
856
|
+
// 的未记账失败一律谎报成「接口抛错」,把运维引去查 HTTP 端点。现在默认值只有
|
|
857
|
+
// 这一个来源,日志行不再兜底。
|
|
858
|
+
if (q && !embedder) diag.reason = "no-embedder";
|
|
859
|
+
void Promise.resolve(q && embedder ? withEmbedTimeout(embedQueryVector(embedder, q, diag), embedTimeoutMs, diag) : undefined)
|
|
860
|
+
.catch((err) => { diag.reason = "embed-error"; return undefined; }) // 查询嵌入失败降级规则档(与 searchMemories 同口径)
|
|
676
861
|
.then((queryVector) => {
|
|
677
862
|
const pinnedStats = {};
|
|
863
|
+
// items 本身失败(injectCandidates 抛错,典型如库被关)回 500 是正当的:
|
|
864
|
+
// 没有可返回的内容。items 一旦算好,后面的档位判定与降级日志就只是诊断,
|
|
865
|
+
// 任何一步出错都不该把已经算好的 items 丢掉——所以下面每一处 logger 调用
|
|
866
|
+
// 都各自包了 try/catch(logger 来自宿主,stdout 管道断裂时 warn 自身抛错是
|
|
867
|
+
// 真实可发生的,而 `logger?.warn?.()` 只防 logger 缺失,防不了这个)。
|
|
678
868
|
const items = service.injectCandidates({ query: q, maxItems, threshold, queryVector, scope, pinnedStats });
|
|
869
|
+
// #383 档位信号:嵌入降级在调用方视角原本完全静默(items 看着正常,只是
|
|
870
|
+
// 召回质量悄悄变差),外部集成方无从感知自己长期跑在降级档上。
|
|
871
|
+
// 三态 fail-closed——判不出来报 unknown,不报 full:
|
|
872
|
+
// full 语义档,查询嵌入真的成功了且与库里的向量可比
|
|
873
|
+
// degraded 规则档:没装配 embedder / 嵌入超时 / 不可用向量 / 抛错 /
|
|
874
|
+
// 维度不符(拿到了向量但语义召回整个不可用)
|
|
875
|
+
// unknown 语义路未启用(空 q),或判不出来(维度探针没跑成 / 读不出
|
|
876
|
+
// 任何向量)——那不是降级,但也不能宣称 full
|
|
877
|
+
// 比 /search 的 mode 更精确:/search 把「空查询」和「向量失败」都报
|
|
878
|
+
// keyword,这里能分开(空 q 直接可观测)。
|
|
879
|
+
//
|
|
880
|
+
// 预算:判档不是免费的——q 非空且配了 embedder 时,它真的会起一次查询
|
|
881
|
+
// 嵌入(一次网络往返 + embedTimeoutMs 的超时窗口),因为档位要回答的
|
|
882
|
+
// 正是「这次嵌入成不成」。这里**没有**复用宿主 /health 那份现成数据源:
|
|
883
|
+
// standalone 的 /health 只回 {ok:true},而 embeddingDimensionBreakdown
|
|
884
|
+
// 是 src/api.js 里的一次全表扫(每行 JSON.parse 一个向量),按请求跑会
|
|
885
|
+
// 把语义路成本翻倍,且它根本不在 standalone 的装配里。维度探针因此走
|
|
886
|
+
// service.vectorDimensionState()——存在性判据(有没有一条活跃向量与查询
|
|
887
|
+
// 同维),成本模型见 store.js 那段注释:常态一次全 id 扫描 + 解析 1 个
|
|
888
|
+
// 向量,只有要报 degraded 时才走完全表。
|
|
889
|
+
let tier = "unknown";
|
|
890
|
+
if (q && queryVector) {
|
|
891
|
+
// #508 Major:探针判据从「读一行的维度」换成存在性。LIMIT 1 拿任意一条
|
|
892
|
+
// 代表全库,在混合维度库里两个方向都错——抽到与查询不符的那条会误报
|
|
893
|
+
// degraded(明明有可比的向量),首条解析失败又会漏报成 full。现在问的是
|
|
894
|
+
// 「有没有一条活跃向量的维度等于查询维度」,与 searchVector 的行集同源。
|
|
895
|
+
//
|
|
896
|
+
// 探针失败(库被锁 / 半开 / 旧装配没有这个方法)判不出来:tier 保持
|
|
897
|
+
// unknown,不宣称 full,也不把已经算好的 items 丢掉。sentinel 不能用
|
|
898
|
+
// null / undefined——那正是「库里没有向量」的合法读数,混起来就会把一次
|
|
899
|
+
// 真实的探针故障谎报成 full(fail-open)。隔离只在调用侧:store 与 service
|
|
900
|
+
// 透传都保持抛出口,不把异常吞成某个 state。
|
|
901
|
+
let state;
|
|
902
|
+
// 判不出来时的留痕。unknown 现在有三个生产者(空 q / 探针没跑成 / 有行却
|
|
903
|
+
// 一条都读不出来),而响应体只有 tier 一个键、分不出它们,调用方对 unknown
|
|
904
|
+
// 的策略又常常是「不告警」——不留一条 warn,一次真实的判档故障就从运维面上
|
|
905
|
+
// 消失了。这条 warn **不是** diag.reason 枚举的一员(tier 不是 degraded,
|
|
906
|
+
// 没有降级原因可记),运维程序不要按降级原因枚举解析它;消息也与
|
|
907
|
+
// 「degraded to the rule tier (<reason>)」分开,因为语义路这次是真的跑了,
|
|
908
|
+
// 只是没判成,两种故障的后续动作不同。
|
|
909
|
+
let judgeFailure;
|
|
910
|
+
try {
|
|
911
|
+
state = typeof service.vectorDimensionState === "function"
|
|
912
|
+
? service.vectorDimensionState(queryVector.length)
|
|
913
|
+
: undefined;
|
|
914
|
+
} catch (error) {
|
|
915
|
+
state = undefined;
|
|
916
|
+
judgeFailure = `dimension probe failed (${String(error)})`;
|
|
917
|
+
}
|
|
918
|
+
if (state?.state === "match" || state?.state === "none") {
|
|
919
|
+
// match:有维度相符的活跃向量,语义路通(通 ≠ 有命中,四道门仍可能让
|
|
920
|
+
// 语义档贡献为 0)。none:行集本身为空——没有东西可比,不是维度不符。
|
|
921
|
+
tier = "full";
|
|
922
|
+
} else if (state?.state === "mismatch") {
|
|
923
|
+
// 维度不符:searchVector 对不等长的行直接跳过(见 store.js 的
|
|
924
|
+
// distill-tx-embed-gap 注释),不抛错、只是命中为零——于是召回静默
|
|
925
|
+
// 退回 BM25/规则档,而嵌入侧一切正常。这是 issue #383 点名的那一种
|
|
926
|
+
// 「悄悄退化」,必须有自己的名字:修法是重建索引,不是查端点。
|
|
927
|
+
diag.reason = "embed-dimension";
|
|
928
|
+
tier = "degraded";
|
|
929
|
+
} else if (state?.state === "unreadable" || state?.state === "invalid") {
|
|
930
|
+
// 有行却一条都读不出来(比维度不符更严重:语义召回 100% 死亡),或查询
|
|
931
|
+
// 维度本身不是正整数。判不出来,留 unknown。
|
|
932
|
+
judgeFailure = `no comparable vector readable (${state.state})`;
|
|
933
|
+
}
|
|
934
|
+
// undefined(探针方法缺失 / 返回垃圾 / 异步漂移)同样留 unknown。
|
|
935
|
+
if (judgeFailure) {
|
|
936
|
+
try {
|
|
937
|
+
logger?.warn?.(`dsh-mneme: /context tier left unknown: ${judgeFailure}`);
|
|
938
|
+
} catch { /* 日志通道坏了也不是把 items 丢掉的理由 */ }
|
|
939
|
+
}
|
|
940
|
+
} else if (q) {
|
|
941
|
+
tier = "degraded";
|
|
942
|
+
}
|
|
943
|
+
if (tier === "degraded") {
|
|
944
|
+
// reason 由唯一决策点(q && !embedder)+ 三个写入方(embedQueryVector /
|
|
945
|
+
// withEmbedTimeout / 查询嵌入 .catch)+ 维度判据保证必已赋值,所以这里不再
|
|
946
|
+
// 写第二份默认值——#508 之前的那份兜底表达式(`embedder ? "embed-failed" :
|
|
947
|
+
// "no-embedder"`)两个默认值互相矛盾、且都在文档枚举之外,其中 embed-error
|
|
948
|
+
// 分支在 embedder 存在时一次都走不到,却会把未来任何新增的未记账失败一律
|
|
949
|
+
// 谎报成「接口抛错」。现在只把枚举内的值写进日志:越界或漏记账都露出
|
|
950
|
+
// (undefined),那是看得见的缺陷信号,不是一个编出来的因。
|
|
951
|
+
const reason = CONTEXT_DEGRADE_REASONS.includes(diag.reason) ? diag.reason : undefined;
|
|
952
|
+
try {
|
|
953
|
+
logger?.warn?.(`dsh-mneme: /context degraded to the rule tier (${reason})`);
|
|
954
|
+
} catch { /* 日志通道坏了也不是把 items 丢掉的理由 */ }
|
|
955
|
+
}
|
|
679
956
|
sendJson(res, 200, {
|
|
680
957
|
profile: (settings?.getProfile?.() ?? "").trim(),
|
|
681
958
|
rules: settings?.getRules?.() ?? [],
|
|
682
959
|
items: service.toApiList(items),
|
|
683
|
-
pinnedCount: pinnedStats.shown ?? 0
|
|
960
|
+
pinnedCount: pinnedStats.shown ?? 0,
|
|
961
|
+
tier
|
|
684
962
|
});
|
|
685
963
|
})
|
|
686
964
|
.catch(() => {
|
package/lib/api.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { URL } from "node:url";
|
|
2
2
|
import { isAbsolute } from "node:path";
|
|
3
3
|
import { existsSync, readdirSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
4
5
|
import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
5
6
|
import { FEATURE_FLAG_SPEC } from "./settings.js";
|
|
6
7
|
import { TYPES } from "./store.js";
|
|
@@ -8,12 +9,17 @@ import { TYPE_FILE, renderMirrorText, renderFileHeader, parseHumanEdits, MIRROR_
|
|
|
8
9
|
import { langOf } from "./lang.js";
|
|
9
10
|
import { computeHeat } from "./heat.js";
|
|
10
11
|
import { EFFORT_REJECT_RE, describeStreamFailure, resolveRoute, withEffortFallback } from "./dream.js";
|
|
12
|
+
// Issue #521:面板要按归一化原因显示 skipped,归一化只有一份实现(住在
|
|
13
|
+
// decisions.js,与产生这些 error 的校验器同一文件,避免措辞漂移)。
|
|
14
|
+
import { summarizeSkipped } from "./dream/decisions.js";
|
|
11
15
|
import { getInjectionSnapshot } from "./inject.js";
|
|
12
16
|
import { renderUserSettingsBlock } from "./user-settings.js";
|
|
17
|
+
import { parseProvenanceQuery, provenanceReply } from "./provenance-http.js";
|
|
13
18
|
import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
|
|
14
19
|
import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
|
|
15
20
|
import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
|
|
16
21
|
import { parseReclaimQuery, parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.js";
|
|
22
|
+
import { parseShareBody, shareReply, shareFilename, parseReceiveBody, receiveReply, parseUndoBody, undoReply } from "./share-http.js";
|
|
17
23
|
import { readPipelineDoc, loadPipelines, projectPipelineDir } from "./pipeline.js";
|
|
18
24
|
import { loadRunsById, runsDir, RUNS_SUFFIX } from "./pipeline-runs.js";
|
|
19
25
|
import { observationDir, loadObservationsById } from "./pipeline-observations.js";
|
|
@@ -387,6 +393,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
387
393
|
...m,
|
|
388
394
|
archived: rows[i].archived === true || rows[i].archived === 1,
|
|
389
395
|
quality_score: rows[i].quality_score ?? null,
|
|
396
|
+
// 外来区的包号:抽屉里的「收起这个包」按 pack_id 整批删,寻址键必须由行自己带上。
|
|
397
|
+
// 与 heat 同一个做法——**缺省就不带键**,面板的撤销按钮因此天然自门控。
|
|
398
|
+
// /search 那一面为什么没有这一格,别说错原因:召回侧的候选行内存里带着
|
|
399
|
+
// share_pack_id(searchMemories 只是把 source 盖成渠道名),toApiList 也从不透出这一列;
|
|
400
|
+
// 真正的事实是这层扩展只长在 /list 的答复上,搜索面没有一处代码去补它。所以搜索态
|
|
401
|
+
// 按不了撤销,那条代价写在 docs/SHARE.md 的车道 D 一节。
|
|
402
|
+
// 扩展点留在 HTTP 层而不是 toApiList:模型工具严格复用后者(src/service.js:2094 与
|
|
403
|
+
// MEMORY_ITEM_SCHEMA),给它们加一格没有第二个消费者,却要多改一处 schema 声明。
|
|
404
|
+
...(rows[i].share_pack_id ? { share_pack_id: rows[i].share_pack_id } : {}),
|
|
390
405
|
...(heatOn ? { heat: computeHeat(rows[i], Date.now(), config ?? {}) } : {})
|
|
391
406
|
}));
|
|
392
407
|
// Total honors the same filters as the rows, or the pager's
|
|
@@ -1293,6 +1308,52 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1293
1308
|
}
|
|
1294
1309
|
});
|
|
1295
1310
|
|
|
1311
|
+
// --- 记忆级溯源读出口(#399 写、#421 读)-----------------------------------
|
|
1312
|
+
// #399 把蒸馏窗口的原文落进 distill_records、把 memory → (session, seq 区间)
|
|
1313
|
+
// 的归因写进 memory_provenance,但只做了写:库里有答案、service.provenanceFor
|
|
1314
|
+
// 能查,外部(面板 / standalone API / 脚本)拿不到,「这条记忆是从哪几句蒸出来
|
|
1315
|
+
// 的」只能开 sqlite 手写 JOIN。本端点补最后一段接线。
|
|
1316
|
+
//
|
|
1317
|
+
// 一个链接一个窗口:区间 + 区间里的原文行(按 seq 升序)。没有链接的记忆
|
|
1318
|
+
// (memory_save / 工具写入 / 冷启动)返回空 windows——「不是蒸馏来的」本身是
|
|
1319
|
+
// 有效答案,不是错误,所以不 404。
|
|
1320
|
+
//
|
|
1321
|
+
// policy 是 **token**,不是 open(#509 维护者评审定):records 带的是蒸馏窗口的
|
|
1322
|
+
// 逐条会话原文,而 /list 是 open 且逐条带 id——两者串起来等于「先枚举 id、再按 id
|
|
1323
|
+
// 捞回原文」,可达结果与 /export(一次拿全库,本仓 2026-10 审计后列 gated)同量级,
|
|
1324
|
+
// 只是换了形状。抽稀不该靠「调用方不知道有这条路」。
|
|
1325
|
+
//
|
|
1326
|
+
// 口径只在 src/provenance-http.js 写一份,两个入口都调它(#411 的教训:两张皮
|
|
1327
|
+
// 整段复制必然漂移)。
|
|
1328
|
+
//
|
|
1329
|
+
// distillRecords.enabled 门(#509 评审整改):这个读出口吃的是**写侧同一枚开关、
|
|
1330
|
+
// 同一个判据**(isDistillRecordsEnabled,与 src/summarize.js:988 逐字同源;本文件
|
|
1331
|
+
// 不重写那个表达式,静态锁盯着)。关着时回 409,错误码取共用模块导出的
|
|
1332
|
+
// DISTILL_RECORDS_DISABLED——「用户关了」而不是「装配坏了」(后者是 503,
|
|
1333
|
+
// src/index.js:589-591 立的可区分性原则),也不是 404(本端点刻意不就「这条记忆
|
|
1334
|
+
// 是否存在」作断言)。cfg 是启动期冻结值,所以面板改开关要到下次重启才对这里生效;
|
|
1335
|
+
// 该键在 settings 白名单与 NESTED_FLAG_PATHS 里都登记着,面板会显示「待重启」
|
|
1336
|
+
// (#481 的教训)。阶梯全貌见 provenance-http.js 文件头。
|
|
1337
|
+
register({
|
|
1338
|
+
kind: "exact",
|
|
1339
|
+
path: "/api/dsh-mneme/memory/provenance",
|
|
1340
|
+
policy: "token",
|
|
1341
|
+
handler(req, res) {
|
|
1342
|
+
try {
|
|
1343
|
+
const url = new URL(req.url, "http://localhost");
|
|
1344
|
+
const parsed = parseProvenanceQuery(url.searchParams.get("id"));
|
|
1345
|
+
if (!parsed.ok) {
|
|
1346
|
+
sendJson(res, parsed.status, { error: parsed.error });
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
const { status, body } = provenanceReply({ service, id: parsed.id, config, warn: (m) => ctx.logger?.warn?.(m) });
|
|
1350
|
+
sendJson(res, status, body);
|
|
1351
|
+
} catch {
|
|
1352
|
+
sendJson(res, 500, { error: "internal" });
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1296
1357
|
// --- ego graph: 1-2 hop neighborhood of one entity (graph panel P1) ---
|
|
1297
1358
|
// Read-only like list/search/semantic, so it stays open when apiToken is set.
|
|
1298
1359
|
// BFS from the root entity over entity_relations (both directions; the
|
|
@@ -1518,6 +1579,133 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1518
1579
|
}
|
|
1519
1580
|
});
|
|
1520
1581
|
|
|
1582
|
+
// --- 分享包(spec §4)------------------------------------------------------
|
|
1583
|
+
// 读侧全库口径,鉴权与 /export 同门(token):它导出的是**脱敏后**的行集,但
|
|
1584
|
+
// 「谁在往外打整库」这件事本身就需要跟写路由一样登记在册。
|
|
1585
|
+
// GET 不开口子:这条路由的存在感来自「有人主动按分享键」,不是给面板轮询用的。
|
|
1586
|
+
// 判据一条都不在这里:哪些行进包、超没超限、抛错回什么码,全在 share-http.js 那份
|
|
1587
|
+
// 共用实现里(#411 的口径——两张皮只许剩鉴权与路径不同)。本侧独有的只有两件事:
|
|
1588
|
+
// Content-Disposition(sendJson 的 headers 第四参,数据面没有这个签名)和把
|
|
1589
|
+
// shareReply 的 warn 接到宿主 logger 上——它兜住抛错回 500,不接日志就是静默失败。
|
|
1590
|
+
register({
|
|
1591
|
+
kind: "exact",
|
|
1592
|
+
path: "/api/dsh-mneme/share",
|
|
1593
|
+
policy: "token",
|
|
1594
|
+
handler(req, res) {
|
|
1595
|
+
try {
|
|
1596
|
+
if (req.method !== "POST") {
|
|
1597
|
+
sendJson(res, 405, { error: "method-not-allowed" });
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
return readBody(req, res).then((text) => {
|
|
1601
|
+
if (text === null) return; // readBody 已经答过 413,再写一次就是双写响应
|
|
1602
|
+
const parsed = parseShareBody(text);
|
|
1603
|
+
if (!parsed.ok) {
|
|
1604
|
+
sendJson(res, 400, { error: parsed.error });
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
const out = shareReply({
|
|
1608
|
+
service,
|
|
1609
|
+
packageVersion: PACKAGE_VERSION,
|
|
1610
|
+
homeDir: homedir(),
|
|
1611
|
+
options: parsed.options,
|
|
1612
|
+
warn: (msg) => ctx.logger?.warn?.(msg)
|
|
1613
|
+
});
|
|
1614
|
+
if (out.status === 200) {
|
|
1615
|
+
sendJson(res, 200, out.body, {
|
|
1616
|
+
"Content-Disposition": `attachment; filename="${shareFilename(out.body.pack_id)}"`
|
|
1617
|
+
});
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
sendJson(res, out.status, out.body);
|
|
1621
|
+
}).catch(() => sendJson(res, 500, { error: "internal" }));
|
|
1622
|
+
// .catch 与下面的外层 try 各挡一类:外层那个只包得住同步那半,而 readBody 的回调
|
|
1623
|
+
// 抛出时它早已退栈。共用层从不抛(连 warn 自己坏都被守卫吞掉),所以这条链现在能被
|
|
1624
|
+
// 走到的只剩**面自己**写答复那一步——sendJson 的 writeHead 在真 socket 上会因
|
|
1625
|
+
// 「一个响应两写」抛 ERR_HTTP_HEADERS_SENT。少这一声就是 unhandled rejection +
|
|
1626
|
+
// 一个字节都没回(#410),而下面 /receive 两条一直带着它。
|
|
1627
|
+
} catch {
|
|
1628
|
+
sendJson(res, 500, { error: "internal" });
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1633
|
+
// --- 分享包入库与整包退出(车道 C,spec §5)--------------------------------
|
|
1634
|
+
// 写库的一面(token-write:非 GET 要 Bearer)。判据一条都不在这里:包体合格不合格住在
|
|
1635
|
+
// share.js 的闸门(由 service 的 importSharePack 调),错误码 → 状态的映射住在
|
|
1636
|
+
// share-http.js 那份共用实现里——#411 的口径是两张皮只许剩鉴权与路径不同。本侧独有的
|
|
1637
|
+
// 只有把 warn 接到宿主 logger 上(不接就是一条静默的 500,现场只剩 internal)。
|
|
1638
|
+
//
|
|
1639
|
+
// 非 POST 一律 405。这**故意**与上面的 /maintenance/reclaim 不同(那边掉到末尾 404):
|
|
1640
|
+
// 每条 receive 都有一个数据面双胞胎(C6 的 POST /receive、/receive/undo),那里只认
|
|
1641
|
+
// 405,状态码差一次就是第三处差别——一致性在这里比「照抄本文件的邻居」更硬。
|
|
1642
|
+
//
|
|
1643
|
+
// 外层 try 与 .catch 都要留着:外层那个在 .then 的回调里已经退栈,回调里抛出去就是没人接
|
|
1644
|
+
// 的 rejection,客户端拿到的不是 500 而是「一个字节都没回」(#410)。
|
|
1645
|
+
register({
|
|
1646
|
+
kind: "exact",
|
|
1647
|
+
path: "/api/dsh-mneme/receive",
|
|
1648
|
+
policy: "token-write",
|
|
1649
|
+
handler(req, res) {
|
|
1650
|
+
try {
|
|
1651
|
+
if (req.method !== "POST") {
|
|
1652
|
+
sendJson(res, 405, { error: "method-not-allowed" });
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
return readBody(req, res).then((text) => {
|
|
1656
|
+
if (text === null) return; // readBody 已经答过 413,再写一次就是双写响应
|
|
1657
|
+
const parsed = parseReceiveBody(text);
|
|
1658
|
+
if (!parsed.ok) {
|
|
1659
|
+
sendJson(res, 400, { error: parsed.error });
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
const out = receiveReply({
|
|
1663
|
+
service,
|
|
1664
|
+
pack: parsed.pack,
|
|
1665
|
+
dryRun: parsed.dryRun,
|
|
1666
|
+
warn: (msg) => ctx.logger?.warn?.(msg)
|
|
1667
|
+
});
|
|
1668
|
+
sendJson(res, out.status, out.body);
|
|
1669
|
+
}).catch(() => sendJson(res, 500, { error: "internal" }));
|
|
1670
|
+
} catch {
|
|
1671
|
+
sendJson(res, 500, { error: "internal" });
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1676
|
+
// 退出按 pack_id 收起一整包(物删不归档,理由见 src/share-import.js)。dry-run 同样是
|
|
1677
|
+
// 缺省:没带 confirm 就只报「会删掉几条」,与 reclaim、与入库那侧同一个脾气。
|
|
1678
|
+
register({
|
|
1679
|
+
kind: "exact",
|
|
1680
|
+
path: "/api/dsh-mneme/receive/undo",
|
|
1681
|
+
policy: "token-write",
|
|
1682
|
+
handler(req, res) {
|
|
1683
|
+
try {
|
|
1684
|
+
if (req.method !== "POST") {
|
|
1685
|
+
sendJson(res, 405, { error: "method-not-allowed" });
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
return readBody(req, res).then((text) => {
|
|
1689
|
+
if (text === null) return;
|
|
1690
|
+
const parsed = parseUndoBody(text);
|
|
1691
|
+
if (!parsed.ok) {
|
|
1692
|
+
sendJson(res, 400, { error: parsed.error });
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
const out = undoReply({
|
|
1696
|
+
service,
|
|
1697
|
+
packId: parsed.packId,
|
|
1698
|
+
dryRun: parsed.dryRun,
|
|
1699
|
+
warn: (msg) => ctx.logger?.warn?.(msg)
|
|
1700
|
+
});
|
|
1701
|
+
sendJson(res, out.status, out.body);
|
|
1702
|
+
}).catch(() => sendJson(res, 500, { error: "internal" }));
|
|
1703
|
+
} catch {
|
|
1704
|
+
sendJson(res, 500, { error: "internal" });
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
});
|
|
1708
|
+
|
|
1521
1709
|
// --- 导入(Markdown 镜像回填)----------------------------------------------
|
|
1522
1710
|
// 写路径(requireAuth)。body {type, markdown}:type 是镜像 TYPE_FILE 键,
|
|
1523
1711
|
// markdown 是与镜像文件同构的文本。解析复用 readHumanEdits 的纯函数核心 parseHumanEdits(同一实现,
|
|
@@ -1582,7 +1770,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1582
1770
|
// 数据来自 runSleep 写入的 decisions.demotion({demoted,archived} 数组)。
|
|
1583
1771
|
demotion: r.decisions?.demotion
|
|
1584
1772
|
? { demoted: (r.decisions.demotion.demoted ?? []).length, archived: (r.decisions.demotion.archived ?? []).length }
|
|
1585
|
-
: null
|
|
1773
|
+
: null,
|
|
1774
|
+
// Issue #521:degraded 轮的 skipped 明细按归一化原因聚合。status:
|
|
1775
|
+
// degraded + applied: 3 从外面看完全是健康轮的样子,不展开 skipped
|
|
1776
|
+
// 看不出「一整个 action 类型 100% 失败」——实测那个 bug 跑了一整天才
|
|
1777
|
+
// 被发现。这里只回聚合(原因 + 动作 + 条数),明细仍留在库里。
|
|
1778
|
+
skippedCount: Array.isArray(r.skipped) ? r.skipped.length : 0,
|
|
1779
|
+
skippedReasons: summarizeSkipped(r.skipped)
|
|
1586
1780
|
}));
|
|
1587
1781
|
const pendingConflicts = service.countConflictPending?.() ?? 0;
|
|
1588
1782
|
const ids = new Set();
|