@a9i5k4/dsh-auto-memory 3.0.0 → 3.1.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 (116) hide show
  1. package/README.md +30 -13
  2. package/README.zh-CN.md +30 -13
  3. package/docs/FRONTEND-CO-CREATION.md +191 -0
  4. package/docs/GM53-HOMEPAGE-PROMPT.md +323 -0
  5. package/docs/HANDBOOK.md +88 -52
  6. package/docs/HOMEPAGE-CONTENT-FOR-GM53.md +299 -0
  7. package/docs/PROMO-PROMPT-3.0.md +100 -0
  8. package/docs/USER-GUIDE.en.md +11 -11
  9. package/docs/USER-GUIDE.zh-CN.md +11 -11
  10. package/docs/WHITEPAPER.md +207 -0
  11. package/docs/screenshots/promo/promo-0-banner-v3.png +0 -0
  12. package/docs/screenshots/promo/promo-0-banner-v4.png +0 -0
  13. package/docs/screenshots/promo/promo-1b-auto-recall.png +0 -0
  14. package/lib/activation-host.js +69 -10
  15. package/lib/board-mode.js +1 -1
  16. package/lib/client.js +1697 -285
  17. package/lib/config-io.js +156 -0
  18. package/lib/context-bridge.js +3 -0
  19. package/lib/context-host.js +23 -10
  20. package/lib/degrade.js +385 -0
  21. package/lib/dsh-home.js +143 -0
  22. package/lib/episodic-store.js +142 -18
  23. package/lib/evidence-store.js +8 -1
  24. package/lib/fact-store.js +484 -43
  25. package/lib/hub-io.js +217 -0
  26. package/lib/index-sync.js +13 -1
  27. package/lib/index.js +1730 -202
  28. package/lib/intent-clean-safe.js +258 -40
  29. package/lib/l0-extract.js +231 -16
  30. package/lib/m4-corpus.js +8 -2
  31. package/lib/m7-index-sync-host.js +8 -1
  32. package/lib/memory-envelope.js +6 -1
  33. package/lib/memory-hub.js +164 -17
  34. package/lib/memory-index.js +4 -2
  35. package/lib/note-status-apply.js +118 -0
  36. package/lib/note-status.js +204 -0
  37. package/lib/procedure-store.js +333 -31
  38. package/lib/procedure-switch.js +38 -0
  39. package/lib/python-sidecar-client.js +314 -11
  40. package/lib/recall-fusion.js +83 -12
  41. package/lib/rules-edit.js +159 -0
  42. package/lib/semantic-decide.js +41 -8
  43. package/lib/semantic-js.js +51 -6
  44. package/lib/shadow-host.js +3 -5
  45. package/lib/skill-export-host.js +153 -0
  46. package/lib/skill-export.js +239 -0
  47. package/lib/storage-manage.js +6 -0
  48. package/lib/temporal-parse.js +191 -159
  49. package/lib/tier0-catalog.js +45 -3
  50. package/lib/wb-contract.js +198 -2
  51. package/lib/wb-sidecar.js +54 -3
  52. package/package.json +6 -2
  53. package/docs/internal/ACCEPT-35-LIVE.md +0 -143
  54. package/docs/internal/ACCEPTANCE-20260914.md +0 -90
  55. package/docs/internal/ARCH-REVIEW-BRIEF.md +0 -411
  56. package/docs/internal/ARCH-REVIEW-REQUEST.md +0 -201
  57. package/docs/internal/ARCH-REVIEW-ROUND2.md +0 -169
  58. package/docs/internal/ARCH-REVIEW-ROUND3.md +0 -206
  59. package/docs/internal/ART-DIRECTION-WIREFRAME.md +0 -181
  60. package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +0 -314
  61. package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +0 -192
  62. package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +0 -72
  63. package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +0 -131
  64. package/docs/internal/CUA-VISION-FIX-NOTES.md +0 -78
  65. package/docs/internal/DECISIONS-20260914-SESSION.md +0 -269
  66. package/docs/internal/DESIGN-OVERHAUL-PRE-RESEARCH.md +0 -292
  67. package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +0 -219
  68. package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +0 -132
  69. package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +0 -13
  70. package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +0 -74
  71. package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +0 -352
  72. package/docs/internal/GPT-REVIEW-PROMPT.md +0 -216
  73. package/docs/internal/GROUP-DIGEST-SETUP.md +0 -62
  74. package/docs/internal/GROUP-LISTENER-SETUP.md +0 -49
  75. package/docs/internal/GROUP-WEBHOOK-SETUP.md +0 -93
  76. package/docs/internal/HANDOFF-TO-ZCODE.md +0 -168
  77. package/docs/internal/KICKOFF-P0.md +0 -254
  78. package/docs/internal/MASTER-PLAN-3.0.md +0 -411
  79. package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +0 -85
  80. package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +0 -222
  81. package/docs/internal/NEXT-VERSION-TODO.md +0 -95
  82. package/docs/internal/OFFICIAL-DISCUSSION-DRAFT.md +0 -80
  83. package/docs/internal/PENDING-FIXES-20260916.md +0 -289
  84. package/docs/internal/RAG-KARPATHY-PROGRAM.md +0 -229
  85. package/docs/internal/RELEASE-PROCESS.md +0 -99
  86. package/docs/internal/REPORT-P0-NIGHTLY.md +0 -212
  87. package/docs/internal/REPORT-P5-ACCEPTANCE.md +0 -31
  88. package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +0 -153
  89. package/docs/internal/REVIEW-WB-GRAPH-SELF.md +0 -81
  90. package/docs/internal/ROADMAP-20260917-WEEK.md +0 -305
  91. package/docs/internal/ROADMAP.md +0 -106
  92. package/docs/internal/RUN-P0-NIGHTLY.md +0 -227
  93. package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +0 -175
  94. package/docs/internal/S10-GAPS-PLAIN-20260917.md +0 -125
  95. package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +0 -360
  96. package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +0 -90
  97. package/docs/internal/SUBAGENT-REPORT-ROUTING-PRE-RESEARCH.md +0 -261
  98. package/docs/internal/THREE-LAYER-CONTRACT.md +0 -210
  99. package/docs/internal/TODO-BACKLOG.md +0 -263
  100. package/docs/internal/TODO-GRAPH.html +0 -715
  101. package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +0 -493
  102. package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +0 -710
  103. package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +0 -710
  104. package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +0 -703
  105. package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +0 -710
  106. package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +0 -715
  107. package/docs/internal/WB-FORMAT-CONVENTION.md +0 -112
  108. package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +0 -71
  109. package/docs/internal/WB-GRAPH-INTEGRATION-PLAN.md +0 -386
  110. package/docs/internal/WB-GRAPH-RESEARCH-BRIEF.md +0 -118
  111. package/docs/internal/WB-GRAPH-RESEARCH-EXTERNAL.md +0 -228
  112. package/docs/internal/WB-GRAPH-RESEARCH-LOCAL.md +0 -190
  113. package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +0 -56
  114. package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +0 -787
  115. package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +0 -112
  116. package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +0 -230
