@modusensus/dsh-mneme 0.9.3 → 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.
Files changed (102) hide show
  1. package/README.md +56 -10
  2. package/bin/cli.mjs +215 -2
  3. package/docs/CONFIGURATION.md +10 -2
  4. package/docs/DAEMON.md +9 -2
  5. package/docs/DISTILL.md +66 -4
  6. package/docs/SHARE.md +552 -0
  7. package/docs/SLEEP.md +1 -1
  8. package/docs/STORAGE.md +1 -1
  9. package/lib/api-standalone.js +286 -8
  10. package/lib/api.js +254 -3
  11. package/lib/client.js +1000 -50
  12. package/lib/config.js +50 -1
  13. package/lib/document.js +15 -2
  14. package/lib/dream/decisions.js +88 -3
  15. package/lib/dream/sleep.js +63 -13
  16. package/lib/dream.js +66 -10
  17. package/lib/index.js +6 -1
  18. package/lib/inject.js +10 -25
  19. package/lib/lang.js +48 -6
  20. package/lib/memory-source.js +9 -0
  21. package/lib/organize.js +9 -3
  22. package/lib/pipeline.js +62 -1
  23. package/lib/profile-extract.js +161 -0
  24. package/lib/provenance-http.js +126 -0
  25. package/lib/serve.js +13 -0
  26. package/lib/service.js +70 -6
  27. package/lib/settings.js +17 -13
  28. package/lib/share-http.js +281 -0
  29. package/lib/share-import.js +234 -0
  30. package/lib/share.js +335 -0
  31. package/lib/store.js +272 -11
  32. package/lib/summarize.js +118 -8
  33. package/lib/tools.js +240 -1
  34. package/lib/user-settings.js +78 -0
  35. package/package.json +1 -1
  36. package/src/api-standalone.js +286 -8
  37. package/src/api.js +254 -3
  38. package/src/config.js +50 -1
  39. package/src/document.js +15 -2
  40. package/src/dream/decisions.js +88 -3
  41. package/src/dream/sleep.js +63 -13
  42. package/src/dream.js +66 -10
  43. package/src/index.js +6 -1
  44. package/src/inject.js +10 -25
  45. package/src/lang.js +48 -6
  46. package/src/memory-source.js +9 -0
  47. package/src/organize.js +9 -3
  48. package/src/pipeline.js +62 -1
  49. package/src/profile-extract.js +161 -0
  50. package/src/provenance-http.js +126 -0
  51. package/src/serve.js +13 -0
  52. package/src/service.js +70 -6
  53. package/src/settings.js +17 -13
  54. package/src/share-http.js +281 -0
  55. package/src/share-import.js +234 -0
  56. package/src/share.js +335 -0
  57. package/src/store.js +272 -11
  58. package/src/summarize.js +118 -8
  59. package/src/tools.js +240 -1
  60. package/src/user-settings.js +78 -0
  61. package/test/api.test.js +384 -3
  62. package/test/audit.test.js +2 -2
  63. package/test/cli-receive.test.js +390 -0
  64. package/test/cli-share.test.js +437 -0
  65. package/test/client-share.test.js +225 -0
  66. package/test/client.test.js +215 -6
  67. package/test/dedupe-candidate-window.test.js +180 -0
  68. package/test/dream-candidate-hybrid.test.js +2 -2
  69. package/test/dream-fence.test.js +164 -0
  70. package/test/dream.test.js +87 -5
  71. package/test/helpers/dream-mock.js +6 -2
  72. package/test/lang.test.js +11 -0
  73. package/test/llm-message-source.test.js +1 -1
  74. package/test/mint-source.test.js +1 -1
  75. package/test/organize-tool.test.js +232 -0
  76. package/test/persona-addressing.test.js +83 -0
  77. package/test/persona-settings.test.js +30 -8
  78. package/test/pipeline-r1r2.test.js +129 -0
  79. package/test/policy-epoch.test.js +2 -2
  80. package/test/profile-extract.test.js +269 -0
  81. package/test/provenance-http.test.js +184 -0
  82. package/test/readme-claims.test.js +45 -0
  83. package/test/reasoning-effort.test.js +10 -10
  84. package/test/scope-candidates.test.js +7 -4
  85. package/test/scope-state.test.js +130 -1
  86. package/test/serve.test.js +62 -0
  87. package/test/share-foreign-rows.test.js +144 -0
  88. package/test/share-http.test.js +144 -0
  89. package/test/share-import.test.js +577 -0
  90. package/test/share-imported-guard.test.js +311 -0
  91. package/test/share-imported-identity.test.js +111 -0
  92. package/test/share-pack.test.js +511 -0
  93. package/test/share-receive-http.test.js +756 -0
  94. package/test/share-routes.test.js +337 -0
  95. package/test/share-store.test.js +133 -0
  96. package/test/share-validate.test.js +101 -0
  97. package/test/standalone-api.test.js +524 -5
  98. package/test/summarize-parse-diagnostics.test.js +75 -0
  99. package/test/summarize.test.js +240 -2
  100. package/test/tools-dto-schema-matrix.test.js +48 -5
  101. package/test/tools.test.js +6 -1
  102. package/test/user-settings-preview.test.js +127 -0
@@ -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) return undefined;
285
+ if (!embedSingle) {
286
+ if (diag) diag.reason = "embed-unavailable";
287
+ return undefined;
288
+ }
237
289
  const vector = await embedSingle(q);
238
- return Array.isArray(vector) && vector.length ? vector : undefined;
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(() => resolve(undefined), timeoutMs);
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
- void Promise.resolve(q && embedder ? withEmbedTimeout(embedQueryVector(embedder, q), embedTimeoutMs) : undefined)
675
- .catch(() => undefined) // 查询嵌入失败降级规则档(与 searchMemories 同口径)
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(() => {