@modusensus/dsh-mneme 0.9.4 → 0.10.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.
Files changed (126) hide show
  1. package/README.md +57 -10
  2. package/bin/cli.mjs +215 -2
  3. package/docs/CONFIGURATION.md +25 -2
  4. package/docs/DAEMON.md +13 -2
  5. package/docs/DISTILL.md +150 -4
  6. package/docs/SHARE.md +552 -0
  7. package/docs/SLEEP.md +21 -7
  8. package/docs/STORAGE.md +2 -1
  9. package/lib/api-standalone.js +294 -8
  10. package/lib/api.js +217 -3
  11. package/lib/client.js +919 -39
  12. package/lib/config.js +33 -1
  13. package/lib/continuity.js +11 -3
  14. package/lib/document.js +15 -2
  15. package/lib/dream/decisions.js +176 -30
  16. package/lib/dream/sleep.js +530 -94
  17. package/lib/dream.js +174 -21
  18. package/lib/fence.js +24 -0
  19. package/lib/index.js +15 -6
  20. package/lib/inject.js +9 -4
  21. package/lib/lang.js +38 -16
  22. package/lib/memory-source.js +9 -0
  23. package/lib/organize.js +5 -1
  24. package/lib/pipeline-nudge-auto.js +3 -0
  25. package/lib/pipeline-nudge.js +41 -8
  26. package/lib/pipeline-runner.js +15 -0
  27. package/lib/pipeline.js +62 -1
  28. package/lib/pre-inject-gate.js +14 -0
  29. package/lib/profile-extract.js +4 -1
  30. package/lib/provenance-http.js +251 -0
  31. package/lib/serve.js +13 -0
  32. package/lib/service.js +97 -4
  33. package/lib/session-gate.js +91 -0
  34. package/lib/settings.js +13 -1
  35. package/lib/share-http.js +281 -0
  36. package/lib/share-import.js +234 -0
  37. package/lib/share.js +335 -0
  38. package/lib/store.js +797 -28
  39. package/lib/summarize.js +209 -23
  40. package/lib/tools.js +26 -6
  41. package/package.json +4 -2
  42. package/src/api-standalone.js +294 -8
  43. package/src/api.js +217 -3
  44. package/src/config.js +33 -1
  45. package/src/continuity.js +11 -3
  46. package/src/document.js +15 -2
  47. package/src/dream/decisions.js +176 -30
  48. package/src/dream/sleep.js +530 -94
  49. package/src/dream.js +174 -21
  50. package/src/fence.js +24 -0
  51. package/src/index.js +15 -6
  52. package/src/inject.js +9 -4
  53. package/src/lang.js +38 -16
  54. package/src/memory-source.js +9 -0
  55. package/src/organize.js +5 -1
  56. package/src/pipeline-nudge-auto.js +3 -0
  57. package/src/pipeline-nudge.js +41 -8
  58. package/src/pipeline-runner.js +15 -0
  59. package/src/pipeline.js +62 -1
  60. package/src/pre-inject-gate.js +14 -0
  61. package/src/profile-extract.js +4 -1
  62. package/src/provenance-http.js +251 -0
  63. package/src/serve.js +13 -0
  64. package/src/service.js +97 -4
  65. package/src/session-gate.js +91 -0
  66. package/src/settings.js +13 -1
  67. package/src/share-http.js +281 -0
  68. package/src/share-import.js +234 -0
  69. package/src/share.js +335 -0
  70. package/src/store.js +797 -28
  71. package/src/summarize.js +209 -23
  72. package/src/tools.js +26 -6
  73. package/test/api.test.js +523 -6
  74. package/test/audit.test.js +2 -2
  75. package/test/cli-receive.test.js +390 -0
  76. package/test/cli-share.test.js +437 -0
  77. package/test/client-share.test.js +225 -0
  78. package/test/client.test.js +354 -0
  79. package/test/config.test.js +8 -0
  80. package/test/conflict-reviewed.test.js +79 -0
  81. package/test/continuity.test.js +29 -1
  82. package/test/dedupe-candidate-window.test.js +180 -0
  83. package/test/dependency-overrides.test.js +78 -0
  84. package/test/differentiate-feedback.test.js +248 -0
  85. package/test/dream-candidate-hybrid.test.js +2 -2
  86. package/test/dream-decision-evidence.test.js +314 -0
  87. package/test/dream-fence.test.js +207 -0
  88. package/test/dream.test.js +87 -5
  89. package/test/helpers/dream-mock.js +6 -2
  90. package/test/inject-pools.test.js +2 -1
  91. package/test/llm-message-source.test.js +1 -1
  92. package/test/mint-source.test.js +84 -1
  93. package/test/pipeline-nudge-auto.test.js +26 -2
  94. package/test/pipeline-nudge.test.js +87 -0
  95. package/test/pipeline-r1r2.test.js +129 -0
  96. package/test/pipeline-runner.test.js +26 -0
  97. package/test/policy-epoch.test.js +2 -2
  98. package/test/prompt-stance.test.js +128 -0
  99. package/test/provenance-http.test.js +367 -0
  100. package/test/readme-claims.test.js +45 -0
  101. package/test/reasoning-effort.test.js +10 -10
  102. package/test/release-docs.test.js +7 -1
  103. package/test/scope-candidates.test.js +7 -4
  104. package/test/scope-state.test.js +130 -1
  105. package/test/security-doc.test.js +43 -4
  106. package/test/serve.test.js +62 -0
  107. package/test/session-gate.test.js +264 -0
  108. package/test/settings.test.js +4 -0
  109. package/test/share-foreign-rows.test.js +144 -0
  110. package/test/share-http.test.js +144 -0
  111. package/test/share-import.test.js +577 -0
  112. package/test/share-imported-guard.test.js +311 -0
  113. package/test/share-imported-identity.test.js +111 -0
  114. package/test/share-pack.test.js +511 -0
  115. package/test/share-receive-http.test.js +756 -0
  116. package/test/share-routes.test.js +337 -0
  117. package/test/share-store.test.js +133 -0
  118. package/test/share-validate.test.js +101 -0
  119. package/test/sleep-action-set.test.js +16 -11
  120. package/test/sleep-catchup.test.js +315 -0
  121. package/test/sleep-failure-backoff.test.js +156 -0
  122. package/test/sleep.test.js +293 -0
  123. package/test/standalone-api.test.js +574 -5
  124. package/test/store.test.js +220 -0
  125. package/test/summarize-parse-diagnostics.test.js +75 -0
  126. package/test/summarize.test.js +187 -2