@@ -9,7 +9,19 @@
9
9
  * 错误 epoch/未知 requestId/重复或过期 response 全部计账丢弃,绝不注入上层。
10
10
  * - 四种身份不混用:requestId(transport)/observationId(M5)/activationId(M6)/syncId(index)。
11
11
  * - timeout/AbortSignal(cancel 通知)/latest-wins(由上层 M5 bridge 驱动)/crash recovery/circuit breaker。
12
- * 无 shell;无 HTTP;stdout 只进协议解析器;stderr 仅有界诊断。UTF-8 无 BOM。
12
+ * - ★ #107 看门狗:连续超时(心跳缺失)达阈值 ⇒ **kill 旧进程 + 下次请求重生新进程**。旧实现
13
+ * 超时只发 cancel 通知、从不 kill ⇒ 挂死的 worker 永远无人收尸,后续每个请求各自等到超时。
14
+ * stats 把「看门狗主动击杀」与「崩溃退出」分开记;熔断只由**终态成功**清零,段内进度帧
15
+ * (index_sync_begin / index_sync_page)的 ack 成功不清零 —— 否则每个 Segment 一次 begin 就把
16
+ * 累计失败抹平,峰值恒 1、阈值 3 永不达,熔断形同虚设。
17
+ * - ★ #108 stderr 可见:exit/fatal 分支把 stderr 尾部**原样**落一条 diag(同一次退出只记一条,
18
+ * 按行与字符双重截断防刷屏),并把截断后文本暴露为 stats.lastStderrTail /
19
+ * debugView().lastStderrTail;同时保留 stderrTailBytes 以区分「确实没输出」与「输出被截」。
20
+ * 无 shell;无 HTTP;stdout 只进协议解析器;stderr 有界诊断 + 有界 diag 环形缓冲(不再只留字节数)。
21
+ * ★ 影响面纪律:本模块是「JS 语义默认 / Python 发烧友可选」里**Python 那一侧**的传输层,不参与
22
+ * JS 语义路径;下述改动只影响 Python 档自身的行为,不改变 JS 端任何行为,也不让 python 侧的
23
+ * 存在或健康度成为 JS 语义生效的前提(未启动时零进程零 IO,调用方按需 lazy start)。
24
+ * UTF-8 无 BOM。
13
25
  */
14
26
  import { spawn } from 'node:child_process'
15
27
  import { randomBytes } from 'node:crypto'