package/docs/SLEEP.md CHANGED
@@ -42,6 +42,9 @@ flowchart TD
42
42
  | `sleepModeEnabled` | `false` | 全局开关(Opt-in),开启后激活调度器 |
43
43
  | `sleepIdleMinutes` | `5` | 连续无操作触发睡眠的阈值(分钟) |
44
44
  | `sleepMinIntervalHours` | `8` | 两次 Sleep Run 的最小时间间隔(小时) |
45
+ | `sleepFailureBackoff` | `false` | 连续失败退避(#529,opt-in):有效间隔 = `sleepMinIntervalHours` × 2^连败数,成功清零,封顶 `max(sleepMinIntervalHours, 24h)`(配得比 24h 大就按配的算——退避只放大间隔,不压缩用户自己设的基数)。判据看整轮 `status === "failed"`——`noop`(通路正常、只是没活干)不算失败,否则在自己工作良好的库里也会越退越长 |
46
+ | `sleepConflictMaxPairs` | `12` | **单批**送审的冲突对上限(0 = 不限,回退旧行为) |
47
+ | `sleepConflictMaxPairsPerRun` | `60` | **单轮**最多送审多少对(0 = 不限)。与上一条分工:一个管批次大小、一个管整轮总量 |
45
48
  | `sleepConflictStrictness` | `'normal'` | 冲突消解严格度:`'gentle'`(0.92) / `'normal'`(0.85) / `'aggressive'`(0.75) |
46
49
  | `sleepArchiveDays` | `30` | 进入冷存储/摘要降级的天数阈值 |
47
50
  | `sleepCompressDays` | `90` | 进入完全归档(Archive)的天数阈值 |
@@ -54,18 +57,27 @@ flowchart TD
54
57
  | `sleepMaxTokens` | `8192` | 冲突消解与模式发现阶段的 LLM 输出预算上限(token,#257:原硬编码 2048——`sleepActionSet: full` 实测 24 对需约 7000,截断即整轮裁决失败)。流式计费按实际用量,调大不增加成本 |
55
58
 
56
59
  ## 四阶段详解
57
- 每个阶段独立包裹 `try/catch`,任一阶段失败仅跳过该阶段,不阻塞后续流程。
60
+ 每个阶段独立包裹 `try/catch`,任一阶段失败仅跳过该阶段,不阻塞后续流程。四个相位读取的记忆行池都先剔掉 `source='imported'` 的外来行(`src/dream/sleep.js`)——外来行不经本方蒸馏与召回,时间分层与冲突判据在它身上系统性失真,我方对它的原文和归档面没有处置权,所以下文「输入」一栏里的「全量」一律读作「本方活跃行」,契约见 [SHARE.md](SHARE.md)。
58
61
 
59
62
  ### 1. `conflict_resolution`(冲突消解)
60
63
  - **目标**:清理向量空间中的重复、矛盾或过时记忆。
61
64
  - **输入**:全量/增量记忆向量索引、`sleepConflictStrictness` 阈值。
62
65
  - **算法**:
63
- 1. 向量检索 `findPotentialConflicts` 获取高相似度对。
64
- 2. LLM 仲裁:基于严格度阈值生成 `keep/merge/discard` 决策。
65
- 3. `validateDecisions`:严格校验决策合法性,缺省策略为 `keep`。
66
- 4. `applyDecisions`:批量执行状态变更。
67
- - **输出**:冲突消解报告(决策明细)。
66
+ 1. 向量检索 `findPotentialConflicts` 获取高相似度对;`sleepActionSet: "full"` 时再补一遍跨类型对。
67
+ 2. **已裁决对先压掉**:`differentiated_pairs` 账本里记着「这一对判过"互补、两条都留"」,两侧正文的 `content_hash` 与记账时一致就不再送模型(任一侧被改写则重新进候选)。这一轮被压掉几对记在 `phase.suppressed`,与「一对都没检测到」(`no conflicts found`)分开报。
68
+ 3. **台账预筛**(#529):查 `conflict_reviewed`,把「已经判过、且两侧 `updated_at` 都不晚于 `judged_at`」的对从候选池里剔掉——它们上一轮已有结论,两侧都没动就没必要再问一遍。任一字段更新即重新入池(与 `conflict_pending` 复核项 4 同一条宽松代理)。两条边界:跨 scope 对**不筛**(它们本来就要停车进人工队列,不能被机器台账吞掉),`conflictFreezeEnabled` 档**不筛**(那一档只停车不送 LLM)。
69
+ 4. **去重**:每条记忆至多参与一对,相似度高者优先。**必须排在 3 之后**——先占位再去掉,一个已审的对会把它那一端记忆的配对名额占住,而它真正的对手(另一条没审过的记忆)因此进不了这一轮。被台账挡下的对数记在 `phase.ledgerSkipped`。
70
+ 5. **每轮总量上限**(`sleepConflictMaxPairsPerRun`,默认 60):没进这一轮的对留在下一轮,既不丢也不记账。
71
+ 6. **分批**(`sleepConflictMaxPairs`,默认 12):每批单独一次 LLM 往返、单独 `validateDecisions` + `applyDecisions`。某批撞上下文或输出坏掉只让**这一批**失败并计入 `degraded`,其余批照常落地;只有一批都没成功才整轮 `failed`。
72
+ 7. LLM 仲裁:基于严格度阈值生成决策(默认档 `keep/merge/conflict/create/archive/update`,full 档另开 `supersede/differentiate`)。
73
+ 8. `validateDecisions`:严格校验决策合法性,缺省策略为 `keep`。**`reflectionUpdateMaxPerRun` 是整轮上限,不是每批上限**——额度由分批循环持有,每批只领剩余额度,校验通过后按本批通过校验的 `update` 数扣减;额度耗尽的那批若还含 `update` 会被校验器整单拒绝(记 `failed`),其余批照常继续。
74
+ 9. `applyDecisions`:批量执行状态变更。
75
+ 10. **台账记账**:本批**全部送审对**都写一行 `conflict_reviewed`(不只记被裁决成冲突的那几对,判 `keep` 的正是下一轮重复送审的元凶),`outcome` 取两端里非 `keep` 的那一侧。记账是幂等 upsert,按 `(memory_a, memory_b)` 排序归一化。两个防"假已审"的细节:`judged_at` 取**发送本批之前**的时刻(不是落地之后——LLM 往返期间若一侧被外部改动,它的 `updated_at` 会晚于 `judged_at`,下一轮自然重新入池;按落地时刻封盘等于用陈旧结论锁死这一对),且取 `max(墙钟, 本批两侧最新的 updated_at)`——`updated_at` 用的是 store 的**单调时钟**(`src/store.js` 的 `nowIso` 在同一毫秒内的连续写入上逐次 +1ms),它可能领先墙钟几毫秒,两列不同源的话 `alreadyJudged` 会把「刚判过」读成「判过之后又被改过」,判 `keep` 的死循环原样复活;本批 `failures` / `conflicts`(CAS 冲突)/ `skipped` 里出现过的 id,其所在对**不记账**——没落地的裁决记成已审,会让这一对从此不再送审,沉默地永久压制一个真实冲突。
76
+ - **`differentiate` 不改正文**(#533):`#126` 曾把同一段差异注记**同时**追加到两侧,指望注记进 embedding 后下一轮不再判重复。离线复现证伪了这个假设——两侧共享同一段后缀会让两条向量**同向靠拢**(余弦 0.876 → 0.960 只用 5 轮,注记截到 0 字照样往上爬),于是同一对每轮又被判成重复、又追加一段(实测单条堆到 19 段,正文 150 字膨胀到 2200+)。现在只落 `differentiated_pairs` 账本(配对 + 两侧当时的 `content_hash` + 模型给的 `distinctions`),正文一个字节都不动。存量库里仍带旧注记块的行按「已裁决」处理,升级不清账。
77
+ - **输出**:冲突消解报告(决策明细 + `pairs` / `batches` / `ledgerSkipped` / `perRunSkipped`)。**失败轮同样带 `pairs` 与 `batches`**——此前只有成功路径带,85 条失败审计行连"送了多少对"都查不到。批间被用户活动打断时报 `aborted`(不是 `noop`:`noop` 的语义是"看过、没冲突",而没送出去的批根本没看过),整轮 `deriveStatus` 也沿用同一个词,不再折回 `noop`。
68
78
  - **验收**:无合法记忆被误删;仲裁结果符合配置的严格度阈值;决策可回滚。
79
+ - **为什么要有预算(#529 的现场)**:整池相似对一次拼进一个 prompt,实测单请求 **163,054** 输入 token(服务端 `--ctx-size` 163,840,占 99.5%),撞墙后按冷却重排,送进去的还是同一份输入,于是连续 **23 轮** `llm failed`;而 `keep` 当时是彻底的 no-op(不留痕),同一对每个冷却周期都被重新问一遍。三处一起改之后单请求降到 806–2,011 token、连续失败 23 轮 → 0 轮。
80
+ - **台账为什么不复用 `conflict_pending`**:那张表是**人工**待裁决队列,面板徽标数就是 `countConflictPending()`(`resolved_at IS NULL` 的行数)。机器每判一对写一行会把这个数字淹掉,人就再也看不出"有哪些待我裁决"。两张表分工:`conflict_pending` = 人要看,`conflict_reviewed` = 机器记账,零界面。
69
81
 
70
82
  ### 2. `archival_demotion`(归档降级)
71
83
  - **目标**:按时间衰减对记忆进行分层压缩,释放热存储压力。
@@ -110,7 +122,9 @@ flowchart TD
110
122
 
111
123
  ## 调度器与可中断机制
112
124
  - **`noteWrite()` 心跳重置**:每次用户写入/读取记忆时触发。必须先 `clearTimeout` 清除旧计时器,再重新设置 Idle Timer,彻底杜绝 Stale Timer 导致的误触发。
113
- - **`maybeSchedule()` 准入控制**:串行检查 `sleepModeEnabled` → 空闲时长 `>= sleepIdleMinutes` → 距上次运行 `>= sleepMinIntervalHours` → 状态非 `running/disposed`。任一不满足则放弃调度。
125
+ - **`maybeSchedule()` 准入控制**:串行检查 `sleepModeEnabled` → 空闲时长 `>= sleepIdleMinutes` → 距上次运行 `>= 有效最小间隔` → 状态非 `running/disposed`。任一不满足则放弃调度。
126
+ - **有效最小间隔**(#529):`sleepFailureBackoff` 关(默认)时逐字节等于 `sleepMinIntervalHours`;开启时按连续失败次数指数推迟,封顶 `max(sleepMinIntervalHours, 24h)`——不是硬 24h:基数允许配到 168 小时,那时封顶跟着基数走,退避只能放宽间隔、不会把用户设的基数压小。`armNextWindow()` 排下一次闹钟用的是同一个函数——两处口径必须一致,否则退避期闹钟会在基数到点响、被 `shouldRun` 否掉、再重排一次,白转一轮。
127
+ - 退避**不是**按"同一份输入又失败"判的:`runSleep` 里那个 `snapshotHash` 是跑完之后对 `phases` 结果算的摘要哈希,不是输入快照,拿它当判据会把"同样的输入"认成"不同的结果"。改用连续失败计数,与 `autoDream` 的失败退避(#292)同构。
114
128
  - **`service.enqueue` 串行队列**:所有 Sleep Run 必须进入全局串行队列执行,与 `autoDream` 严格互斥,避免并发写入冲突与资源争抢。
115
129
  - **`AbortController` 可中断**:调度器持有 AbortController 实例。一旦检测到用户活动(`noteWrite` 或显式交互),立即调用 `abort()` 中断当前 `runSleep` 的执行上下文,保证实时性优先。
116
130
 
package/docs/STORAGE.md CHANGED
@@ -31,12 +31,13 @@ dsh-mneme reclaim --json # 原始 JSON,给脚本用
31
31
  |---|---|---|
32
32
  | `dream_runs.input`(窗口外) | 置空 | 它是当时的记忆库快照,可由记忆库重建 |
33
33
  | `dream_runs` 的骨架 / `decisions` / `outcome` / `receipt` | 不动 | 不可重建:拆了就回放不了那次裁决 |
34
+ | `dream_runs.raw_output` | 不动 | 失败轮的诊断摘要(#534):收到文本但解析或校验失败时留下的原文头尾,只在那一次调用里存在过,日志滚掉就再也拿不回来。**它不参与回放**——删了不会让保留的决策放不出来,只是丢掉「模型当时到底吐了什么」这条证据;无正文的流错误本来就是 `NULL` |
34
35
  | `memories.embedding`(`archived = 1`) | 置空 | 检索恒带 `archived = 0`,按定义不可达 |
35
36
  | `memories` 的行 | 一条不删 | 本入口止体积不止条数;条数与重复归 #254 与整理动作面 |
36
37
  | 活跃行的向量 | 不动 | 检索正在用 |
37
38
  | `distill_records`(窗口外,#399) | 删行 | 蒸馏原文留存是 opt-in 旁路索引;到期即弃。**`memory_provenance` 链接保留**指向空区间(面板显示「来源已过期」),记忆与链接都不删——原文本就是「回查用」的,保留期到了就该走 |
38
39
 
39
- `distill_records` 只在 `distillRecords.enabled=true` 时才有行(默认关,连收集都不做),所以对大多数库这一项恒为 0。
40
+ `distill_records` 只在 `distillRecords.enabled=true` 时才会有行(默认关,连收集都不做),所以对大多数库这一项恒为 0。**曾经开过再关的库有行**:关掉开关只是不再写、也不再经 HTTP 读出去,已留的行一直躺在库里,直到 `dsh-mneme reclaim`(不读这个键)或重新开启后的启动裁剪(见 [蒸馏原文留存](DISTILL.md))。
40
41
 
41
42
  取消归档时(`memory_archive` 还原)服务端会重新排队嵌入,所以被回收的向量能拿回来。
42
43
 
@@ -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 { parseProvenanceSelector, 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,40 @@ 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
+ // 两个读方向共一条路径(#524):?id=<memoryId> 走方向 1(记忆 → 窗口),
491
+ // ?sessionId=<sid> 走方向 2(会话 → 窗口)。互斥校验在共用模块里(都缺/都给
492
+ // 都是 400),本壳只把三个 query 参数原样递进去——拆分与择一的判断只准写一份。
493
+ //
494
+ // 这张面本来就要 Bearer token(除 /health 外无例外),所以不涉及宿主侧那次
495
+ // open → token 的归类调整;daemon 与宿主共用 service,口径天然一致。
496
+ //
497
+ // distillRecords.enabled 门(#509 评审整改):config 原样转交给共用模块,
498
+ // 判据只在那边算一次(isDistillRecordsEnabled,与写侧 src/summarize.js:988
499
+ // 同源,本文件不重写)。config 缺省 {} → 判据为假 → 409,错误码取共用模块导出的
500
+ // DISTILL_RECORDS_DISABLED:「不传即关」对这个返回逐字原文的端点是正确的失败
501
+ // 方向,别当成 bug 修掉。阶梯全貌(401 → 400 → 409 → 503 → 读)见
502
+ // provenance-http.js 文件头。
503
+ if (req.method === "GET" && pathname === "/memory/provenance") {
504
+ const parsed = parseProvenanceSelector({
505
+ memoryId: url.searchParams.get("id"),
506
+ sessionId: url.searchParams.get("sessionId"),
507
+ limit: url.searchParams.get("limit")
508
+ });
509
+ if (!parsed.ok) {
510
+ sendJson(res, parsed.status, { error: parsed.error });
511
+ return;
512
+ }
513
+ const { status, body } = provenanceReply({ service, selector: parsed.selector, config, warn: (m) => logger?.warn?.(m) });
514
+ sendJson(res, status, body);
515
+ return;
516
+ }
517
+
417
518
  // --- GET/PUT/DELETE /memories/:id --------------------------------------
418
519
  const idMatch = pathname.match(/^\/memories\/([^/]+)$/);
419
520
  if (idMatch) {
@@ -581,6 +682,84 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
581
682
  return;
582
683
  }
583
684
 
685
+ // --- POST /share: 分享包(spec §4)--------------------------------------
686
+ // 判据一条都不在这里:哪些行进包、超没超限、抛错回什么码,全在 share-http.js
687
+ // 那份共用实现里(#411 的口径——两张皮只许差在鉴权与路径)。本侧独有的只有
688
+ // 鉴权(Bearer,除 GET /health 外没有例外)与「不出 Content-Disposition」:
689
+ // 下面的 sendJson 连 headers 参数都没有,CLI 拿 JSON 自己落盘。
690
+ //
691
+ // GET 回 405 而不是掉到末尾的 404,与面板那侧一致。这**故意**与上面的
692
+ // /maintenance/reclaim(GET → 404)不同:状态码差一次就是第三处差别,而两条
693
+ // 路由的「两张皮」约束比这份文件里邻居的写法更硬。
694
+ //
695
+ // .catch 不是装饰:shareReply 现在自己兜得住,但这条链上 readBody 之前之后的
696
+ // 任何一处抛错都没人接——本文件那个同步 try/catch 在 .then 的回调里已经退栈,
697
+ // 漏出去就是未处理拒绝、客户端一个字节都没收到(#410)。
698
+ if (pathname === "/share") {
699
+ if (req.method !== "POST") {
700
+ sendJson(res, 405, { error: "method-not-allowed" });
701
+ return;
702
+ }
703
+ void readBody(req, res).then((text) => {
704
+ if (text === null) return; // readBody 已经答过 413,再写一次就是双写响应
705
+ const parsed = parseShareBody(text);
706
+ if (!parsed.ok) {
707
+ sendJson(res, 400, { error: parsed.error });
708
+ return;
709
+ }
710
+ const out = shareReply({
711
+ service,
712
+ packageVersion: PACKAGE_VERSION,
713
+ homeDir: homedir(),
714
+ options: parsed.options,
715
+ warn: (msg) => logger?.warn?.(msg)
716
+ });
717
+ sendJson(res, out.status, out.body);
718
+ }).catch(() => sendJson(res, 500, { error: "internal" }));
719
+ return;
720
+ }
721
+
722
+ // --- POST /receive + POST /receive/undo: 分享包入库与整包退出(车道 C)-----
723
+ // 判据一条都不在这里:包体合格不合格住在 share.js 的闸门(由 service 的
724
+ // importSharePack 调),错误码 → 状态的映射与 dry_run 那一格住在 src/share-http.js
725
+ // 这份共用实现里——#411 的口径是两张皮只许差在鉴权与路径(面板那半见
726
+ // /api/dsh-mneme/receive)。本面独有的只有两件事:鉴权是这里唯一的 Bearer 一道门
727
+ // (没有 policy 名可用,除 GET /health 外人人都要带),以及 sendJson 连 headers
728
+ // 参数都没有,所以不出 Content-Disposition。
729
+ //
730
+ // 非 POST 一律 405,与面板逐字同款:这**故意**与上面的 /maintenance/reclaim 不同
731
+ // (那边掉到末尾 404)。状态码差一次就是第三处差别,而每条 receive 都还有个面板
732
+ // 双胞胎,一致性比照抄本文件的邻居更硬。
733
+ //
734
+ // void + .catch,不留外层 try 兜底:本文件那个 try 在 .then 的回调里早已退栈,
735
+ // 回调里抛出去就是没人接的 rejection、客户端一个字节都收不到(#410)。共用层
736
+ // 从不抛(连 warn 自己坏都被守卫吞掉),所以这里能被走到的只剩面自己写答复那一步。
737
+ // readBody 超限时会自己答 413 并把 text 解析成 null:那句 return 是必需的,少了
738
+ // 它就是在已发出的响应上再写一次。
739
+ if (pathname === "/receive" || pathname === "/receive/undo") {
740
+ const isUndo = pathname === "/receive/undo";
741
+ if (req.method !== "POST") {
742
+ sendJson(res, 405, { error: "method-not-allowed" });
743
+ return;
744
+ }
745
+ void readBody(req, res).then((text) => {
746
+ if (text === null) return; // readBody 已经答过 413,再写一次就是双写响应
747
+ const parsed = isUndo ? parseUndoBody(text) : parseReceiveBody(text);
748
+ // 两个解析器都回 {ok:true,…}|{ok:false,error},所以一处判断就够。别把它写成
749
+ // 「有 error 就 400」:那会把本面缺路径时该落的 404 与真正的坏 body 混成一种
750
+ // 答复,排查时看不出是哪一层没接上。
751
+ if (!parsed.ok) {
752
+ sendJson(res, 400, { error: parsed.error });
753
+ return;
754
+ }
755
+ const out = isUndo
756
+ ? undoReply({ service, packId: parsed.packId, dryRun: parsed.dryRun, warn: (msg) => logger?.warn?.(msg) })
757
+ : receiveReply({ service, pack: parsed.pack, dryRun: parsed.dryRun, warn: (msg) => logger?.warn?.(msg) });
758
+ sendJson(res, out.status, out.body);
759
+ }).catch(() => sendJson(res, 500, { error: "internal" }));
760
+ return;
761
+ }
762
+
584
763
  // --- POST /bootstrap: cold-start memories from repo files (#220) -------
585
764
  // 显式收 dir(插件没有工作区根目录概念,不猜路径);确定性解析零 LLM;
586
765
  // 幂等骑 saveWithDedupe 的 (type, title, scope) 去重 + _overwrite 刷新。
@@ -671,16 +850,123 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
671
850
  const thresholdRaw = Number(url.searchParams.get("threshold") ?? 3);
672
851
  const threshold = Number.isFinite(thresholdRaw) ? thresholdRaw : 3;
673
852
  const scope = scopeFromSearchParams(url);
674
- void Promise.resolve(q && embedder ? withEmbedTimeout(embedQueryVector(embedder, q), embedTimeoutMs) : undefined)
675
- .catch(() => undefined) // 查询嵌入失败降级规则档(与 searchMemories 同口径)
853
+ // #383:降级原因记账用。embedQueryVector / withEmbedTimeout 只负责「拿不到
854
+ // 向量时返回 undefined」,原因(无可用接口 / 空向量 / 超时 / 抛错 / 维度不符 /
855
+ // 没装配 embedder)在这里回收,供 daemon 侧日志枚举——响应体只多 tier 一个
856
+ // 键,不加第二个字段。
857
+ const diag = {};
858
+ // 记账的唯一决策点:q 非空但没装配 embedder。#508 Minor 的成因正是这条路不写
859
+ // reason,靠日志行的兜底表达式临时拼一个值(no-embedder),而那个值不在文档
860
+ // 声明的枚举里;同一表达式里还躺着第二个越界字面量 embed-failed。兜底表达式的
861
+ // 问题不只是越界:它把「默认值」编码在两处,且 embedder 存在时那个 embed-error
862
+ // 分支一次都走不到(10 种降级场景实测:只要 embedder 为真,embedQueryVector /
863
+ // withEmbedTimeout / 下面的 .catch 三者必居其一地写好原因),却会把未来任何新增
864
+ // 的未记账失败一律谎报成「接口抛错」,把运维引去查 HTTP 端点。现在默认值只有
865
+ // 这一个来源,日志行不再兜底。
866
+ if (q && !embedder) diag.reason = "no-embedder";
867
+ void Promise.resolve(q && embedder ? withEmbedTimeout(embedQueryVector(embedder, q, diag), embedTimeoutMs, diag) : undefined)
868
+ .catch((err) => { diag.reason = "embed-error"; return undefined; }) // 查询嵌入失败降级规则档(与 searchMemories 同口径)
676
869
  .then((queryVector) => {
677
870
  const pinnedStats = {};
871
+ // items 本身失败(injectCandidates 抛错,典型如库被关)回 500 是正当的:
872
+ // 没有可返回的内容。items 一旦算好,后面的档位判定与降级日志就只是诊断,
873
+ // 任何一步出错都不该把已经算好的 items 丢掉——所以下面每一处 logger 调用
874
+ // 都各自包了 try/catch(logger 来自宿主,stdout 管道断裂时 warn 自身抛错是
875
+ // 真实可发生的,而 `logger?.warn?.()` 只防 logger 缺失,防不了这个)。
678
876
  const items = service.injectCandidates({ query: q, maxItems, threshold, queryVector, scope, pinnedStats });
877
+ // #383 档位信号:嵌入降级在调用方视角原本完全静默(items 看着正常,只是
878
+ // 召回质量悄悄变差),外部集成方无从感知自己长期跑在降级档上。
879
+ // 三态 fail-closed——判不出来报 unknown,不报 full:
880
+ // full 语义档,查询嵌入真的成功了且与库里的向量可比
881
+ // degraded 规则档:没装配 embedder / 嵌入超时 / 不可用向量 / 抛错 /
882
+ // 维度不符(拿到了向量但语义召回整个不可用)
883
+ // unknown 语义路未启用(空 q),或判不出来(维度探针没跑成 / 读不出
884
+ // 任何向量)——那不是降级,但也不能宣称 full
885
+ // 比 /search 的 mode 更精确:/search 把「空查询」和「向量失败」都报
886
+ // keyword,这里能分开(空 q 直接可观测)。
887
+ //
888
+ // 预算:判档不是免费的——q 非空且配了 embedder 时,它真的会起一次查询
889
+ // 嵌入(一次网络往返 + embedTimeoutMs 的超时窗口),因为档位要回答的
890
+ // 正是「这次嵌入成不成」。这里**没有**复用宿主 /health 那份现成数据源:
891
+ // standalone 的 /health 只回 {ok:true},而 embeddingDimensionBreakdown
892
+ // 是 src/api.js 里的一次全表扫(每行 JSON.parse 一个向量),按请求跑会
893
+ // 把语义路成本翻倍,且它根本不在 standalone 的装配里。维度探针因此走
894
+ // service.vectorDimensionState()——存在性判据(有没有一条活跃向量与查询
895
+ // 同维),成本模型见 store.js 那段注释:常态一次全 id 扫描 + 解析 1 个
896
+ // 向量,只有要报 degraded 时才走完全表。
897
+ let tier = "unknown";
898
+ if (q && queryVector) {
899
+ // #508 Major:探针判据从「读一行的维度」换成存在性。LIMIT 1 拿任意一条
900
+ // 代表全库,在混合维度库里两个方向都错——抽到与查询不符的那条会误报
901
+ // degraded(明明有可比的向量),首条解析失败又会漏报成 full。现在问的是
902
+ // 「有没有一条活跃向量的维度等于查询维度」,与 searchVector 的行集同源。
903
+ //
904
+ // 探针失败(库被锁 / 半开 / 旧装配没有这个方法)判不出来:tier 保持
905
+ // unknown,不宣称 full,也不把已经算好的 items 丢掉。sentinel 不能用
906
+ // null / undefined——那正是「库里没有向量」的合法读数,混起来就会把一次
907
+ // 真实的探针故障谎报成 full(fail-open)。隔离只在调用侧:store 与 service
908
+ // 透传都保持抛出口,不把异常吞成某个 state。
909
+ let state;
910
+ // 判不出来时的留痕。unknown 现在有三个生产者(空 q / 探针没跑成 / 有行却
911
+ // 一条都读不出来),而响应体只有 tier 一个键、分不出它们,调用方对 unknown
912
+ // 的策略又常常是「不告警」——不留一条 warn,一次真实的判档故障就从运维面上
913
+ // 消失了。这条 warn **不是** diag.reason 枚举的一员(tier 不是 degraded,
914
+ // 没有降级原因可记),运维程序不要按降级原因枚举解析它;消息也与
915
+ // 「degraded to the rule tier (<reason>)」分开,因为语义路这次是真的跑了,
916
+ // 只是没判成,两种故障的后续动作不同。
917
+ let judgeFailure;
918
+ try {
919
+ state = typeof service.vectorDimensionState === "function"
920
+ ? service.vectorDimensionState(queryVector.length)
921
+ : undefined;
922
+ } catch (error) {
923
+ state = undefined;
924
+ judgeFailure = `dimension probe failed (${String(error)})`;
925
+ }
926
+ if (state?.state === "match" || state?.state === "none") {
927
+ // match:有维度相符的活跃向量,语义路通(通 ≠ 有命中,四道门仍可能让
928
+ // 语义档贡献为 0)。none:行集本身为空——没有东西可比,不是维度不符。
929
+ tier = "full";
930
+ } else if (state?.state === "mismatch") {
931
+ // 维度不符:searchVector 对不等长的行直接跳过(见 store.js 的
932
+ // distill-tx-embed-gap 注释),不抛错、只是命中为零——于是召回静默
933
+ // 退回 BM25/规则档,而嵌入侧一切正常。这是 issue #383 点名的那一种
934
+ // 「悄悄退化」,必须有自己的名字:修法是重建索引,不是查端点。
935
+ diag.reason = "embed-dimension";
936
+ tier = "degraded";
937
+ } else if (state?.state === "unreadable" || state?.state === "invalid") {
938
+ // 有行却一条都读不出来(比维度不符更严重:语义召回 100% 死亡),或查询
939
+ // 维度本身不是正整数。判不出来,留 unknown。
940
+ judgeFailure = `no comparable vector readable (${state.state})`;
941
+ }
942
+ // undefined(探针方法缺失 / 返回垃圾 / 异步漂移)同样留 unknown。
943
+ if (judgeFailure) {
944
+ try {
945
+ logger?.warn?.(`dsh-mneme: /context tier left unknown: ${judgeFailure}`);
946
+ } catch { /* 日志通道坏了也不是把 items 丢掉的理由 */ }
947
+ }
948
+ } else if (q) {
949
+ tier = "degraded";
950
+ }
951
+ if (tier === "degraded") {
952
+ // reason 由唯一决策点(q && !embedder)+ 三个写入方(embedQueryVector /
953
+ // withEmbedTimeout / 查询嵌入 .catch)+ 维度判据保证必已赋值,所以这里不再
954
+ // 写第二份默认值——#508 之前的那份兜底表达式(`embedder ? "embed-failed" :
955
+ // "no-embedder"`)两个默认值互相矛盾、且都在文档枚举之外,其中 embed-error
956
+ // 分支在 embedder 存在时一次都走不到,却会把未来任何新增的未记账失败一律
957
+ // 谎报成「接口抛错」。现在只把枚举内的值写进日志:越界或漏记账都露出
958
+ // (undefined),那是看得见的缺陷信号,不是一个编出来的因。
959
+ const reason = CONTEXT_DEGRADE_REASONS.includes(diag.reason) ? diag.reason : undefined;
960
+ try {
961
+ logger?.warn?.(`dsh-mneme: /context degraded to the rule tier (${reason})`);
962
+ } catch { /* 日志通道坏了也不是把 items 丢掉的理由 */ }
963
+ }
679
964
  sendJson(res, 200, {
680
965
  profile: (settings?.getProfile?.() ?? "").trim(),
681
966
  rules: settings?.getRules?.() ?? [],
682
967
  items: service.toApiList(items),
683
- pinnedCount: pinnedStats.shown ?? 0
968
+ pinnedCount: pinnedStats.shown ?? 0,
969
+ tier
684
970
  });
685
971
  })
686
972
  .catch(() => {