@@ -22,6 +34,31 @@ import {
22
34
 
23
35
  const B = M7_TRANSPORT_BUDGET_V1
24
36
 
37
+ /**
38
+ * 看门狗/诊断策略(**客户端本地策略量**,刻意不放进 M7_TRANSPORT_BUDGET_V1)。
39
+ * 理由有二:①传输预算按契约冻结、变更须升协议版本,而「多久判挂死」是客户端自愈策略、不是 wire 契约;
40
+ * ②wire 文件同期另有 PR(#122/#124)在改,本任务不去碰它以避开写域冲突。
41
+ */
42
+ const SIDECAR_WATCHDOG_V1 = Object.freeze({
43
+ stallThreshold: 3, // 连续超时(心跳缺失)达此数 ⇒ 判定挂死,看门狗击杀旧进程
44
+ stderrTailChars: 4096, // stderr 环形尾部保留长度(既有语义,原为字面量 4096)
45
+ diagRingCapacity: 16, // diag 环形缓冲条数(超出丢最旧)
46
+ diagTailLines: 8, // 单条 diag 最多保留的 stderr 行数(防刷屏)
47
+ diagTailChars: 1200, // 单条 diag 最多保留的 stderr 字符数
48
+ })
49
+
50
+ /** 退出归因优先级:高优先级不被低优先级覆盖。 */
51
+ const EXIT_KIND_RANK_V1 = Object.freeze({ crash: 0, restart: 1, dispose: 1, fatal: 2, watchdog: 3 })
52
+
53
+ /**
54
+ * ★ #107 熔断清零口径:只有「终态成功」才把累计失败清零。
55
+ * index_sync_begin / index_sync_page 是**段内进度帧**(每个 Segment 都会 begin 一次),它们的 ack
56
+ * 成功只证明 worker 读到并回了一行,不能证明它健康。旧实现无差别清零 ⇒ 每个 Segment 的 begin 一经
57
+ * 成功就把累计失败抹平,峰值恒 1、阈值 3(breakerFailureThreshold)永不达,熔断形同虚设。
58
+ * health / context_push / index_sync_commit 才是各自工作单元的终态应答。
59
+ */
60
+ const TERMINAL_SUCCESS_TYPES_V1 = new Set(['health', 'context_push', 'index_sync_commit'])
61
+
25
62
  /** 捆绑 fake worker 的默认绝对路径(python/worker_v1.py)。 */
26
63
  export function defaultWorkerScriptPathPre() {
27
64
  try {
@@ -53,6 +90,19 @@ export function createPythonSidecarClientPre(opts = {}) {
53
90
  const seenActivationIdSet = new Set()
54
91
  let writeChain = Promise.resolve()
55
92
  let lastSentFrame = null
93
+ // ---- #107 看门狗 / #108 stderr 可见:状态 ----
94
+ let genSeq = 0 // 世系序号发生器
95
+ let generation = 0 // 当前世系号(0 = 从未启动过)
96
+ let consecutiveTimeouts = 0 // 连续超时(心跳缺失)计数
97
+ let halfOpenPending = false // 熔断冷却到期后待用 health() 探活
98
+ let respawnPending = false // 看门狗击杀后待重生(下次 spawn 计一次 watchdog.respawns)
99
+ let lastStderr = { tail: '', bytes: 0, truncated: false, at: 0, generation: 0, totalLines: 0, keptLines: 0 }
100
+ let lastExitDiagKey = '' // 「同一次退出只记一条」去重键 = 世系号 + 退出种类
101
+ let lastExitDiagRef = null // 该条 diag 记录引用(供 exit 之后迟到的 stderr 就地刷新)
102
+ let lastExitGen = 0 // 最近一次退出的世系号
103
+ const genOfProc = new WeakMap() // ChildProcess -> 世系号
104
+ const exitKindByGen = new Map() // 世系号 -> 退出种类(crash/restart/dispose/fatal/watchdog)
105
+ const diagRing = [] // 有界 diag 环形缓冲(给人看的诊断,含中文归因)
56
106
  const breaker = { consecutiveFailures: 0, openUntil: 0 }
57
107
  const stats = {
58
108
  starts: 0, exits: 0, framesIn: 0, requests: 0, succeeded: 0, activationsReceived: 0,
@@ -60,6 +110,17 @@ export function createPythonSidecarClientPre(opts = {}) {
60
110
  dropped: { badJson: 0, badEnvelope: 0, staleEpoch: 0, unknownRequest: 0, typeMismatch: 0, duplicateActivation: 0 },
61
111
  failed: { timeout: 0, crashed: 0, aborted: 0, circuitOpen: 0, unavailable: 0, backpressure: 0, protocol: 0, workerError: 0, disposed: 0 },
62
112
  lastExit: null, lastFatal: null,
113
+ // ★ #107 看门狗:把「主动重启」与「崩溃退出」分开记,不再混在一个 exits 计数里。
114
+ watchdog: { kills: 0, respawns: 0, lastReason: null, lastAt: 0, lastDetail: null },
115
+ exitKinds: { crash: 0, restart: 0, dispose: 0, fatal: 0, watchdog: 0, spawnError: 0 },
116
+ breakerProbes: { attempted: 0, ok: 0, failed: 0 },
117
+ lastExitNote: null,
118
+ // ★ #108 stderr:lastStderrTail 是**截断后可读文本**(不再只有字节数);
119
+ // stderrTailBytes 仍在 debugView 上,用来区分「没输出」与「输出被截」。
120
+ lastStderrTail: '',
121
+ lastStderrTailBytes: 0,
122
+ lastStderrTailTruncated: false,
123
+ lastStderrTailAt: 0,
63
124
  }
64
125
 
65
126
  function resolveOpt(k) { return opt(k, null) }
@@ -75,14 +136,170 @@ export function createPythonSidecarClientPre(opts = {}) {
75
136
 
76
137
  function breakerOpen() { return Date.now() < breaker.openUntil }
77
138
 
139
+ // ============ #107 看门狗 / #108 stderr 可见:诊断与自愈 ============
140
+ // 说明:以下全部只服务 Python 侧车自身的自愈与可观测性,不参与 JS 语义路径。
141
+
142
+ /** 有界 diag 环形缓冲:落一条**给人看**的中文诊断;opts.onDiag 在场时同步外发一份。 */
143
+ function pushDiag(level, text) {
144
+ const rec = { at: Date.now(), level, sidecar: 'python-sidecar-pre', generation, message: String(text) }
145
+ diagRing.push(rec)
146
+ while (diagRing.length > Math.max(1, Number(opt('diagRingCapacity', SIDECAR_WATCHDOG_V1.diagRingCapacity)))) diagRing.shift()
147
+ const sink = resolveOpt('onDiag')
148
+ if (typeof sink === 'function') { try { sink({ at: rec.at, level: rec.level, generation, message: rec.message }) } catch (_) {} }
149
+ return rec
150
+ }
151
+
152
+ /**
153
+ * 把 stderr 尾部整理成可读文本:保留尾 N 行(默认 8)且不超过 M 字符(默认 1200)。
154
+ * 被截断时在正文前留一行说明 —— 避免读者误以为「这就是全部输出」。
155
+ */
156
+ function tailForDiag(raw) {
157
+ const NL = String.fromCharCode(10)
158
+ const CR = String.fromCharCode(13)
159
+ const src = String(raw == null ? '' : raw)
160
+ const bytes = src.length
161
+ // 刻意不用正则字面量:本仓所有 .js 均为 CRLF,避免任何转义歧义。
162
+ const all = src.split(NL).map((l) => (l.endsWith(CR) ? l.slice(0, -1) : l))
163
+ while (all.length && all[all.length - 1] === '') all.pop()
164
+ const maxLines = Math.max(1, Number(opt('diagTailLines', SIDECAR_WATCHDOG_V1.diagTailLines)))
165
+ const maxChars = Math.max(1, Number(opt('diagTailChars', SIDECAR_WATCHDOG_V1.diagTailChars)))
166
+ const kept = all.slice(-maxLines)
167
+ let truncated = kept.length < all.length
168
+ let text = kept.join(NL)
169
+ if (text.length > maxChars) { text = text.slice(-maxChars); truncated = true }
170
+ const head = truncated ? '…[已按行/字符截断,下面不是全部输出]…' + NL : ''
171
+ return { text: head + text, truncated, keptLines: kept.length, totalLines: all.length, bytes }
172
+ }
173
+
174
+ /** 退出归因中文说明(回答「哪一侧车、哪一代、怎么退的、退出码多少」)。 */
175
+ function humanExitNote(gen, kind, code, signalName) {
176
+ const why = kind === 'watchdog' ? '被看门狗主动击杀(连续超时=心跳缺失达阈值,判定挂死)'
177
+ : kind === 'fatal' ? '因协议致命错误被就地击杀(帧纪律违规)'
178
+ : kind === 'restart' ? '被 restart() 主动重启(刻意换代,不是故障)'
179
+ : kind === 'dispose' ? '随 dispose() 收尾关闭(刻意关闭,不是故障)'
180
+ : '自行退出(非看门狗击杀)——即「崩溃退出」'
181
+ const c = (code === null || code === undefined) ? 'null' : String(code)
182
+ const s = signalName ? String(signalName) : 'null'
183
+ return '第 ' + gen + ' 代 Python 语义侧车' + why + ':退出码 code=' + c + ',signal=' + s
184
+ }
185
+
186
+ /** 退出归因写入(优先级 watchdog > fatal > dispose/restart > crash;低优先级不覆盖高优先级)。 */
187
+ function markExitKind(gen, kind) {
188
+ if (!gen) return
189
+ const cur = exitKindByGen.get(gen) || 'crash'
190
+ if ((EXIT_KIND_RANK_V1[kind] || 0) < (EXIT_KIND_RANK_V1[cur] || 0)) return
191
+ exitKindByGen.set(gen, kind)
192
+ while (exitKindByGen.size > 64) exitKindByGen.delete(exitKindByGen.keys().next().value)
193
+ }
194
+
195
+ /**
196
+ * ★ #108 关键修复:把 stderr 尾部**原样**落一条 diag。
197
+ * 旧实现只把 4KB 尾部收进 `stderrTail` 变量、对外仅暴露 `stderrTailBytes` 一个整数,
198
+ * **内容没有任何读者** ⇒ Python traceback 永远看不到,用户只能看到「字节数非 0」。
199
+ * 同一次退出只记一条:以「世系号 + 退出种类」为键,命中则**就地刷新**(退出之后 stdio 往往
200
+ * 还在排空,close 晚于 exit、尾字节会迟到;就地刷新既补全内容又不新开第二条)。
201
+ */
202
+ function logExitDiag(gen, kind, reasonText) {
203
+ const t = tailForDiag(stderrTail)
204
+ lastStderr = { tail: t.text, bytes: t.bytes, truncated: t.truncated, at: Date.now(), generation: gen, totalLines: t.totalLines, keptLines: t.keptLines }
205
+ stats.lastStderrTail = t.text
206
+ stats.lastStderrTailBytes = t.bytes
207
+ stats.lastStderrTailTruncated = t.truncated
208
+ stats.lastStderrTailAt = lastStderr.at
209
+ const note = t.bytes === 0
210
+ ? 'stderr 零输出(确实没打印,不是被截断)'
211
+ : (t.truncated
212
+ ? 'stderr 尾部累计 ' + t.bytes + ' 字符 / ' + t.totalLines + ' 行,此处只保留最后 ' + t.keptLines + ' 行共 ' + t.text.length + ' 字符(已按行与字符双重截断,防刷屏)'
213
+ : 'stderr 尾部累计 ' + t.bytes + ' 字符 / ' + t.totalLines + ' 行,已全部保留')
214
+ const NL = String.fromCharCode(10)
215
+ const body = String(reasonText) + ';' + note + (t.text ? NL + '—— stderr 原文 ——' + NL + t.text : '')
216
+ const key = gen + ':' + kind
217
+ const level = (kind === 'crash' || kind === 'watchdog' || kind === 'fatal') ? 'error' : 'info'
218
+ if (lastExitDiagKey === key && lastExitDiagRef) {
219
+ lastExitDiagRef.message = body
220
+ lastExitDiagRef.at = lastStderr.at
221
+ lastExitDiagRef.level = level
222
+ return lastExitDiagRef
223
+ }
224
+ lastExitDiagKey = key
225
+ lastExitGen = gen
226
+ lastExitDiagRef = pushDiag(level, body)
227
+ return lastExitDiagRef
228
+ }
229
+
230
+ /**
231
+ * ★ #107 看门狗:判定侧车挂死 ⇒ **kill 旧进程 + 下次请求重生新进程**。
232
+ * 旧实现在超时分支只 `notify('cancel')`、从不 kill ⇒ 挂死的 worker 永远无人收尸,后续每个
233
+ * 请求各自等到自己的超时、再各自发一条无用的 cancel;对用户的表现是「Python 档语义静默失效」。
234
+ * 刻意不在此处立即 spawn:避免在熔断打开期制造进程(既有套件断言「打开期零 spawn」);
235
+ * 重建交给下一次 ensureStarted() —— 那里必然带新 epoch,index 重同步也随之触发。
236
+ */
237
+ function disarmHungChild(trigger, detail) {
238
+ const proc = child
239
+ consecutiveTimeouts = 0
240
+ if (!proc) return
241
+ markExitKind(generation, 'watchdog')
242
+ child = null
243
+ epoch = null
244
+ buffer = Buffer.alloc(0)
245
+ respawnPending = true
246
+ stats.watchdog.kills++
247
+ stats.watchdog.lastReason = trigger
248
+ stats.watchdog.lastAt = Date.now()
249
+ stats.watchdog.lastDetail = detail || null
250
+ const thr = Math.max(1, Number(opt('watchdogStallThreshold', SIDECAR_WATCHDOG_V1.stallThreshold)))
251
+ pushDiag('error', '第 ' + generation + ' 代 Python 语义侧车连续 ' + thr + ' 次心跳缺失(' + trigger + ')⇒ 看门狗主动击杀旧进程;下次请求将重生新进程(新 epoch),未经确认的旧 in-flight 请求按 crashed 结算。')
252
+ logExitDiag(generation, 'watchdog', humanExitNote(generation, 'watchdog', null, null))
253
+ try { proc.stdin.destroy() } catch (_) {}
254
+ try { proc.kill() } catch (_) {}
255
+ rejectAll('crashed')
256
+ }
257
+
258
+ /** 心跳缺失证据:只把「超时」算挂死(abort 是调用方主动取消,不构成挂死证据)。 */
259
+ function noteStall(type, requestId, timeoutMs) {
260
+ consecutiveTimeouts++
261
+ const thr = Math.max(1, Number(opt('watchdogStallThreshold', SIDECAR_WATCHDOG_V1.stallThreshold)))
262
+ if (consecutiveTimeouts < thr) return
263
+ disarmHungChild('连续超时', { type: String(type), requestId: String(requestId), timeoutMs: Number(timeoutMs), consecutiveTimeouts })
264
+ }
265
+
266
+ /**
267
+ * ★ #107 熔断半开探测:冷却到期后不放行盲发,先用 health()(有界)自证 worker 活着再放行。
268
+ * 这是 health() 在仓内的**唯一真实调用点**。type==='health' 不入此分支 ⇒ 不会递归,
269
+ * 也不会把探针自身的失败再算作一次新的挂死证据。
270
+ */
271
+ async function probeThenSend(type, payload, rOpts) {
272
+ halfOpenPending = false
273
+ stats.breakerProbes.attempted++
274
+ const probeTimeout = Math.min(Math.max(1, Number(opt('requestTimeoutMs', B.requestTimeoutMs))), 2000)
275
+ const probe = await request('health', {}, { timeoutMs: probeTimeout })
276
+ if (probe && probe.ok) {
277
+ stats.breakerProbes.ok++
278
+ breaker.consecutiveFailures = 0
279
+ consecutiveTimeouts = 0
280
+ return request(type, payload, rOpts)
281
+ }
282
+ stats.breakerProbes.failed++
283
+ breaker.openUntil = Date.now() + Number(opt('breakerCooldownMs', B.breakerCooldownMs))
284
+ halfOpenPending = true
285
+ stats.failed.circuitOpen++
286
+ pushDiag('error', '熔断半开探测未通过:Python 语义侧车 health 探针返回 ' + String((probe && probe.code) || 'unknown') + ' ⇒ 重新打开熔断 ' + Number(opt('breakerCooldownMs', B.breakerCooldownMs)) + 'ms;本次请求按 circuit-open 返回。')
287
+ return { ok: false, code: 'circuit-open', retryInMs: breaker.openUntil - Date.now() }
288
+ }
289
+
78
290
  /** lazy start:no-shell spawn;仅由显式启用的调用路径触达。 */
79
291
  function ensureStarted() {
80
292
  if (disposed) return { ok: false, code: 'disposed' }
81
293
  if (child && (child.killed || (child.stdin && child.stdin.destroyed))) {
82
294
  // 上一个进程正在收尾(exit 事件未到):按已死处理,允许立即重生
295
+ // ★ issue #72 修复(2026-09-19):此处必须**同时清空共享 buffer**。
296
+ // 旧实现只置 `epoch = null`(等 `:121` 的 exit 事件去清 buffer),但这条分支的前提正是
297
+ // **exit 尚未到达** ⇒ 旧 worker 残留在 `buffer` 里的**无换行尾字节**会被新 worker 的首帧
298
+ // `concat` 进去 ⇒ 该行 badJson ⇒ 应答被吞、请求白等到超时(默认 5000ms)。
83
299
  try { child.kill() } catch (_) {}
84
300
  child = null
85
301
  epoch = null
302
+ buffer = Buffer.alloc(0) // ★ 丢弃旧代残尾,避免污染新 worker 首帧
86
303
  }
87
304
  if (child) return { ok: true }
88
305
  const scriptPath = String(resolveOpt('scriptPath') || defaultWorkerScriptPathPre())
@@ -101,9 +318,16 @@ export function createPythonSidecarClientPre(opts = {}) {
101
318
  }
102
319
  child = proc
103
320
  stats.starts++
321
+ generation = ++genSeq
322
+ genOfProc.set(proc, generation)
323
+ exitKindByGen.set(generation, 'crash')
324
+ stderrTail = '' // ★ #108:stderr 尾部按**世系**隔离,避免把上代 traceback 误挂到本代
325
+ consecutiveTimeouts = 0
326
+ if (respawnPending) { stats.watchdog.respawns++; respawnPending = false }
104
327
  proc.stdout.on('data', (chunk) => { try { feed(chunk) } catch (_) { fatal('protocol') } })
105
328
  proc.stderr.on('data', (chunk) => {
106
- stderrTail = (stderrTail + chunk.toString('utf8')).slice(-4096)
329
+ stderrTail = (stderrTail + chunk.toString('utf8'))
330
+ .slice(-Math.max(256, Number(opt('stderrTailChars', SIDECAR_WATCHDOG_V1.stderrTailChars))))
107
331
  })
108
332
  proc.on('error', () => {
109
333
  // spawn 失败(ENOENT 等):结构化失败,不计崩溃重启
@@ -115,12 +339,19 @@ export function createPythonSidecarClientPre(opts = {}) {
115
339
  noteFailure('unavailable')
116
340
  })
117
341
  proc.on('exit', (code, signalName) => {
342
+ const gen = genOfProc.get(proc) || 0
343
+ const kind = exitKindByGen.get(gen) || 'crash'
344
+ // ★ #107:无论是否当前世系都先记账 —— 「哪一侧车、第几代、怎么退的、退出码多少」
345
+ // stats.exits 仍是总退出数;active 的归因细分在 stats.exitKinds。
346
+ stats.exits++
347
+ stats.exitKinds[kind] = (stats.exitKinds[kind] || 0) + 1
348
+ stats.lastExit = { code, signal: signalName, generation: gen, kind }
349
+ stats.lastExitNote = humanExitNote(gen, kind, code, signalName)
350
+ logExitDiag(gen, kind, stats.lastExitNote)
118
351
  if (child !== proc) return
119
352
  child = null
120
353
  epoch = null
121
354
  buffer = Buffer.alloc(0)
122
- stats.exits++
123
- stats.lastExit = { code, signal: signalName }
124
355
  rejectAll('crashed')
125
356
  })
126
357
  return { ok: true }
@@ -128,13 +359,21 @@ export function createPythonSidecarClientPre(opts = {}) {
128
359
 
129
360
  function rejectAll(code) {
130
361
  for (const [, entry] of pending) settle(entry, { ok: false, code })
131
- noteFailure(code)
362
+ // ★ #107:不再在循环外额外 noteFailure(code) —— settle() 已按每个失败请求各计一次,
363
+ // 旧实现在此重复计数 ⇒ 崩溃时挂 K 个请求会被算成 K+1 次失败,熔断阈值提前触发(假熔断)。
132
364
  }
133
365
 
134
366
  function fatal(kind) {
135
367
  stats.lastFatal = kind
136
368
  buffer = Buffer.alloc(0) // 丢弃残留半帧,防止污染重生进程的解析流
137
- if (child) { try { child.stdin.destroy() } catch (_) {} try { child.kill() } catch (_) {} }
369
+ // ★ #108:致命分支同样把 stderr 尾部原样落一条 diag;与随后 exit 同键(世系号+fatal)
370
+ // ⇒ 同一次退出只记一条。
371
+ logExitDiag(generation, 'fatal', humanExitNote(generation, 'fatal', null, null) + ' 触发原因=' + String(kind))
372
+ if (child) {
373
+ markExitKind(generation, 'fatal')
374
+ try { child.stdin.destroy() } catch (_) {}
375
+ try { child.kill() } catch (_) {}
376
+ }
138
377
  rejectAll('protocol')
139
378
  }
140
379
 
@@ -161,7 +400,16 @@ export function createPythonSidecarClientPre(opts = {}) {
161
400
  const v = validateTransportFramePre(obj, { direction: 'in' })
162
401
  if (!v.ok) { stats.dropped.badEnvelope++; return }
163
402
  const frame = v.frame
164
- if (epoch !== null && frame.workerEpoch !== epoch) { stats.dropped.staleEpoch++; return }
403
+ // ★ issue #72 修复(2026-09-19):把入站 epoch 门从 **fail-open 改为 fail-closed**,与文件头 :7 的承诺一致。
404
+ // 旧写法 `if (epoch !== null && frame.workerEpoch !== epoch)`:**`epoch === null` 时整条门失效**,
405
+ // 任何合法 envelope 的帧都会穿透到 handler。epoch 为 null 的窗口是真实存在的
406
+ // (未启动 / `exit` 事件已置 null 但旧 stdio 尚未排空 / restart 后到新 spawn 之间),
407
+ // 而 `activation_request` 上游 `context-host` **无二次校验**(同 activationId 有 dedup 兜底,
408
+ // 但**新 activationId 可穿透**)⇒ 幽灵激活。
409
+ // 新判据:**epoch 为空 ⇒ 一律丢弃**。理由:spawn 时同步赋 epoch(:91 `epoch = 'wk_' + …` 在
410
+ // `spawn()` **之前**执行),出站帧也就带上了 epoch;对端 worker 以 `--expect-epoch` 自证身份后才回帧
411
+ // ⇒ **正常路径不存在"帧先于 epoch"**,丢弃不会误伤任何合法流量。
412
+ if (!epoch || frame.workerEpoch !== epoch) { stats.dropped.staleEpoch++; return }
165
413
  if (frame.type === 'activation_request') {
166
414
  const activation = frame.payload && frame.payload.activation
167
415
  const aid = activation && activation.activationId
@@ -194,7 +442,17 @@ export function createPythonSidecarClientPre(opts = {}) {
194
442
  if (entry.timer) clearTimeout(entry.timer)
195
443
  if (entry.onAbort) { try { entry.signal.removeEventListener('abort', entry.onAbort) } catch (_) {} }
196
444
  pending.delete(entry.requestId)
197
- if (result.ok) { stats.succeeded++; breaker.consecutiveFailures = 0 }
445
+ if (result.ok) {
446
+ stats.succeeded++
447
+ // ★ #107 熔断清零口径:只有「终态成功」清零累计失败;段内进度帧(index_sync_begin /
448
+ // index_sync_page)的 ack 成功**不清零** —— 否则每个 Segment 一次 begin 就把累计失败
449
+ // 抹平,峰值恒 1、阈值 3 永不达,熔断形同虚设。
450
+ if (TERMINAL_SUCCESS_TYPES_V1.has(entry.type)) {
451
+ breaker.consecutiveFailures = 0
452
+ consecutiveTimeouts = 0
453
+ halfOpenPending = false
454
+ }
455
+ }
198
456
  else {
199
457
  const bucket = stats.failed[result.code]
200
458
  if (bucket === undefined) stats.failed.protocol++
@@ -208,9 +466,18 @@ export function createPythonSidecarClientPre(opts = {}) {
208
466
  lastSentFrame = frame
209
467
  if (!child || !child.stdin || child.stdin.destroyed) return false
210
468
  const line = Buffer.from(JSON.stringify(frame) + '\n', 'utf8')
469
+ // ★ issue #75 CC-9 修复(2026-09-19):**建帧时捕获当时的 child 引用**,flush 时校验身份。
470
+ // 旧实现:`writeChain.then(...)` 回调里解引用**当时的**模块级 `child` 变量。
471
+ // 若 writeChain 排队期间发生 respawn(`ensureStarted` 的收尾中重生 / `restart()`),
472
+ // 回调会读到**新** child,把**旧 epoch 的帧**写进新 worker 的 stdin ⇒ 对端回帧 epoch-mismatch
473
+ // ⇒ 被判 `staleEpoch` 丢弃 ⇒ 该请求挂满自身超时(默认 5000ms)。
474
+ // 现改为闭包捕获 `sentTo`,且 flush 时 `child !== sentTo` 即视为该帧已随旧进程作废。
475
+ const sentTo = child
476
+ const sentStdin = child.stdin
211
477
  writeChain = writeChain.then(() => new Promise((done) => {
212
- if (!child || !child.stdin || child.stdin.destroyed) { done(); return }
213
- child.stdin.write(line, () => done())
478
+ if (child !== sentTo) { done(); return } // 已换代:本帧作废,不得写入新 worker
479
+ if (!sentStdin || sentStdin.destroyed) { done(); return }
480
+ sentStdin.write(line, () => done())
214
481
  }))
215
482
  writeChain = writeChain.catch(() => {})
216
483
  return true
@@ -227,6 +494,19 @@ export function createPythonSidecarClientPre(opts = {}) {
227
494
  stats.failed.circuitOpen++
228
495
  return Promise.resolve({ ok: false, code: 'circuit-open', retryInMs: breaker.openUntil - Date.now() })
229
496
  }
497
+ // ★ #107 熔断半开探测:冷却到期后不放行盲发,先用 health()(有界)自证 worker 活着再放行。
498
+ // 这是 health() 在仓内的**唯一真实调用点** —— 旧实现 health() 全仓零调用,文件里那句
499
+ // 「health 探针(breaker half-open 用;有界响应)」是空头承诺。type==='health' 不入此分支
500
+ // ⇒ 不会递归,也不会把探针本身的失败再算作一次挂死证据。
501
+ if (halfOpenPending && type !== 'health') return probeThenSend(type, payload, rOpts)
502
+ return sendNow(type, payload, rOpts)
503
+ }
504
+
505
+ /**
506
+ * 真正的建帧 + 入队。与旧 request() 同体,刻意保持「调用后同步可见 _lastFrameForTest()」
507
+ * 的既有契约(既有套件靠它在 await 之前做帧级断言)。
508
+ */
509
+ function sendNow(type, payload, rOpts = {}) {
230
510
  const started = ensureStarted()
231
511
  if (!started.ok) {
232
512
  stats.failed[started.code] = (stats.failed[started.code] || 0) + 1
@@ -239,7 +519,7 @@ export function createPythonSidecarClientPre(opts = {}) {
239
519
  const mf = makeRequestFramePre({ type, payload, requestId, workerEpoch: epoch, sentAt })
240
520
  if (!mf.ok) { stats.failed.protocol++; return Promise.resolve({ ok: false, code: 'protocol', reason: mf.reason }) }
241
521
  return new Promise((resolve) => {
242
- const entry = { requestId, expectedType: RESPONSE_TYPE_FOR_V1[type], resolve, settled: false, timer: null, signal: rOpts.signal || null, onAbort: null }
522
+ const entry = { requestId, type, expectedType: RESPONSE_TYPE_FOR_V1[type], resolve, settled: false, timer: null, signal: rOpts.signal || null, onAbort: null }
243
523
  pending.set(requestId, entry)
244
524
  stats.requests++
245
525
  const written = writeFrame(mf.frame)
@@ -248,6 +528,9 @@ export function createPythonSidecarClientPre(opts = {}) {
248
528
  entry.timer = setTimeout(() => {
249
529
  settle(entry, { ok: false, code: 'timeout', timeoutMs })
250
530
  notify('cancel', { requestId })
531
+ // ★ #107:旧实现到此为止(只发 cancel,不 kill)⇒ 挂死的 worker 无人收尸。
532
+ // 这里补上心跳缺失证据的累计与看门狗收尸(达阈值才动,避免偶发慢帧被误杀)。
533
+ noteStall(type, requestId, timeoutMs)
251
534
  }, timeoutMs)
252
535
  if (entry.signal) {
253
536
  if (entry.signal.aborted) {
@@ -282,7 +565,14 @@ export function createPythonSidecarClientPre(opts = {}) {
282
565
  if (child) { try { child.kill() } catch (_) {} }
283
566
  child = null
284
567
  epoch = null
568
+ // ★ issue #72 修复:restart 同样要清 buffer(kill 后 exit 事件可能滞后,
569
+ // 旧代残尾会污染新 worker 首帧 ⇒ 该行 badJson ⇒ 请求白等到超时)。
570
+ buffer = Buffer.alloc(0)
285
571
  stats.restarts++
572
+ markExitKind(generation, 'restart')
573
+ breaker.consecutiveFailures = 0
574
+ consecutiveTimeouts = 0
575
+ pushDiag('info', 'restart() 主动重启 Python 语义侧车:旧进程已击杀、新 epoch 待下次请求重生(原因:' + String(reason === undefined || reason === null ? '未给' : reason) + ')。')
286
576
  void reason
287
577
  }
288
578
 
@@ -296,7 +586,18 @@ export function createPythonSidecarClientPre(opts = {}) {
296
586
  epoch: epoch ? epoch.slice(0, 12) + '…' : null,
297
587
  pending: pending.size,
298
588
  breaker: { open: breakerOpen(), consecutiveFailures: breaker.consecutiveFailures, cooldownMs: Number(opt('breakerCooldownMs', B.breakerCooldownMs)) },
589
+ generation,
590
+ // ★ #108:stderrTailBytes 保留原语义(当前世系累计保留的尾部长度)——0 表示确实没输出,
591
+ // 非 0 且 lastStderrTailTruncated=true 表示有输出但被截断。文本本体见 lastStderrTail。
299
592
  stderrTailBytes: stderrTail.length,
593
+ lastStderrTail: lastStderr.tail,
594
+ lastStderrTailBytes: lastStderr.bytes,
595
+ lastStderrTailTruncated: lastStderr.truncated,
596
+ lastStderrTailAt: lastStderr.at,
597
+ consecutiveTimeouts,
598
+ halfOpenProbePending: halfOpenPending,
599
+ watchdog: JSON.parse(JSON.stringify(stats.watchdog)),
600
+ recentDiag: diagRing.slice(-4).map((d) => ({ at: d.at, level: d.level, message: d.message })),
300
601
  stats: JSON.parse(JSON.stringify(stats)),
301
602
  }
302
603
  }
@@ -304,6 +605,7 @@ export function createPythonSidecarClientPre(opts = {}) {
304
605
  function dispose(reason) {
305
606
  if (disposed) return
306
607
  disposed = true
608
+ markExitKind(generation, 'dispose')
307
609
  if (child) { try { child.kill() } catch (_) {} }
308
610
  child = null
309
611
  epoch = null
@@ -322,5 +624,6 @@ export function createPythonSidecarClientPre(opts = {}) {
322
624
  onActivation(handler) { activationHandlers.add(handler); return () => activationHandlers.delete(handler) },
323
625
  _statsForTest: stats,
324
626
  _pendingForTest: pending,
627
+ _diagRingForTest: diagRing,
325
628
  }
326
629
  }
@@ -21,6 +21,19 @@ export const FUSION_RRF_K_V1 = 60
21
21
  /** rank 尺度常数:rank/divisor 把秩归到 (0,1] 量级(默认与 k 同值)。 */
22
22
  export const FUSION_RRF_DIVISOR_V1 = 60
23
23
 
24
+ /**
25
+ * ★M2.5b:层次优先级序(与注入侧一致)。
26
+ *
27
+ * 出处:`tier-layer-inject.js:58 TIER_LAYER_ORDER_V1` —— **必须与之逐字相同**。
28
+ *
29
+ * 为什么不直接 import:本模块头注释承诺「**零依赖**、纯函数、零 IO」,
30
+ * 引入跨模块依赖会破坏该契约(且 tier-layer-inject 自身还有依赖链)。
31
+ * 为了既不破契约、又不产生「双源漂移」,此处**本地复刻**并由测试**断言两者相等**
32
+ * (见 smoke-test-m25b-layer-arm-pre.mjs 的「常量一致性」断言)——
33
+ * 任何一侧改动而另一侧未同步,测试立刻红。
34
+ */
35
+ export const FUSION_LAYER_ORDER_V1 = Object.freeze(['project', 'whiteboard', 'user', 'reflection', 'log'])
36
+
24
37
  /** 版本标识。 */
25
38
  export const RECALL_FUSION_VERSION = 'rrf_fusion_v1'
26
39
 
@@ -45,20 +58,63 @@ export function rankFusionRRFPre(pairs, opts = {}) {
45
58
  const list = Array.isArray(pairs) ? pairs.filter((p) => p && typeof p.memoryId === 'string') : []
46
59
 
47
60
  // 每臂独立排名:非空有限值按分数降序(平局 memoryId 升序)取秩(1 起);缺席者无秩。
48
- const rankArm = (key) => {
61
+ // ★M2.5b:新增 `strMode` —— 层次臂的值是**字符串**,默认的「只收有限数」过滤会把它全部滤掉
62
+ // (实测踩坑:layer 臂曾因此静默恒为 0,套件 [3] 抓到)。strMode=true 时接受非空字符串。
63
+ const rankArm = (key, cmp, strMode) => {
49
64
  const entries = []
50
65
  for (const p of list) {
51
66
  const v = p[key]
52
- if (typeof v === 'number' && Number.isFinite(v)) entries.push({ memoryId: p.memoryId, v })
67
+ if (strMode) {
68
+ if (typeof v === 'string' && v) entries.push({ memoryId: p.memoryId, v })
69
+ } else if (typeof v === 'number' && Number.isFinite(v)) {
70
+ entries.push({ memoryId: p.memoryId, v })
71
+ }
53
72
  }
54
- entries.sort((a, b) => (b.v !== a.v ? b.v - a.v : (a.memoryId < b.memoryId ? -1 : a.memoryId > b.memoryId ? 1 : 0)))
73
+ const tie = (a, b) => (a.memoryId < b.memoryId ? -1 : a.memoryId > b.memoryId ? 1 : 0)
74
+ entries.sort(cmp || ((a, b) => (b.v !== a.v ? b.v - a.v : tie(a, b))))
55
75
  const ranks = new Map()
56
76
  entries.forEach((e, i) => ranks.set(e.memoryId, i + 1))
57
77
  return ranks
58
78
  }
79
+
80
+ /**
81
+ * ★M2.5b:层次臂的**序** = 注入侧的层次优先级序(二者必须一致)。
82
+ * 出处:`tier-layer-inject.js:58 TIER_LAYER_ORDER_V1`
83
+ * = ['project','whiteboard','user','reflection','log']。
84
+ * 语义:**rank 1 = 最高优先级层**。未列出的层(含新增层)排在已知层之后,按名称升序(确定性)。
85
+ * 注意:这里只定义**偏好顺序**,不定义权重;权重问题走 rank-space(见下),
86
+ * 需要调权重时应改分档/序,而不是给分数乘系数(Hindsight #3956)。
87
+ */
88
+ const layerRankOf = (name) => {
89
+ const i = FUSION_LAYER_ORDER_V1.indexOf(name)
90
+ return i >= 0 ? i : FUSION_LAYER_ORDER_V1.length
91
+ }
92
+ const cmpLayer = (a, b) => {
93
+ const la = layerRankOf(String(a.v))
94
+ const lb = layerRankOf(String(b.v))
95
+ if (la !== lb) return la - lb
96
+ const sa = String(a.v), sb = String(b.v)
97
+ if (sa !== sb) return sa < sb ? -1 : 1
98
+ return a.memoryId < b.memoryId ? -1 : a.memoryId > b.memoryId ? 1 : 0
99
+ }
100
+
59
101
  const denseRanks = rankArm('dense')
60
102
  const lexRanks = rankArm('lex')
61
103
 
104
+ // ★M2.5b 层次臂(2026-09-18):可选第四臂。layer 为层次名('project'/'user'/'log'/
105
+ // 'reflection'/'whiteboard')或缺失。与时间臂同一条**退化安全**纪律:
106
+ // 所有 pair 均缺 layer **或 layer 全相等** → 不构建 layer 秩,行为与三臂版**逐字节一致**
107
+ // (fused 不加 0 项、输出对象不含新字段)。
108
+ // 为什么需要它(实测不一致,2026-09-18):Tier-0 **注入**侧有层次优先级
109
+ // (tier-layer-inject.js:58 TIER_LAYER_ORDER_V1 = ['project','whiteboard','user','reflection','log']
110
+ // + tier0-catalog.js:419-455 的 caps/floors),而 **排序**侧对层次完全无感
111
+ // ⇒ 结论层(project)与流水层(log)在融合里**平等竞争**,笔记层常被日志挤掉。
112
+ // rank-space(Hindsight #3956 禁止 score-space 加权):层次只贡献**秩**,不乘分。
113
+ const layers = []
114
+ for (const p of list) { if (typeof p.layer === 'string' && p.layer) layers.push(p.layer) }
115
+ const hasLayer = layers.length > 0 && layers.some((v) => v !== layers[0])
116
+ const layerRanks = hasLayer ? rankArm('layer', cmpLayer, true) : null
117
+
62
118
  // 时间臂(P12 后新增,2026-09-09):可选第三臂。temp 为 1(命中时间范围)/0(未命中)/
63
119
  // 缺失(无时间臂或非日期来源)。所有 pair 均缺 temp **或 temp 全相等**(全 0/全 1)→
64
120
  // 不构建 temp 秩,行为与两臂版逐字节一致(fused 不加 0 项、输出对象不含新字段)——
@@ -84,16 +140,31 @@ export function rankFusionRRFPre(pairs, opts = {}) {
84
140
  rankDense: rd === undefined ? null : rd,
85
141
  rankLex: rl === undefined ? null : rl,
86
142
  }
87
- if (!hasTemp) return base
88
- const rt = tempRanks.get(p.memoryId)
89
- const rrfTemp = rt === undefined ? 0 : 1 / (k + rt / divisor)
90
- return {
91
- ...base,
92
- fused: rrfDense + rrfLex + rrfTemp,
93
- rrfTemp,
94
- tempRaw: typeof p.temp === 'number' && Number.isFinite(p.temp) ? p.temp : null,
95
- rankTemp: rt === undefined ? null : rt,
143
+ // 时间臂与层次臂**互不依赖**:各自独立判 has*,可单独生效、可同时生效。
144
+ let out = base
145
+ if (hasTemp) {
146
+ const rt = tempRanks.get(p.memoryId)
147
+ const rrfTemp = rt === undefined ? 0 : 1 / (k + rt / divisor)
148
+ out = {
149
+ ...out,
150
+ fused: out.fused + rrfTemp,
151
+ rrfTemp,
152
+ tempRaw: typeof p.temp === 'number' && Number.isFinite(p.temp) ? p.temp : null,
153
+ rankTemp: rt === undefined ? null : rt,
154
+ }
155
+ }
156
+ if (hasLayer) {
157
+ const rlay = layerRanks.get(p.memoryId)
158
+ const rrfLayer = rlay === undefined ? 0 : 1 / (k + rlay / divisor)
159
+ out = {
160
+ ...out,
161
+ fused: out.fused + rrfLayer,
162
+ rrfLayer,
163
+ layerRaw: typeof p.layer === 'string' && p.layer ? p.layer : null,
164
+ rankLayer: rlay === undefined ? null : rlay,
165
+ }
96
166
  }
167
+ return out
97
168
  })
98
169
  .sort((x, y) => (y.fused !== x.fused ? y.fused - x.fused : (x.memoryId < y.memoryId ? -1 : x.memoryId > y.memoryId ? 1 : 0)))
99
170
  }